mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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.
346 lines
14 KiB
TypeScript
346 lines
14 KiB
TypeScript
// One Durable commit as one commit of the store's series (slice 6b §2.3): staged before the queue (rows encoded,
|
|
// contents serialized, explicit ids raising the store's allocator), then inside the series' transaction what
|
|
// `SqliteStorage` does inside its own, with the step's reader: Durable's checks of ids and documents and the copies
|
|
// resolved from their sources (its messages, a refusal), then its one composite write: the table puts, and the
|
|
// documents in two passes, existing ones first (their content, then their retirement, which frees the addresses this
|
|
// commit's creations take), then the creations and copies.
|
|
|
|
import {
|
|
type EncodedRow,
|
|
type DocumentContent as EngineContent,
|
|
type DocumentRecord as EngineRecord,
|
|
findDocument,
|
|
insertDocument,
|
|
type OwnerTable,
|
|
readDocument,
|
|
retireRecord,
|
|
type SeriesCommit,
|
|
type StagedWrite,
|
|
type Step,
|
|
type StoreWrite,
|
|
writeContent,
|
|
} from "@earendil-works/chord";
|
|
import type { Context } from "@earendil-works/chord/context";
|
|
import type { JsonValue } from "@earendil-works/chord/json";
|
|
import { StorageRequestError } from "../../errors.ts";
|
|
import type {
|
|
DocumentContent,
|
|
DocumentCopySource,
|
|
DocumentCreate,
|
|
DocumentId,
|
|
Id,
|
|
JsonObject,
|
|
StorageWrite,
|
|
} from "../../types.ts";
|
|
import { addressOf, durableRecord, engineRecord, readAt } from "./documents.ts";
|
|
import { conversationRow, entryRow, submissionRow, taskRow } from "./tables.ts";
|
|
|
|
/** Durable's record types, as `record_ids` names them. */
|
|
export type RecordType = "conversation" | "entry" | "task" | "submission" | "document";
|
|
|
|
/** The storage's tables, as a commit writes them. */
|
|
export type CommitTables = {
|
|
readonly recordIds: OwnerTable;
|
|
readonly conversations: OwnerTable;
|
|
readonly entries: OwnerTable;
|
|
readonly entryHeads: OwnerTable;
|
|
readonly tasks: OwnerTable;
|
|
readonly submissions: OwnerTable;
|
|
};
|
|
|
|
/** A table write of the commit: its record's id and type, its table and its row encoded at staging. */
|
|
type RowWrite = {
|
|
readonly id: number;
|
|
readonly type: Exclude<RecordType, "document">;
|
|
readonly table: OwnerTable;
|
|
readonly row: EncodedRow;
|
|
};
|
|
|
|
/** A base as the commit stores it: its version and value, and the value serialized. */
|
|
type StagedBase = { readonly version: number; readonly value: JsonObject; readonly text: string };
|
|
|
|
/** What one commit does with one document, as `SqliteStorage`'s action map has it. */
|
|
type DocumentAction = {
|
|
create?: DocumentCreate;
|
|
copy?: DocumentCopySource;
|
|
/** A creation's base: the write's own, or a copy's once resolved. */
|
|
base?: StagedBase;
|
|
content?: { readonly content: DocumentContent; readonly text: string };
|
|
retire: boolean;
|
|
};
|
|
|
|
/** A Durable commit staged for the store's series: its checks, then itself as its one write. */
|
|
export class DurableCommit implements SeriesCommit, StagedWrite {
|
|
readonly order = "write";
|
|
readonly writes: readonly StagedWrite[];
|
|
readonly #owner: string;
|
|
readonly #tables: CommitTables;
|
|
readonly #context: Context;
|
|
/** Every id a write claims, in write order (documents created or copied included), for the id checks. */
|
|
readonly #claims: { readonly id: number; readonly type: RecordType }[] = [];
|
|
readonly #rows: RowWrite[] = [];
|
|
readonly #actions = new Map<DocumentId, DocumentAction>();
|
|
/** What `check` found for `apply`, in the same step: the stored types of the claimed ids, the changed documents. */
|
|
#checked:
|
|
| { readonly claimed: Map<number, RecordType>; readonly existing: Map<DocumentId, EngineRecord> }
|
|
| undefined;
|
|
|
|
/**
|
|
* Stages `writes` (rule 8: rows and contents encoded here, before the queue); `raise` makes room above each explicit
|
|
* id. A malformed batch (two content commands for one document, a second retirement) throws Durable's message.
|
|
*/
|
|
constructor(
|
|
writes: readonly StorageWrite[],
|
|
owner: string,
|
|
tables: CommitTables,
|
|
raise: (id: number) => void,
|
|
context: Context,
|
|
) {
|
|
this.#owner = owner;
|
|
this.#tables = tables;
|
|
this.#context = context;
|
|
for (const write of writes) this.#stage(write, raise);
|
|
this.writes = [this];
|
|
}
|
|
|
|
/**
|
|
* Durable's checks against the state the series' earlier commits left, writing nothing: the ids, the documents and
|
|
* the copies; a throw refuses the commit.
|
|
*/
|
|
async check(step: Step): Promise<void> {
|
|
const claimed = await this.#checkIds(step);
|
|
const existing = await this.#checkDocuments(step);
|
|
await this.#resolveCopies(step);
|
|
this.#checked = { claimed, existing };
|
|
}
|
|
|
|
/**
|
|
* Resolves the copies (S11): each source read at its point and checked against the copy's record, entered as a
|
|
* creation carrying the source's value as its base.
|
|
*/
|
|
async #resolveCopies(step: Step): Promise<void> {
|
|
for (const [id, action] of this.#actions) {
|
|
const source = action.copy;
|
|
if (source === undefined) continue;
|
|
if (this.#actions.has(source.id)) throw new Error(`Document copy ${id} source is changed in the copy batch`);
|
|
try {
|
|
const read = await readAt(step, source.id, source.at, this.#owner);
|
|
if (read.kind === "current_only") {
|
|
throw new StorageRequestError(`Document ${read.id} does not retain historical content`);
|
|
}
|
|
const stored = read.document;
|
|
if (stored === undefined) throw new Error(`Fork source document ${source.id} cannot be read`);
|
|
const create = action.create!;
|
|
if (
|
|
stored.record.scope.kind !== "conversation" ||
|
|
create.scope.kind !== "conversation" ||
|
|
stored.record.kind !== create.kind ||
|
|
stored.record.key !== create.key ||
|
|
stored.record.history !== create.history ||
|
|
stored.record.fork !== create.fork
|
|
) {
|
|
throw new Error(`Fork source document ${source.id} does not match the copied record`);
|
|
}
|
|
action.base = { version: stored.version, value: stored.value, text: JSON.stringify(stored.value) };
|
|
} catch (error) {
|
|
throw new Error(`Document copy ${id} was rejected`, { cause: error });
|
|
}
|
|
}
|
|
}
|
|
|
|
/** The commit's writes, after its `check` in the same step. */
|
|
async apply(step: Step): Promise<readonly StoreWrite[]> {
|
|
const recorded: StoreWrite[] = [];
|
|
const { claimed, existing } = this.#checked!;
|
|
for (const write of this.#rows) {
|
|
if (claimed.get(write.id) === undefined) recorded.push(await this.#claim(step, write.id, write.type));
|
|
claimed.set(write.id, write.type);
|
|
const stamp = write.table === this.#tables.entries ? "commit_seq" : undefined;
|
|
recorded.push(await write.table.put(step, write.row, stamp));
|
|
}
|
|
// Existing documents first: a retirement frees its address before a creation of this commit takes it.
|
|
for (const [id, record] of existing) recorded.push(...(await this.#change(step, record, this.#actions.get(id)!)));
|
|
for (const [id, action] of this.#actions) {
|
|
if (action.create === undefined) continue;
|
|
recorded.push(await this.#claim(step, id, "document"));
|
|
recorded.push(await this.#create(step, action.create, action.base!, action.retire));
|
|
}
|
|
return recorded;
|
|
}
|
|
|
|
/** Stages one write: a table row encoded, a document command into its action. */
|
|
#stage(write: StorageWrite, raise: (id: number) => void): void {
|
|
const tables = this.#tables;
|
|
switch (write.type) {
|
|
case "conversation":
|
|
this.#row(write.value.id, write.type, tables.conversations, conversationRow(write.value), raise);
|
|
return;
|
|
case "entry": {
|
|
const { id, conversationId, head } = write.value;
|
|
this.#row(id, write.type, tables.entries, entryRow(write.value), raise);
|
|
// Its claim is the entry's: `apply` claims an id once.
|
|
if (head !== undefined) {
|
|
const row = tables.entryHeads.encode({ id, conversation_id: conversationId });
|
|
this.#rows.push({ id, type: write.type, table: tables.entryHeads, row });
|
|
}
|
|
return;
|
|
}
|
|
case "task":
|
|
this.#row(write.value.id, write.type, tables.tasks, taskRow(write.value), raise);
|
|
return;
|
|
case "submission":
|
|
this.#row(write.value.id, write.type, tables.submissions, submissionRow(write.value), raise);
|
|
return;
|
|
case "document.create":
|
|
case "document.copy": {
|
|
const { id } = write.record;
|
|
const action = this.#action(id);
|
|
if (action.create !== undefined || action.content !== undefined || action.copy !== undefined) {
|
|
throw new Error(`Document ${id} has more than one content command`);
|
|
}
|
|
action.create = write.record;
|
|
if (write.type === "document.copy") action.copy = write.source;
|
|
else action.base = { ...write.content, text: JSON.stringify(write.content.value) };
|
|
this.#claims.push({ id, type: "document" });
|
|
raise(id);
|
|
return;
|
|
}
|
|
case "document.change": {
|
|
const action = this.#action(write.id);
|
|
// A creation's base is its content command (`SqliteStorage` counts it so too), in either order.
|
|
if (action.create !== undefined || action.content !== undefined || action.copy !== undefined) {
|
|
throw new Error(`Document ${write.id} has more than one content command`);
|
|
}
|
|
const { content } = write;
|
|
action.content = { content, text: JSON.stringify(content.kind === "base" ? content.value : content.ops) };
|
|
return;
|
|
}
|
|
case "document.retire": {
|
|
const action = this.#action(write.id);
|
|
if (action.retire) throw new Error(`Document ${write.id} is retired more than once`);
|
|
action.retire = true;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
#row(id: Id<string>, type: RowWrite["type"], table: OwnerTable, row: object, raise: (id: number) => void): void {
|
|
this.#claims.push({ id, type });
|
|
this.#rows.push({ id, type, table, row: table.encode(row) });
|
|
raise(id);
|
|
}
|
|
|
|
#action(id: DocumentId): DocumentAction {
|
|
let action = this.#actions.get(id);
|
|
if (action === undefined) {
|
|
action = { retire: false };
|
|
this.#actions.set(id, action);
|
|
}
|
|
return action;
|
|
}
|
|
|
|
/**
|
|
* Durable's global id checks (`SqliteStorage.checkGlobalIds`): a conversation, entry or document id claimed by
|
|
* anything, or written twice; a task or submission id claimed by another type. Returns the stored types.
|
|
*/
|
|
async #checkIds(step: Step): Promise<Map<number, RecordType>> {
|
|
const stored = new Map<number, RecordType | undefined>();
|
|
const claimed = new Map<number, RecordType>();
|
|
for (const { id, type } of this.#claims) {
|
|
let existing = stored.get(id);
|
|
if (!stored.has(id)) {
|
|
const row = await this.#tables.recordIds.get(step, [id], this.#context);
|
|
existing = row?.record_type as RecordType | undefined;
|
|
stored.set(id, existing);
|
|
}
|
|
const earlier = claimed.get(id);
|
|
if (type === "conversation" || type === "entry" || type === "document") {
|
|
if (existing !== undefined) throw new Error(`ID ${id} already belongs to ${existing}`);
|
|
if (earlier !== undefined) throw new Error(`ID ${id} is written more than once`);
|
|
} else {
|
|
if (existing !== undefined && existing !== type) throw new Error(`ID ${id} already belongs to ${existing}`);
|
|
if (earlier !== undefined && earlier !== type) throw new Error(`ID ${id} is written as two record types`);
|
|
}
|
|
claimed.set(id, type);
|
|
}
|
|
const types = new Map<number, RecordType>();
|
|
for (const [id, type] of stored) if (type !== undefined) types.set(id, type);
|
|
return types;
|
|
}
|
|
|
|
/**
|
|
* Durable's document checks (`SqliteStorage.checkDocumentActions`): a change or retirement of an unknown or retired
|
|
* document, a delta on another version, more than one live incarnation per address after the commit. Returns the
|
|
* existing records the commit changes or retires, in action order.
|
|
*/
|
|
async #checkDocuments(step: Step): Promise<Map<DocumentId, EngineRecord>> {
|
|
const existing = new Map<DocumentId, EngineRecord>();
|
|
for (const [id, action] of this.#actions) {
|
|
if (action.create !== undefined) continue;
|
|
const stored = await readDocument(step, id);
|
|
const record = stored === undefined ? undefined : durableRecord(stored, this.#owner);
|
|
if (record === undefined) throw new Error(`Unknown document: ${id}`);
|
|
if (record.retiredAt !== undefined) throw new Error(`Document ${id} is retired`);
|
|
const content = action.content?.content;
|
|
if (content?.kind === "delta" && content.version !== stored!.version) {
|
|
throw new Error(`Document ${id} version transition requires a base`);
|
|
}
|
|
existing.set(id, stored!);
|
|
}
|
|
/** Per address a live creation takes: its live incarnations after the commit so far. */
|
|
const live = new Map<string, number>();
|
|
for (const action of this.#actions.values()) {
|
|
if (action.create === undefined || action.retire) continue;
|
|
const address = addressOf(action.create, this.#owner);
|
|
const key = JSON.stringify([address.namespace, address.kind, address.key]);
|
|
let count = live.get(key);
|
|
if (count === undefined) {
|
|
// The incarnation live there now counts unless this commit retires it.
|
|
const current = await findDocument(step, address);
|
|
count = current === undefined || this.#actions.get(current.id as DocumentId)?.retire === true ? 0 : 1;
|
|
}
|
|
if (++count > 1) throw new Error("Document address already has a current incarnation");
|
|
live.set(key, count);
|
|
}
|
|
return existing;
|
|
}
|
|
|
|
/** Puts `id`'s `record_ids` row: its record type. */
|
|
#claim(step: Step, id: number, type: RecordType): Promise<StoreWrite> {
|
|
const table = this.#tables.recordIds;
|
|
return table.put(step, table.encode({ id, record_type: type }));
|
|
}
|
|
|
|
/** An existing document's content, then its retirement, as their writes. */
|
|
async #change(step: Step, record: EngineRecord, action: DocumentAction): Promise<StoreWrite[]> {
|
|
const writes: StoreWrite[] = [];
|
|
let { revision } = record;
|
|
if (action.content !== undefined) {
|
|
const { content, text } = action.content;
|
|
const stored = engineContent(content);
|
|
await writeContent(step, record.id, record.history, revision, stored, text);
|
|
writes.push({ kind: "document.change", id: record.id, revision, content: stored });
|
|
revision++;
|
|
}
|
|
if (action.retire) {
|
|
await retireRecord(step, record);
|
|
writes.push({ kind: "document.retire", id: record.id, revision });
|
|
}
|
|
return writes;
|
|
}
|
|
|
|
/** A creation or a resolved copy (retired at once for a create-plus-retire, an empty lifetime), as its write. */
|
|
async #create(step: Step, create: DocumentCreate, base: StagedBase, retired: boolean): Promise<StoreWrite> {
|
|
const record = engineRecord(create, this.#owner, base.version, step.seq, retired);
|
|
await insertDocument(step, record, base.text);
|
|
const value = base.value as JsonValue;
|
|
return { kind: "document.create", record, base: { version: base.version, fingerprint: "", value } };
|
|
}
|
|
}
|
|
|
|
/** A Durable content as the engine stores it: its documents have no schema fingerprint. */
|
|
function engineContent(content: DocumentContent): EngineContent {
|
|
return content.kind === "base"
|
|
? { kind: "base", version: content.version, fingerprint: "", value: content.value }
|
|
: { kind: "delta", version: content.version, ops: content.ops };
|
|
}
|