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.
- SQL and Blobs
- Files in a database: a file and the row that names it commit together in
db.tx.
- Files in a database: a file and the row that names it commit together in
- KV
- Older encryption keys: values sealed with an old key still open, and
resealmoves them to the new one.
- Older encryption keys: values sealed with an old key still open, and
- Backup
- One moment of the whole store: every engine's file copied at the same instant.
- Metrics
- Samples you don't store: aggregate series that a Rust program holds in memory, and sum floats exactly.
- A smaller metrics.db: 1.19 bytes per sample instead of 2.72 for pings sent every second.
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.filesfails with aninvaliderror 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 keystore = await tinystore.open(
"data",
encryption_key_file="/run/secrets/tinystore-2026.key",
older_encryption_keys=["/run/secrets/tinystore-2025.key"],
)
moved = await passwords.reseal() # 120store, err := tinystore.Open(ctx, "data", tinystore.EncryptionKeyFile("/run/secrets/tinystore-2026.key"),
tinystore.OlderEncryptionKeys("/run/secrets/tinystore-2025.key"))
moved, err := passwords.Reseal(ctx) // 120let 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
invaliderror 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
resealmoves 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}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:
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.9999999999999999Only 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.0pip install tinyshed-tinystore==0.4.0go get tinyshed.org/tinystore@v0.4.0cargo add tinystore@0.4.0
cargo install tinystore-cli@0.4.0A 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.