Releases / TinyStore 0.6.0

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.

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

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

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:    []
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:     []
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}]
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 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). replace swaps every sample of a range for the samples you give:

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
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
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
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 and Correct a gauge's 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):

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()
@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()
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()
#[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 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). 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:

FeaturesBinarySQLite inside
typos alone, without settings0.22 MBno
config-load1.12 MBno
config3.30 MByes

Only Rust has it. Bun, Node, Python and Go read their settings through a store. Layers 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):

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'),
])
@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")])
config, err := tinystore.OpenConfigJSON(ctx, store, "dashbin", declaration, // the JSON below
	tinystore.File("dashbin.toml"), tinystore.Env("DASHBIN"))
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:

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 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). 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 has the details.

One rule for colours

Every logger, in every language, and the tinystore command line now choose colours by one rule (#51):

EnvironmentColours
NO_COLOR set to anything but ""off
TERM=dumboff
FORCE_COLOR other than 0on, in a pipe too
none of them, on a terminalon
none of them, in a pipe or a fileoff

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

import { colors } from '@tinyshed/tinystore'

const painted = colors(process.stdout)
import sys
import tinystore

painted = tinystore.colors(sys.stdout)
painted := console.Colors(os.Stdout)
let painted = tinystore::console::colors(&std::io::stdout());

Before, the command line had a rule of its own. Fixes says what changed for it. 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).
  • 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).
  • 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

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

Edit this page on GitHub Updated 3 November 2026

esc
↑↓ navigate ↵ open ⇥ filter 0 results