src/next is src (the main export), src/next-node is src/node with short subpaths (/http, /workers, /plugins, /plugins/runtime, /app, /surfaces, /web, /react, /tui, /tui/launcher); the foundations get /json and /watch; Chord v1 moves to src/old (/old, /old/node) until p2 runs on v2; tests and docs move likewise (test/, docs/, test/old, docs/old). Durable and env import the foundations from their subpaths.
53 KiB
Slice 6a: the store (spec)
Status: built (2026-10-27: four build-review rounds and six fix rounds; later rules are in the decision log's
2026-10-27 entries and design.md, which win over this spec). Was: ready to build (2026-10-27; the second spec review decided by its majority, section 15), written by the redesign session after
the first spec review (/tmp/s6a-review-{astra,fable,opus,sol}.md) and the owner's decisions of 2026-10-26 and
2026-10-27, including the general-model discussion (facets, attempts, the seal fence, keyed members). Guiding rules
(owner): simple, fast, correct; Chord and Durable aligned as much as possible.
The single source for building 6a, together with: design.md read in full; the store book store/book.md; the
decision log packages/chord/docs/decisions.md (later entries win; superseded parts are marked), in particular "The
store", S1-S17, W1-W14, P2, P3, "Flat columns", "Query runner", "Index definitions are recorded", R8, R12-R15, D9, D10,
"Slice 6 first decisions", "One retirement mechanism", "Text is plain UTF-8, in code point order", and every entry of
2026-10-26 and 2026-10-27 from "The host's store and how it is opened" to "Slice 6a small items"; the feature map and
the carry-over of slices.md (slice 6a); the root AGENTS.md section "Chord v2 Code Style". Builds on slice 5
(4a5b45c45), the adaptation to the shared watch.ts (cc2d61f53) and pi 8025c414d (runCounted).
6a builds the store and puts Chord's persisted data on it (tables, persisted documents, keyed state members), and
reworks three parts of the built slices that the store needs: facets only declare, objects own their documents, line
and job runs across attempts, and the seal fence. Durable on the store (StoreStorage), persisted jobs and
durableView are 6b. Sync is not a slice: the store keeps an optional outbox the sync layer will read.
Words. An object is one identity (type, params, principal key); an attempt is one try at activating it
(factory, references, natives, members, derives, routines). A seq numbers a commit, one counter per store;
lastSeq is the seq of the store's last commit. A write (StoreWrite) is one change of the store (put,
update, updateWhere, table, index, document.create, …); a commit in the outbox is the writes of one seq; a
batch is the commits from..to that sync ships; an archive is a manifest, the writes from 0 to lastSeq and an
end. A line serializes one object's transactions; the commit queue serializes only the store's commit step. A
pin keeps one key of a keyed document loaded.
What an app can do after this slice
// the start code of a session's worker
const db = await openNodeSqliteDatabase(join(dir, "session.sqlite")); // chord/sqlite/node
const store = await openStore(sqliteBackend(db), ctx, { outbox: false }); // omitted: keep the persisted setting
const worker = createHost<P>({ address: Session({ id }), principal, store, onError });
…
await worker.close(ctx);
await store.close(); // the app owns the store
// contracts
const NoteSchema = obj({ title: str(), body: str(), author: str() });
export const NoteStore = local("notes.store", {
version: 1, params: {},
members: {
notes: keyed(NoteSchema), // a keyed state member
create: method(obj({ title: str() }), str()),
rename: method(obj({ id: str(), title: str() })),
remove: method(obj({ id: str() })),
},
});
export const NoteView = remote("notes.view", {
version: 1, params: { session: str(), id: str() },
placement: ({ session }) => Session({ id: session }),
variants: {
editor: { state: { note: NoteSchema }, methods: { rename: method(obj({ title: str() })) } },
reader: { state: { note: obj({ title: str(), body: str() }) } },
},
});
// a plugin: the facet only declares
const Note = defineState("notes.note", {
schema: NoteSchema, version: 1, initial: () => ({ title: "", body: "", author: "" }),
keyed: true, persist: true, checkpointWhen: (_v, _ops, info) => info.deltasSinceBase >= 64,
});
const Edits = defineTable("edits", {
version: 1,
columns: { id: str(), path: str(), at: int(), note: opt(str()), meta: json(EditMeta, { version: 1 }) },
key: ["id"],
indexes: { by_path: ["path", "at"] },
});
export default defineFacet((host) => {
host.serve(NoteStore, (_, self) => { // local, empty params (P3): host lifetime
const notes = self.state(Note); // persisted, keyed, evictable
const edits = self.table(Edits);
self.on(notes.at("pinned"), async ({ value, retired }, ctx) => { … }); // routines live in objects
return {
notes,
create: async ({ title }, ctx) => {
const id = uuidv7();
await self.tx(async (tx) => {
tx.put(edits, { id: uuidv7(), path: `notes/${id}`, at: Date.now(), meta });
(await tx.draft(notes, id)).title = title;
}, ctx);
return id;
},
rename: ({ id, title }, ctx) => notes.change(id, (d) => { d.title = title; }, ctx),
remove: ({ id }, ctx) => notes.retire(id, ctx),
};
});
host.serve(NoteView, ({ id }, principal, self) => { // a view with params: only in-memory state of its own
const store = self.get(NoteStore, {});
const note = store.notes.at(id); // pins key `id` while this view lives
if (principal.kind === "owner") {
return { variant: "editor", members: { note, rename: ({ title }, ctx) => store.rename({ id, title }, ctx) } };
}
return { variant: "reader", members: { note: self.derive([note], ([n]) => ({ title: n.title, body: n.body })) } };
});
});
// a browser tab: an ordinary hold of the view
const view = await tab.get(NoteView, { session: "S1", id: "n-42" }, ctx);
const watch = await view.note.watch(ctx);
watch.start(async ({ value, retired }) => (retired ? showDeleted() : show(value)));
// tables
await self.tx(async (tx) => {
const old = await tx.get(edits, { id });
tx.update(edits, { id }, { note: "fix" }); // plain values
tx.updateWhere(edits, (e) => prefix(e.path, "a/"), (e) => ({ path: replacePrefix(e.path, "a/", "b/") }));
}, ctx);
const page = await edits.page(query(Edits).where((e) => eq(e.path, p)).orderBy((e) => e.at, "desc"), 20, ctx);
// export and import
await pipeline(Readable.from(encodeJsonl(store.export(ctx))), createWriteStream(archivePath));
const copy = await importStore(sqliteBackend(otherDb), decodeJsonl(createReadStream(archivePath)), ctx);
1. Changes to the built slices
1.1 Facets only declare (decisions "Facet documents are removed", "Facets only declare")
A facet's setup declares only host.serve and host.references (self.contribute comes with slice 8). Removed:
FacetSelf (the setup takes host alone until slice 8), facet documents (FacetSelf.state), facet routines
(FacetSelf.on/onLive), the facet's Documents and line, the lock order across two lines, Documents.facet,
Transaction.#facetLine/close(), the facet hydration and routine start in host.ts#setUp, the facet drain in
#close, ChordHost.#facets beyond the names, and their tests (facet.test.ts, the facet cases of
routines.test.ts and documents/transaction tests, support.ts helpers). A facet cannot fail after load (a
throwing setup still rejects load, slice 2). A transaction covers only its own object.
1.2 Objects own documents, line and job runs (decisions "Documents and the line belong to the object", "Job runs belong to the object")
- Slots and handles. The object (
ChordObject) holds one slot per declared document name (trackers, the stored version and fingerprint, pins) and the line. Each attempt'sself.state(def)returns a thin handle bound to the attempt's seal token and the definition's version; old handles keep their token and version (R15: old code touching a slot migrated past its version throws, one integer comparison per read and draft). The same name with a higher version migrates on access (in memory too); the same version with another fingerprint isbroken; a flippedkeyedorpersistisbroken. "Declared twice" is checked per attempt. - Lifetime. Slots live from the object's first activation to its end, across retries, failures and native
restarts.
initial()is taken once per slot (again when the declaring definition changes). In-memory slots the new factory no longer declares are dropped when the factory returns (never on a throwing factory); persisted ones drop their trackers only. Persisted singletons load once per object, keyed data once per residency, again after a failed load or a drop (section 15). In-memory state survives a failure and retry; ending the object clears it. - Job runs. Per job name the object holds the records, keys, active count,
running, run state documents and pending actions; the attempt holds the implementation (run,finish) and the running invocations (its drain, its seal). A run's state changes and its outcome are fenced by the run's own invocation;finishruns on the current live attempt (a finishing run waits for one); a run of a job the new code no longer declares staysblocked; a run waiting for a live attempt keeps its object alive only while an attempt is live or activating.Job.sealfires signals only; waiters reject at the object's end. - Derives and watches. Derives detach with their attempt and the next attempt's derives attach to the same slots.
Routine watches end by their attempt's ctx cancellation, never by a slot drop. Local references re-bind every state
member by name when the object's new attempt goes live, as a remote reference re-holds and gets a fresh snapshot:
their member watches go on across attempts (documents and derives alike; one frame from the last value), and a
consumer's derive over a member recomputes from the new attempt's member. While the object is failed or activating
again, the members keep their last values and get no frames; the builder states this in one rule in
members.ts. Keys a reference holds on a keyed member (at(key),watch(key)) follow the object's attempts the same way (fix round 3). A failed derive heals on every later change of its sources and on a re-bind; failures reach watches asfailedcalls (decision "Failures are watch calls", fix round 4).
1.3 The seal fence (decision "The seal fence")
- Tokens. Every attempt has a seal token; every job run invocation its own (for
ctx.state.changeand its outcome). - At use: after the seal, new work through the attempt's handles is refused with
released:self.tx,ctx.get(it checks the call's abort signal first, before anything is created), a job start, a routine start. The attempt's references stay usable for its own natives' closes, then are released. - At commit: a commit is checked when it enters the store's commit step (sealed: rejected
released, nothing written, not a storage failure) and at adopt (admitted while current, sealed during the SQL: durable, adopted into the object's slots, its staged job changes adopted into the object, their invocations deferred to the next live attempt; the handler'sself.txresolves withfn's result, Durable's rule). In-memory commits are checked inside the batch. The store gets the check through its interface:store.commit(writes, { admit, adopt }, ctx)(section 15). - The line: at the seal, a transaction of the attempt that has not entered the step yet is aborted and the line released; a step already started finishes before the attempt's teardown completes.
- Cleanup still runs after the seal:
ctx.getreferences of stuck calls released at teardown, presence entries,host.activitycounts. The existing start guards stay (ChordObject.#run's attempt identity,Job.#invoke, actx.getresolving after its call settled). - Not a replacement: a pure swap (slice 7) keeps the attempt. Natives' effects outside Chord are the plugin's;
their
closeis honoured.
1.4 A live object can fail (decision "A live object can fail")
A runtime failure of the owner (a migrate failing on access, a stored version newer than the code) seals the live
attempt and makes the object failed with a broken cause: calls reject at once with the cause (the triggering call
too), state members keep their last values (same-host reads return them) and get no frames after the commits already
admitted to the step (those adopt and publish once), natives close, slots stay; onError gets object_failed { object, cause }
once; recovery by reload or ref.retry() (a new attempt; only the failed load is redone). Live dependents stay live;
their calls into it fail.
1.5 Smaller changes
ctx.getchecks the call's abort signal first (an ended handler creates nothing).- Names (decision "Restricted names instead of assigned table names"): object type names and host kind names use
[a-z0-9@/._~-]+, lowercase, checked at declaration (contracts/declare.ts,host-kind.ts);hostKind("client")is refused. - Errors: a new
HostErrorkindsstorage_failed { error }andobject_failed { object, cause }; a new reasonunavailable/storage_failedin the protocol's reason list (the test pinning the list changes;PROTOCOL_VERSIONstays 1 until p3 ships). - Keyed handles take a context:
get(key, ctx),keys(ctx)(code point order, in memory too),watch(key, ctx)(decision "Slice 6a's three code questions"). retiredon the wire and in sources (decision "One retirement mechanism"):Source.advancetakesretired;Subscriber.nextgets it;Singleton.removesends{ value: initial(), ops: [["r", initial()]], retired: true }; astateentry lists retired members (retired: [index]), kept by the hold while its window is full (ORed until a message carries it, as#failed), validated as member indexes; a derive over a retired source carries the flag on; aboundsnapshot carries no flag.
2. The store
- Opening:
openStore(backend, ctx, { outbox? })withsqliteBackend(db)over anySqliteDatabase(Node throughchord/sqlite/node, Durable Objects throughchord/sqlite/cloudflare, sqlite-wasm later). There is no hand-written memory backend: tests use SQLite:memory:throughchord/sqlite/node. The backend interface is internal; the portable core imports no Node module. NoopenNodeStoreshortcut. openStoreruns the store's own schema migrations (applySqliteMigrations), checks that the file uses the UTF-8 encoding, and reads the metadata once:lastSeq, the outbox setting, the table definitions (version, state, columns withjson(...)versions and fingerprints, index definitions) and the document id counter (S10); in memory from then on (one writer per store), persisted with every commit that changes them (S9). In-memory metadata changes only afterCOMMITreturned.- Lifetime: the app owns the store; the host never closes it (6b's Durable Session shares it).
store.close()(no context, as Durable'sStorage.close()) waits for a running step, rejects queued commits, running exports and reads withreleased, closes the backend; idempotent, also afterstorage_failed. A host without a store fails an object declaring persisted data at activation (broken, "persist needs a store"). storage_failedreaches the host through an internal listener the store calls once; the host reports{ kind: "storage_failed", error }once and closes itself.- The outbox (decisions "The outbox is optional, internal and versioned", small items 3-4): opened with
{ outbox: true }, off with{ outbox: false }, an omitted option keeps the persisted setting (new stores: off). Turning it on records "outbox from seq N+1"; turning it off drops its commits. A tableoutbox(seq, format, writes)in the backend, written in the step's transaction;store.outboxisundefinedwithout an outbox (typed). The store persists a floor (N when turned on at N, advanced bytrim):store.outbox.read(afterSeq, limit, ctx)returns{ seq, writes }[]in the currentformat(older ones stepped forward, a newer one refused with the data intact) forfloor <= afterSeq <= lastSeqand rejects any otherafterSeq(invalid/args);store.outbox.trim(throughSeq, ctx)is idempotent, rejects beyondlastSeq, takes no seq and runs outside the commit queue (serialized by the backend). No pluggable encoding.
3. The commit step
- One commit queue per store serializes the step only; each object keeps its own line for the whole transaction.
- A step: the seal-fence check;
BEGIN; the writes (rows, revisions, the outbox commit when there is an outbox, the newlastSeq);COMMIT; then the owner'sadopt(seq, ids)in onehost.batch.commit(...)(W1), so every source the commit touched is marked in the step and the flush right after sends one message per link. The next step starts only afteradoptreturned (S1). Document ids are assigned in the step (monotonic with seq) and passed toadopt. - Interface (internal, Durable's shape):
store.commit(writes, adopt, ctx): Promise<number>. - seq: one counter per store, strictly increasing; an empty commit takes none.
- Errors (decision "Store errors: Durable's rule"): any backend error in a step is
storage_failed: the store stops, every later commit and read rejectsunavailable/storage_failed, the host closes. Plugin mistakes are refused when a write is staged (invalid/args) and never reach the step. A seal-fenced commit is rejectedreleasedbeforeBEGIN. A backend error on a read isstorage_failedunless the read'sctxwas aborted (aborted); invalid arguments (a foreign cursor, a bad key) are checked before the backend (invalid/args). Anadoptthat throws isstorage_failed. Accepted: integer overflow past 2^63 in a bulkaddis fatal. - Counts through
chord/sqlite'srunCounted(pi8025c414d), only where a count matters: bulk writes, the engine's conditionalUPDATE, singleupdate/delete. - Statements per commit: a delta commit of one document is about three statements (revision, the record's
UPDATE … WHERE id = ? AND version = ?counted,lastSeq), plus oneINSERTwith an outbox, plus oneDELETEon a checkpoint (pruning). - In-memory commits never enter the queue and take no seq: a transaction with only in-memory writes commits synchronously in a batch of its own (no extra await, no promise; a flag set at the first persisted write, no scan).
4. Transactions (decision "A Chord transaction enters the commit step as Durable's does")
take the object's line
-> run fn (only await tx.*)
-> abort or error: discard everything, reject
-> prepare every draft; checkpointWhen picks base or delta per persisted document
-> nothing persisted written: batch.commit(adopt) synchronously (W12)
-> otherwise: await store.commit(writes, adopt, ctx without its abort signal)
queue; seal check; BEGIN; revisions, rows, outbox, lastSeq; COMMIT
adopt(seq, ids) = host.batch.commit(() => { persisted slots; in-memory slots; staged changes })
-> release the line; settle the staged changes; drafts revoked (R12)
- The line is held until the step returned; once admitted, a commit runs to its end (the caller's abort no longer counts); the in-memory part is adopted inside the same step (P2).
- Cold keyed loads (decision "Cold keyed loads on the line"): a cold
get,watchor pin of a keyed document is a job on the object's line, as Durable'ssnapshot; cached reads take no line;tx.draftloads inside its transaction; one load in flight per key, shared, each caller'sctxending only its own wait; a load installs its tracker only while the key is still absent.tx.retire(handle, key)of an unloaded keyed persisted document finds it inside the transaction (one read; absent: a no-op, R13). - A migrated base is written back by the next commit that changes the document; until then a load migrates again (once per object).
5. Tables
defineTable(name, { version, columns, key, indexes? })(later versions throughevolveTable, section 9).columnsis a flat record of L1's helpers as a restricted subset:str(),int(),bool(),num(),opt(...)of those, andjson(schema, { version, migrate? }); defaults asint({ default: 0 }); no nesting outsidejson(...)(types and declaration). Keys, index columns andorderBycolumns are non-optstrorintcolumns. Every index ends with the primary key.self.table(Def)only in the factory of alocaltype with empty params (P3; type error and declaration error), once per table name per object.- Names (decision "Restricted names"): table names
[a-z0-9@/._~-]+, lowercase; the SQL table name is"<host kind>:<object type>:<table>"(a host without an address usesclient);:cannot occur in the parts, so the rendering is injective. Index names[a-z0-9_]+, rendered<table>:<index>. Column names[A-Za-z_][A-Za-z0-9_]*, no two equal ignoring ASCII case, no$. - Layout (S16): a real SQLite table per logical table (
STRICT,WITHOUT ROWID, the declared key as primary key), one column per declared column; ajson(...)column isTEXTplus a hidden integer<column>$v; a hidden integer$vper row (eager steps applied). The store renders the DDL and records each definition as atablewrite (columns with types,opt, defaults,json(...)versions and fingerprints, key, version, state). - Text ("Text is plain UTF-8, in code point order"):
str()is plain UTF-8TEXTcompared by code point; written values and every operand go throughtoWellFormed()(checkisWellFormed()first; copy only when needed); NUL failsinvalid/args;compareText(a, b)exported; a prefix's upper bound is the next code point after its last, carrying past U+10FFFF (an all-U+10FFFF prefix has no upper bound; the empty prefix matches every value);substroffsets count code points. - Values at write:
inta safe integer,numfinite,boolstored 0/1 and read back asboolean,stras above; a row has every non-optcolumn and no other field; one compiled closed check per table (a plugin's row is a trust boundary);json(...)values are not walked (R9). - Nullable columns (decision "Nullable columns and the
setform"): anopt(...)column is an optional field, absent whenNULL, never an explicitnull; SQL'sNULLrules apply to every comparison.
6. Queries and reads
query(Table)is immutable data:.where((t) => …)witheq ne lt lte gt gte inArray isNull prefix and or notover scalar columns,.orderBy((t) => t.col, "asc" | "desc")(several, one direction per query in 6a); no joins, grouping or projections (S12). The default order is the key ascending; the key is appended to everyorderByas the tie-break.- SQL is generated from the query data; values always bound, identifiers quoted;
limitand cursor values bound,inArrayone JSON parameter throughjson_each(no 32,766-parameter limit;inArray([])matches nothing). Query shapes are unbounded (anyand/ortree), so the facade's prepared-statement cache is bounded (pi change, a fixed LRU inchord/sqlite/node, section 15). - Outside a transaction the handle reads committed state:
page(q, limit, ctx, { after? })→{ items, next? },rows(q, ctx)(all pages),get(key, ctx),count(q, ctx). A key is an object with exactly the key columns ({ id },{ path, entry }); other or missing fields failinvalid/args. A query for another table is refused. - Cursors are opaque strings bound to table, order columns and direction; decoding one is a trust boundary
(
invalid/args). - Inside
self.tx: the table handle first (another object's is refused); reads async:tx.page(table, q, limit, { after? })andtx.rows(table, q)read committed rows, stable for the transaction;tx.get(table, key)overlays the transaction's ownput/update/delete(one evaluator forwhereandset, also used for the overlay); after anupdateWhere/deleteWhereon a table,tx.geton it throwsinvalid/read_after_bulk(W10).
7. Writes
- Five writes (S17), synchronous and buffered like a draft's ops (checked and staged, no I/O, no line; a bad row
throws at its own call; the buffer is written in the step or dropped):
tx.put(table, row)(an upsert: the whole row replaced),tx.update(table, key, set),tx.delete(table, key),tx.updateWhere(table, where, set),tx.deleteWhere(table, where). No write shorthands on the handle. Anupdate/deleteof an absent key changes nothing. setis a plain object of values or a callback with typed column references:{ note: "fix" },(e) => ({ hits: add(e.hits, 1), path: replacePrefix(e.path, from, to) });undefinedclears anoptcolumn. Expressions over real columns only:add sub max min concat replacePrefix coalesce when(when(cond, a, b)withcondawhereexpression over the row); integer arithmetic only; every right side is evaluated against the row before the write ({ a: e.b, b: e.a }swaps);replacePrefixon a value without the prefix leaves it unchanged.- Writes in the outbox commit carry what a replica needs, explicitly (decision "Writes carry their versions"):
putwith the row,vand eachjson(...)column's version;updatewith itssetandcount;deletewith the key andcount;updateWhere/deleteWherewithwhere,setandcount(a replica affecting another count stops applying, S17). $vis written only by eager migration steps (an internal write form) and byput(the table's version);update,updateWhere,deleteWherenever touch it; a write of ajson(...)column stores its current version (W11).
8. The documents engine
- One engine (S13). Address
(namespace, owner, kind, key); Chord's: namespacechord, owner<host kind>:<object type>(W7;clientwithout an address), kind the definition's name, key the keyed document's key or""for a singleton (so the partial unique index on the live address also refuses two live singletons). Owner, kind, key andmetastrings follow thestr()rules. - A record per incarnation (
id, address,history: "latest" | "rewindable",createdAt(a seq),retiredAt?,meta,revision); revisions: a base (version, fingerprint, value) and deltas (ops). Chord useshistory: "latest"(a new base and a retirement prune older revisions). - Reads:
find(address),load(id)(base plus deltas applied once: value, version, fingerprint,deltasSinceBase),list(query, limit, cursor)by key. Reads at an older seq (at) andhistory: "rewindable"are 6b. - Writes inside the step:
document.create,document.change,document.retire, checked by the writes (one live per address by a partial unique index; a delta only on its record's current version through the counted conditionalUPDATE; nothing after retirement); a failed check isstorage_failed(memory and disk disagree).
9. Migrations
-
json(schema, { version, migrate? })columns ("Thejson(...)column helper"): a read of an older value runsmigrateand checks the result once (R8); a read never writes back; withoutmigrate, a throwing one or an invalid result the read is refused and the owner fails (section 1.4); a newer stored version is refused the same way; at activation, the same version with another fingerprint fails the objectbroken(D9). -
Eager migrations:
evolveTable(decision "Second spec review: the majority"; all four reviewers accept it; the derived adds and drops are withdrawn: a store at v1 cannot reconstruct v2's columns from v3's declaration):const EditsV1 = defineTable("edits", { version: 1, columns: { id: str(), file: str(), at: int() }, key: ["id"] }); const EditsV2 = evolveTable(EditsV1, (t) => t.rename("file", "path").add("dir", str({ default: "" })).rows((row) => ({ dir: dirname(row.path) }))); const EditsV3 = evolveTable(EditsV2, (t) => t.drop("dir").index("by_path", ["path", "at"])); host.serve(FileHistoryStore, (_, self) => { const edits = self.table(EditsV3); … });- History.
defineTable(name, { version, columns, key, indexes? })is a base written in full (nomigrationsoption);evolveTable(prev, (t) => t…)appendsprev.version + 1(contracts'Increment<N>) and its edit returns the editor it was given (contracts' rule, checked). Every version is materialized and fingerprinted at declaration (TABLE_FORM_VERSION, its own form version: table fingerprints are persisted, not sent). The object serves the newest. A new base (defineTable(name, { version: 7, … })) starts a new history; stores below it failbroken. - Edits:
rename(from, to)(key, index and hidden<column>$vreferences follow),add(name, column)(opt(...)or with adefault),drop(name)(its indexes are dropped first in the same commit; a key column cannot be dropped),index(name, columns),dropIndex(name),json(name, json(schema, { version, migrate? }))(ajson(...)column's version change: no DDL, no data step, cells still migrate on access; every change of a table definition is a table version), and at most one data step:rows(fn)orupdate({ where?, set }). No key or column-type changes (rebuild, carry-over). The editor's types enforce the order:rename/add/jsonbefore the data step,drop/index/dropIndexafter it; the data step's row type is the columns at that point of the chain, so a step may read a column its version drops and fill one its version adds. - Commits per version N: (A) the edits before the data step as DDL plus
table { version: N, state: "migrating" }, one commit; the data step's chunks; (Z) the last chunk (or theupdate) with the edits after the step, their index changes andstate: "ready", one commit. No data step: (A) and (Z) are one commit. Recorded writes:alter { table, version, edit }per column edit (rename | add | drop | json),index/dropIndex,table { version, state, columns, key, fingerprint }; the applier renders DDL from them. Resume atversion N, migrating: (A) is done; the step continues; then (Z); N+1 starts only after N isready. - Data steps:
rowsis synchronous and pure; it gets that version's typed row (json(...)cells migrated on access to that version's column versions) and returns a patch of non-key columns ({}stamps$vonly; a key or unknown column failsbroken); chunks of 100 in key order, paging forward by the last processed key (a crash restarts the scan, skipping rows with$v >= N); the patch plus$v = Nwritten through an internal write form.updateis oneupdateWherewithand(where, lt($v, N))stamping$v = N,readyin the same commit, so a non-idempotentsetnever applies twice. A throwingrowsfails the owning objectbroken, the table staysmigrating, committed chunks stay. Data functions are immutable once deployed (the guide). - Activation: a recorded version newer than the code's newest, older than the code's base, or the same version
with another fingerprint (a history edited in place) fails
broken;self.tableof an older version than the recorded one failsbroken. A fresh table is created directly at the newest version (ready,$v= newest), with its indexes, no replay. - Shared with contracts:
hash64, L1'ssnapshotSchemanormal form per column,Increment<N>and the "returns the editor it was given" check move into one shared module; the table history is its own concept (store/table-history.ts,store/table-evolve.ts), not contracts'Draft,EditororcheckAccepts.
- History.
-
Where and when: at the owning object's activation, after its persisted documents loaded and before its references are bound, on the store that owns the data (a replica never migrates). The attempt's
readyTimeoutdeadline is paused while eager migration work runs and resumed with its remaining budget (one pause scope over the table checks, (A) and (Z) commits, data chunks and their waits in the commit queue; resumed in afinally); the attempt's abort is checked between chunks (a chunk admitted to the step completes); the migration yields to the event loop between chunks; the start barrier waits for host-lifetime objects' migrations. Getters and parked calls keep their own deadlines. -
Old code meeting a newer table version or a newer
json(...)version fails the objectbroken, data intact.
10. Persisted documents
defineState(name, { …, persist: true, checkpointWhen }):checkpointWhen(value, ops, info)required for persisted documents (a type-level constraint), called once per commit per changed document; creation and version transitions always store a base. The fingerprint is L1's structural fingerprint of the schema, computed once.- Only in
localtypes with empty params (P3). - Loading at activation: once per object, right after the first factory, every persisted singleton and every
pinned
at(key)with plain reads (the object holds its line; no read transaction): find, load, the fingerprint check,migratewith its result checked once (R8); an absent document readsinitial()(Q11). A storage error isstorage_failed; a fingerprint mismatch, a throwingmigrateor an invalid result fail the objectbroken. - Keyed persisted documents load on demand (section 4):
get(key, ctx)(undefinedwhen absent),keys(ctx)(pages from the engine in code point order),watch(key, ctx)(undefinedwhen absent),tx.draft(handle, key). - Pins and eviction: each loaded key counts its pins: an
at(key)declared by the current attempt's factory, a keyed member'sat(key)of a consumer while it lives, each watch, each load in flight. A key with no pins not read or written for 10 minutes leaves memory (one owner-level timer and idle structure, no timer per key; eviction runs on the object's line). Persisted singletons stay in memory while the object lives (handle.valueis a synchronous read).
11. Keyed watches, at(key) and retirement
- Every document, in memory or persisted, is watchable; a watch follows the address:
- a singleton's retirement is one call
{ value: initial(), ops: [["r", initial()]], retired: true }; it readsinitial()meanwhile; a later write is an ordinary change; await handle.watch(key, ctx)isundefinedwhen absent; a retirement is the same call and the watch goes on; a later write delivers the new incarnation as[["r", value]](a retire and recreate in one transaction: one call withretired: trueand the new value);- watches end
endedwith their object.
- a singleton's retirement is one call
handle.at(key)in the factory: a view of the keyed handle's slot for that key (the same tracker, the same draft in a transaction: never a second document), pinned while the declaring attempt lives,initial()while absent; a state member, a derive source, anonsource.
12. Keyed state members (decision "Keyed state members, local contracts only")
keyed(schema)is a third member kind (MemberKind"keyed"), part of the fingerprint (normal form updated), only inlocalcontracts (remote()refuses it at the type level and at declaration).- Provider: returns a keyed document handle for it (in memory or persisted), or re-exposes another reference's keyed member; checked at activation as state members are.
- Consumer on the same host:
ref.member.at(key)only in its factory (aWatchable<T>: a pin on the provider's per-key source while the consumer's attempt lives; loaded before the consumer goes live;initial()while absent; theretiredflag on retirement);ref.member.get(key, ctx)andref.member.watch(key, ctx)anywhere (watchon an absent key givesundefined). - Hydration:
host.getof a consumer waits for its declaredat(key)sources like any state member's value; keys read dynamically are not part of hydration. - A key whose
migratefails fails its owning objectbroken(section 1.4), never the store. Versions of keyed members map per key in slice 7. - Nothing on the wire changes: across links a view per entity is held as an ordinary remote object.
13. Export and import (decisions "Export archives are operations", "Writes carry their versions; archives are lossless", small items 6-7)
-
store.export(ctx)yieldsAsyncIterable<ArchivePart>: the state after itslastSeqas writes, lossless: a manifest{ kind: "manifest", format: 1, lastSeq, schema }; per table itstablewrite (withstate), itsputs (stored values and versions) and itsindexwrites; thedocument.creates (stored version and fingerprint; forlatestthe current value as the base); anendwith the counts. Retiredlatestrecords, blobs, the outbox and sync state are not in it; tables of plugins that are not loaded travel along.{"kind":"manifest","format":1,"lastSeq":4812,"schema":1} {"kind":"table","table":"session:file-history.store:edits","version":4,"state":"ready","columns":{…},"key":["id"]} {"kind":"put","table":"session:file-history.store:edits","row":{…},"v":4,"columns":{"meta":1}} {"kind":"index","table":"session:file-history.store:edits","name":"by_path","columns":["path","at"]} {"kind":"document.create","record":{…},"base":{…}} {"kind":"end","writes":10061} -
It takes the commit queue at its first
next()and holds it until the iteration ends, throws, is closed (return()) orctxaborts (an abort listener releases it even while the consumer is suspended; a laternext()rejectsaborted); it reads page by page with plain reads (no long read transaction). The guide: export to a local file, then upload. -
importStore(backend, parts, ctx, { outbox? })is a trust boundary: every part checked against one compiledArchivePartschema, every name against the name rules, every row against its table's recorded columns, the order above and theendcounts; it refuses a non-empty backend (one with the store's tables) and parts afterend; applies everything in one backend transaction with the replica's applier (one code path for import, bootstrap and batch apply) and returns the store at the manifest'slastSeq, its outbox (if asked) starting after it. -
The encoding is pluggable: the store yields and takes parts; it ships plain JSONL (
encodeJsonl(parts): AsyncIterable<Uint8Array>,decodeJsonl(bytes: AsyncIterable<Uint8Array>)).
14. Benchmarks and tests
- Benchmarks (
test/bench/store.ts, results inbenchmarks.md), the gate (decision "The commit-step benchmark"): the store without outbox no slower than Durable'sSqliteStorageper commit on (a) one document delta, (b) one tableput(againstSqliteStorage's entry append), (c) 1,000 commits back to back, with and without a watched document; bursts (median and p95 over several runs), memory after GC, the same pragmas and file system. The outbox's extra cost reported separately; also query pages, a 10k-rowupdateWhere(its step time: bulk writes block every owner's commits), text per row, a migration chunk. A failed gate goes to the owner before the build continues. - Cost guards (in-memory telemetry adapter): validations (
migrateresults), materializations (document loads), store commits and statements per commit; none on the in-memory-only path; a cached keyedgettouches no SQLite. - Tests, one
describeper mechanism: facets that only declare; slots, handles and job runs on the object across retries, failures and native restarts (R15 through old handles); the seal fence (sealed handles, both commit checks, a queued commit of a sealed attempt rejected, an admitted one adopted into the object, the line released, cleanup); a live object failing; the commit step (order, adoption inside the step, errors,storage_failedonce, reads after the stop); transactions (the line across the step, no abort once admitted, the in-memory part in the same step, in-memory-only commits without a step); cold keyed loads on the line (a coldgetracing a draft of the same key, a cancelled waiter); the outbox (on, off, omitted, switching,readgaps,trim,format); tables (declaration and name checks, DDL, P3 errors, values, text, nullable columns); queries (every operator,NULLrules, order and tie-breaks, cursors, keys as objects, reads inside and outside transactions, W10, bounded SQL texts); writes (all five,setforms, the swap, upserts, counts, their outbox writes); migrations (json(...)on access, fingerprints, eager steps, chunks, resume, indexes, the paused deadline with a fake clock, a timer firing during a long migration); the documents engine (rules, ids, pruning, the singleton address); persisted documents (load once per object, fingerprints,migrate, keyedget/keys/eviction and pins, checkpoints, the migrated base written back); retirement (singleton, keyed,at(key),self.on, derives carrying the flag, the wire with a full window); keyed state members (pins, hydration, retirement, two consumers of one key); export and import (round trips with old versions, mid-migration and uncheckpointed deltas, every refusal, the queue released on abort while suspended); GC (evicted documents, unpinned keys, ended objects, stopped watches, export iterators closed early, abort listeners). Examples:20-tables.ts,21-persisted-documents.ts,22-migrations.ts,23-keyed-watches.ts,24-keyed-members.ts,25-export-import.ts.
15. Rules from the second spec review (decision "Second spec review: the majority")
The owner delegated the second review's findings to the reviewers' majority (/tmp/s6a-review2-{astra,fable,opus,sol}.md;
a point raised by one reviewer only takes that reviewer's recommendation). These rules complete the sections above;
where a section above says otherwise, this section wins.
Objects, attempts and the fence
- Slot versions: a slot keeps
valueVersion(the version of its value in memory, the handle fence, R15) andstoredVersion(the version on disk, for write preparation), with their fingerprints, as Durable'sLoadedDocument. An old handle whose version is below the slot's fails that access, not the owner; a stored version newer than the current definition fails the owner (1.4). 6a builds the version comparison and the in-memorymigrate(tested through a retry whose factory declares a newer definition); slice 7 wires it to reload.initial()is retaken when the declaring definition's version or fingerprint changes, not on every attempt. - The store's admission check:
store.commit(writes, { admit, adopt }, ctx)(internal):admit()is called at the step's entry (false: the store rejectsreleasedand drops the entry, never writes it);adopt(seq, ids)afterCOMMIT,idsmapping the commit'sdocument.createwrites by index to their ids. 6b'sStoreStoragepassesadmit: () => true. - A transaction at the seal is discarded: drafts aborted, every later
tx.*throwsreleased, a callback that returns afterwards is rejectedreleasedwithout a commit, and the line is released. As Durable'sTransaction, it tracks its pending asynctx.*operations and refuses a callback that settles with one still pending. - Sealed handles may still read synchronously (
.value, cached keyed reads); anything taking the line (change,retire, a coldget/watch/keys) is refusedreleased. A live failure of an already sealed attempt is ignored. - Jobs at the seal: staged changes of an admitted commit are adopted into the object (section 1.3). A run cut off
by its attempt's seal ends
failed { aborted, reason: "restart" }(design §15's reason, now also "the attempt ended while the object continues"); its outcome transaction runs on the object's line fenced by the run's own token (a transaction path owned by the object, not an attempt). Afinishsealed mid-way is refused at the step and retried on the next live attempt, not counted as one of the five failures. A run's state-change admission closes whenrunreturns; changes already admitted drain, then the outcome is recorded. - Pins between attempts: the object holds the factory's
at(key)pins until the next factory returns (the moment unused in-memory slots are dropped). Each attempt loads its declared singletons and pins not in memory (line jobs) before it goes live. A consumer whose pin load fails the provider fails as a dependency failure (transient, R18). - One host per store:
createHostrefuses a store attached to an open host (invalid/args).
The commit step and errors
8. Serialization before BEGIN: every write is serialized when it is staged (table writes) or prepared (documents,
before the queue); a value JSON.stringify cannot encode (a cycle, a BigInt) fails there (invalid/args or the
transaction), never in the step.
9. Order inside one commit: retirements before creations (the partial unique index on the live address), then
changes, rows, the outbox commit, lastSeq.
10. Integers: an int column has a SQL CHECK keeping it within ±(2^53 - 1); an expression result outside it
(add past the safe range) fails the step, which is storage_failed (accepted, as the 2^63 case was); the JS
evaluator (the tx.get overlay) checks the same range and refuses out-of-range results. add/sub take int
columns only; max/min take int or num.
11. Reads after a failure: as Durable's GuardedStorage, a read that settles after the store stopped rejects
unavailable/storage_failed, never returns a value. A shared cold load runs under the owner's context, not its
first waiter's.
12. Open and import failures: openStore and importStore close the backend they were given when they reject;
a refused import rolls back every table it created. An import whose input iterator stalls is cancelled by ctx
without holding the backend transaction past it.
Tables, queries, writes
13. Key columns are immutable in update, updateWhere and migration patches (types and staging, invalid/args);
moving a row is delete plus put.
14. Canonical writes: both set forms compile at staging into one data-only expression vocabulary, with an
explicit null literal for a clear ({ note: undefined } would serialize to {}); the outbox and archives carry
that form, never callbacks or undefined. An empty set is invalid/args.
15. Defaults: a column with a default may be omitted in a put (SQL DEFAULT); reads always return it.
16. Operands: comparing an opt column with undefined is invalid/args (ask with isNull).
17. Pages: limit is a positive safe integer; rows() pages internally by 100; next comes from fetching
limit + 1; a cursor is a position in the order (one from another where with the same order is accepted);
duplicate orderBy columns are refused and the key tie-break is not repeated.
18. The prepared-statement cache of chord/sqlite/node is a fixed LRU (pi change, upstream first, before the
build): arbitrary query shapes cannot grow it without bound.
Documents and keyed members
19. Retiring a latest record deletes its row and revisions (Chord never reads history; a key retired and
recreated often leaves no dead rows); full records keep them (6b). The record's counter is revision (not
"version") in the conditional UPDATE.
20. Absent keys: a keyed get of an absent key is not cached (a line job plus a read each time; accepted, stated in
the guide); watch(key) on an absent key returns undefined, and a later creation is seen through at(key) or a
new watch.
21. Retired derives: a derive over a retired source publishes retired: true even when its value is unchanged and
its ops are empty (Derive.deliver's identity and empty-diff shortcuts must not swallow the flag).
22. Keyed members: a re-exposed keyed member resolves to the ultimate provider's source (no second tracker); pins
are released exactly once (failed consumer activation, retry, release, re-exposure); ref.member.get/watch while
the provider is loading or failed behave as calls (park while loading, reject with the cause when failed); keyed
members have no keys (a method lists keys); keyed(...) adds a node to the fingerprint without bumping
NORMAL_FORM_VERSION.
23. Eviction's idle structure is Durable's (#touchDocument's granule rule: one move per tenth of the retention,
O(1) per touch); one owner-level timer.
Export and import
24. The manifest carries nextDocumentId (the id high-water mark), restored atomically and checked against the
imported records; import keeps ids and createdAt seqs.
25. Import validation also covers record ids and seq ranges, one live record per address, versions, duplicate
definitions and agreement with the manifest; json(...) cells are checked as JSON text only (no plugin code runs).
26. Export cancellation releases the queue and its page buffers and metadata references (GC-tested with a retained,
aborted iterator). Code that commits while consuming its own export waits until ctx aborts (the guide; a test).
Benchmarks and cost 27. A fourth gate case, (d) a commit with one in-memory document only (the store adds nothing to the W12 path); queue wait reported separately from step time; late migration chunks measured as well as the first.
Carry-over (to slices.md): slice 7: remote holds and remote objects leaving live (Hold.moved,
RemoteObject.failed; not reachable in 6a, P3), old table handles' version fence, the routine handover fenced per
routine invocation, holds re-bound by member name, keyed members' versions per key, rebuild.
Races
- Two objects commit at once: their steps queue; each adopts inside its own step, in seq order.
- A transaction discarded after its callback while another owner's step runs: it never entered the queue.
- The store fails during a step: that commit rejects; later commits and reads reject
unavailable/storage_failed; the host closes. - An object's attempt is sealed while its commit waits in the queue: rejected at the step's entry; one already in the step lands and is adopted into the object's slots; the next attempt drafts from them.
- A cold
getand a transaction's draft of the same key: both on the line; the load installs only while absent. - A keyed document evicted while a
getfor it is in flight: eviction runs on the line and skips pinned keys. - A migration crashes in the middle: the next activation finds
version N, migratingand continues. - An export holds the queue while commits and migration chunks wait; the export's
ctxaborts while its consumer is suspended: the queue is released. - A retire and a recreate of one key in one transaction: one call with
retired: trueand the new value. - Old code (after a rollback) meets a newer table, column or document version: the object fails
broken, data intact; a newer outboxformat:outbox.readrejects, data intact. - The outbox is switched on for a store with data: the metadata says from which seq it starts.
Cost
| Work | Cost |
|---|---|
| commit of one document delta | one step: about three statements, plus one with an outbox, plus one on a checkpoint |
| commit of in-memory documents only | as today: no step, no seq, no await |
| table put | two statements in the step (the row, lastSeq), plus its write in the outbox commit |
updateWhere over N rows |
one statement, one write; holds the queue for its duration |
| read of a page | one query |
keyed get (cached) |
no SQLite; one touch of the idle structure (Durable's granule rule); (cold) a line job and one or two reads |
| activation of an object with persisted singletons | plain reads, once per object |
a json(...) column read at the current version |
one JSON.parse; an older version adds migrate and one check |
rows migration |
about 15 µs per row, one commit per 100 rows |
| a keyed commit with no pins on the key | one Map.get |
Carry-over taken
From slices.md "Slice 6a": facets only declare (1.1), documents, line and job runs on the object (1.2), keyed state
members (12), W12 and W7 (sections 3, 5, 8), retirement in Chord (1.5, 11).
Carry-over left
- Slice 6b: persisted jobs,
StoreStorage(Durable's record tables in the store: declared tables or internal), reads at an older seq,history: "rewindable"export,durableView. - Slice 7: in-memory
migrateon reload; the routine handover fenced per routine invocation; holds unsubscribed at seal and re-bound by member name; keyed members' versions per key; a native restart drains first (decided);rebuild(key or column-type changes) if a case needs it. - The sync layer: upload, acknowledgement and
outbox.trim, epochs, pull-back, the split, snapshot reads for large exports. - Keyed members across links (Fable's design or selector holds) when a real case needs them.
Size
About 3,000-3,500 source lines (the store core about 1,650, persisted documents about 400, tables and migrations about 600, retirement and keyed watches about 200, keyed members about 300-350, the object/attempt/fence rework about 300), minus the facet code removed, plus the tests.