# TinyStore 0.3.0

TinyStore 0.3.0 came out on 27 October 2026. Metrics can now read and
aggregate a range of any length, one page at a time. The command line can
now write to a store without an SDK. It can also run a command under a lock,
let an AI agent write, and complete names on Tab.

- **Metrics**
  - [Read in pages](#read-in-pages): a range of any length, up to 100,000 samples per page.
  - [Aggregate in pages](#aggregate-in-pages): pages that end between two whole buckets.
- **Command line**
  - [Write from a shell](#write-from-a-shell): kv, jobs, files, events, channels, config and sql, through the server that holds the store.
  - [Metrics from a shell](#metrics-from-a-shell): read, aggregate and delete, and ingest a scrape or a stream that never ends.
  - [Run a command under a lock](#run-a-command-under-a-lock): a cron job that never runs twice at once.
  - [Let an AI agent write](#let-an-ai-agent-write): `mcp --write`, one tool per operation.
  - [Tab completion](#tab-completion): commands, flags and a store's names in bash, zsh, fish and PowerShell.

> \[!WARNING]
> **One change can break your build**
> `read` and `aggregate` in metrics now return a page instead of a list.
> Take the list from the page in every language:
>
> ```diff for=bun
> - const series = await store.metrics.read({ name: 'ping_rtt_ms', since: '1h' })
> + const { series } = await store.metrics.read({ name: 'ping_rtt_ms', since: '1h' })
> ```
>
> ```diff for=python
> - series = await store.metrics.read(name="ping_rtt_ms", since="1h")
> + series = (await store.metrics.read(name="ping_rtt_ms", since="1h")).series
> ```
>
> ```diff for=go
> - series, err := metrics.Read(ctx, store, q)
> + page, err := metrics.Read(ctx, store, q)
> + series := page.Series
> ```
>
> ```diff for=rust
> - let series = stats.query().name("ping_rtt_ms").since(Duration::from_hours(1)).read()?;
> + let series = stats.query().name("ping_rtt_ms").since(Duration::from_hours(1)).read()?.series;
> ```

## Read in pages

Thirty days of one sample per second is 2.6 million samples per series, more
than one read may return. Give `limit`, and `read` returns a page of that many
samples at most. Walk the page, and it reads the pages after it one at a time:

```ts
const page = await store.metrics.read({ name: 'ping_rtt_ms', since: '30d', limit: 10_000 })
for await (const series of page) chart.append(series.labels, series.times, series.values) // this page, then every page after it
```

```python
page = await store.metrics.read(name="ping_rtt_ms", since="30d", limit=10_000)
async for series in page:  # this page, then every page after it
    chart.append(series.labels, series.times, series.values)
```

```go
page, err := metrics.Read(ctx, store, metrics.Query{Name: "ping_rtt_ms", Since: 30 * 24 * time.Hour, Limit: 10_000})
for series, err := range page.All(ctx) { // this page, then every page after it
	if err != nil {
		return err
	}
	chart.Append(series.Labels, series.Samples)
}
```

```rust
let stats = store.metrics()?;
let page = stats.query().name("ping_rtt_ms").since(Duration::from_hours(30 * 24)).limit(10_000).read()?;
for series in page.all() {
    chart.append(&series?); // this page, then every page after it
}
```

- A page holds 1 to 100,000 samples. It has `next` when there is more, and
  the last page has none.
- Pass `next` as `after` with the same query to read the next page yourself.
  Save it after each page, and an export can resume after a crash.
- `next` keeps the range of the first page, so `since: '30d'` means the same
  30 days on every page.
- Without `limit`, the page holds the whole answer, or `read` fails with a
  `limit` error, as before.

[Reading](https://tinyshed.org/tinystore/docs/guides/metrics/reading#read-a-long-range-in-pages) has the
details.

## Aggregate in pages

A year of one-minute buckets is 525,600 buckets per group. `aggregate` takes
`limit` and `after` too, and its page holds buckets:

```ts
const page = await store.metrics.aggregate({ name: 'requests_total', since: '365d', every: '1m', op: 'rate', limit: 10_000 })
for await (const group of page) chart.appendBuckets(group.labels, group.buckets) // this page, then every page after it
```

```python
page = await store.metrics.aggregate(name="requests_total", since="365d", every="1m", op="rate", limit=10_000)
async for group in page:  # this page, then every page after it
    chart.append_buckets(group.labels, group.buckets)
```

```go
q := metrics.Query{Name: "requests_total", Since: 365 * 24 * time.Hour, Every: time.Minute, Op: metrics.Rate, Limit: 10_000}
page, err := metrics.Aggregate(ctx, store, q)
for group, err := range page.All(ctx) { // this page, then every page after it
	if err != nil {
		return err
	}
	chart.AppendBuckets(group.Labels, group.Buckets)
}
```

```rust
let year = stats.query().name("requests_total").since(Duration::from_hours(365 * 24)).every(Duration::from_mins(1));
let page = year.limit(10_000).aggregate(Op::Rate)?;
for group in page.all() {
    chart.append_buckets(&group?); // this page, then every page after it
}
```

- A page ends only between two whole buckets, and each bucket counts every
  series of its group. The pages give exactly the buckets that one call
  without `limit` gives.
- A `rate` or an `increase` in the first bucket of a page starts from the
  sample before it, so the edge of a page never makes a bucket `partial`.
- Pages don't lift the limit of 1,000 series per query. Use `where` to find
  fewer.

[Aggregates](https://tinyshed.org/tinystore/docs/guides/metrics/aggregates#aggregate-in-pages) has the
details.

## Write from a shell

The command line used to read a store and nothing more. Now it writes keys,
jobs, files, events, messages, live settings, metrics and rows, so a script,
a cron job or a person at a terminal needs no SDK:

```sh
tinystore kv ./data set flags beta on --ttl 1h
tinystore jobs ./data add emails '{"to": "ada@example.com"}' --id welcome-42 --delay 1m
tinystore files ./data put avatars users/42/avatar.png ./avatar.png
tinystore events ./data append audit role.changed user:=42 role=admin
tinystore channels ./data publish chat-events '{"kind": "reload"}'
tinystore config ./data update chat model.max_tokens=2048
```

- A write goes through the server that holds the directory, or a sidecar
  that the command starts and that stops 30 seconds after its last client.
  Your program sees the change at once.
- A store that a program opened `embedded` is refused, because no other
  process can write to it (contract CLI-05).
- `--id` makes a job or an event safe to send again: a queue or a stream
  that holds the id adds nothing.

Each engine has a page:

- [kv](https://tinyshed.org/tinystore/docs/guides/cli/kv-sql-schema#write-a-key): `set` and `delete` a key,
  text unless `--as int` or `--as json` says otherwise.
- [jobs](https://tinyshed.org/tinystore/docs/guides/cli/jobs): `add`, `list` and `cancel` a queue's jobs.
- [files](https://tinyshed.org/tinystore/docs/guides/cli/files): `put`, `get`, `list` and `delete` a set's
  files, of any size.
- [events](https://tinyshed.org/tinystore/docs/guides/cli/events): `append` one event, or every line of
  another program's output, and `list` a stream.
- [channels](https://tinyshed.org/tinystore/docs/guides/cli/channels): `publish` a message, and `subscribe`
  to print each one that arrives.
- [config](https://tinyshed.org/tinystore/docs/guides/cli/config): `explain` a config as the store keeps it,
  and `update` or `reset` its live settings.
- [sql](https://tinyshed.org/tinystore/docs/guides/cli/kv-sql-schema): `--write` now goes through the server
  that holds the store, so you no longer need to stop it first.

`tinystore status ./data --json` now lists every name a store holds:
buckets, queues, sets of files, streams, metrics, databases and indexes.

## Metrics from a shell

`tinystore metrics` writes the Prometheus text that an exporter serves, and
reads, aggregates and deletes series:

```sh
curl -s localhost:9100/metrics | tinystore metrics ./data ingest --label instance=web-1
tinystore metrics ./data read ping_rtt_ms --where target=poland --since 1h
tinystore metrics ./data aggregate ping_rtt_ms --since 1h --every 10m --op max --by target
```

`-f` follows a stream that never ends, as `tail -f` follows a file. It writes
what arrived every second:

```sh
ping -i 1 poland.example.com \
  | awk -F'time=' '/time=/ { split($2, ms, " "); print "ping_rtt_ms{target=\"poland\"}", ms[1]; fflush() }' \
  | tinystore metrics ./data ingest -f --keep 30d
```

- `read` and `aggregate` print a page of 1000 samples or buckets at most, a
  table for a person or a line of JSON per sample with `--json`. `--after`
  reads the next page.
- `-f` skips a line that it can't read, says its number, and writes the rest
  (contract CLI-06).
- `names` lists a store's metrics, and `delete` removes a series.

[metrics](https://tinyshed.org/tinystore/docs/guides/cli/metrics) has the details.

## Run a command under a lock

`tinystore lock hold` runs a command only while it holds a key of a lock. A
cron job on two machines that share a server never runs twice at once:

```sh
tinystore lock ./data hold cron nightly-backup -- ./backup.sh
```

- The command gets the lease's fence in `TINYSTORE_FENCE`. A write can compare
  it to refuse a holder that stalled.
- `lock hold` renews the lease while the command runs. If the lease is lost,
  the command is stopped, with `SIGTERM` and then `SIGKILL` on Linux and
  macOS, at once on Windows.
- It lets the key go when the command ends, and exits with the command's exit
  code (contract CLI-08).

[lock](https://tinyshed.org/tinystore/docs/guides/cli/lock) has the details.

## Let an AI agent write

`tinystore mcp` still only reads unless told otherwise. `--write` adds the
tools that write, one operation each, and only those it names:

```sh
claude mcp add tinystore -- tinystore mcp ./data --write kv_set,jobs_add
```

- Each tool is marked as writing, and as destructive when it removes or
  replaces something, so the agent's host asks you before each call.
- `--write all` offers all eleven: `kv_set`, `kv_delete`, `jobs_add`,
  `jobs_cancel`, `files_put`, `files_delete`, `events_append`,
  `channels_publish`, `config_update`, `config_reset` and `sql_exec`.
- A flag is not a sandbox for an agent that also has a shell, because it can
  run `tinystore kv set` itself.

[AI agents](https://tinyshed.org/tinystore/docs/guides/cli/mcp#writing) has the details.

## Tab completion

```sh
eval "$(tinystore completion bash)"                          # in ~/.bashrc
eval "$(tinystore completion zsh)"                           # in ~/.zshrc
tinystore completion fish | source                           # in ~/.config/fish/config.fish
tinystore completion powershell | Out-String | Invoke-Expression   # in $PROFILE
```

- Tab offers commands, flags and the names that a store holds:
  `kv ./data get se` gives `sessions`.
- When nothing starts with what you typed, Tab offers the word it is a typo
  of: `sesion` gives `sessions`.
- Completion reads names beside the store, never creates one, and writes
  nothing (contract CLI-07).

`tinystore help --all` prints every command's help at once, and
`help --all --json` gives the same as data, for an AI agent that learns the
command line in one call. [The command line](https://tinyshed.org/tinystore/docs/guides/cli#tab-completion)
has the details.

## Changed names

`read` and `aggregate` return a page. Without `limit`, the page holds the
whole answer and has no `next`, so its list is what the call returned before:

```ts
// 0.2
const series = await store.metrics.read({ name: 'ping_rtt_ms', since: '1h' })
const groups = await store.metrics.aggregate({ name: 'orders', since: '30d', op: 'sum' })
// 0.3
const { series } = await store.metrics.read({ name: 'ping_rtt_ms', since: '1h' })
const { groups } = await store.metrics.aggregate({ name: 'orders', since: '30d', op: 'sum' })
```

```python
# 0.2
series = await store.metrics.read(name="ping_rtt_ms", since="1h")
groups = await store.metrics.aggregate(name="orders", since="30d", op="sum")
# 0.3
series = (await store.metrics.read(name="ping_rtt_ms", since="1h")).series
groups = (await store.metrics.aggregate(name="orders", since="30d", op="sum")).groups
```

```go
// 0.2
series, err := metrics.Read(ctx, store, metrics.Query{Name: "ping_rtt_ms", Since: time.Hour})
groups, err := metrics.Aggregate(ctx, store, metrics.Query{Name: "orders", Since: 30 * 24 * time.Hour, Op: metrics.Sum})
// 0.3
page, err := metrics.Read(ctx, store, metrics.Query{Name: "ping_rtt_ms", Since: time.Hour})
series := page.Series
buckets, err := metrics.Aggregate(ctx, store, metrics.Query{Name: "orders", Since: 30 * 24 * time.Hour, Op: metrics.Sum})
groups := buckets.Groups
```

```rust
// 0.2: Result<Vec<Series>> and Result<Vec<Aggregate>>
let series = stats.query().name("ping_rtt_ms").since(Duration::from_hours(1)).read()?;
let groups = stats.query().name("orders").since(Duration::from_hours(30 * 24)).aggregate(Op::Sum)?;
// 0.3: Result<SeriesPage> and Result<AggregatePage>
let series = stats.query().name("ping_rtt_ms").since(Duration::from_hours(1)).read()?.series;
let groups = stats.query().name("orders").since(Duration::from_hours(30 * 24)).aggregate(Op::Sum)?.groups;
```

- A series and a group also have `more`, which is true when a page cut it.
- In Go, `Read` now fails with an invalid error when the query sets
  `Every`, `Calendar`, `TimeZone`, `Op`, `By`, `Without` or `Estimate`, as
  Rust's `read` does. Before, it ignored them.

## Fixes

- A server ends a client's subscriptions, follows, tails and job watches as
  soon as the client closes its side of the connection. Before, the
  connection stayed open for ten more seconds.
- On Windows, a Node program whose store ran as a private child could wait
  forever for an answer that stopped part way. The child now writes its
  answers whole.
- A `tinystore` command whose output goes to a pipe that closes early, as
  `| head` does, exits 0 quietly instead of panicking (contract CLI-04).

## Install

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

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

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

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

A store that 0.2 wrote opens in 0.3.0 as it is, and nothing changes on disk.
If your program reaches a server, update the server with the SDK, because a
0.2 server refuses `limit` and `after`. The server image is
`ghcr.io/tinyshed/tinystore:0.3.0`.

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