> Fetch the index of the TinyStore docs, every page as markdown, at https://tinyshed.org/tinystore/llms.txt

# TinyStore 0.6.0

TinyStore 0.6.0 came out on 3 November 2026. An audit event can now commit
with the database change that it records, and a stream says which fields its
events carry. A collector can correct a gauge's past samples, even after the
store packed them. Config reads a map of named entries, reads a plugin's
settings as the plugin declares them, and a Rust program that only loads its
settings no longer links SQLite. A Rust program can also
serve its own MCP tools, and every logger and the command line choose colours
by one rule.

- **Records**
  - [An audit that commits with the change](#an-audit-that-commits-with-the-change): `db.events('audit')` keeps an event with the database's rows.
  - [What a stream's events carry](#what-a-streams-events-carry): its event names and fields, with counts and kinds of value.
- **Metrics**
  - [Correct a gauge after the fact](#correct-a-gauge-after-the-fact): `replace` swaps the samples of a range, packed or not.
- **Config**
  - [Entries](#entries): a map of named groups, each field a setting of its own.
  - [Settings without SQLite](#settings-without-sqlite): `config-load` for a Rust program that only loads its settings.
  - [Settings whose shape is known at run time](#settings-whose-shape-is-known-at-run-time): a plugin's settings chosen by the entry's `plugin`.
- **MCP**
  - [Your own MCP server](#your-own-mcp-server): a Rust program serves its own tools beside TinyStore's.
- **Console**
  - [One rule for colours](#one-rule-for-colours): every logger and the command line agree, and a program can ask.

> \[!WARNING]
> **0.5.1 can't open `records.db` after 0.6.0**
> When 0.6.0 first opens `records.db`, it adds a step to the file's
> migrations, so that a stream can count what its events carry. After that,
> 0.5.1 refuses the file and names the step it lacks (contract STORE-11).
> The other files of a store don't change. If you may need to go back to
> 0.5.1, take a backup with 0.5.1 before you upgrade.

## An audit that commits with the change

An audit line has to exist exactly when the change it records does. Open the
stream from the database with `db.events`, and append inside the database's
transaction. The event is kept with the rows, and it reaches the stream once
they commit ([#53](https://github.com/tinyshed/tinystore/issues/53)):

```ts
const db = await store.database('app')
const audit = db.events('audit', { keep: '365d' })

await db.tx(async tx => {
	await tx.exec`update roles set role = ${'admin'} where user_id = ${42}`
	await tx.with(audit).append('role.changed', { userId: 42, role: 'admin' }) // kept with the update
})
const page = await audit.list({ newest: true, limit: 50 }) // the line is there once db.tx returns
```

```python
db = await store.database("app")
audit = db.events("audit", keep="365d")


async def promote(tx: tinystore.SqlTx) -> None:
    await tx.exec("update roles set role = ? where user_id = ?", "admin", 42)
    await tx.with_(audit).append("role.changed", {"user_id": 42, "role": "admin"})  # kept with the update


await db.tx(promote)
page = await audit.list(newest=True, limit=50)  # the line is there once db.tx returns
```

```go
db, err := sqldb.Open(ctx, store, "app")
audit, err := records.OpenEvents(db, "audit", records.Keep(365*24*time.Hour))

err = db.Tx(ctx, func(tx *sqldb.Tx) error {
	if _, err := tx.Exec(ctx, "update roles set role = ? where user_id = ?", "admin", 42); err != nil {
		return err
	}
	_, err := audit.In(tx).Append(ctx, "role.changed", records.Fields{"userId": 42, "role": "admin"}) // kept with the update
	return err
})
page, err := audit.List(ctx, records.Query{Newest: true, Limit: 50}) // the line is there once db.Tx returns
```

```rust
let db = store.database("app").open()?;
let audit = db.events("audit").keep(Duration::from_hours(365 * 24)).open()?;

db.tx(|tx| -> tinystore::Result<()> {
    tx.exec(sql!("update roles set role = ? where user_id = ?", "admin", 42))?;
    tx.with(&audit).append("role.changed", &RoleChanged { user_id: 42, role: "admin" })?; // kept with the update
    Ok(())
})?;
let page = audit.list().newest().limit(50).page()?; // the line is there once db.tx returns
```

- If the transaction rolls back, its events never reach the stream. If it
  commits, they are in the stream when `db.tx` returns (contract REC-13).
- If the process crashes between the commit and the move, the events wait
  in the database's file. The store writes each of them once, when the
  stream opens again or within a minute.
- A batch carries an event too, with
  `appending / appending / Appending / appending`, next to its statements.
- The stream is the store's own, in `records.db`. Reads, tails,
  `tinystore logs` and the MCP tools see its events like any other.
- A node can't keep a database's events yet. On a node, `db.events` fails
  with an invalid error.

[An audit that commits with the change](https://tinyshed.org/tinystore/docs/guides/records/audit.md) has the
details.

## What a stream's events carry

`fields` says which names a stream's events have and which keys their fields
hold. For each, it says how many events carry it and what kinds of value it
holds. It reads no packed event to answer, so a program can draw a chart of a
stream that it has never seen
([#54](https://github.com/tinyshed/tinystore/issues/54)):

```ts
const orders = await store.events().fields('orders')
// eventNames: [{ name: 'order.paid', count: 100 }]
// fields:     [{ name: 'amount', kinds: ['float'], count: 100 },
//              { name: 'currency', kinds: ['text'], count: 100 },
//              { name: 'order_id', kinds: ['integer'], count: 100 },
//              { name: 'paid', kinds: ['bool'], count: 100 }]
// context:    []
```

```python
orders = await store.events().fields("orders")
# event_names: [NameCount("order.paid", 100)]
# fields:      [FieldInfo("amount", ["float"], 100), FieldInfo("currency", ["text"], 100),
#               FieldInfo("order_id", ["integer"], 100), FieldInfo("paid", ["bool"], 100)]
# context:     []
```

```go
events, err := records.OpenAllEvents(store)
orders, err := events.Fields(ctx, "orders")
// EventNames: [{Name: "order.paid", Count: 100}]
// Fields:     [{Name: "amount", Kinds: [KindFloat], Count: 100}, {Name: "currency", Kinds: [KindText], Count: 100},
//              {Name: "order_id", Kinds: [KindInteger], Count: 100}, {Name: "paid", Kinds: [KindBool], Count: 100}]
```

```rust
let orders = store.all_events()?.fields("orders")?;
// event_names: [NameCount { name: "order.paid", count: 100 }]
// fields:      [FieldInfo { name: "amount", kinds: [Kind::Float], count: 100 },
//               FieldInfo { name: "currency", kinds: [Kind::Text], count: 100 }, …]
```

- `kinds` lists each kind of value that the key holds, in this order:
  `float`, `integer`, `text`, `bool`, `object`, `list` and `null`.
- The counts cover every event the stream holds, packed or waiting to be
  packed. When `keep` removes old events, their counts go with them
  (contract REC-14).
- `context` lists the keys of the events' contexts in the same way.
- Events that 0.5.1 or an earlier release packed have no counts yet. After
  the store opens, its maintenance counts them, 16 segments per minute.
  Until then, `fields` leaves those events out.

[What a stream's events carry](https://tinyshed.org/tinystore/docs/guides/records/reading.md#what-a-streams-events-carry)
has the details.

## Correct a gauge after the fact

Some numbers change after you write them. A refund lowers yesterday's
revenue, and a late order raises it. Before 0.6.0, a collector couldn't
change a sample once the store had packed it
([#29](https://github.com/tinyshed/tinystore/issues/29)). `replace` swaps
every sample of a range for the samples you give:

```ts
const day = (back: number) => new Date(Date.UTC(2026, 10, 3 - back)) // midnight, `back` days before 3 November
// the last three days, read again from the shop's database: a refund took 100 off yesterday
const revenue = { name: 'revenue', kind: 'gauge', labels: { shop: 'msk' }, samples: [[day(3), 1000], [day(2), 1000], [day(1), 900]] } as const
await store.metrics.replace(revenue, { from: day(3), to: day(0) })
await store.metrics.replace(revenue, { from: day(3), to: day(0) }) // the next run reads the same: nothing changes
```

```python
def day(back: int) -> datetime:  # midnight, `back` days before 3 November
    return datetime(2026, 11, 3, tzinfo=UTC) - timedelta(days=back)

# the last three days, read again from the shop's database: a refund took 100 off yesterday
samples = [(day(3), 1000), (day(2), 1000), (day(1), 900)]
revenue = Series(name="revenue", kind="gauge", labels={"shop": "msk"}, samples=samples)
await store.metrics.replace(revenue, from_=day(3), to=day(0))
await store.metrics.replace(revenue, from_=day(3), to=day(0))  # the next run reads the same: nothing changes
```

```go
day := func(back int) time.Time { return time.Date(2026, 11, 3-back, 0, 0, 0, 0, time.UTC) } // midnight, back days before 3 November
// the last three days, read again from the shop's database: a refund took 100 off yesterday
revenue := metrics.Series{Name: "revenue", Kind: metrics.KindGauge, Labels: metrics.Labels{"shop": "msk"},
	Samples: []metrics.Sample{{At: day(3), Value: 1000}, {At: day(2), Value: 1000}, {At: day(1), Value: 900}}}
err := metrics.Replace(ctx, store, revenue, day(3), day(0))
err = metrics.Replace(ctx, store, revenue, day(3), day(0)) // the next run reads the same: nothing changes
```

```rust
let stats = store.metrics()?;
let day = |back: u64| UNIX_EPOCH + Duration::from_secs(1_793_664_000 - back * 86_400); // midnight, `back` days before 3 November
// the last three days, read again from the shop's database: a refund took 100 off yesterday
let revenue = Series::gauge("revenue").label("shop", "msk");
let revenue = revenue.sample(day(3), 1000.0).sample(day(2), 1000.0).sample(day(1), 900.0);
stats.replace(&revenue, day(3)..day(0))?;
stats.replace(&revenue, day(3)..day(0))?; // the next run reads the same: nothing changes
```

- After the call, the range from `from` up to `to` holds exactly your
  samples. A time you give takes its value, a new time is added, and a time
  you leave out is deleted.
- `replace` is one write. A read or an aggregate sees the range as it was or
  as it is now, never half of each (contract MET-19).
- The same samples sent again write nothing, so a collector can read the
  last few days again on every run.
- Only a gauge can be replaced. A counter or a histogram fails with an
  invalid error, because a counter's increase is added up across packed
  blocks.
- One call takes up to 10,000 samples, as many as one `ingest`.

The command line does the same, with one sample per line on stdin:

```sh
printf '2026-11-01T00:00Z 1000\n2026-11-02T00:00Z 900\n' | tinystore metrics ./data replace revenue shop=msk --from 2026-11-01T00:00Z --to 2026-11-03T00:00Z
```

[Correcting a gauge after the fact](https://tinyshed.org/tinystore/docs/guides/metrics/ingesting.md#correcting-a-gauge-after-the-fact)
and [Correct a gauge's range](https://tinyshed.org/tinystore/docs/guides/cli/metrics.md#correct-a-gauges-range)
have the details.

## Entries

A program that reads several databases, each under a name the operator
picks, declares them as entries. Each field of an entry is a setting of its
own, with its default, its rules and its marks
([#49](https://github.com/tinyshed/tinystore/issues/49)):

```ts
const site = { plugin: 'postgres', settings: { host: 'localhost', port: 5432, password: secret(String) } }
const config = await store.config('dashbin', [{ instance: entries(site) }, file('dashbin.toml'), env('DASHBIN')])
const password = config.value.instance.aimee?.settings.password.reveal()
```

```python
@dataclass
class Postgres:
    host: str = "localhost"
    port: int = 5432
    password: Secret = secret()


@dataclass
class Site:
    plugin: str = "postgres"
    settings: Postgres = field(default_factory=Postgres)


@dataclass
class Dashbin:
    instance: Entries[Site] = entries()


config = await store.config("dashbin", [Dashbin, file("dashbin.toml"), env("DASHBIN")])
password = config.value.instance["aimee"].settings.password.reveal()
```

```go
type Postgres struct {
	Host     string           `json:"host"`
	Port     int              `json:"port"`
	Password tinystore.Secret `json:"password" config:"required"`
}

type Site struct {
	Plugin   string   `json:"plugin"`
	Settings Postgres `json:"settings"`
}

type Dashbin struct {
	Instance tinystore.Entries[Site] `json:"instance"`
}

config, err := tinystore.OpenConfig(ctx, store, "dashbin", Dashbin{},
	tinystore.File("dashbin.toml"), tinystore.Env("DASHBIN"))
password := config.Get().Instance["aimee"].Settings.Password.Reveal()
```

```rust
#[derive(Clone, Serialize, Deserialize, Default)]
#[serde(default)]
struct Dashbin {
    instance: Entries<Site>, // Site and its Postgres are serde structs with a Default, the password a Secret<String>
}

let config = store.config("dashbin", Dashbin::default()).file("dashbin.toml").env("DASHBIN").open()?;
let password = config.get().instance["aimee"].settings.password.reveal();
```

A file, `dashbin.toml`, gives an entry field by field:

```toml
[instance.aimee.settings]
host = "127.0.0.1"
port = 15432
```

A variable, or its `_FILE`, gives one field of one entry:

```sh
DASHBIN_INSTANCE_AIMEE_SETTINGS_PASSWORD_FILE=/run/secrets/aimee
```

- The variable is the map's variable, then the entry's key, then the
  field's path, in upper snake case.
- A later layer merges into an entry field by field. No layer removes an
  entry.
- A required field or a secret has to be given for every entry
  (contract CFG-11).
- `explain` prints each field of each entry on its own line, and a secret
  as `***`.

[Entries](https://tinyshed.org/tinystore/docs/guides/config/layers.md#entries) has the details.

## Settings without SQLite

A Rust program that only reads its settings, such as a small command-line
tool or an agent in a container, loads them with `config::load` and opens no
store. Before 0.6.0, it still linked SQLite, because the `config` feature
turns on the store ([#52](https://github.com/tinyshed/tinystore/issues/52)).
Now turn off the default features and ask for `config-load` alone:

```toml
[dependencies]
tinystore = { version = "0.6", default-features = false, features = ["config-load"] }
```

The program then links no store and no SQLite, and it builds without a C
compiler. So a static Linux binary needs no `musl-gcc` (contract CFG-12).
The call stays the same:

```rust
let agent = tinystore::config::load("agent", Agent::default())
    .file(File::optional("agent.toml"))
    .env(Env::prefix("AGENT"))
    .open()?;
```

`config-load` reads files, the environment, `_FILE` variables, secrets,
required settings, entries and rules, and `explain` works. The `config`
feature, which is on by default, adds `store.config` and live settings to it.

The program from issue #52 loads one setting from the environment and looks
up a misspelt word. Built for `x86_64-pc-windows-msvc` with Rust 1.99.0, in
release with `lto = "fat"`, `codegen-units = 1` and `strip = true`, its
binary weighs:

| Features                        | Binary  | SQLite inside |
| ------------------------------- | ------- | ------------- |
| `typos` alone, without settings | 0.22 MB | no            |
| `config-load`                   | 1.12 MB | no            |
| `config`                        | 3.30 MB | yes           |

Only Rust has it. Bun, Node, Python and Go read their settings through a
store. [Layers](https://tinyshed.org/tinystore/docs/guides/config/layers.md) has the details.

## Settings whose shape is known at run time

An instance's settings may be its plugin's: postgres takes a host, a port and
a password, stripe takes one key, and a plugin an admin installs today
declares its own. Declare a group that a setting beside it chooses, and each
entry's settings are read, checked and masked as the group its `plugin`
names ([#55](https://github.com/tinyshed/tinystore/issues/55)):

```ts
const postgres = { host: 'localhost', port: int(5432), password: secret(String) }
const stripe = { key: secret(String) }
const config = await store.config('dashbin', [
	{ instance: entries({ plugin: required(String), settings: chosenBy('plugin', { postgres, stripe }) }) },
	file('dashbin.toml'),
	env('DASHBIN'),
])
```

```python
@dataclass(kw_only=True)
class Instance:
    plugin: str
    settings: Postgres | Stripe = chosen_by("plugin", {"postgres": Postgres, "stripe": Stripe})


config = await store.config("dashbin", [Dashbin, file("dashbin.toml"), env("DASHBIN")])
```

```go
config, err := tinystore.OpenConfigJSON(ctx, store, "dashbin", declaration, // the JSON below
	tinystore.File("dashbin.toml"), tinystore.Env("DASHBIN"))
```

```rust
let declaration = Declaration::from_json(json!({
    "instance": { "entries": {
        "plugin": { "kind": "text", "required": true },
        "settings": { "chosenBy": "plugin", "groups": { "postgres": postgres, "stripe": stripe } },
    } },
}))?;
let config = store.config("dashbin", declaration).file("dashbin.toml").env("DASHBIN").open()?;
```

With `plugin = "postgres"` in `dashbin.toml`, aimee's port takes its default,
5432, `DASHBIN_INSTANCE_AIMEE_SETTINGS_PASSWORD` or its `_FILE` gives the
password, and `explain` shows it as `***`. A key of another plugin's group,
or a typo, is refused with the nearest name, and so is a plugin no group
names:

```text
config dashbin: dashbin.toml: instance.aimee.settings.hots: no such setting; did you mean instance.aimee.settings.host?
config dashbin: dashbin.toml: instance.aimee.plugin: "postgress" is not one of postgres, stripe; did you mean postgres?
```

A Rust or Go program, whose declaration is a type it compiled, now declares
such settings as data, in the JSON every SDK sends, and reads them as a
`serde_json::Value` or a `map[string]any` (contracts CFG-13 and CFG-14).
[Settings whose shape is known at run time](https://tinyshed.org/tinystore/docs/guides/config/runtime.md) has
the details.

## Your own MCP server

The `mcp` feature lets a Rust program serve its own tools to an AI agent
over the Model Context Protocol, the way `tinystore mcp` does
([#50](https://github.com/tinyshed/tinystore/issues/50)). A tool that writes
is offered only when your `--write` flag names it:

```rust
use serde_json::json;
use tinystore::mcp::{self, Tool};

let store = tinystore::Store::open("data", Default::default())?;
let write = std::env::args().skip_while(|arg| arg != "--write").nth(1); // your --write flag
let name = json!({ "type": "object", "properties": { "name": { "type": "string" } }, "required": ["name"] });

mcp::server("dashbin", env!("CARGO_PKG_VERSION"))
    .tools(mcp::read_tools(&store).prefix("store_")) // store_logs, store_kv_get, …
    .tool(Tool::reads("dashboards_list", "The project's dashboards.", json!({ "type": "object" }), |_| {
        Ok(json!([{ "name": "latency" }]))
    }))
    .tool(Tool::writes("dashboard_save", "Saves a dashboard.", name, |args| {
        let name = args["name"].as_str().ok_or("dashboard_save takes name")?;
        Ok(json!({ "saved": name }))
    }))
    .allow(write.as_deref()) // None, "all", or "dashboard_save"
    .serve()?; // answers stdin until it closes
```

- `Tool::reads`, `Tool::writes` and `Tool::removes` give each tool its mark,
  which the agent's host reads before it runs the tool (contract MCP-02).
- A tool that writes or removes, and that `--write` doesn't name, is not
  offered. A call of it is refused and runs nothing (contract MCP-01).
- `mcp::read_tools(&store)` gives the read tools of `tinystore mcp` on your
  store, such as `logs`, `kv_get` and `sql_query`. They write nothing to it
  (contract MCP-04).

Only Rust has it so far. The Bun, Node, Python and Go SDKs don't.
[Your own MCP server](https://tinyshed.org/tinystore/docs/guides/cli/mcp-server.md) has the details.

## One rule for colours

Every logger, in every language, and the `tinystore` command line now choose
colours by one rule ([#51](https://github.com/tinyshed/tinystore/issues/51)):

| Environment                         | Colours           |
| ----------------------------------- | ----------------- |
| `NO_COLOR` set to anything but `""` | off               |
| `TERM=dumb`                         | off               |
| `FORCE_COLOR` other than `0`        | on, in a pipe too |
| none of them, on a terminal         | on                |
| none of them, in a pipe or a file   | off               |

A program that colours its own words asks the same rule, so that they match
the log lines beside them (contract LOG-11):

```ts
import { colors } from '@tinyshed/tinystore'

const painted = colors(process.stdout)
```

```python
import sys
import tinystore

painted = tinystore.colors(sys.stdout)
```

```go
painted := console.Colors(os.Stdout)
```

```rust
let painted = tinystore::console::colors(&std::io::stdout());
```

Before, the command line had a rule of its own. [Fixes](#fixes) says what
changed for it. [Colours](https://tinyshed.org/tinystore/docs/guides/records/console.md#colours) has the
details.

## Changed names

- Rust: the config engine is `tinystore::config` alone. Its second path,
  `tinystore::kv::config`, which no guide used, is gone.

## Fixes

- A `list` with `where` reads on until its page holds `limit` events that
  match, or the range ends. Before, a page of a rare value could come back
  empty and still have a `next`
  ([#47](https://github.com/tinyshed/tinystore/issues/47)).
- In Rust, a secret inside a map or a list prints as `***` in `explain`.
  Before, it printed in clear. A change to a live map or list that holds a
  secret is refused, because `kv.db` would keep the secret
  ([#48](https://github.com/tinyshed/tinystore/issues/48)).
- The command line colours its output by the loggers' rule. It no longer
  reads `CLICOLOR_FORCE`, and `TERM=dumb` turns colours off even under
  `FORCE_COLOR`. `NO_COLOR` with any value but an empty one turns them off,
  `NO_COLOR=0` too.
- A Rust logger on a Windows console asks the console to read escape
  sequences before it colours a line. Before, a console that hadn't turned
  them on printed the codes as text.

## Install

```sh for=bun
bun add @tinyshed/tinystore@0.6.0
npm install @tinyshed/tinystore@0.6.0
```

```sh for=python
pip install tinyshed-tinystore==0.6.0
```

```sh for=go
go get tinyshed.org/tinystore@v0.6.0
```

```sh for=rust
cargo add tinystore@0.6.0
cargo install tinystore-cli@0.6.0
```

0.6.0 adds a step to `records.db`'s migrations when it first opens the file,
and 0.5.1 refuses the file after that. The other files of a store don't
change. A database that `db.events` wrote to keeps a table of its own,
`_tinystore_records_outbox`, and 0.5.1 opens that database as before. If your
program reaches a server, update the server with the SDK: a 0.5.1 server
answers `db.events`, `fields` and `replace` with an `unimplemented` error that
names its version. A 0.5.1 SDK works with a 0.6.0 server. The server image is
`ghcr.io/tinyshed/tinystore:0.6.0`.

Every commit since 0.5.1 is in the
[GitHub release](https://github.com/tinyshed/tinystore/releases/tag/v0.6.0).
