pi-mono/packages/chord/src/documents/persistence.ts
Mario Zechner e9c0ff4d84 feat(chord): Chord v2 is the package's root
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.
2026-11-02 22:36:55 +00:00

194 lines
8.7 KiB
TypeScript

// Persisted documents' store side (slice 6a §8, §10): a slot's address in the documents engine, what is stored of each
// loaded document (Durable's `LoadedDocument` fields), its loads with the version and fingerprint checks and the
// checked `migrate`, and the writes one commit stages for a changed or retired document.
import type { Context } from "../context/index.ts";
import type { Op, Prepared } from "../delta/index.ts";
import {
changeDocument,
createDocument,
type DocumentAddress,
type DocumentContent,
findDocument,
listDocumentKeys,
loadDocument,
retireDocument,
type StoredDocument,
} from "../store/engine.ts";
import type { StagedWrite } from "../store/step.ts";
import type { ChordStore } from "../store/store.ts";
import type { JsonObject } from "./definition.ts";
import type { AnyDefinition } from "./slot.ts";
/** What is stored of a loaded document: its record's id and revision, its base's version, the deltas after it. */
export type Stored = {
readonly id: number;
readonly revision: number;
/** The version of the stored base: below the definition's, the next change stores a base (rule 1). */
readonly version: number;
readonly deltasSinceBase: number;
};
/** A live document found without loading it: what is stored of it and the fingerprint it was stored with. */
export type FoundDocument = { readonly stored: Stored; readonly fingerprint: string };
/** A loaded document: its value at the definition's version, and what is stored of it. */
export type Loaded = { readonly value: JsonObject; readonly stored: Stored };
/** One transaction's change of one document: its prepared draft (if changed), and whether it retires it. */
export type DocumentChange = { readonly prepared: Prepared<JsonObject> | undefined; readonly retires: boolean };
/** A run state's held ops (D8), stored before the next change's own; `base` when a held change asked for a base. */
export type Held = { readonly ops: readonly Op[]; readonly base: boolean };
/**
* What one commit stores of a document, in the store writes' words: a retirement (nothing stored after it), a
* creation whose id the step assigns (`create`: `created.id` once it applied), a change of the record `stored` was
* prepared on, or nothing yet (`held`: a deferred change of a run's state, D8, kept in memory over `stored`).
*/
export type StoredChange =
| { readonly kind: "retire" }
| { readonly kind: "create"; readonly version: number; readonly created: { id: number } }
| { readonly kind: "change"; readonly stored: Stored }
| { readonly kind: "held"; readonly stored: Stored; readonly withinMs: number };
/**
* The documents of one persisted name on one object: `(<object type>, namespace, name, key)`, the namespace `chord` for
* `self.state`, `chord.jobs` and `chord.job-states` for persisted job runs (slice 6b D5).
*/
export class Persistence {
readonly store: ChordStore;
readonly #address: Omit<DocumentAddress, "key">;
/** `owner` is the object's type (R2); `kind` the definition's or the job's name. */
constructor(store: ChordStore, owner: string, namespace: string, kind: string) {
this.store = store;
this.#address = { owner, namespace, kind };
}
/**
* The stored document at `key`, `undefined` when absent: found and loaded with plain reads (a store error rejects
* as the store's). Counted (`chord.store.loads`).
*/
async read(key: string, context: Context): Promise<StoredDocument | undefined> {
const address = this.at(key);
const document = await this.store.read(async (executor) => {
const record = await findDocument(executor, address);
return record === undefined ? undefined : loadDocument(executor, record);
}, context);
if (document !== undefined) context.telemetry.metrics?.counter("chord.store.loads").add(1);
return document;
}
/**
* `document` as `definition` reads it: a stored version newer than the code's, or the same version with another
* fingerprint (D9), throws; an older one is migrated and the result checked once (R8). What throws here is the
* code's (a failing `migrate` too, whatever it throws), never the store's.
*/
decode(document: StoredDocument, definition: AnyDefinition, context: Context): Loaded {
const { record, deltasSinceBase } = document;
checkReadable(record, definition);
// The engine stores what commits gave it: JSON objects of the record's version.
const stored = document.value as JsonObject;
const value =
record.version === definition.version ? stored : definition.migrate(stored, record.version, context);
return {
value,
stored: { id: record.id, revision: record.revision, version: record.version, deltasSinceBase },
};
}
/**
* What is stored of the live document at `key` without loading it (a retirement's find) and the schema fingerprint
* it was stored with (`checkReadable` takes both), `undefined` when absent.
*/
async find(key: string, context: Context): Promise<FoundDocument | undefined> {
const record = await this.store.read((executor) => findDocument(executor, this.at(key)), context);
if (record === undefined) return undefined;
// The change counter is all a retirement needs; `deltasSinceBase` is not read for one.
const stored = { id: record.id, revision: record.revision, version: record.version, deltasSinceBase: 0 };
return { stored, fingerprint: record.fingerprint };
}
/** Up to `limit` keys of live documents after `after`, in code point order. */
keys(after: string | undefined, limit: number, context: Context): Promise<string[]> {
return this.store.read((executor) => listDocumentKeys(executor, this.#address, after, limit), context);
}
/**
* Stages one commit's writes of the document at `key` into `writes` (rule 8: serialized here, before the queue):
* its retirement when `retired` is stored, then its creation, or the change of `stored` (a base when created, when
* the stored version is older, or when `checkpointWhen` asks; a delta otherwise), `held` ops stored before the
* change's own (D8: a held base makes it a base). Returns what the commit stores.
*/
stage(
key: string,
definition: AnyDefinition,
stored: Stored | undefined,
change: DocumentChange,
writes: StagedWrite[],
held?: Held,
): StoredChange {
const { prepared, retires } = change;
if (retires && stored !== undefined) writes.push(retireDocument(stored.id, stored.revision));
if (prepared === undefined) return { kind: "retire" };
const { version, fingerprint } = definition;
if (retires || stored === undefined) {
const base = { version, fingerprint, value: prepared.value };
const created = { id: 0 };
const write = createDocument(this.at(key), base, JSON.stringify(prepared.value), (id) => {
created.id = id;
});
writes.push(write);
return { kind: "create", version, created };
}
const info = { deltasSinceBase: stored.deltasSinceBase };
const base =
held?.base === true ||
stored.version !== version ||
definition.checkpointWhen(prepared.value, prepared.ops, info);
const ops = held === undefined ? prepared.ops : [...held.ops, ...prepared.ops];
const content: DocumentContent = base
? { kind: "base", version, fingerprint, value: prepared.value }
: { kind: "delta", version, ops };
const text = JSON.stringify(base ? prepared.value : ops);
writes.push(changeDocument(stored.id, stored.revision, content, text));
const deltasSinceBase = base ? 0 : stored.deltasSinceBase + 1;
return { kind: "change", stored: { id: stored.id, revision: stored.revision + 1, version, deltasSinceBase } };
}
/** The address of `key`: a singleton's (`""`) has no key. */
at(key: string): DocumentAddress {
return key === "" ? this.#address : { ...this.#address, key };
}
}
/**
* Throws when `definition` cannot read a document stored at `stored`'s version and fingerprint: a newer version than
* the code's, or the same version with another fingerprint (D9). A load and a retirement check alike (R15).
*/
export function checkReadable(
stored: { readonly version: number; readonly fingerprint: string },
definition: AnyDefinition,
): void {
const { name, version, fingerprint } = definition;
if (stored.version > version) {
throw new Error(`${name}: stored at version ${stored.version}, newer than the code's ${version}`);
}
if (stored.version === version && stored.fingerprint !== fingerprint) {
throw new Error(`${name}: stored with another schema at version ${version} (a schema changed without a bump)`);
}
}
/** What is stored after a commit's `change` was adopted. */
export function storedAfter(change: StoredChange): Stored | undefined {
switch (change.kind) {
case "retire":
return undefined;
case "create":
return { id: change.created.id, revision: 1, version: change.version, deltasSinceBase: 0 };
case "change":
case "held":
return change.stored;
}
}