TinyStore

Releases / TinyStore 0.3.0

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.

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:

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
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)
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)
}
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 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:

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
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)
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)
}
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 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: set and delete a key, text unless --as int or --as json says otherwise.
  • jobs: add, list and cancel a queue's jobs.
  • files: put, get, list and delete a set's files, of any size.
  • events: append one event, or every line of another program's output, and list a stream.
  • channels: publish a message, and subscribe to print each one that arrives.
  • config: explain a config as the store keeps it, and update or reset its live settings.
  • sql: --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 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 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 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 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:

// 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' })
# 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
// 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
// 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

bun add @tinyshed/tinystore@0.3.0
npm install @tinyshed/tinystore@0.3.0
pip install tinyshed-tinystore==0.3.0
go get tinyshed.org/tinystore@v0.3.0
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.

Edit this page on GitHub Updated 26 October 2026

esc
↑↓ navigate ↵ open ⇥ filter 0 results