TinyStore

Releases / TinyStore 0.4.0

TinyStore 0.4.0

TinyStore 0.4.0 came out on 27 October 2026. A database can now keep files that commit with the rows that name them. Encrypted buckets can open values with older keys, so you can rotate a key. A backup copies every file of the store at one moment. Metrics keeps a series sampled every second in less than half the space.

Files in a database

A set of files can now live in an SQL database, so that a file and the row that names it commit together. A plugin's package and its row in plugins are both written, or neither:

const packages = db.files('packages')

await db.tx(async tx => {
	await tx.exec`insert into plugins (file) values (${'stripe-1.0.0.js'})`
	await tx.with(packages).put('stripe-1.0.0.js', pkg) // committed with the row, or not at all
})
const file = await packages.get('stripe-1.0.0.js')
packages = db.files("packages")


async def install(tx: tinystore.SqlTx) -> None:
    await tx.exec("insert into plugins (file) values (?)", "stripe-1.0.0.js")
    await tx.with_(packages).put("stripe-1.0.0.js", package)  # committed with the row, or not at all


await db.tx(install)
file = await packages.get("stripe-1.0.0.js")
packages, err := blobs.OpenFiles(db, "packages")

err = db.Tx(ctx, func(tx *sqldb.Tx) error {
	if _, err := tx.Exec(ctx, "insert into plugins (file) values (?)", "stripe-1.0.0.js"); err != nil {
		return err
	}
	_, err := packages.In(tx).Put(ctx, "stripe-1.0.0.js", bytes.NewReader(pkg)) // committed with the row, or not at all
	return err
})
file, found, err := packages.Get(ctx, "stripe-1.0.0.js")
let db = store.database("app").migrations("migrations").open()?;
let packages = db.files("packages").open()?;

db.tx(|tx| -> tinystore::Result<()> {
    tx.exec(sql!("insert into plugins (file) values (?)", "stripe-1.0.0.js"))?;
    tx.with(&packages).put("stripe-1.0.0.js", &package)?; // committed with the row, or not at all
    Ok(())
})?;
let mut file = packages.get("stripe-1.0.0.js")?.expect("committed with its row");
  • If the transaction rolls back, the bytes are removed at once. If the process dies before the commit, the next open removes them. A row never names bytes that are not there.
  • Outside a transaction, the files have every call of Blobs, and they share the database's writer.
  • A store that is a node can't keep a database's files yet: db.files fails with an invalid error there.

Files in a database has the details.

Older encryption keys

Each encrypted value records which key sealed it. The store seals new values with its encryption key, and it opens a value with any key it holds. Give the old keys as older keys, and reseal moves a bucket's values to the new key:

const store = await open('./data', {
	encryptionKeyFile: '/run/secrets/tinystore-2026.key',
	olderEncryptionKeys: ['/run/secrets/tinystore-2025.key'],
})
const moved = await passwords.reseal() // 120: the values the 2025 key sealed, now sealed with the 2026 key
store = await tinystore.open(
    "data",
    encryption_key_file="/run/secrets/tinystore-2026.key",
    older_encryption_keys=["/run/secrets/tinystore-2025.key"],
)
moved = await passwords.reseal()  # 120
store, err := tinystore.Open(ctx, "data", tinystore.EncryptionKeyFile("/run/secrets/tinystore-2026.key"),
	tinystore.OlderEncryptionKeys("/run/secrets/tinystore-2025.key"))
moved, err := passwords.Reseal(ctx) // 120
let store = Store::open("data", Options {
    encryption_key_file: Some("/run/secrets/tinystore-2026.key".into()),
    older_encryption_keys: vec!["/run/secrets/tinystore-2025.key".into()],
    ..Options::default()
})?;
let moved = passwords.reseal()?; // 120
  • After a restore without the key, put a new key where the store reads it. New values are sealed with it in the same buckets. A read of an old value fails with an invalid error that names the key that sealed it.
  • If the old key turns up later, add it to the older keys. Its values open again, and reseal moves them to the new key.

Older keys has the details.

One moment of the whole store

backup is called as before, and its zip now holds every engine's file as it was at one instant:

const backup = await store.backup('backups/chat-2026-10-19.zip') // { files, bytes, keyLeftOut }
backup = await store.backup("backups/chat-2026-10-19.zip")  # Backup(files, bytes, key_left_out)
backup, err := store.Backup(ctx, "backups/chat-2026-10-19.zip") // tinystore.Backup{Files, Bytes, KeyLeftOut}
let backup = store.backup().write("backups/chat-2026-10-19.zip")?;

Before, each engine's file was copied at its own moment, so a row in one file could name something that the copy of another file didn't have yet. Now a row that names what another file holds is restored with it (contract BAK-01). For that instant, the backup holds every file's commits back. A transaction that is already running finishes first, and the store keeps working while the files are copied.

What a backup guarantees has the details.

Samples you don't store

A Rust program that reads numbers live and never keeps them, such as rows of another database or a payments API, can aggregate them without a store. Call query on a Series that you built in memory. It returns the same buckets that a store's query returns for the same samples, and it writes nothing to disk:

rust
use tinystore::metrics::{self, ExactSum, Op, Series};

let requests = Series::counter("requests").sample(t0, 100.0).sample(t0 + Duration::from_secs(10), 150.0)
    .sample(t0 + Duration::from_secs(20), 20.0); // the process restarted
let minute = requests.query().from(t0).to(t0 + Duration::from_mins(1)).aggregate(Op::Increase)?;
// minute.buckets[0]: value 70, resets 1, as a store's query of these samples gives

let total: ExactSum = amounts.iter().copied().collect();
let paid = total.value(); // 1.0 for ten amounts of 0.1, where a float sum gives 0.9999999999999999

Only Rust has it so far. Bun, Node, Python and Go reach the metrics engine through a store.

Samples you don't store has the details.

Faster

  • A smaller metrics.db. A store of real pings, six hosts pinged every second, took 2.72 bytes per sample in 0.3.0 and takes 1.19 bytes in 0.4.0, every value and time read back bit for bit. The times of a block are kept by how far each lies from the usual step, which suits times taken when an answer arrives. Values that change by small steps take a shorter code. A series sampled every second packs a block every five minutes instead of every minute. The report has the numbers, the machine and the commands.
  • The price is one-minute buckets of per-second series. A one-minute bucket now falls inside a five-minute block, so it decodes the block's samples instead of reading its summary. In the same report, an hour of one-minute buckets of six series took 1.36 ms instead of 0.66 ms. Buckets of five minutes and longer read a fifth of the summaries, and over twenty hours they took a third less time.

Install

bun add @tinyshed/tinystore@0.4.0
npm install @tinyshed/tinystore@0.4.0
pip install tinyshed-tinystore==0.4.0
go get tinyshed.org/tinystore@v0.4.0
cargo add tinystore@0.4.0
cargo install tinystore-cli@0.4.0

A store that 0.3 wrote opens in 0.4.0, which migrates its metrics.db and blobs.db at the first open. If your program reaches a server, update the server with the SDK, because a 0.3 server doesn't have db.files. The server image is ghcr.io/tinyshed/tinystore:0.4.0.

Every commit since 0.3.0 is in the GitHub release.

Edit this page on GitHub Updated 27 October 2026

esc
↑↓ navigate ↵ open ⇥ filter 0 results