mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
1300 lines
52 KiB
TypeScript
1300 lines
52 KiB
TypeScript
import { BACKGROUND_CONTEXT, type Context } from "@earendil-works/chord/context";
|
|
import {
|
|
type Change,
|
|
type Draft,
|
|
type Op,
|
|
type PendingOps,
|
|
type Prepared,
|
|
type Tracker,
|
|
track,
|
|
} from "@earendil-works/chord/delta";
|
|
import { copyJson, type JsonValue } from "@earendil-works/chord/json";
|
|
import type { TelemetryCarrier } from "@earendil-works/pi-telemetry";
|
|
import {
|
|
type AnyDocDefinition,
|
|
type AnyDocToken,
|
|
addressId,
|
|
checkRecordScope,
|
|
checkRecordVersion,
|
|
documentCreate,
|
|
materializeDocumentValue,
|
|
type ResolvedAddress,
|
|
resolveAddress,
|
|
} from "../documents.ts";
|
|
import { ReadAfterWrite } from "../errors.ts";
|
|
import type {
|
|
ConversationDocFamilyToken,
|
|
ConversationDocToken,
|
|
ConversationId,
|
|
ConversationOwnership,
|
|
ConversationQuery,
|
|
ConversationRecord,
|
|
Cursor,
|
|
DocumentAddress,
|
|
DocumentCommitChange,
|
|
DocumentContent,
|
|
DocumentCopySource,
|
|
DocumentCreate,
|
|
DocumentId,
|
|
DocumentRecord,
|
|
Entry,
|
|
EntryDraft,
|
|
EntryId,
|
|
EntryQuery,
|
|
EntryRecord,
|
|
JsonObject,
|
|
Seq,
|
|
SessionDocFamilyToken,
|
|
SessionDocToken,
|
|
Storage,
|
|
StorageWrite,
|
|
SubmissionCreate,
|
|
SubmissionId,
|
|
SubmissionRecord,
|
|
SubmissionSettlement,
|
|
Task,
|
|
TaskDocFamilyToken,
|
|
TaskDocToken,
|
|
TaskId,
|
|
TaskOptions,
|
|
TaskQuery,
|
|
TaskRecord,
|
|
Tx,
|
|
TypedEntry,
|
|
TypedEntryDraft,
|
|
} from "../types.ts";
|
|
import { ROOT_CONVERSATION_ID } from "../types.ts";
|
|
import { prepareForkDocumentCopies } from "./forks.ts";
|
|
import { documentKey, incarnationKey, scopeKey, tableKeys } from "./keys.ts";
|
|
|
|
type AnyTaskRecord = TaskRecord<JsonValue, JsonValue, JsonValue>;
|
|
|
|
/** A staged submission change: a settlement, or the placement of a queued submission at its entry. */
|
|
type SubmissionChange = SubmissionSettlement | { readonly status: "placed"; readonly entry: EntryId };
|
|
|
|
/**
|
|
* Complete record after applying one change. Placement turns a queued input `placed` and a queued write `done`; a
|
|
* continue is never queued, so never placed. Only a placed input or continue can be answered. A settled record stays.
|
|
*/
|
|
function applySubmissionChange(current: SubmissionRecord, change: SubmissionChange): SubmissionRecord {
|
|
if (current.status === "done" || current.status === "unanswered") return current;
|
|
if (change.status === "placed") {
|
|
if (current.status !== "queued") throw new Error(`Submission ${current.id} is not queued`);
|
|
const status = current.type === "input" ? "placed" : "done";
|
|
return { ...current, status, entry: change.entry } as SubmissionRecord;
|
|
}
|
|
if (change.status === "done" && current.status !== "placed") {
|
|
throw new Error(`Submission ${current.id} is not a placed input or continue`);
|
|
}
|
|
// Queued and placed records carry no answer, reason, or detail; an unanswered input keeps its entry.
|
|
return { ...current, ...change } as SubmissionRecord;
|
|
}
|
|
|
|
const INTERNAL_SCAN_PAGE_SIZE = 256;
|
|
const EMPTY_OPERATIONS: readonly Op[] = [];
|
|
const TABLE_JSON_COPY_OPTIONS = { omitUndefinedProperties: true } as const;
|
|
|
|
/** One committed document incarnation owned by the Session tracker cache. */
|
|
export type LoadedDocument = {
|
|
readonly addressId: string;
|
|
readonly record: DocumentRecord;
|
|
/** Persisted definition version; older while the tracked value is migrated only in memory. */
|
|
storedVersion: number;
|
|
/** Definition version whose shape the tracked value has; access with another version reloads from Storage. */
|
|
readonly valueVersion: number;
|
|
/** Stored deltas after the newest base; advanced by adoption so the next predicate call needs no read. */
|
|
deltasSinceBase: number;
|
|
readonly tracker: Tracker<JsonObject>;
|
|
};
|
|
|
|
/**
|
|
* The changes of a cached incarnation that deferred commits adopted in memory and the Session has not stored yet
|
|
* (`CommitPersistence`), folded. The Session owns it; a commit that stores them takes them.
|
|
*/
|
|
export type HeldDocument = {
|
|
readonly loaded: LoadedDocument;
|
|
readonly pending: PendingOps;
|
|
/** Set when a held commit selected a base (`checkpointWhen`): their store writes the whole value. */
|
|
base: boolean;
|
|
};
|
|
|
|
/** What adoption publishes: to the Session's own observers, and the stored changes to `observeCommits()`. */
|
|
export type AdoptedDocuments = {
|
|
/** This commit's own changes, as memory adopted them. */
|
|
readonly observed: readonly DocumentCommitChange[];
|
|
/** What Storage got: `observed`, unless the commit also stored held changes, which precede its own. */
|
|
readonly stored: readonly DocumentCommitChange[];
|
|
};
|
|
|
|
/** Session services used by a transaction while it holds the mutation line. */
|
|
export interface TransactionHost {
|
|
/** Reads wait while their key has an unpublished writer (`GatedStorage`). */
|
|
readonly storage: Storage;
|
|
/** Wait while `key` has an unpublished writer; `undefined` when it has none. */
|
|
touch(key: string): Promise<void> | undefined;
|
|
/** Wall clock for task lifecycle times. */
|
|
now(): number;
|
|
/** Return the cached current incarnation without loading. */
|
|
cached(addressId: string): LoadedDocument | undefined;
|
|
/** Return the cached current incarnation, cold-loading and migrating it when necessary. */
|
|
load(
|
|
definition: AnyDocDefinition,
|
|
addressId: string,
|
|
address: DocumentAddress,
|
|
context: Context,
|
|
): Promise<LoadedDocument | undefined>;
|
|
/** Install a newly committed incarnation. */
|
|
install(document: LoadedDocument): void;
|
|
/** Remove a retired incarnation if it is still the cached occupant of its address. */
|
|
evict(addressId: string, recordId: DocumentId): void;
|
|
/** Stage writes that belong to every newly created or forked conversation, in its creating transaction. */
|
|
conversationCreated(tx: Transaction, record: ConversationRecord): Promise<void>;
|
|
/** The newest entry ID visible from a conversation, known from the Session's commits. */
|
|
latestEntryId(conversationId: ConversationId, context: Context): Promise<EntryId | undefined>;
|
|
/**
|
|
* The current documents of a live task when the Session knows all of them (it created the task), else `undefined`.
|
|
*/
|
|
taskDocuments(taskId: TaskId): Iterable<DocumentRecord> | undefined;
|
|
/** The newest visible head marker of a conversation, known from the Session's commits. */
|
|
latestHeadMarker(
|
|
conversationId: ConversationId,
|
|
context: Context,
|
|
): Promise<(EntryRecord & { readonly head: EntryId }) | undefined>;
|
|
}
|
|
|
|
/** Committed and candidate state for one task touched by this transaction. */
|
|
type TransactionTask = {
|
|
committedRead?: Promise<AnyTaskRecord | undefined>;
|
|
write?: { readonly kind: "create" | "replace"; readonly record: AnyTaskRecord };
|
|
publicationConversationId?: ConversationId;
|
|
};
|
|
|
|
/** Internal run creation resolves submission carriers against staged records before reading storage. */
|
|
export type TaskCreationOptions = TaskOptions & { readonly submissionIds?: readonly SubmissionId[] };
|
|
|
|
/** Defaults a commit binds to: `tx.createTask()` conversation and the task attributed to appended entries. */
|
|
export type TransactionScope = {
|
|
readonly conversationId?: ConversationId;
|
|
/** Task whose runtime commit this is; stamped as `byTaskId` on appended entries. */
|
|
readonly taskId?: TaskId;
|
|
};
|
|
|
|
/** Storage/cache provenance of one staged document incarnation. */
|
|
type DocumentTarget =
|
|
| { readonly kind: "loaded"; readonly document: LoadedDocument }
|
|
| {
|
|
readonly kind: "created";
|
|
readonly record: DocumentCreate;
|
|
readonly version: number;
|
|
readonly tracker: Tracker<JsonObject>;
|
|
}
|
|
| {
|
|
readonly kind: "fork-copy";
|
|
readonly record: DocumentCreate;
|
|
readonly source: DocumentCopySource;
|
|
}
|
|
| { readonly kind: "retire-only"; readonly record: DocumentRecord };
|
|
|
|
/** One document incarnation acquired, created, or retired by this transaction. */
|
|
type DocumentEntry = {
|
|
readonly addressId: string;
|
|
readonly address: DocumentAddress;
|
|
/** Absent for definition-free fork copies and retirement entries discovered by a terminal-task scan. */
|
|
definition?: AnyDocDefinition;
|
|
/** Memoized public acquisition; absent for metadata-only retirement. */
|
|
draftPromise?: Promise<Draft<JsonObject>>;
|
|
/** Set after acquisition or retirement lookup finds the affected incarnation. */
|
|
target?: DocumentTarget;
|
|
change?: Change<JsonObject>;
|
|
prepared?: Prepared<JsonObject>;
|
|
retireOnCommit: boolean;
|
|
};
|
|
|
|
/**
|
|
* What one staged incarnation writes and publishes, decided once before Storage admission so adoption only applies it.
|
|
* `record` is a `DocumentRecord` for an incarnation that is already committed and a `DocumentCreate` for a new one.
|
|
*/
|
|
type DocumentPlan = {
|
|
readonly addressId: string;
|
|
readonly record: DocumentCreate | DocumentRecord;
|
|
retire: boolean;
|
|
/** Creation, copy, or change content; absent when only retirement is written. */
|
|
content?: Extract<StorageWrite, { readonly type: "document.create" | "document.copy" | "document.change" }>;
|
|
/** Prepared change of a tracked incarnation; absent for fork copies and retirement-only entries. */
|
|
readonly change?: {
|
|
readonly tracker: Tracker<JsonObject>;
|
|
readonly prepared: Prepared<JsonObject>;
|
|
readonly version: number;
|
|
/** The cached incarnation this change updates; absent when it creates one. */
|
|
readonly loaded?: LoadedDocument;
|
|
readonly definition?: AnyDocDefinition;
|
|
};
|
|
/** Resolved before Storage admission so adoption performs no reads. */
|
|
conversationId?: ConversationId;
|
|
/**
|
|
* Set when this commit also stores held changes of the incarnation (`takeHeld()`): the operations its stored change
|
|
* publishes, held first, and whether it has a change of its own to show the Session's observers.
|
|
*/
|
|
held?: { readonly ops: readonly Op[]; readonly observed: boolean };
|
|
};
|
|
|
|
/**
|
|
* Transaction for one Session commit callback.
|
|
*
|
|
* Every asynchronous operation is tracked so callback settlement can reject and drain unfinished work. Session calls
|
|
* one settlement method, then either discards prepared changes or adopts them once after Storage succeeds.
|
|
*/
|
|
export class Transaction implements Tx {
|
|
readonly #host: TransactionHost;
|
|
#context: Context;
|
|
readonly #scope: TransactionScope;
|
|
readonly #pendingOperations = new Set<Promise<unknown>>();
|
|
#sealed = false;
|
|
#hasTableWrite = false;
|
|
|
|
/** Atomic batch; conversation and entry writes stage eagerly, while task and document writes assemble later. */
|
|
readonly #writes: StorageWrite[] = [];
|
|
readonly #createdConversationIds = new Set<ConversationId>();
|
|
readonly #forkSourceConversationIds = new Set<ConversationId>();
|
|
readonly #forkSourceDocumentIds = new Set<DocumentId>();
|
|
/** One entry per task touched by a public read, candidate write, or document-owner lookup. */
|
|
readonly #tasksById = new Map<TaskId, TransactionTask>();
|
|
/** Submissions created by this transaction, by ID. */
|
|
readonly #submissions = new Map<SubmissionId, SubmissionRecord>();
|
|
/** Submission settlements and placements in staging order; resolved against the latest candidate record during assembly. */
|
|
readonly #submissionChanges: { readonly id: SubmissionId; readonly change: SubmissionChange }[] = [];
|
|
|
|
/** Write and publication plans of every staged incarnation, built during assembly. */
|
|
readonly #plans: DocumentPlan[] = [];
|
|
/** Every document acquisition or retirement marker in staging order. */
|
|
readonly #documents: DocumentEntry[] = [];
|
|
/** Latest transaction-local incarnation or retirement marker at each logical address. */
|
|
readonly #latestDocumentByAddress = new Map<string, DocumentEntry>();
|
|
|
|
constructor(host: TransactionHost, context: Context, scope: TransactionScope = {}) {
|
|
this.#host = host;
|
|
this.#context = context;
|
|
this.#scope = scope;
|
|
}
|
|
|
|
/** Internal: the context of the commit this transaction belongs to. */
|
|
get context(): Context {
|
|
return this.#context;
|
|
}
|
|
|
|
/** Internal: whether a table write was staged, after which table reads throw `ReadAfterWrite`. */
|
|
get hasTableWrite(): boolean {
|
|
return this.#hasTableWrite;
|
|
}
|
|
|
|
/**
|
|
* Internal: whether `tx` is a transaction of a commit of the Session behind `host`. A brand check, so a proxy or
|
|
* another wrapper of a transaction is not one.
|
|
*/
|
|
static ownedBy(tx: Tx, host: TransactionHost): tx is Transaction {
|
|
return #host in tx && tx.#host === host;
|
|
}
|
|
|
|
// ─── Table reads ────────────────────────────────────────────────────────
|
|
|
|
conversation(id: ConversationId): Promise<ConversationRecord | undefined> {
|
|
return this.#read("conversation", () => this.#host.storage.conversation(id, this.#context));
|
|
}
|
|
|
|
entry(id: EntryId): Promise<EntryRecord | undefined>;
|
|
entry<D extends JsonValue>(token: Entry<D>, id: EntryId): Promise<TypedEntry<D> | undefined>;
|
|
entry(first: EntryId | { readonly kind: string }, second?: EntryId): Promise<EntryRecord | undefined> {
|
|
const kind = typeof first === "number" ? undefined : first.kind;
|
|
const id = typeof first === "number" ? first : second!;
|
|
return this.#read("entry", async () => {
|
|
const entry = (await this.#host.storage.entry(id, this.#context))?.entry;
|
|
return kind === undefined || entry?.kind === kind ? entry : undefined;
|
|
});
|
|
}
|
|
|
|
task(id: TaskId): Promise<AnyTaskRecord | undefined> {
|
|
return this.#read("task", () => this.#committedTask(id));
|
|
}
|
|
|
|
scanConversations(query: ConversationQuery, limit: number, cursor?: Cursor) {
|
|
return this.#read("scanConversations", () =>
|
|
this.#host.storage.scanConversations(query, limit, cursor, this.#context),
|
|
);
|
|
}
|
|
|
|
scanEntries(query: EntryQuery, limit: number, cursor?: Cursor) {
|
|
return this.#read("scanEntries", () => this.#host.storage.scanEntries(query, limit, cursor, this.#context));
|
|
}
|
|
|
|
latestHeadMarker(conversationId: ConversationId) {
|
|
return this.#read("latestHeadMarker", () => this.#host.latestHeadMarker(conversationId, this.#context));
|
|
}
|
|
|
|
latestEntryId(conversationId: ConversationId): Promise<EntryId | undefined> {
|
|
return this.#read("latestEntryId", () => this.#host.latestEntryId(conversationId, this.#context));
|
|
}
|
|
|
|
scanTasks(query: TaskQuery, limit: number, cursor?: Cursor) {
|
|
return this.#read("scanTasks", () => this.#host.storage.scanTasks(query, limit, cursor, this.#context));
|
|
}
|
|
|
|
/** Committed submission record. */
|
|
submission(id: SubmissionId): Promise<SubmissionRecord | undefined> {
|
|
return this.#read("submission", () => this.#host.storage.submission(id, this.#context));
|
|
}
|
|
|
|
/** Committed submission with a conversation-scoped request ID. */
|
|
submissionByRequest(conversationId: ConversationId, requestId: string): Promise<SubmissionRecord | undefined> {
|
|
return this.#read("submissionByRequest", () =>
|
|
this.#host.storage.submissionByRequest(conversationId, requestId, this.#context),
|
|
);
|
|
}
|
|
|
|
// ─── Table writes ───────────────────────────────────────────────────────
|
|
|
|
createConversation(options: { readonly ownership: ConversationOwnership }): Promise<ConversationRecord> {
|
|
return this.#write(() => this.#stageConversation(undefined, options.ownership));
|
|
}
|
|
|
|
/** Internal final-form bootstrap path for the reserved root identity. */
|
|
createRootConversation(): Promise<ConversationRecord> {
|
|
return this.#write(() => this.#stageConversation(undefined, { kind: "ownerless" }, ROOT_CONVERSATION_ID));
|
|
}
|
|
|
|
forkConversation(
|
|
parentConversationId: ConversationId,
|
|
at: EntryId,
|
|
options: { readonly ownership: ConversationOwnership },
|
|
): Promise<ConversationRecord> {
|
|
return this.#write(() =>
|
|
this.#stageConversation({ conversationId: parentConversationId, at }, options.ownership),
|
|
);
|
|
}
|
|
|
|
async #stageConversation(
|
|
parent: NonNullable<ConversationRecord["parent"]> | undefined,
|
|
ownership: ConversationOwnership,
|
|
reservedId?: ConversationId,
|
|
): Promise<ConversationRecord> {
|
|
const ownerTaskId = ownership.kind === "task" ? ownership.taskId : undefined;
|
|
const id = reservedId ?? (await this.#host.storage.mintId<ConversationId>());
|
|
this.#assertOpen();
|
|
let owner: ConversationRecord["owner"];
|
|
if (ownerTaskId !== undefined) {
|
|
const task = await this.#currentTask(ownerTaskId);
|
|
this.#assertOpen();
|
|
if (task === undefined) throw new Error(`Conversation owner task ${ownerTaskId} does not exist`);
|
|
owner = { conversationId: task.conversationId, taskId: ownerTaskId };
|
|
}
|
|
const record: ConversationRecord = {
|
|
id,
|
|
...(parent === undefined ? {} : { parent }),
|
|
...(owner === undefined ? {} : { owner }),
|
|
};
|
|
const copies =
|
|
parent === undefined
|
|
? []
|
|
: await prepareForkDocumentCopies(this.#host.storage, parent.conversationId, parent.at, id, this.#context);
|
|
this.#assertOpen();
|
|
for (const copy of copies) {
|
|
this.#forkSourceDocumentIds.add(copy.source.id);
|
|
const entry: DocumentEntry = {
|
|
addressId: addressId(copy.record),
|
|
address: copy.record,
|
|
target: { kind: "fork-copy", ...copy },
|
|
retireOnCommit: false,
|
|
};
|
|
this.#documents.push(entry);
|
|
this.#latestDocumentByAddress.set(entry.addressId, entry);
|
|
}
|
|
if (parent !== undefined) this.#forkSourceConversationIds.add(parent.conversationId);
|
|
this.#createdConversationIds.add(id);
|
|
this.#writes.push({ type: "conversation", value: record });
|
|
await this.#host.conversationCreated(this, record);
|
|
this.#assertOpen();
|
|
return record;
|
|
}
|
|
|
|
appendEntry(conversationId: ConversationId, value: EntryDraft): Promise<EntryRecord>;
|
|
appendEntry<D extends JsonValue>(
|
|
token: Entry<D>,
|
|
conversationId: ConversationId,
|
|
value: TypedEntryDraft<NoInfer<D>>,
|
|
): Promise<TypedEntry<D>>;
|
|
appendEntry(
|
|
first: ConversationId | { readonly kind: string },
|
|
second: ConversationId | EntryDraft,
|
|
third?: object,
|
|
): Promise<EntryRecord> {
|
|
if (typeof first === "number") return this.#appendEntry(first, second as EntryDraft);
|
|
return this.#appendEntry(second as ConversationId, { ...third, kind: first.kind } as EntryDraft);
|
|
}
|
|
|
|
#appendEntry(conversationId: ConversationId, value: EntryDraft): Promise<EntryRecord> {
|
|
return this.#write(async () => {
|
|
await this.#requireConversation(conversationId);
|
|
this.#assertOpen();
|
|
const id = await this.#host.storage.mintId<EntryId>();
|
|
this.#assertOpen();
|
|
const { head, ...rest } = value;
|
|
// Undefined `head` and `byTaskId` are omitted by the copy.
|
|
const record = copyJson(
|
|
{
|
|
...rest,
|
|
id,
|
|
conversationId,
|
|
head: head === "self" ? id : head,
|
|
byTaskId: this.#scope.taskId,
|
|
},
|
|
TABLE_JSON_COPY_OPTIONS,
|
|
) as unknown as EntryRecord;
|
|
this.#writes.push({ type: "entry", value: record });
|
|
return record;
|
|
});
|
|
}
|
|
|
|
createTask<I, S extends { phase: string }, R, H extends object>(
|
|
task: Task<I, S, R, H>,
|
|
input: I,
|
|
options: TaskCreationOptions,
|
|
): Promise<TaskId<R>> {
|
|
return this.#write(async () => {
|
|
if (options.submissionIds !== undefined) {
|
|
let trace: TaskOptions["trace"];
|
|
let links: TelemetryCarrier[] | undefined;
|
|
for (let index = 0; index < options.submissionIds.length; index++) {
|
|
const id = options.submissionIds[index]!;
|
|
const submission = this.#submissions.get(id) ?? (await this.#host.storage.submission(id, this.#context));
|
|
if (index === 0) trace = submission?.trace ?? "root";
|
|
else if (submission?.trace !== undefined) {
|
|
links ??= [];
|
|
links.push(submission.trace);
|
|
}
|
|
}
|
|
options = { ...options, trace, links };
|
|
}
|
|
const ownership = options.ownership;
|
|
let owner: AnyTaskRecord | undefined;
|
|
if (ownership.kind === "task") {
|
|
// Validated again against the owner's final candidate during assembly.
|
|
owner = await this.#currentTask(ownership.taskId);
|
|
this.#assertOpen();
|
|
if (owner === undefined) throw new Error(`Task owner ${ownership.taskId} does not exist`);
|
|
if (options.background === true) throw new TypeError("A child task cannot be background");
|
|
if (options.conversationId !== undefined && options.conversationId !== owner.conversationId) {
|
|
throw new Error(`A child task lives in its owner's conversation ${owner.conversationId}`);
|
|
}
|
|
}
|
|
const conversationId = owner?.conversationId ?? options.conversationId ?? this.#scope.conversationId;
|
|
if (conversationId === undefined) throw new TypeError("Tx.createTask() requires options.conversationId");
|
|
await this.#requireConversation(conversationId);
|
|
this.#assertOpen();
|
|
const definition = task.definition;
|
|
const checkpoint = definition.initial(input);
|
|
const id = await this.#host.storage.mintId<TaskId<R>>();
|
|
this.#assertOpen();
|
|
const record = copyJson(
|
|
{
|
|
id,
|
|
conversationId,
|
|
kind: definition.name,
|
|
version: definition.version,
|
|
input,
|
|
trace:
|
|
options.trace === "root"
|
|
? undefined
|
|
: (options.trace ?? this.#context.telemetry.propagation?.inject(this.#context.telemetry)),
|
|
links: options.links,
|
|
...(owner === undefined ? {} : { owner: owner.id }),
|
|
background: options.background ?? false,
|
|
abortRequested: false,
|
|
...(options.abandonOnRestart === true ? { abandonOnRestart: true } : {}),
|
|
state: { status: "pending", checkpoint },
|
|
},
|
|
TABLE_JSON_COPY_OPTIONS,
|
|
) as unknown as AnyTaskRecord;
|
|
this.#tasksById.set(id, { write: { kind: "create", record } });
|
|
return id;
|
|
});
|
|
}
|
|
|
|
/** Create a raw submission record with a fresh ID; no admission rules apply. */
|
|
createSubmission(create: SubmissionCreate): Promise<SubmissionRecord> {
|
|
return this.#write(async () => {
|
|
await this.#requireConversation(create.conversationId);
|
|
this.#assertOpen();
|
|
const id = await this.#host.storage.mintId<SubmissionId>();
|
|
this.#assertOpen();
|
|
const record = copyJson({ ...create, id }, TABLE_JSON_COPY_OPTIONS) as unknown as SubmissionRecord;
|
|
this.#submissions.set(id, record);
|
|
return record;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Settle a submission. Resolved during assembly against the transaction's latest candidate record, falling back to
|
|
* committed state, so it is not a caller table read and works after the first table write.
|
|
*/
|
|
settleSubmission(id: SubmissionId, settlement: SubmissionSettlement): void {
|
|
this.#assertOpen();
|
|
this.#hasTableWrite = true;
|
|
this.#submissionChanges.push({ id, change: copyJson(settlement) as SubmissionSettlement });
|
|
}
|
|
|
|
/** Place a queued submission at `entry`; resolved during assembly like `settleSubmission()`. */
|
|
placeSubmission(id: SubmissionId, entry: EntryId): void {
|
|
this.#assertOpen();
|
|
this.#hasTableWrite = true;
|
|
this.#submissionChanges.push({ id, change: { status: "placed", entry } });
|
|
}
|
|
|
|
/** Internal: replace one task record completely. Tasks change their own state through their runtime. */
|
|
setTask(value: AnyTaskRecord): void {
|
|
this.#assertOpen();
|
|
this.#hasTableWrite = true;
|
|
const task = this.#taskEntry(value.id);
|
|
const candidate = task.write?.record;
|
|
if (candidate?.state.status === "terminal") {
|
|
throw new Error(`Task ${value.id} already has a terminal candidate`);
|
|
}
|
|
if (candidate !== undefined && candidate.conversationId !== value.conversationId) {
|
|
throw new Error(`Task ${value.id} cannot change conversations`);
|
|
}
|
|
task.write = {
|
|
kind: task.write?.kind === "create" ? "create" : "replace",
|
|
record: copyJson(this.#stampTimes(value, candidate), TABLE_JSON_COPY_OPTIONS) as unknown as AnyTaskRecord,
|
|
};
|
|
}
|
|
|
|
/** Internal: candidate records of the tasks this transaction created or replaced so far. */
|
|
stagedTasks(): AnyTaskRecord[] {
|
|
const records: AnyTaskRecord[] = [];
|
|
for (const task of this.#tasksById.values()) if (task.write !== undefined) records.push(task.write.record);
|
|
return records;
|
|
}
|
|
|
|
/** Internal: IDs of the tasks this transaction creates. */
|
|
createdTaskIds(): TaskId[] {
|
|
const ids: TaskId[] = [];
|
|
for (const [id, task] of this.#tasksById) if (task.write?.kind === "create") ids.push(id);
|
|
return ids;
|
|
}
|
|
|
|
/** Internal: conversations this transaction created or forked so far. */
|
|
stagedConversations(): ConversationRecord[] {
|
|
const records: ConversationRecord[] = [];
|
|
for (const write of this.#writes) if (write.type === "conversation") records.push(write.value);
|
|
return records;
|
|
}
|
|
|
|
// ─── Documents ──────────────────────────────────────────────────────────
|
|
doc<T extends JsonObject>(token: SessionDocToken<T>): Promise<Draft<T>>;
|
|
doc<T extends JsonObject>(token: ConversationDocToken<T>, conversationId: ConversationId): Promise<Draft<T>>;
|
|
doc<T extends JsonObject>(token: TaskDocToken<T>, taskId: TaskId): Promise<Draft<T>>;
|
|
doc<T extends JsonObject, I extends JsonValue>(
|
|
token: SessionDocFamilyToken<T, I>,
|
|
key: string,
|
|
seed: I,
|
|
): Promise<Draft<T>>;
|
|
doc<T extends JsonObject, I extends JsonValue>(
|
|
token: ConversationDocFamilyToken<T, I>,
|
|
conversationId: ConversationId,
|
|
key: string,
|
|
seed: I,
|
|
): Promise<Draft<T>>;
|
|
doc<T extends JsonObject, I extends JsonValue>(
|
|
token: TaskDocFamilyToken<T, I>,
|
|
taskId: TaskId,
|
|
key: string,
|
|
seed: I,
|
|
): Promise<Draft<T>>;
|
|
doc(token: AnyDocToken, ...args: readonly unknown[]): Promise<Draft<JsonObject>> {
|
|
try {
|
|
this.#assertOpen();
|
|
const definition = token.definition;
|
|
const resolved = resolveAddress(definition, args);
|
|
this.#assertTaskDocumentsOpen(resolved);
|
|
const latest = this.#latestDocumentByAddress.get(resolved.id);
|
|
if (latest !== undefined && !latest.retireOnCommit) {
|
|
if (latest.draftPromise !== undefined) return latest.draftPromise;
|
|
if (latest.target?.kind === "fork-copy") {
|
|
latest.draftPromise = this.#track(this.#acquireForkCopy(latest, definition, latest.target));
|
|
return latest.draftPromise;
|
|
}
|
|
}
|
|
const seed = definition.family === true ? copyJson(args[resolved.nextArgument]) : undefined;
|
|
const docEntry: DocumentEntry = {
|
|
addressId: resolved.id,
|
|
address: resolved.address,
|
|
definition,
|
|
retireOnCommit: false,
|
|
};
|
|
this.#documents.push(docEntry);
|
|
this.#latestDocumentByAddress.set(docEntry.addressId, docEntry);
|
|
// Capture retirement before awaiting so a pending old acquisition and its replacement stay distinct.
|
|
docEntry.draftPromise = this.#track(this.#acquire(docEntry, seed, latest?.retireOnCommit === true));
|
|
return docEntry.draftPromise;
|
|
} catch (error) {
|
|
return Promise.reject(error);
|
|
}
|
|
}
|
|
|
|
retireDoc<T extends JsonObject>(token: SessionDocToken<T>): Promise<void>;
|
|
retireDoc<T extends JsonObject>(token: ConversationDocToken<T>, conversationId: ConversationId): Promise<void>;
|
|
retireDoc<T extends JsonObject>(token: TaskDocToken<T>, taskId: TaskId): Promise<void>;
|
|
retireDoc<T extends JsonObject, I extends JsonValue>(token: SessionDocFamilyToken<T, I>, key: string): Promise<void>;
|
|
retireDoc<T extends JsonObject, I extends JsonValue>(
|
|
token: ConversationDocFamilyToken<T, I>,
|
|
conversationId: ConversationId,
|
|
key: string,
|
|
): Promise<void>;
|
|
retireDoc<T extends JsonObject, I extends JsonValue>(
|
|
token: TaskDocFamilyToken<T, I>,
|
|
taskId: TaskId,
|
|
key: string,
|
|
): Promise<void>;
|
|
retireDoc(token: AnyDocToken, ...args: readonly unknown[]): Promise<void> {
|
|
try {
|
|
this.#assertOpen();
|
|
const definition = token.definition;
|
|
const resolved = resolveAddress(definition, args);
|
|
const latest = this.#latestDocumentByAddress.get(resolved.id);
|
|
if (latest?.retireOnCommit) return Promise.resolve();
|
|
if (latest?.target?.kind === "fork-copy") {
|
|
checkRecordScope(definition, latest.target.record);
|
|
latest.retireOnCommit = true;
|
|
return Promise.resolve();
|
|
}
|
|
if (latest?.draftPromise !== undefined) {
|
|
// Retirement of an acquired draft persists its final content before retirement.
|
|
latest.retireOnCommit = true;
|
|
return this.#track(latest.draftPromise.then(() => undefined));
|
|
}
|
|
const entry: DocumentEntry = {
|
|
addressId: resolved.id,
|
|
address: resolved.address,
|
|
definition,
|
|
retireOnCommit: true,
|
|
};
|
|
this.#documents.push(entry);
|
|
this.#latestDocumentByAddress.set(entry.addressId, entry);
|
|
return this.#track(this.#findRetirement(entry));
|
|
} catch (error) {
|
|
return Promise.reject(error);
|
|
}
|
|
}
|
|
|
|
async #acquire(entry: DocumentEntry, seed: JsonValue | undefined, skipLoad: boolean): Promise<Draft<JsonObject>> {
|
|
const definition = entry.definition!;
|
|
const loaded = skipLoad
|
|
? undefined
|
|
: await this.#host.load(definition, entry.addressId, entry.address, this.#context);
|
|
this.#assertOpen();
|
|
if (loaded !== undefined) {
|
|
checkRecordScope(definition, loaded.record);
|
|
checkRecordVersion(definition, loaded.record, loaded.storedVersion);
|
|
entry.target = { kind: "loaded", document: loaded };
|
|
entry.change = loaded.tracker.beginChange();
|
|
return entry.change.state;
|
|
}
|
|
const scope = entry.address.scope;
|
|
if (scope.kind === "conversation") await this.#requireConversation(scope.conversationId);
|
|
if (scope.kind === "task") {
|
|
const task = await this.#currentTask(scope.taskId);
|
|
if (task === undefined) throw new Error(`Task ${scope.taskId} does not exist`);
|
|
if (task.state.status === "terminal") throw new Error(`Task ${scope.taskId} is terminal`);
|
|
}
|
|
this.#assertOpen();
|
|
const value = copyJson(
|
|
definition.family === true ? definition.initial(seed) : definition.initial(),
|
|
) as JsonObject;
|
|
const id = await this.#host.storage.mintId<DocumentId>();
|
|
this.#assertOpen();
|
|
const tracker = track(value);
|
|
entry.target = {
|
|
kind: "created",
|
|
record: documentCreate(definition, entry.address, id),
|
|
version: definition.version,
|
|
tracker,
|
|
};
|
|
entry.change = tracker.beginChange();
|
|
return entry.change.state;
|
|
}
|
|
|
|
async #acquireForkCopy(
|
|
entry: DocumentEntry,
|
|
definition: AnyDocDefinition,
|
|
target: Extract<DocumentTarget, { readonly kind: "fork-copy" }>,
|
|
): Promise<Draft<JsonObject>> {
|
|
const stored = await this.#host.storage.document(target.source.id, target.source.at, this.#context);
|
|
this.#assertOpen();
|
|
if (stored === undefined) {
|
|
throw new Error(`Fork source document ${target.source.id} cannot be read`);
|
|
}
|
|
if (
|
|
stored.record.scope.kind !== "conversation" ||
|
|
stored.record.kind !== target.record.kind ||
|
|
stored.record.key !== target.record.key ||
|
|
stored.record.history !== target.record.history ||
|
|
stored.record.fork !== target.record.fork
|
|
) {
|
|
throw new Error(`Fork source document ${target.source.id} does not match the copied record`);
|
|
}
|
|
const value = materializeDocumentValue(definition, target.record, stored.version, stored.value);
|
|
const tracker = track(value);
|
|
entry.definition = definition;
|
|
entry.target = { kind: "created", record: target.record, version: definition.version, tracker };
|
|
entry.change = tracker.beginChange();
|
|
return entry.change.state;
|
|
}
|
|
|
|
async #findRetirement(entry: DocumentEntry): Promise<void> {
|
|
const gate = this.#host.touch(documentKey(entry.addressId));
|
|
if (gate !== undefined) await gate;
|
|
const record =
|
|
this.#host.cached(entry.addressId)?.record ??
|
|
(await this.#host.storage.findDocument(entry.address, "current", this.#context));
|
|
this.#assertOpen();
|
|
if (record === undefined) return;
|
|
checkRecordScope(entry.definition!, record);
|
|
entry.target = { kind: "retire-only", record };
|
|
}
|
|
|
|
// ─── Settlement ─────────────────────────────────────────────────────────
|
|
|
|
/** Seal after callback failure: abort every change and observe every pending operation. */
|
|
async settleFailure(): Promise<void> {
|
|
this.#sealed = true;
|
|
this.#abortChanges();
|
|
await Promise.allSettled(this.#pendingOperations);
|
|
this.#context = BACKGROUND_CONTEXT;
|
|
}
|
|
|
|
/**
|
|
* Seal after callback success, prepare every open change, and assemble the atomic batch.
|
|
* Any failure aborts every change before Storage admission.
|
|
*/
|
|
async settleSuccess(): Promise<readonly StorageWrite[]> {
|
|
this.#sealed = true;
|
|
if (this.#pendingOperations.size > 0) {
|
|
this.#abortChanges();
|
|
await Promise.allSettled(this.#pendingOperations);
|
|
this.#context = BACKGROUND_CONTEXT;
|
|
throw new Error("Session commit callback settled before its pending Tx operations");
|
|
}
|
|
try {
|
|
// Synchronously prepare every open change; this revokes every draft.
|
|
for (const document of this.#documents) {
|
|
if (document.change !== undefined) document.prepared = document.change.prepare();
|
|
}
|
|
return await this.#assemble();
|
|
} catch (error) {
|
|
this.#abortChanges();
|
|
throw error;
|
|
} finally {
|
|
this.#context = BACKGROUND_CONTEXT;
|
|
}
|
|
}
|
|
|
|
/** Abort every prepared change after Storage failure or when no write is required. */
|
|
discard(): void {
|
|
this.#abortChanges();
|
|
}
|
|
|
|
/**
|
|
* Internal: what a deferred commit holds in memory instead of storing (`CommitPersistence`), each changed incarnation
|
|
* with its operations and whether they selected a base; `undefined` when the commit must be stored. It may be held
|
|
* only when every write changes an existing `latest`, `initial`-fork conversation document at its stored version.
|
|
*/
|
|
holdable(): { readonly loaded: LoadedDocument; readonly ops: readonly Op[]; readonly base: boolean }[] | undefined {
|
|
if (this.#writes.some((write) => write.type !== "document.change")) return undefined;
|
|
const held = [];
|
|
for (const plan of this.#plans) {
|
|
const { content, change } = plan;
|
|
if (content === undefined && !plan.retire) continue;
|
|
if (plan.retire || change?.loaded === undefined || content?.type !== "document.change") return undefined;
|
|
const { loaded, prepared, version } = change;
|
|
const record = loaded.record;
|
|
if (record.scope.kind !== "conversation" || record.history !== "latest" || record.fork !== "initial") {
|
|
return undefined;
|
|
}
|
|
// A migration stores a base at once.
|
|
if (loaded.storedVersion !== version) return undefined;
|
|
held.push({ loaded, ops: prepared.ops, base: content.content.kind === "base" });
|
|
}
|
|
return held;
|
|
}
|
|
|
|
/**
|
|
* Internal: adopt a held commit (`holdable()`) in memory only. Trackers advance by pointer swap; stored versions and
|
|
* delta counts stay, as nothing was stored. Returns its publication for the Session's own observers.
|
|
*/
|
|
adoptHeld(): DocumentCommitChange[] {
|
|
const publications: DocumentCommitChange[] = [];
|
|
for (const plan of this.#plans) {
|
|
// Every plan of a held commit changes or reads a loaded incarnation (`holdable()`).
|
|
const { tracker, prepared, version, loaded } = plan.change!;
|
|
if (prepared.ops.length === 0) {
|
|
prepared.abort();
|
|
continue;
|
|
}
|
|
tracker.adopt(prepared);
|
|
const record = loaded!.record;
|
|
const conversationId = plan.conversationId;
|
|
publications.push({
|
|
type: "document",
|
|
kind: "change",
|
|
record,
|
|
conversationId,
|
|
version,
|
|
value: prepared.value,
|
|
ops: prepared.ops,
|
|
});
|
|
}
|
|
return publications;
|
|
}
|
|
|
|
/**
|
|
* Internal: store the held changes of every incarnation this commit reads or writes in front of its own, so a stored
|
|
* document never misses an operation its later ones build on. Called right before Storage admission, after every
|
|
* fallible step, because it empties the holds: a failure earlier keeps them. Returns the holds it took.
|
|
*/
|
|
takeHeld<H extends HeldDocument>(held: (id: DocumentId) => H | undefined): H[] {
|
|
const taken: H[] = [];
|
|
for (const plan of this.#plans) {
|
|
// A new incarnation holds nothing yet.
|
|
if (!("createdAt" in plan.record)) continue;
|
|
const hold = held(plan.record.id);
|
|
if (hold === undefined) continue;
|
|
taken.push(hold);
|
|
const own = plan.content?.type === "document.change" ? plan.content : undefined;
|
|
const change = plan.change;
|
|
const value = change?.prepared.value ?? hold.loaded.tracker.value;
|
|
const stored = storeHeld(hold, change?.prepared.ops ?? EMPTY_OPERATIONS, value, hold.loaded.valueVersion);
|
|
// A base this commit selected or must write covers what was held.
|
|
const content = own?.content.kind === "base" ? own.content : stored.content;
|
|
const write = { type: "document.change", id: plan.record.id, content } as const;
|
|
if (own !== undefined) this.#writes[this.#writes.indexOf(own)] = write;
|
|
else this.#insertBeforeRetirement(write);
|
|
if (!plan.retire) plan.held = { ops: stored.ops, observed: own !== undefined };
|
|
plan.content = write;
|
|
// Held documents are conversation documents; one this commit only read has no owner resolved yet.
|
|
if (plan.record.scope.kind === "conversation") plan.conversationId = plan.record.scope.conversationId;
|
|
}
|
|
return taken;
|
|
}
|
|
|
|
/**
|
|
* Internal: the keys this commit writes, once settled (and after `takeHeld()`), for the Session's unpublished write
|
|
* set: its table records, and the address and incarnation of every document it creates, changes, or retires, with
|
|
* the scope of every incarnation it creates or retires.
|
|
*/
|
|
writeKeys(): string[] {
|
|
const keys: string[] = [];
|
|
tableKeys(this.#writes, keys);
|
|
for (const plan of this.#plans) {
|
|
if (plan.content === undefined && !plan.retire) continue;
|
|
keys.push(documentKey(plan.addressId), incarnationKey(plan.record.id));
|
|
if (plan.retire || !("createdAt" in plan.record)) keys.push(scopeKey(plan.record.scope));
|
|
}
|
|
return keys;
|
|
}
|
|
|
|
/** Stage a content write for an incarnation, before its retirement when it retires. */
|
|
#insertBeforeRetirement(write: Extract<StorageWrite, { readonly type: "document.change" }>): void {
|
|
const at = this.#writes.findIndex(
|
|
(candidate) => candidate.type === "document.retire" && candidate.id === write.id,
|
|
);
|
|
if (at < 0) this.#writes.push(write);
|
|
else this.#writes.splice(at, 0, write);
|
|
}
|
|
|
|
/** Adopt every prepared change by pointer swap after Storage success and describe the publication. */
|
|
adopt(seq: Seq): AdoptedDocuments {
|
|
const publications: DocumentCommitChange[] = [];
|
|
// Only when the commit stored held changes: then what Storage got differs from what memory adopted.
|
|
let stored: DocumentCommitChange[] | undefined;
|
|
for (const plan of this.#plans) {
|
|
const committed = "createdAt" in plan.record;
|
|
let record: DocumentRecord = committed ? (plan.record as DocumentRecord) : { ...plan.record, createdAt: seq };
|
|
if (plan.retire) record = { ...record, retiredAt: seq };
|
|
const change = plan.change;
|
|
if (change !== undefined) {
|
|
const { tracker, prepared, loaded, version } = change;
|
|
// A new incarnation is adopted unless it retires in the same commit; a loaded one only when it changed.
|
|
if (loaded === undefined ? !plan.retire : prepared.ops.length > 0) tracker.adopt(prepared);
|
|
else prepared.abort();
|
|
if (loaded !== undefined) {
|
|
if (loaded.storedVersion < version) loaded.storedVersion = version;
|
|
if (plan.content?.type === "document.change") {
|
|
if (plan.content.content.kind === "base") loaded.deltasSinceBase = 0;
|
|
else loaded.deltasSinceBase++;
|
|
}
|
|
} else if (!plan.retire) {
|
|
this.#host.install({
|
|
addressId: plan.addressId,
|
|
record,
|
|
storedVersion: version,
|
|
valueVersion: version,
|
|
deltasSinceBase: 0,
|
|
tracker,
|
|
});
|
|
}
|
|
}
|
|
if (plan.retire && committed) this.#host.evict(plan.addressId, record.id);
|
|
const publication = describe(plan, record);
|
|
if (publication === undefined) continue;
|
|
const held = plan.held;
|
|
if (held === undefined) {
|
|
publications.push(publication);
|
|
stored?.push(publication);
|
|
continue;
|
|
}
|
|
stored ??= [...publications];
|
|
if (held.observed) publications.push(publication);
|
|
// Only a document change of a loaded incarnation carries held operations; a retirement is published alone.
|
|
stored.push(
|
|
publication.type === "document" && publication.kind === "change"
|
|
? { ...publication, ops: held.ops }
|
|
: publication,
|
|
);
|
|
}
|
|
return { observed: publications, stored: stored ?? publications };
|
|
}
|
|
|
|
async #assemble(): Promise<StorageWrite[]> {
|
|
const storage = this.#host.storage;
|
|
const plans = this.#plans;
|
|
for (const document of this.#documents) {
|
|
const plan = planDocument(document);
|
|
if (plan !== undefined) plans.push(plan);
|
|
}
|
|
this.#rejectForkSourceWrites(plans);
|
|
await this.#validateOwners();
|
|
for (const [id, task] of this.#tasksById) {
|
|
if (task.write?.kind !== "replace") continue;
|
|
const committed = await this.#committedTask(id);
|
|
if (committed === undefined) throw new Error(`Task ${id} does not exist`);
|
|
if (committed.state.status === "terminal") throw new Error(`Task ${id} is already terminal`);
|
|
if (committed.conversationId !== task.write.record.conversationId) {
|
|
throw new Error(`Task ${id} cannot change conversations`);
|
|
}
|
|
}
|
|
|
|
// Terminal settlement retires every task document, including ones created by this transaction.
|
|
const terminalTaskIds = new Set<TaskId>();
|
|
for (const task of this.#tasksById.values()) {
|
|
if (task.write?.record.state.status === "terminal") terminalTaskIds.add(task.write.record.id);
|
|
}
|
|
if (terminalTaskIds.size > 0) {
|
|
const retiring = new Set<DocumentId>();
|
|
for (const plan of plans) {
|
|
const scope = plan.record.scope;
|
|
if (scope.kind !== "task" || !terminalTaskIds.has(scope.taskId)) continue;
|
|
plan.retire = true;
|
|
retiring.add(plan.record.id);
|
|
}
|
|
const retire = (record: DocumentRecord): void => {
|
|
if (retiring.has(record.id)) return;
|
|
plans.push({ addressId: addressId(record), record, retire: true });
|
|
retiring.add(record.id);
|
|
};
|
|
for (const taskId of terminalTaskIds) {
|
|
if (this.#tasksById.get(taskId)?.write?.kind === "create") continue;
|
|
// The Session knows the documents of the tasks it created; any other task's are scanned.
|
|
const gate = this.#host.touch(scopeKey({ kind: "task", taskId }));
|
|
if (gate !== undefined) await gate;
|
|
const known = this.#host.taskDocuments(taskId);
|
|
if (known !== undefined) {
|
|
for (const record of known) retire(record);
|
|
continue;
|
|
}
|
|
let cursor: Cursor | undefined;
|
|
do {
|
|
const page = await storage.scanDocuments(
|
|
{ scope: { kind: "task", taskId }, at: "current" },
|
|
INTERNAL_SCAN_PAGE_SIZE,
|
|
cursor,
|
|
this.#context,
|
|
);
|
|
for (const record of page.items) retire(record);
|
|
cursor = page.next;
|
|
} while (cursor !== undefined);
|
|
}
|
|
}
|
|
|
|
// Resolve publication ownership before Storage admission so adoption remains synchronous.
|
|
for (const plan of plans) {
|
|
if (!publishes(plan)) continue;
|
|
const scope = plan.record.scope;
|
|
if (scope.kind === "conversation") plan.conversationId = scope.conversationId;
|
|
if (scope.kind !== "task") continue;
|
|
const task = this.#taskEntry(scope.taskId);
|
|
if (task.publicationConversationId === undefined) {
|
|
const current = await this.#currentTask(scope.taskId);
|
|
if (current !== undefined) task.publicationConversationId = current.conversationId;
|
|
}
|
|
plan.conversationId = task.publicationConversationId;
|
|
}
|
|
|
|
for (const { id, change } of this.#submissionChanges) {
|
|
const current = this.#submissions.get(id) ?? (await storage.submission(id, this.#context));
|
|
if (current === undefined) throw new Error(`Submission ${id} does not exist`);
|
|
const next = applySubmissionChange(current, change);
|
|
if (next !== current) this.#submissions.set(id, next);
|
|
}
|
|
|
|
const writes = this.#writes;
|
|
for (const value of this.#submissions.values()) writes.push({ type: "submission", value });
|
|
for (const task of this.#tasksById.values()) {
|
|
if (task.write !== undefined) writes.push({ type: "task", value: task.write.record });
|
|
}
|
|
for (const plan of plans) {
|
|
const change = plan.change;
|
|
// Checkpoint predicates run last, after every validation.
|
|
if (plan.content?.type === "document.change" && plan.content.content.kind === "delta" && change?.loaded) {
|
|
const { prepared, version } = change;
|
|
const info = { deltasSinceBase: change.loaded.deltasSinceBase };
|
|
if (change.definition!.checkpointWhen(prepared.value, prepared.ops, info)) {
|
|
plan.content = { ...plan.content, content: { version, kind: "base", value: prepared.value } };
|
|
}
|
|
}
|
|
if (plan.content !== undefined) writes.push(plan.content);
|
|
if (plan.retire) writes.push({ type: "document.retire", id: plan.record.id });
|
|
}
|
|
return writes;
|
|
}
|
|
|
|
// ─── Helpers ────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* New owned work needs a live owner, judged on the owner's final candidate: not `completing`, terminal, or
|
|
* abort-marked. A task therefore cannot create owned work in the commit that finishes it (spec §5.5).
|
|
*/
|
|
async #validateOwners(): Promise<void> {
|
|
const owners: { readonly what: string; readonly taskId: TaskId }[] = [];
|
|
for (const write of this.#writes) {
|
|
if (write.type !== "conversation" || write.value.owner === undefined) continue;
|
|
owners.push({ what: "Conversation owner task", taskId: write.value.owner.taskId });
|
|
}
|
|
for (const task of this.#tasksById.values()) {
|
|
const record = task.write?.kind === "create" ? task.write.record : undefined;
|
|
if (record?.owner !== undefined) owners.push({ what: "Task owner", taskId: record.owner });
|
|
}
|
|
for (const { what, taskId } of owners) {
|
|
const task = await this.#currentTask(taskId);
|
|
if (task === undefined) throw new Error(`${what} ${taskId} does not exist`);
|
|
if (task.state.status === "terminal" || task.state.status === "completing") {
|
|
throw new Error(`${what} ${taskId} is ${task.state.status}`);
|
|
}
|
|
if (task.abortRequested) throw new Error(`${what} ${taskId} is abort-marked`);
|
|
}
|
|
}
|
|
|
|
#rejectForkSourceWrites(plans: readonly DocumentPlan[]): void {
|
|
for (const plan of plans) {
|
|
if (plan.content === undefined && !plan.retire) continue;
|
|
const record = plan.record;
|
|
if (this.#forkSourceDocumentIds.has(record.id)) {
|
|
throw new Error(`Cannot change fork source document ${record.id} in the fork transaction`);
|
|
}
|
|
const scope = plan.record.scope;
|
|
if (
|
|
scope.kind === "conversation" &&
|
|
this.#forkSourceConversationIds.has(scope.conversationId) &&
|
|
record.scope.kind === "conversation" &&
|
|
record.fork === "current"
|
|
) {
|
|
throw new Error(
|
|
`Cannot fork conversation ${scope.conversationId} while changing its current-policy documents`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#abortChanges(): void {
|
|
for (const document of this.#documents) document.change?.abort();
|
|
}
|
|
|
|
#assertOpen(): void {
|
|
if (this.#sealed) throw new Error("Transaction has settled");
|
|
}
|
|
|
|
#assertTaskDocumentsOpen(resolved: ResolvedAddress): void {
|
|
const scope = resolved.address.scope;
|
|
if (scope.kind === "task" && this.#tasksById.get(scope.taskId)?.write?.record.state.status === "terminal") {
|
|
throw new Error(`Task ${scope.taskId} is terminal`);
|
|
}
|
|
}
|
|
|
|
/** Register an operation so callback settlement can reject and drain it. */
|
|
#track<T>(operation: Promise<T>): Promise<T> {
|
|
this.#pendingOperations.add(operation);
|
|
const settle = (): void => {
|
|
this.#pendingOperations.delete(operation);
|
|
};
|
|
operation.then(settle, settle);
|
|
return operation;
|
|
}
|
|
|
|
#read<T>(method: string, read: () => Promise<T>): Promise<T> {
|
|
try {
|
|
this.#assertOpen();
|
|
if (this.#hasTableWrite) throw new ReadAfterWrite(`Tx.${method}()`);
|
|
return this.#track(read());
|
|
} catch (error) {
|
|
return Promise.reject(error);
|
|
}
|
|
}
|
|
|
|
#write<T>(write: () => Promise<T>): Promise<T> {
|
|
try {
|
|
this.#assertOpen();
|
|
this.#hasTableWrite = true;
|
|
return this.#track(write());
|
|
} catch (error) {
|
|
return Promise.reject(error);
|
|
}
|
|
}
|
|
|
|
async #requireConversation(id: ConversationId): Promise<void> {
|
|
if (this.#createdConversationIds.has(id)) return;
|
|
if ((await this.#host.storage.conversation(id, this.#context)) === undefined) {
|
|
throw new Error(`Conversation ${id} does not exist`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Lifecycle times: `startedAt` on the first change to `running`, `endedAt` on the change to `terminal`. Once set,
|
|
* they carry over from the replaced record; records written before they existed lack them.
|
|
*/
|
|
#stampTimes(value: AnyTaskRecord, candidate: AnyTaskRecord | undefined): AnyTaskRecord {
|
|
const status = value.state.status;
|
|
const now = (stamps: boolean) => (stamps ? this.#host.now() : undefined);
|
|
const startedAt = candidate?.startedAt ?? value.startedAt ?? now(status === "running");
|
|
const endedAt = candidate?.endedAt ?? value.endedAt ?? now(status === "terminal");
|
|
if (startedAt === value.startedAt && endedAt === value.endedAt) return value;
|
|
return {
|
|
...value,
|
|
...(startedAt === undefined ? {} : { startedAt }),
|
|
...(endedAt === undefined ? {} : { endedAt }),
|
|
};
|
|
}
|
|
|
|
#taskEntry(id: TaskId): TransactionTask {
|
|
let task = this.#tasksById.get(id);
|
|
if (task === undefined) {
|
|
task = {};
|
|
this.#tasksById.set(id, task);
|
|
}
|
|
return task;
|
|
}
|
|
|
|
/** Latest candidate task record, falling back to committed state; not a caller table read. */
|
|
async #currentTask(id: TaskId): Promise<AnyTaskRecord | undefined> {
|
|
return this.#tasksById.get(id)?.write?.record ?? (await this.#committedTask(id));
|
|
}
|
|
|
|
#committedTask(id: TaskId): Promise<AnyTaskRecord | undefined> {
|
|
const task = this.#taskEntry(id);
|
|
task.committedRead ??= this.#host.storage.task(id, this.#context);
|
|
return task.committedRead;
|
|
}
|
|
}
|
|
|
|
/** Plan of one staged document: its record, content write, and prepared change. Retirement is decided later. */
|
|
function planDocument(document: DocumentEntry): DocumentPlan | undefined {
|
|
const target = document.target;
|
|
if (target === undefined) return undefined;
|
|
const plan = { addressId: document.addressId, retire: document.retireOnCommit };
|
|
switch (target.kind) {
|
|
case "created": {
|
|
const prepared = document.prepared!;
|
|
const { record, version, tracker } = target;
|
|
const content = { version, kind: "base", value: prepared.value } as const;
|
|
return {
|
|
...plan,
|
|
record,
|
|
content: { type: "document.create", record, content },
|
|
change: { tracker, prepared, version },
|
|
};
|
|
}
|
|
case "fork-copy":
|
|
return {
|
|
...plan,
|
|
record: target.record,
|
|
content: { type: "document.copy", record: target.record, source: target.source },
|
|
};
|
|
case "retire-only":
|
|
return { ...plan, record: target.record };
|
|
case "loaded": {
|
|
const loaded = target.document;
|
|
const definition = document.definition!;
|
|
const prepared = document.prepared!;
|
|
const version = definition.version;
|
|
const id = loaded.record.id;
|
|
// A version change stores a base even without operations; otherwise only a change stores a delta.
|
|
const content =
|
|
loaded.storedVersion < version
|
|
? ({ type: "document.change", id, content: { version, kind: "base", value: prepared.value } } as const)
|
|
: prepared.ops.length > 0
|
|
? ({ type: "document.change", id, content: { version, kind: "delta", ops: prepared.ops } } as const)
|
|
: undefined;
|
|
const change = {
|
|
tracker: loaded.tracker,
|
|
prepared,
|
|
version,
|
|
loaded,
|
|
definition,
|
|
};
|
|
return { ...plan, record: loaded.record, change, ...(content === undefined ? {} : { content }) };
|
|
}
|
|
}
|
|
}
|
|
|
|
/** The publication of one adopted plan, if it publishes (`publishes()`), with `record` as committed. */
|
|
function describe(plan: DocumentPlan, record: DocumentRecord): DocumentCommitChange | undefined {
|
|
const conversationId = plan.conversationId;
|
|
if (plan.retire) return { type: "document", kind: "retire", record, conversationId };
|
|
if (plan.content?.type === "document.copy") {
|
|
return { type: "document.copy", record, conversationId: conversationId!, source: plan.content.source };
|
|
}
|
|
const change = plan.change;
|
|
if (change === undefined || !publishes(plan)) return undefined;
|
|
const ops = change.loaded === undefined ? EMPTY_OPERATIONS : change.prepared.ops;
|
|
return {
|
|
type: "document",
|
|
kind: "change",
|
|
record,
|
|
conversationId,
|
|
version: change.version,
|
|
value: change.prepared.value,
|
|
ops,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Take a hold's changes after `ops`, a commit's own on top of them, as one stored content for an incarnation now at
|
|
* `value`, and the operations a reader of the stored state applies to reach it: the folded ones, or a root replacement
|
|
* after the queue overflowed, which stores a base, as does a hold whose commits selected one.
|
|
*/
|
|
export function storeHeld(
|
|
hold: HeldDocument,
|
|
ops: readonly Op[],
|
|
value: JsonObject,
|
|
version: number,
|
|
): { readonly content: DocumentContent; readonly ops: readonly Op[] } {
|
|
hold.pending.push(ops);
|
|
const taken = hold.pending.take();
|
|
if (taken.kind === "replace") return { content: { version, kind: "base", value }, ops: [["r", value]] };
|
|
if (hold.base) return { content: { version, kind: "base", value }, ops: taken.ops };
|
|
return { content: { version, kind: "delta", ops: taken.ops }, ops: taken.ops };
|
|
}
|
|
|
|
/**
|
|
* Whether adoption publishes the plan: every creation, copy, and retirement, and a loaded incarnation that writes
|
|
* content, which includes a migration-only base so observers of the older shape receive the new value.
|
|
*/
|
|
function publishes(plan: DocumentPlan): boolean {
|
|
return plan.retire || plan.change?.loaded === undefined || plan.content !== undefined;
|
|
}
|