# 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.

- **SQL and Blobs**
  - [Files in a database](#files-in-a-database): a file and the row that names it commit together in `db.tx`.
- **KV**
  - [Older encryption keys](#older-encryption-keys): values sealed with an old key still open, and `reseal` moves them to the new one.
- **Backup**
  - [One moment of the whole store](#one-moment-of-the-whole-store): every engine's file copied at the same instant.
- **Metrics**
  - [Samples you don't store](#samples-you-dont-store): aggregate series that a Rust program holds in memory, and sum floats exactly.
  - [A smaller metrics.db](#faster): 1.19 bytes per sample instead of 2.72 for pings sent every second.

> \[!WARNING]
> **0.3 can't open a store that 0.4.0 has opened**
> When 0.4.0 opens a store, it migrates `metrics.db` and `blobs.db`. A 0.3
> program then refuses both files at open and names the step it lacks,
> instead of reading data it doesn't understand (contract STORE-11). Nothing
> renames what your program calls, so your code builds as it is. If you may
> need to go back to 0.3, take a backup with 0.3 before you upgrade.

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

```ts
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')
```

```python
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")
```

```go
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")
```

```rust
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](https://tinyshed.org/tinystore/docs/guides/sql/buckets-and-queues#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:

```ts
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
```

```python
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
```

```go
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
```

```rust
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](https://tinyshed.org/tinystore/docs/guides/kv/encrypted#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:

```ts
const backup = await store.backup('backups/chat-2026-10-19.zip') // { files, bytes, keyLeftOut }
```

```python
backup = await store.backup("backups/chat-2026-10-19.zip")  # Backup(files, bytes, key_left_out)
```

```go
backup, err := store.Backup(ctx, "backups/chat-2026-10-19.zip") // tinystore.Backup{Files, Bytes, KeyLeftOut}
```

```rust
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](https://tinyshed.org/tinystore/docs/guides/backup) 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](https://tinyshed.org/tinystore/docs/guides/metrics/aggregates#samples-you-dont-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](https://github.com/tinyshed/research/blob/main/tinystore/reports/pingmon-storage-2026-10-27.md)
  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

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

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

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

```sh for=rust
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](https://github.com/tinyshed/tinystore/releases/tag/v0.4.0).
