From a16ccd9be8e35e941a518e0930e7d36aeb8469e6 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 18 Sep 2026 21:01:46 +0200 Subject: [PATCH] feat(durable): refine record and query contracts --- packages/durable/docs/pico-v5-chord-usage.md | 36 +-- packages/durable/docs/pico-v5-handoff.md | 6 +- packages/durable/docs/pico-v5.md | 128 +++++----- packages/durable/src/index.ts | 7 +- packages/durable/src/memory-storage.ts | 117 ++++++--- packages/durable/src/types.ts | 248 +++++++++++++++---- packages/durable/test/memory-storage.test.ts | 79 ++++-- packages/durable/test/types.test.ts | 140 +++++++++++ 8 files changed, 562 insertions(+), 199 deletions(-) create mode 100644 packages/durable/test/types.test.ts diff --git a/packages/durable/docs/pico-v5-chord-usage.md b/packages/durable/docs/pico-v5-chord-usage.md index a95b25db7..6c5a68978 100644 --- a/packages/durable/docs/pico-v5-chord-usage.md +++ b/packages/durable/docs/pico-v5-chord-usage.md @@ -72,7 +72,7 @@ type Stroke = { color: string; points: { x: number; y: number }[] }; type CanvasState = { strokes: Stroke[] }; const CanvasDoc = defineDoc({ - id: "app.canvas", version: 1, scope: "session", + kind: "app.canvas", version: 1, scope: "session", initial: () => ({ strokes: [] }), // This append-only example adds one stroke per commit. checkpointWhen: value => value.strokes.length % 100 === 0, @@ -179,29 +179,29 @@ A method's successful commit does not promise every remote callback has run yet. ## 2. Conversation-scoped diff reviews A **document family** uses one definition for many instances. The logical key is -`(definition, conversationId, instanceId)`; the persisted numeric document ID -identifies an incarnation and is never reused. Initializer input is not identity. +`(kind, conversationId, key)`; the persisted numeric document ID identifies an +incarnation and is never reused. Initializer input is not part of the key. ```ts type ReviewInput = { path: string; patch: string }; type ReviewComment = { id: string; line: number; text: string }; type ReviewState = ReviewInput & { comments: ReviewComment[] }; const ReviewDoc = defineDocFamily({ - id: "app.diff-review", version: 1, family: true, scope: "conversation", + kind: "app.diff-review", version: 1, family: true, scope: "conversation", history: "latest", fork: "current", initial: input => ({ path: input.path, patch: input.patch, comments: [] }), checkpointWhen: value => value.comments.length % 50 === 0, }); interface DiffReviewService { readonly state: ReplicatedState; - identity(context: Context): Promise<{ conversationId: Id; instanceId: string }>; + identity(context: Context): Promise<{ conversationId: Id; key: string }>; addComment(comment: ReviewComment, context: Context): Promise; } const DiffReviews = defineService("app.diff-reviews"); function reviewFacet( session: Session, conversationId: Id, - reviews: readonly { instanceId: string; initial: ReviewInput }[], context: Context, + reviews: readonly { key: string; initial: ReviewInput }[], context: Context, ): Facet { return defineFacet({ id: "app.diff-reviews/session", @@ -213,10 +213,10 @@ function reviewFacet( const source = await session.documentSource(ReviewDoc, target, context); const replica = await documentReplicatedState(source, context); env.own(() => replica.dispose()); - // Chord instance keys route services; they are not persisted document IDs. - instances.spawn(JSON.stringify([conversationId, review.instanceId]), { + // Chord instance keys route services; they are not numeric document incarnation IDs. + instances.spawn(JSON.stringify([conversationId, review.key]), { state: replica.state, - async identity() { return { conversationId, instanceId: review.instanceId }; }, + async identity() { return { conversationId, key: review.key }; }, async addComment(comment, context) { await session.commit(async tx => { const draft = await tx.doc(ReviewDoc, target); @@ -231,8 +231,8 @@ function reviewFacet( } ``` -Example input: `[{ instanceId: "review-7", initial: { path: "a.ts", patch: "-old\n+new" } }]`. -Pass distinct instance IDs and a real conversation ID; install the facet as above. +Example input: `[{ key: "review-7", initial: { path: "a.ts", patch: "-old\n+new" } }]`. +Pass distinct family keys and a real conversation ID; install the facet as above. Keyed consumers use `env.observe(DiffReviews, handler)`, not `env.use`. The handler receives `(review, context)`; call `review.identity(context)` to identify it and `review.state.subscribe` to observe comments. @@ -240,7 +240,7 @@ receives `(review, context)`; call `review.identity(context)` to identify it and These comments are conversation-scoped: ending the generating task or unloading the facet does not retire them. `current` copies every logically present review at fork-commit time into independent child instances with new -numeric IDs, retaining family instance IDs. Parent and child then diverge. +numeric IDs, retaining family keys. Parent and child then diverge. ```text parent review-7: comment A -> transcript entry E -> comment B @@ -251,7 +251,7 @@ child adds C: parent still contains only A and B With `fork: "initial"`, no instance copies; first child access uses supplied input. `latest` cannot read history or fork `asOf`; use `history: "rewindable"` with `asOf` to reflect E's commit. Reaccess ignores `initial`; updating the patch requires -an explicit mutation or a distinct review instance ID. +an explicit mutation or a distinct review key. ## 3. Task-scoped output and a tool/task watch @@ -262,7 +262,7 @@ it task lifetime; no history, fork, owner, or conversation setting is needed. type JobInput = { command: string }; type JobOutput = { stdout: string; chunks: number }; const JobOutputDoc = defineDoc({ - id: "app.job-output", version: 1, scope: "task", + kind: "app.job-output", version: 1, scope: "task", initial: () => ({ stdout: "", chunks: 0 }), checkpointWhen: value => value.chunks % 100 === 0, }); @@ -331,7 +331,7 @@ task-scoped family only when one task needs several independently keyed document `ConversationView` contains `conversation`, raw transcript `entries`, and `docs: Readonly>`. Selected built-in singletons mount -under `docs` by stable definition ID, not numeric incarnation ID. The spec's +under `docs` by stable document kind, not numeric incarnation ID. The spec's illustrative path mapping is: ```text @@ -339,7 +339,7 @@ document ["s", ["message"], value] -> view ["s", ["docs", "pi.live", "message"], value] ``` -Built-in IDs/fields await approval; `pi.live` is illustrative, not an available API. +Built-in kinds and fields await approval; `pi.live` is illustrative, not an available API. The mount publishes one batch per complete Session commit: entry/head changes and changed mounted documents together, without a tracker or semantic projection. Third-party documents are **not automatically mounted**; use their own Chord @@ -374,8 +374,8 @@ nothing and poisons the open Session; close and reopen it instead of continuing. as an audit log. - A listener may call `stop()`, but must not await its own `closed` promise. - Definitions own checkpoints, not storage heuristics. Revise the counting predicates - above if mutations change. Keep schema IDs, versions, fork policies, and public - paths stable; schema changes require migration, not a source-only rename. + above if mutations change. Keep document kinds, versions, fork policies, and + public paths stable; schema changes require migration, not a source-only rename. Chord sources: [types](../../chord/src/types.ts), [facet examples](../../chord/test/facets.test.ts), [state](../../chord/src/services/state.ts), [provider](../../chord/src/services/provider.ts), diff --git a/packages/durable/docs/pico-v5-handoff.md b/packages/durable/docs/pico-v5-handoff.md index 9aa2e3f22..2f6290a34 100644 --- a/packages/durable/docs/pico-v5-handoff.md +++ b/packages/durable/docs/pico-v5-handoff.md @@ -17,7 +17,7 @@ facades, membranes, document routing, view projection, events, or clone chains. Implement IDs, sequences, reserved root conversation ID `1`, `ConversationRecord`, `EntryRecord`, tagged inputs, live/terminal `TaskRecord` -values, document metadata, storage writes, backend-opaque JSON cursors, and +values, document lifecycle records, storage writes, backend-opaque JSON cursors, and detached `MemoryStorage` tables. Reserve `Conversation` for the public conversation object, `Entry` for the typed entry definition, and `Task` for the typed executable definition returned by @@ -77,8 +77,8 @@ unload/reload. ## 7. Document definitions and access -Implement `defineDoc`, `defineDocFamily`, identity validation, the three direct -scopes, and get-or-create `tx.doc`, `snapshot`, and `documentSource` acquisition. +Implement `defineDoc`, `defineDocFamily`, document kind/key validation, the three +direct scopes, and get-or-create `tx.doc`, `snapshot`, and `documentSource` acquisition. Test concurrent initialization once, initial bases, detached snapshots, family initializer use only on first creation, scope/target mismatch, terminal-task diff --git a/packages/durable/docs/pico-v5.md b/packages/durable/docs/pico-v5.md index c52881b0b..7b0e5fadd 100644 --- a/packages/durable/docs/pico-v5.md +++ b/packages/durable/docs/pico-v5.md @@ -103,9 +103,10 @@ order yields the effective prompt and tool set. ```ts type ContextEdit = { readonly target: Id; - readonly action: "omit" | "replace"; - readonly messages?: readonly Message[]; -}; +} & ( + | { readonly action: "omit"; readonly messages?: never } + | { readonly action: "replace"; readonly messages: readonly Message[] } +); type EntryRecord = { readonly id: Id; @@ -265,7 +266,7 @@ interface InputHandle { } type SettledTask = TaskRecord & { - readonly state: TerminalTask; + readonly state: Extract, { status: "terminal" }>; }; type ConversationWatch = WatchHandle; @@ -531,7 +532,7 @@ type DocumentSemantics = | { readonly scope: "task" }; type CommonDocDefinition = { - readonly id: string; + readonly kind: string; readonly version: number; initial(): T; migrate?(value: JsonObject, fromVersion: number): T; @@ -588,7 +589,7 @@ type AnyDocToken = DocToken | DocFamilyToken; Validation rules: -- Definition IDs are unique among loaded definitions. +- Document kinds are unique among loaded definitions. - Versions are positive integers. - Session documents are current-only and belong to the Session. Closing and reopening the Session does not retire them. @@ -605,16 +606,17 @@ Concrete built-in document grouping and semantics are declared when the built-in definitions are implemented. The generic document mechanism does not special case model, tool, inbox, or presentation state. -### 3.2 Identity +### 3.2 Records and lifetimes -A persisted document instance has: +A persisted document instance has one lifecycle record: ```ts -type DocumentIdentity = { - readonly id: Id; // unique incarnation - readonly definitionId: string; - readonly definitionVersion: number; - readonly instanceId?: string; // families only +type DocumentRecord = { + readonly id: Id; // unique incarnation + readonly kind: string; // registered definition kind + readonly key?: string; // families only + readonly createdAt: Seq; // stamped by the committing storage + readonly retiredAt?: Seq; } & ( | { readonly scope: { readonly kind: "session" } } | ({ readonly scope: { readonly kind: "conversation"; readonly conversationId: Id } } & ( @@ -627,19 +629,23 @@ type DocumentIdentity = { | { readonly scope: { readonly kind: "task"; readonly taskId: Id } } ); -type DocumentMetadata = DocumentIdentity & { - readonly createdAt: Seq; // stamped by the committing storage - readonly retiredAt?: Seq; -}; +type DocumentCreate = DocumentRecord extends infer Record + ? Record extends DocumentRecord + ? Omit + : never + : never; ``` -`id` is never reused. Retiring and recreating the same logical definition, -scope, and instance ID creates a new incarnation. +`id` is never reused. Retiring and recreating the same logical kind, scope, and +family key creates a new incarnation. -A singleton is identified logically by definition and scope. A family is -identified logically by definition, scope, and `instanceId`. Persisted metadata -records the scope and conversation history/fork semantics so absent plugin code -does not make existing data disappear. +A singleton is identified logically by kind and scope. A family is identified +logically by kind, scope, and `key`. The record preserves scope and conversation +history/fork semantics so absent plugin code does not make existing data +disappear. Definition versions belong to stored bases and deltas because one +incarnation may contain records written by multiple definition versions. +`DocumentCreate` is not another persisted record; it is the same scoped union +without storage-assigned lifetime fields. ### 3.3 Access and creation @@ -721,14 +727,14 @@ type DocTarget = | { readonly scope: "conversation"; readonly conversationId: Id } | { readonly scope: "task"; readonly taskId: Id }; -type FamilyKey = DocTarget & { readonly instanceId: string }; +type FamilyKey = DocTarget & { readonly key: string }; type FamilyTarget = FamilyKey & { readonly initial: I }; type HistoricalFamilyTarget = { readonly scope: "conversation"; readonly conversationId: Id; - readonly instanceId: string; + readonly key: string; }; ``` @@ -941,11 +947,6 @@ Storage ownership: ### 5.1 Definition ```ts -type LiveTask = { - readonly status: "pending" | "running"; - readonly checkpoint: S; -}; - type TaskOutcome = | { readonly status: "completed"; readonly result: R } | { readonly status: "failed"; readonly error: StoredError; readonly result?: R } @@ -953,10 +954,10 @@ type TaskOutcome = | { readonly status: "orphaned"; readonly reason: string } | { readonly status: "faulted"; readonly error: StoredError }; -type TerminalTask = { - readonly status: "terminal"; - readonly outcome: TaskOutcome; -}; +type TaskState = + | { readonly status: "pending"; readonly checkpoint: S } + | { readonly status: "running"; readonly checkpoint: S } + | { readonly status: "terminal"; readonly outcome: TaskOutcome }; type TaskRecord = { readonly id: Id; @@ -964,15 +965,22 @@ type TaskRecord = { readonly kind: string; readonly version: number; readonly input: I; - readonly state: LiveTask | TerminalTask; readonly after: readonly Id[]; readonly background: boolean; readonly abortRequested: boolean; - readonly memos?: Readonly>; // live tasks only -}; +} & ( + | { + readonly state: Extract, { status: "pending" | "running" }>; + readonly memos?: Readonly>; + } + | { + readonly state: Extract, { status: "terminal" }>; + readonly memos?: never; + } +); type RunningTask = TaskRecord & { - readonly state: LiveTask & { readonly status: "running" }; + readonly state: Extract, { status: "running" }>; }; interface HookRunner { @@ -1782,9 +1790,8 @@ type Cursor = Readonly>; type EntryQuery = { readonly conversationId: Id; - readonly before?: Id; // strict semantic cutoff, independent of page cursor - readonly kind?: string; - readonly withHead?: boolean; + readonly minEntryId?: Id; // inclusive + readonly maxEntryId?: Id; // inclusive }; type TaskQuery = { @@ -1796,9 +1803,9 @@ type TaskQuery = { }; type DocumentQuery = { - readonly definitionId?: string; - readonly scope?: DocumentIdentity["scope"]; - readonly instanceId?: string; + readonly kind?: string; + readonly scope?: DocumentRecord["scope"]; + readonly key?: string; readonly at?: Seq | "current"; readonly includeRetired?: boolean; }; @@ -1808,7 +1815,7 @@ type StoredDocumentRecord = | { readonly seq: Seq; readonly version: number; readonly kind: "delta"; readonly ops: readonly Op[] }; type StoredDocument = { - readonly metadata: DocumentMetadata; + readonly record: DocumentRecord; readonly records: readonly [ Extract, ...Extract[], @@ -1822,12 +1829,13 @@ type StorageWrite = | { readonly type: "input"; readonly value: Input } | { readonly type: "document.create"; - readonly identity: DocumentIdentity; + readonly record: DocumentCreate; + readonly version: number; readonly value: JsonObject; // always a base; storage stamps createdAt } | { readonly type: "document.change"; - readonly metadata: DocumentMetadata; + readonly record: DocumentRecord; readonly definition: AnyDocToken; readonly ops: readonly Op[]; readonly value: JsonObject; @@ -1843,9 +1851,9 @@ interface Storage { conversation(id: Id, context: Context): Promise; scanConversations(cursor: Cursor | undefined, limit: number, context: Context): Promise>; - entries(ids: readonly Id[], context: Context): Promise>; + entry(id: Id, context: Context): Promise<{ readonly entry: EntryRecord; readonly commitSeq: Seq } | undefined>; + findLatestHeadMarker(conversationId: Id, atOrBeforeEntryId: Id | undefined, context: Context): Promise<(EntryRecord & { readonly head: Id }) | undefined>; scanEntries(query: EntryQuery, cursor: Cursor | undefined, limit: number, context: Context): Promise>; - entryCommit(id: Id, context: Context): Promise; task(id: Id, context: Context): Promise | undefined>; scanTasks(query: TaskQuery, cursor: Cursor | undefined, limit: number, context: Context): Promise, Cursor>>; @@ -1854,7 +1862,7 @@ interface Storage { inputByRequest(conversationId: Id, requestId: string, context: Context): Promise; document(id: Id, at: Seq | "current", context: Context): Promise; - scanDocuments(query: DocumentQuery, cursor: Cursor | undefined, limit: number, context: Context): Promise>; + scanDocuments(query: DocumentQuery, cursor: Cursor | undefined, limit: number, context: Context): Promise>; close(context: Context): Promise; } @@ -1865,9 +1873,17 @@ scan on the same storage; cross-storage or cross-query use is unsupported. The Session owns the mutation line, so storage implementations do not add a second caller-facing commit mutex. Each backend still makes one admitted batch atomic. -`EntryQuery` supports conversation ancestry, a strict `before` entry cursor, -kind filtering, and `withHead`. Document queries support definition, scope, -instance, current/as-of membership, and retired membership. Task queries support +`findLatestHeadMarker()` returns the newest visible entry carrying `head` at or +below its optional inclusive cutoff. The returned entry is the marker; its +`head` value is the actual lower bound for context. `scanEntries()` pages the +inclusive ID range in newest-first order while applying every conversation +ancestry cap. With no bounds it pages complete visible history. To read context +through entry `E`, find the marker at or before `E`, then scan from +`marker?.head` through `E`. For current context the upper bound is omitted. +`entry()` combines exact global lookup with the commit sequence required by +historical document reads. `limit` is always the maximum page size. +Document queries support kind, scope, family key, current/as-of membership, and +retired membership. Task queries support conversation, kind, live/terminal status, abort mark, and background status. @@ -1889,7 +1905,7 @@ only after a committed base or retirement. One SQL transaction is one Session commit. SQLite stores: - conversation, entry, task, and input records; -- document metadata; +- document lifecycle records; - indexed document bases/deltas by document and commit sequence. Live task transitions replace one row. Terminal tasks remain as small records. @@ -1959,8 +1975,8 @@ These are contracts, not invitations to add defensive machinery: live. - **Wrong fork setting:** `current`, `initial`, and `asOf` are product semantics, not optimizations. Changing one changes child conversation behavior. -- **Schema stability:** definition IDs, mounted document IDs, and visible paths - are persisted/public protocol. Value migration cannot rename an ID; an ID +- **Schema stability:** document kinds and visible mount paths are + persisted/public protocol. Value migration cannot rename a kind; a kind change requires explicit copy and retirement. - **Watch activation:** `watch.value` is the immutable acquisition snapshot. Initialize the consumer from it before `start()`; queued operations are based diff --git a/packages/durable/src/index.ts b/packages/durable/src/index.ts index eb6b174d4..8ffbc9cf0 100644 --- a/packages/durable/src/index.ts +++ b/packages/durable/src/index.ts @@ -3,14 +3,13 @@ export type { ContextEdit, ConversationRecord, Cursor, - DocumentIdentity, - DocumentMetadata, + DocumentCreate, + DocumentRecord, EntryDraft, EntryQuery, EntryRecord, Id, Input, - LiveTask, Page, Seq, Storage, @@ -19,6 +18,6 @@ export type { TaskOutcome, TaskQuery, TaskRecord, - TerminalTask, + TaskState, } from "./types.ts"; export { ROOT_CONVERSATION_ID } from "./types.ts"; diff --git a/packages/durable/src/memory-storage.ts b/packages/durable/src/memory-storage.ts index 5b531c35c..71483790d 100644 --- a/packages/durable/src/memory-storage.ts +++ b/packages/durable/src/memory-storage.ts @@ -20,7 +20,8 @@ type TableName = "conversation" | "entry" | "task" | "input"; type State = { conversations: Map; entries: Map; - entryCommits: Map; + entryIds: Map; + entryCommitSeqs: Map; tasks: Map; inputs: Map; }; @@ -69,7 +70,8 @@ export class MemoryStorage implements Storage { private readonly state: State = { conversations: new Map(), entries: new Map(), - entryCommits: new Map(), + entryIds: new Map(), + entryCommitSeqs: new Map(), tasks: new Map(), inputs: new Map(), }; @@ -88,10 +90,24 @@ export class MemoryStorage implements Storage { case "conversation": this.state.conversations.set(write.value.id, write.value); break; - case "entry": + case "entry": { this.state.entries.set(write.value.id, write.value); - this.state.entryCommits.set(write.value.id, seq); + this.state.entryCommitSeqs.set(write.value.id, seq); + let ids = this.state.entryIds.get(write.value.conversationId); + if (ids === undefined) { + ids = []; + this.state.entryIds.set(write.value.conversationId, ids); + } + let low = 0; + let high = ids.length; + while (low < high) { + const middle = (low + high) >>> 1; + if (ids[middle] < write.value.id) low = middle + 1; + else high = middle; + } + ids.splice(low, 0, write.value.id); break; + } case "task": this.state.tasks.set(write.value.id, write.value); break; @@ -130,14 +146,26 @@ export class MemoryStorage implements Storage { return page(values, limit); } - async entries(ids: readonly Id[], _context: Context): Promise> { + async entry( + id: Id, + _context: Context, + ): Promise<{ readonly entry: EntryRecord; readonly commitSeq: Seq } | undefined> { this.assertOpen(); - const result = new Map(); - for (const id of ids) { - const value = this.state.entries.get(id); - if (value !== undefined) result.set(id, clone(value)); + const entry = this.state.entries.get(id); + if (entry === undefined) return undefined; + return { entry: clone(entry), commitSeq: this.state.entryCommitSeqs.get(id)! }; + } + + async findLatestHeadMarker( + conversationId: Id, + atOrBeforeEntryId: Id | undefined, + _context: Context, + ): Promise<(EntryRecord & { readonly head: Id }) | undefined> { + this.assertOpen(); + for (const entry of this.visibleEntries(conversationId, undefined, atOrBeforeEntryId)) { + if (entry.head !== undefined) return clone({ ...entry, head: entry.head }); } - return result; + return undefined; } async scanEntries( @@ -147,39 +175,17 @@ export class MemoryStorage implements Storage { _context: Context, ): Promise> { this.assertOpen(); - if (!this.state.conversations.has(query.conversationId)) { - throw new Error(`Unknown conversation: ${query.conversationId}`); - } - const beforeCursor = cursorId(cursor); + const after = cursorId(cursor); + const maxEntryId = + after === undefined ? query.maxEntryId : Math.min(query.maxEntryId ?? Number.POSITIVE_INFINITY, after - 1); const visible: EntryRecord[] = []; - let currentId = query.conversationId; - let upperEntryId = Number.POSITIVE_INFINITY; - const visited = new Set(); - while (true) { - if (visited.has(currentId)) throw new Error("Conversation parent cycle"); - visited.add(currentId); - for (const entry of this.state.entries.values()) { - if (entry.conversationId !== currentId || entry.id > upperEntryId) continue; - if (query.before !== undefined && entry.id >= query.before) continue; - if (beforeCursor !== undefined && entry.id >= beforeCursor) continue; - if (query.kind !== undefined && entry.kind !== query.kind) continue; - if (query.withHead === true && entry.head === undefined) continue; - visible.push(entry); - } - const conversation = this.state.conversations.get(currentId)!; - if (conversation.parent === undefined) break; - upperEntryId = Math.min(upperEntryId, conversation.parent.at); - currentId = conversation.parent.conversationId; + for (const entry of this.visibleEntries(query.conversationId, query.minEntryId, maxEntryId)) { + visible.push(entry); + if (visible.length > limit) break; } - visible.sort((left, right) => right.id - left.id); return page(visible, limit); } - async entryCommit(id: Id, _context: Context): Promise { - this.assertOpen(); - return this.state.entryCommits.get(id); - } - async task(id: Id, _context: Context): Promise { this.assertOpen(); const value = this.state.tasks.get(id); @@ -223,6 +229,41 @@ export class MemoryStorage implements Storage { this.closed = true; } + private *visibleEntries( + conversationId: Id, + minEntryId = Number.NEGATIVE_INFINITY, + maxEntryId = Number.POSITIVE_INFINITY, + ): Generator { + if (!this.state.conversations.has(conversationId)) { + throw new Error(`Unknown conversation: ${conversationId}`); + } + let currentId = conversationId; + let upperEntryId = maxEntryId; + const visited = new Set(); + while (true) { + if (visited.has(currentId)) throw new Error("Conversation parent cycle"); + visited.add(currentId); + const ids = this.state.entryIds.get(currentId) ?? []; + let low = 0; + let high = ids.length; + while (low < high) { + const middle = (low + high) >>> 1; + if (ids[middle] <= upperEntryId) low = middle + 1; + else high = middle; + } + for (let index = low - 1; index >= 0; index--) { + const id = ids[index]; + if (id < minEntryId) break; + yield this.state.entries.get(id)!; + } + const conversation = this.state.conversations.get(currentId)!; + if (conversation.parent === undefined) break; + upperEntryId = Math.min(upperEntryId, conversation.parent.at); + if (upperEntryId < minEntryId) break; + currentId = conversation.parent.conversationId; + } + } + private checkImmutableIds(writes: readonly StorageWrite[]): void { const claimed = new Map(); for (const write of writes) { diff --git a/packages/durable/src/types.ts b/packages/durable/src/types.ts index 6e336f9d8..504095140 100644 --- a/packages/durable/src/types.ts +++ b/packages/durable/src/types.ts @@ -1,66 +1,90 @@ import type { Context, JsonValue } from "@earendil-works/chord"; import type { Message } from "@earendil-works/pi-ai"; -/** Session-global durable record identifier. */ +/** Session-global identifier shared by every durable record table. */ export type Id = number; -/** Storage-assigned atomic commit sequence. */ + +/** Monotonic sequence assigned to one atomic storage commit. */ export type Seq = number; /** The root conversation always uses this reserved ID. */ export const ROOT_CONVERSATION_ID: Id = 1; +/** JSON-safe error snapshot persisted instead of a runtime `Error` object. */ export type StoredError = { readonly message: string; + /** Optional structured diagnostic data for inspection or recovery. */ readonly detail?: JsonValue; }; +/** Immutable identity, history ancestry, and task ownership of a transcript scope. */ export type ConversationRecord = { readonly id: Id; - /** Transcript ancestry and the inclusive entry through which history is inherited. */ + /** Fork source and inclusive parent entry through which history is inherited. */ readonly parent?: { readonly conversationId: Id; readonly at: Id; }; - /** Durable task-ownership edge used for subtree abort and idle traversal. */ + /** Creator edge used for authorization, subtree abort, and subtree idle waits. */ readonly owner?: { readonly conversationId: Id; readonly taskId: Id; }; }; +/** An immutable override of one visible entry's contribution to model context. */ export type ContextEdit = { + /** Entry whose model messages are omitted or replaced. */ readonly target: Id; - readonly action: "omit" | "replace"; - readonly messages?: readonly Message[]; -}; +} & ( + | { + readonly action: "omit"; + readonly messages?: never; + } + | { + readonly action: "replace"; + /** Messages contributed instead of the target entry's model messages. */ + readonly messages: readonly Message[]; + } +); +/** Immutable transcript event with separate model-facing and application-facing payloads. */ export type EntryRecord = { readonly id: Id; readonly conversationId: Id; + /** Application-defined entry discriminator. */ readonly kind: string; /** Messages contributed to model context; absent for display or bookkeeping entries. */ readonly model?: readonly Message[]; + /** JSON payload consumed by views, plugins, or bookkeeping logic. */ readonly data?: JsonValue; - /** Entry at which the active context begins. */ + /** First entry in the active context selected by this entry. */ readonly head?: Id; + /** Context-only overrides of earlier visible entries. */ readonly edits?: readonly ContextEdit[]; + /** Task that appended this entry, when it was produced by durable work. */ readonly byTaskId?: Id; }; +/** Entry content supplied before the Session assigns identity and task attribution. */ export type EntryDraft = Omit & { + /** `"self"` starts active context at the newly assigned entry ID. */ readonly head?: Id | "self"; }; +/** Identity fields shared by every input lifecycle state. */ type InputBase = { readonly id: Id; readonly conversationId: Id; + /** Host-provided deduplication key, scoped to the conversation. */ readonly requestId?: string; }; -/** Durable admission and settlement state for one host input. */ +/** Durable lifecycle of one admitted host input and the handle waiting for it. */ export type Input = InputBase & ( | { + /** Admitted but not yet represented in the transcript. */ readonly status: "queued"; readonly entry?: never; readonly answer?: never; @@ -68,6 +92,7 @@ export type Input = InputBase & readonly detail?: never; } | { + /** Added to the transcript and owned by an active turn. */ readonly status: "placed"; /** User transcript entry created when this input was placed. */ readonly entry: Id; @@ -76,6 +101,7 @@ export type Input = InputBase & readonly detail?: never; } | { + /** Successfully settled with a durable transcript result. */ readonly status: "done"; /** User or passive-write transcript entry created when this input was placed. */ readonly entry: Id; @@ -85,87 +111,175 @@ export type Input = InputBase & readonly detail?: never; } | { + /** Terminal input that can no longer receive an answer. */ readonly status: "unanswered"; /** Present when the input was placed before becoming unanswered. */ readonly entry?: Id; readonly answer?: never; + /** Stable machine-readable explanation such as `"aborted"` or `"stale"`. */ readonly reason: string; + /** Optional structured diagnostic data. */ readonly detail?: JsonValue; } ); -export type LiveTask = { - readonly status: "pending" | "running"; - readonly checkpoint: S; -}; - +/** Durable reason and optional result recorded when a task becomes terminal. */ export type TaskOutcome = - | { readonly status: "completed"; readonly result: R } - /** Expected task/domain failure explicitly committed by its implementation. */ - | { readonly status: "failed"; readonly error: StoredError; readonly result?: R } - | { readonly status: "aborted"; readonly reason?: string; readonly result?: R } - | { readonly status: "orphaned"; readonly reason: string } - /** Harness-detected contract failure, such as an uncaught throw or no durable progress. */ - | { readonly status: "faulted"; readonly error: StoredError }; + | { + readonly status: "completed"; + readonly result: R; + readonly error?: never; + readonly reason?: never; + } + /** Expected task or domain failure explicitly committed by its implementation. */ + | { + readonly status: "failed"; + readonly error: StoredError; + readonly result?: R; + readonly reason?: never; + } + /** Explicit cancellation handled by the task's abort protocol. */ + | { + readonly status: "aborted"; + readonly reason?: string; + readonly result?: R; + readonly error?: never; + } + /** Task that cannot resume because its definition or migration is unavailable. */ + | { + readonly status: "orphaned"; + readonly reason: string; + readonly result?: never; + readonly error?: never; + } + /** Runtime-detected contract failure, such as an uncaught throw or no durable progress. */ + | { + readonly status: "faulted"; + readonly error: StoredError; + readonly result?: never; + readonly reason?: never; + }; -export type TerminalTask = { - readonly status: "terminal"; - readonly outcome: TaskOutcome; -}; +/** Complete durable execution state of a task. */ +export type TaskState = + | { + /** Eligible for scheduling when its dependencies are terminal. */ + readonly status: "pending"; + /** Complete durable state from which execution resumes. */ + readonly checkpoint: S; + readonly outcome?: never; + } + | { + /** Reserved by one in-memory task invocation. */ + readonly status: "running"; + /** Complete durable state from which execution resumes. */ + readonly checkpoint: S; + readonly outcome?: never; + } + | { + /** Permanently settled durable result receipt. */ + readonly status: "terminal"; + readonly checkpoint?: never; + readonly outcome: TaskOutcome; + }; -export type TaskRecord = { +/** Identity, definition, and scheduling fields shared by every task state. */ +type TaskRecordBase = { readonly id: Id; readonly conversationId: Id; + /** Registered task definition name. */ readonly kind: string; + /** Definition version used to migrate live input and checkpoints. */ readonly version: number; + /** Original task input retained while the task is live or terminal. */ readonly input: I; - readonly state: LiveTask | TerminalTask; + /** Tasks that must be terminal before ordinary execution may begin. */ readonly after: readonly Id[]; + /** Whether this task is excluded from ordinary idle waits and conversation aborts. */ readonly background: boolean; + /** Durable abort mark checked before run-mode progress is committed. */ readonly abortRequested: boolean; - readonly memos?: Readonly>; }; -/** Persisted identity of one durable document incarnation. */ -export type DocumentIdentity = { +/** Complete replacement record for one durable task state machine. */ +export type TaskRecord = TaskRecordBase & + ( + | { + readonly state: Extract, { readonly status: "pending" | "running" }>; + /** Small first-writer-wins values retained while the task is live. */ + readonly memos?: Readonly>; + } + | { + readonly state: Extract, { readonly status: "terminal" }>; + readonly memos?: never; + } + ); + +/** Persisted lifecycle record for one create-to-retire document incarnation. */ +export type DocumentRecord = { + /** Unique incarnation ID; never reused when the same logical document is recreated. */ readonly id: Id; - readonly definitionId: string; - readonly definitionVersion: number; - readonly instanceId?: string; + /** Registered document kind. */ + readonly kind: string; + /** Family member key; absent for singleton documents. */ + readonly key?: string; + /** Commit that created the incarnation, stamped by storage. */ + readonly createdAt: Seq; + /** Commit that retired the incarnation; absent while it is current. */ + readonly retiredAt?: Seq; } & ( - | { readonly scope: { readonly kind: "session" } } + | { + readonly scope: { readonly kind: "session" }; + readonly history?: never; + readonly fork?: never; + } | ({ readonly scope: { readonly kind: "conversation"; readonly conversationId: Id } } & ( - | { readonly history: "latest"; readonly fork: "current" | "initial" } | { + /** Retain only current state. */ + readonly history: "latest"; + /** Initialize a fork from current source state or the definition's initial value. */ + readonly fork: "current" | "initial"; + } + | { + /** Retain history needed for as-of reads. */ readonly history: "rewindable"; + /** Initialize a fork at its cutoff, from current state, or from the initial value. */ readonly fork: "asOf" | "current" | "initial"; } )) - | { readonly scope: { readonly kind: "task"; readonly taskId: Id } } + | { + readonly scope: { readonly kind: "task"; readonly taskId: Id }; + readonly history?: never; + readonly fork?: never; + } ); -/** Commit-sequence lifetime of one persisted document incarnation. */ -export type DocumentMetadata = DocumentIdentity & { - readonly createdAt: Seq; - readonly retiredAt?: Seq; -}; +/** Fields supplied when storage creates and stamps a new `DocumentRecord`. */ +export type DocumentCreate = DocumentRecord extends infer Record + ? Record extends DocumentRecord + ? Omit + : never + : never; +/** One ordered scan result and its optional continuation state. */ export type Page = { readonly items: readonly T[]; readonly next?: C; }; -/** Backend-owned, JSON-serializable continuation state. Callers must treat it as opaque. */ +/** Backend-owned JSON continuation state that callers only round-trip to the same scan. */ export type Cursor = Readonly>; +/** Inclusive ID bounds for a newest-first scan of one conversation's fork-aware history. */ export type EntryQuery = { readonly conversationId: Id; - /** Strict semantic cutoff: only entries with IDs less than this value match. */ - readonly before?: Id; - readonly kind?: string; - readonly withHead?: boolean; + /** Oldest entry ID that may be returned. */ + readonly minEntryId?: Id; + /** Newest entry ID that may be returned. */ + readonly maxEntryId?: Id; }; +/** Optional filters for an ordered scan of durable task records. */ export type TaskQuery = { readonly conversationId?: Id; readonly kind?: string; @@ -174,39 +288,61 @@ export type TaskQuery = { readonly background?: boolean; }; -/** Package 1 table writes. Document lifecycle writes are added in Package 2. */ +/** One table mutation in an atomic storage commit; task and input writes replace whole records. */ export type StorageWrite = | { readonly type: "conversation"; readonly value: ConversationRecord } | { readonly type: "entry"; readonly value: EntryRecord } | { readonly type: "task"; readonly value: TaskRecord } | { readonly type: "input"; readonly value: Input }; +/** + * Atomic persistence boundary for Session records. + * + * Implementations return detached values, own cursor encoding, and protect global ID + * uniqueness. The owning Session serializes commits on its mutation line. + */ export interface Storage { - /** - * Persist one batch atomically and return its commit sequence. - * The owning Session serializes calls on its mutation line; Storage implementations - * do not provide a second caller-facing mutation mutex. - */ + /** Atomically persist one batch and return the sequence assigned to that commit. */ commit(writes: readonly StorageWrite[], context: Context): Promise; + + /** Return a fresh candidate from the Session-global record ID namespace. */ mintId(): Id; + /** Look up one conversation by exact ID. */ conversation(id: Id, context: Context): Promise; + + /** Scan conversations in ascending ID order. */ scanConversations( cursor: Cursor | undefined, limit: number, context: Context, ): Promise>; - entries(ids: readonly Id[], context: Context): Promise>; + /** Look up one global entry and the sequence of the commit that persisted it. */ + entry(id: Id, context: Context): Promise<{ readonly entry: EntryRecord; readonly commitSeq: Seq } | undefined>; + + /** + * Return the newest visible entry with a `head` at or below the optional inclusive cutoff. + * The returned entry is the marker; its `head` value is the range's actual lower bound. + */ + findLatestHeadMarker( + conversationId: Id, + atOrBeforeEntryId: Id | undefined, + context: Context, + ): Promise<(EntryRecord & { readonly head: Id }) | undefined>; + + /** Scan the inclusive visible range newest-first, returning at most `limit` entries. */ scanEntries( query: EntryQuery, cursor: Cursor | undefined, limit: number, context: Context, ): Promise>; - entryCommit(id: Id, context: Context): Promise; + /** Look up the latest complete record for one task. */ task(id: Id, context: Context): Promise | undefined>; + + /** Scan task records matching every supplied filter. */ scanTasks( query: TaskQuery, cursor: Cursor | undefined, @@ -214,8 +350,12 @@ export interface Storage { context: Context, ): Promise, Cursor>>; + /** Look up the latest complete record for one admitted input. */ input(id: Id, context: Context): Promise; + + /** Find an input by its conversation-scoped host deduplication key. */ inputByRequest(conversationId: Id, requestId: string, context: Context): Promise; + /** Release backend resources; all later operations must reject. */ close(context: Context): Promise; } diff --git a/packages/durable/test/memory-storage.test.ts b/packages/durable/test/memory-storage.test.ts index b5f069e16..5dc6248a9 100644 --- a/packages/durable/test/memory-storage.test.ts +++ b/packages/durable/test/memory-storage.test.ts @@ -19,7 +19,7 @@ async function createRoot(storage: MemoryStorage): Promise { return ROOT_CONVERSATION_ID; } -function pendingTask(id: Id, conversationId: Id, phase = "ready"): StoredTask { +function pendingTask(id: Id, conversationId: Id, phase = "ready") { return { id, conversationId, @@ -30,7 +30,7 @@ function pendingTask(id: Id, conversationId: Id, phase = "ready"): StoredTask { after: [], background: false, abortRequested: false, - }; + } satisfies StoredTask; } function entry(id: Id, conversationId: Id, kind = "message", extra: Partial = {}): EntryRecord { @@ -71,9 +71,10 @@ describe("Pico MemoryStorage", () => { context, ); - expect(await storage.entries([entryId], context)).toEqual( - new Map([[entryId, entry(entryId, rootId, "user", { data: { text: "hello" } })]]), - ); + expect(await storage.entry(entryId, context)).toEqual({ + entry: entry(entryId, rootId, "user", { data: { text: "hello" } }), + commitSeq: initialSeq, + }); expect(await storage.task(taskId, context)).toEqual(task); expect(await storage.input(inputId, context)).toEqual(input); @@ -97,8 +98,7 @@ describe("Pico MemoryStorage", () => { expect(await storage.task(taskId, context)).toEqual(task); expect(await storage.input(inputId, context)).toEqual(input); - expect(await storage.entries([transientEntryId], context)).toEqual(new Map()); - expect(await storage.entryCommit(transientEntryId, context)).toBeUndefined(); + expect(await storage.entry(transientEntryId, context)).toBeUndefined(); expect( await storage.commit([{ type: "entry", value: entry(storage.mintId(), rootId, "after-rollback") }], context), ).toBe(initialSeq + 1); @@ -137,14 +137,14 @@ describe("Pico MemoryStorage", () => { entryData.nested.push(3); checkpoint.nested.count = 2; detail.codes.push("mutated"); - expect((await storage.entries([entryId], context)).get(entryId)?.data).toEqual({ nested: [1, 2] }); + expect((await storage.entry(entryId, context))?.entry.data).toEqual({ nested: [1, 2] }); expect((await storage.task(taskId, context))?.state).toEqual({ status: "pending", checkpoint: { phase: "ready", nested: { count: 1 } }, }); expect((await storage.input(inputId, context))?.detail).toEqual({ codes: ["initial"] }); - const readEntry = (await storage.entries([entryId], context)).get(entryId)!; + const readEntry = (await storage.entry(entryId, context))!.entry; (readEntry.data as { nested: number[] }).nested.push(9); const readTask = (await storage.task(taskId, context))!; if (readTask.state.status !== "terminal") { @@ -153,7 +153,7 @@ describe("Pico MemoryStorage", () => { const readInput = (await storage.input(inputId, context))!; (readInput.detail as { codes: string[] }).codes.push("read mutation"); - expect((await storage.entries([entryId], context)).get(entryId)?.data).toEqual({ nested: [1, 2] }); + expect((await storage.entry(entryId, context))?.entry.data).toEqual({ nested: [1, 2] }); expect((await storage.task(taskId, context))?.state).toEqual({ status: "pending", checkpoint: { phase: "ready", nested: { count: 1 } }, @@ -253,27 +253,54 @@ describe("Pico MemoryStorage", () => { expect(third.items.map(({ id }) => id)).toEqual([rootFirst]); expect(third.next).toBeUndefined(); + const currentMarker = await storage.findLatestHeadMarker(grandchildId, undefined, context); + expect(currentMarker?.id).toBe(grandchildHead); + expect(currentMarker?.head).toBe(grandchildHead); + const historicalMarker = await storage.findLatestHeadMarker(grandchildId, childForkPoint, context); + expect(historicalMarker?.id).toBe(rootForkPoint); + expect(historicalMarker?.head).toBe(rootFirst); + expect(await storage.findLatestHeadMarker(grandchildId, rootFirst, context)).toBeUndefined(); + + const activeFirst = await storage.scanEntries( + { conversationId: grandchildId, minEntryId: currentMarker?.head }, + undefined, + 1, + context, + ); + expect(activeFirst.items.map(({ id }) => id)).toEqual([grandchildTail]); + expect(activeFirst.next).toBeDefined(); + const activeSecond = await storage.scanEntries( + { conversationId: grandchildId, minEntryId: currentMarker?.head }, + activeFirst.next, + 1, + context, + ); + expect(activeSecond.items.map(({ id }) => id)).toEqual([grandchildHead]); + expect(activeSecond.next).toBeUndefined(); + expect( ( - await storage.scanEntries({ conversationId: grandchildId, before: grandchildHead }, undefined, 10, context) + await storage.scanEntries( + { + conversationId: grandchildId, + minEntryId: historicalMarker?.head, + maxEntryId: childForkPoint, + }, + undefined, + 10, + context, + ) ).items.map(({ id }) => id), ).toEqual([childForkPoint, rootForkPoint, rootFirst]); - expect( - ( - await storage.scanEntries({ conversationId: grandchildId, kind: "marker" }, undefined, 10, context) - ).items.map(({ id }) => id), - ).toEqual([grandchildHead, rootForkPoint]); - expect( - ( - await storage.scanEntries({ conversationId: grandchildId, withHead: true }, undefined, 10, context) - ).items.map(({ id }) => id), - ).toEqual([grandchildHead, rootForkPoint]); - expect(await storage.entryCommit(rootFirst, context)).toBe(rootEntriesSeq); - expect(await storage.entryCommit(rootForkPoint, context)).toBe(rootEntriesSeq); - expect(await storage.entryCommit(grandchildHead, context)).toBe(grandchildEntriesSeq); - expect(await storage.entryCommit(grandchildTail, context)).toBe(grandchildEntriesSeq); - expect(await storage.entryCommit(999_999, context)).toBeUndefined(); + expect(await storage.entry(rootFirst, context)).toEqual({ + entry: entry(rootFirst, rootId), + commitSeq: rootEntriesSeq, + }); + expect((await storage.entry(rootForkPoint, context))?.commitSeq).toBe(rootEntriesSeq); + expect((await storage.entry(grandchildHead, context))?.commitSeq).toBe(grandchildEntriesSeq); + expect((await storage.entry(grandchildTail, context))?.commitSeq).toBe(grandchildEntriesSeq); + expect(await storage.entry(999_999, context)).toBeUndefined(); await expect(storage.scanEntries({ conversationId: 999_999 }, undefined, 10, context)).rejects.toThrow( "Unknown conversation", ); diff --git a/packages/durable/test/types.test.ts b/packages/durable/test/types.test.ts new file mode 100644 index 000000000..890d2b82c --- /dev/null +++ b/packages/durable/test/types.test.ts @@ -0,0 +1,140 @@ +import { expectTypeOf, it } from "vitest"; +import type { + ContextEdit, + DocumentCreate, + DocumentRecord, + Input, + TaskOutcome, + TaskRecord, + TaskState, +} from "../src/index.ts"; + +it("encodes discriminator-dependent fields", () => { + const omit = { target: 1, action: "omit" } satisfies ContextEdit; + const replace = { target: 1, action: "replace", messages: [] } satisfies ContextEdit; + const pending = { status: "pending", checkpoint: { phase: "ready" } } satisfies TaskState< + { phase: string }, + { value: number } + >; + const terminal = { + status: "terminal", + outcome: { status: "completed", result: { value: 1 } }, + } satisfies TaskState<{ phase: string }, { value: number }>; + const conversationDocument = { + id: 1, + kind: "test", + scope: { kind: "conversation", conversationId: 1 }, + history: "rewindable", + fork: "asOf", + } satisfies DocumentCreate; + + expectTypeOf(omit.action).toEqualTypeOf<"omit">(); + expectTypeOf(replace.action).toEqualTypeOf<"replace">(); + expectTypeOf(pending.status).toEqualTypeOf<"pending">(); + expectTypeOf(terminal.status).toEqualTypeOf<"terminal">(); + expectTypeOf(conversationDocument.fork).toEqualTypeOf<"asOf">(); + + const compileTimeFailures = () => { + // @ts-expect-error replacement edits require replacement messages + const missingReplacement: ContextEdit = { target: 1, action: "replace" }; + // @ts-expect-error omission edits cannot carry replacement messages + const omissionWithMessages: ContextEdit = { target: 1, action: "omit", messages: [] }; + const pendingWithOutcome: TaskState<{ phase: string }, number> = { + status: "pending", + checkpoint: { phase: "ready" }, + // @ts-expect-error live task state cannot carry a terminal outcome + outcome: { status: "completed", result: 1 }, + }; + // @ts-expect-error terminal task state cannot retain a live checkpoint + const terminalWithCheckpoint: TaskState<{ phase: string }, number> = { + status: "terminal", + checkpoint: { phase: "ready" }, + outcome: { status: "completed", result: 1 }, + }; + // @ts-expect-error terminal task records cannot retain live memos + const terminalWithMemos: TaskRecord = { + id: 1, + conversationId: 1, + kind: "test", + version: 1, + input: null, + state: { status: "terminal", outcome: { status: "completed", result: 1 } }, + after: [], + background: false, + abortRequested: false, + memos: { retained: true }, + }; + // @ts-expect-error session documents do not declare conversation history behavior + const sessionWithHistory: DocumentRecord = { + id: 1, + kind: "test", + createdAt: 1, + scope: { kind: "session" }, + history: "latest", + fork: "current", + }; + // @ts-expect-error conversation document creation requires history and fork policies + const conversationWithoutPolicy: DocumentCreate = { + id: 1, + kind: "test", + scope: { kind: "conversation", conversationId: 1 }, + }; + // @ts-expect-error session document creation cannot declare conversation policies + const sessionCreateWithPolicy: DocumentCreate = { + id: 1, + kind: "test", + scope: { kind: "session" }, + history: "latest", + fork: "current", + }; + const taskWithPolicy = { + id: 1, + kind: "test", + scope: { kind: "task", taskId: 1 }, + history: "latest", + fork: "initial", + } as const; + // @ts-expect-error task document creation cannot declare conversation policies + const taskCreateWithPolicy: DocumentCreate = taskWithPolicy; + // @ts-expect-error latest document creation cannot use as-of fork behavior + const latestCreateWithAsOf: DocumentCreate = { + id: 1, + kind: "test", + scope: { kind: "conversation", conversationId: 1 }, + history: "latest", + fork: "asOf", + }; + const createWithSequence: DocumentCreate = { + id: 1, + kind: "test", + scope: { kind: "session" }, + // @ts-expect-error storage, not the create command, supplies createdAt + createdAt: 1, + }; + // @ts-expect-error completed outcomes cannot carry errors + const completedWithError: TaskOutcome = { + status: "completed", + result: 1, + error: { message: "impossible" }, + }; + // @ts-expect-error queued inputs cannot reference transcript entries + const queuedWithEntry: Input = { id: 1, conversationId: 1, status: "queued", entry: 2 }; + void [ + missingReplacement, + omissionWithMessages, + pendingWithOutcome, + terminalWithCheckpoint, + terminalWithMemos, + sessionWithHistory, + conversationWithoutPolicy, + sessionCreateWithPolicy, + taskCreateWithPolicy, + latestCreateWithAsOf, + createWithSequence, + completedWithError, + queuedWithEntry, + ]; + }; + + expectTypeOf(compileTimeFailures).toBeFunction(); +});