← Back to site Loading…

rescriptum

The stores

Development

Generated on August 30, 2026

The stores

The stores

Answers come from either a directory of documents (RESCRIPTUM_STORE=files, the default) or a SQLite database (RESCRIPTUM_STORE=sqlite), chosen at runtime.

The trait is deliberately thin

pub trait Store: Send + Sync {
    fn version(&self) -> Version;               // Option<String>, cheap per request
    fn snapshot(&self) -> io::Result<Snapshot>; // only when version moved
    fn describe(&self) -> String;
}

A Snapshot is raw document text and nothing else: RawMachine, RawGroup, RawDefault, each carrying an identifier, a format and a body.

Every decision lives above this. Matching, extends chains, merging, rendering, check — all in select.rs and merge.rs, shared. Keep it that way: the moment a backend starts deciding behaviour, the two drift and the conformance suite stops being able to prove they have not.

The write half is separate, because serving answers never needs it:

pub trait StoreWrite: Store {
    fn put_machine(&self, id: &str, format: &str, body: &str) -> io::Result<()>;
    fn delete_machine(&self, id: &str, format: &str) -> io::Result<bool>;
    fn put_group(&self, name: &str, format: &str, body: &str) -> io::Result<()>;
    fn delete_group(&self, name: &str, format: &str) -> io::Result<bool>;
    fn put_default(&self, format: &str, body: &str) -> io::Result<()>;
    fn delete_default(&self, format: &str) -> io::Result<bool>;
}

Every operation names a format. A document is keyed by what it is for — a machine and an operating system.

An earlier put deleted the other formats of a stem, to avoid “two answers for one machine”. That was the wrong model: they are that machine’s answers for two operating systems, and both are meant to exist. See traps.

tests/stores.rs is the guarantee

Every behavioural case runs twice, once per store, and asserts the identical outcome. 35 cases at last count.

A new behaviour belongs there, not in a store-specific test. A test that covers one backend proves half of what it claims.

The file store

One directory per identity. A machine is a directory named after it, holding one document per format; groups/ holds the same shape for groups, and default/ the fallbacks. Both names are reserved, so a machine cannot claim them — valid_machine_id refuses them in both stores, because a database that accepted one would export into a directory that cannot hold it.

Inside a directory, the extension is the format and the stem is nothing at all. That is the rule that makes two documents of one format in one directory a reported problem rather than a resolved one: there is no tiebreak an operator could have predicted. Sorted order decides which of the two answers, so the choice at least does not depend on readdir — and the loser is named in problems().

A servable document left at the top of the answers directory — the layout that came before — is reported and not served, with its destination spelled out. Half-reading an old layout would mean a machine whose answer moved silently between two files. pending_moves() is the same knowledge exposed for migrate, so the command and the reader cannot disagree about where a document belongs.

version() is the directory’s mtime:

fs::metadata(&self.dir).ok()
    .and_then(|m| m.modified().ok())
    .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
    .map(|d| d.as_nanos().to_string())

One stat replaces a whole directory walk — see the listing cache.

The directory’s mtime moves when an entry is added or removed in it, not when one is edited, and not when something changes one level down. So a machine’s whole directory appearing or leaving is seen at once, while a document added or edited inside one waits for the 1-second reload backstop — which is what already covered a file edited in place. A unit test pins each half.

What the layout costs on a read. A full reload is now a readdir per identity on top of the file it already opened. Measured at 2,000 machines on an M1 Pro: 28 ms flat, 63 ms with a directory each. It is amortised over a second’s worth of requests, and end-to-end throughput did not move measurably — but it is a real 2.2× on the one operation the backstop guarantees will run every second, and it is the reason to reach for a group before a directory per machine.

Writes go through a temporary file plus rename, which is atomic within a directory on POSIX, so a reader never meets a half-written answer. The temporary name carries the process id, and is removed if the rename fails. A test asserts no .tmp file survives.

Reading is DirEntry::file_type(), not fs::metadata. The file type comes back free with the readdir on Unix; only a symlink needs the stat to resolve. That alone was worth 65% at 2,000 files, before caching was added.

The SQLite store

rusqlite with the bundled feature — SQLite is compiled from source into the binary, so there is nothing to install. It cross-compiles to armv7-musl under zigbuild; CI builds that target on every push precisely because a C dependency breaks there first.

WAL mode, so the admin API never stalls an install in progress.

version() reads an in-process atomic, not the database:

Some(self.revision.load(Ordering::Relaxed).to_string())

It is called per request, and a query per request would defeat the point of caching. The consequence is that a change made by another process does not move it — the reload backstop is what catches that.

Schema versions live in PRAGMA user_version. There is one, and nothing has been released under an older one, so migrate() has no steps: it refuses a database from the future, creates the schema when the version is 0, and stamps it. The shapes this went through while it was being written never left the repository, and carrying migrations from them would be carrying code that cannot run.

What the version is for is the rollback direction:

database schema is version 2, this binary understands 1

Refused rather than guessed at, because a database written by a newer binary may hold columns this one would silently ignore — and silently ignoring part of an answer set is how a machine gets installed wrongly.

import / export

$ rescriptum import <dir>    # directory → the configured store
$ rescriptum export <dir>    # the configured store → a directory

Both go through Snapshot, so they share every rule. The round trip is byte-identical — import a directory, export it again, diff -r reports nothing, paths included. A test compares both sides at the same path for exactly that reason: export writing a document somewhere import would not look for it is what would make the database unsafe to leave. That is what makes the database safe to adopt and safe to leave, and it is worth keeping true.

Identifiers become directory names

pub fn valid_id(id: &str) -> bool           // letters, digits, - _ . : and no separators
pub fn valid_machine_id(id: &str) -> bool   // …and not `groups` or `default`

Enforced at the admin API boundary and in both stores. The store is the layer that turns an identifier into a path, so it is the layer that must not be fooled — checking only at the boundary would make the guard depend on every future caller remembering.

valid_format is the equivalent for extensions: a document in a format nobody can read never reaches the store in the first place.

The sqlite cargo feature

On by default, and removable:

BuildARMv7 size
default2,103,456 bytes
--no-default-features944,928 bytes

Dropping it also drops the admin API, which needs the database. CI builds --release --no-default-features on every push so the smallest build cannot rot unnoticed.