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.
- Metrics
- Read in pages: a range of any length, up to 100,000 samples per page.
- Aggregate in pages: pages that end between two whole buckets.
- Command line
- Write from a shell: kv, jobs, files, events, channels, config and sql, through the server that holds the store.
- Metrics from a shell: read, aggregate and delete, and ingest a scrape or a stream that never ends.
- Run a command under a lock: a cron job that never runs twice at once.
- Let an AI agent write:
mcp --write, one tool per operation. - Tab completion: commands, flags and a store's names in bash, zsh, fish and PowerShell.
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 itpage = 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)- A page holds 1 to 100,000 samples. It has
nextwhen there is more, and the last page has none. - Pass
nextasafterwith the same query to read the next page yourself. Save it after each page, and an export can resume after a crash. nextkeeps the range of the first page, sosince: '30d'means the same 30 days on every page.- Without
limit, the page holds the whole answer, orreadfails with alimiterror, 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 itpage = 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)
}- 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
limitgives. - A
rateor anincreasein the first bucket of a page starts from the sample before it, so the edge of a page never makes a bucketpartial. - Pages don't lift the limit of 1,000 series per query. Use
whereto 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:
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
embeddedis refused, because no other process can write to it (contract CLI-05). --idmakes 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:
setanddeletea key, text unless--as intor--as jsonsays otherwise. - jobs:
add,listandcancela queue's jobs. - files:
put,get,listanddeletea set's files, of any size. - events:
appendone event, or every line of another program's output, andlista stream. - channels:
publisha message, andsubscribeto print each one that arrives. - config:
explaina config as the store keeps it, andupdateorresetits live settings. - sql:
--writenow 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:
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:
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 30dreadandaggregateprint a page of 1000 samples or buckets at most, a table for a person or a line of JSON per sample with--json.--afterreads the next page.-fskips a line that it can't read, says its number, and writes the rest (contract CLI-06).nameslists a store's metrics, anddeleteremoves 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:
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 holdrenews the lease while the command runs. If the lease is lost, the command is stopped, withSIGTERMand thenSIGKILLon 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:
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 alloffers all eleven:kv_set,kv_delete,jobs_add,jobs_cancel,files_put,files_delete,events_append,channels_publish,config_update,config_resetandsql_exec.- A flag is not a sandbox for an agent that also has a shell, because it can
run
tinystore kv setitself.
AI agents has the details.
Tab completion
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 segivessessions. - When nothing starts with what you typed, Tab offers the word it is a typo
of:
sesiongivessessions. - 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,
Readnow fails with an invalid error when the query setsEvery,Calendar,TimeZone,Op,By,WithoutorEstimate, as Rust'sreaddoes. 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
tinystorecommand whose output goes to a pipe that closes early, as| headdoes, exits 0 quietly instead of panicking (contract CLI-04).
Install
bun add @tinyshed/tinystore@0.3.0
npm install @tinyshed/tinystore@0.3.0pip install tinyshed-tinystore==0.3.0go get tinyshed.org/tinystore@v0.3.0cargo add tinystore@0.3.0
cargo install tinystore-cli@0.3.0A 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.