From 04b5bad666830eb3a0dddc77a715eed95e12485a Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 25 Sep 2026 17:46:13 +0200 Subject: [PATCH] feat(durable): add typed IDs and explicit ownership --- packages/durable/CHANGELOG.md | 4 +- packages/durable/docs/pico-v5.md | 374 +++++++++++------- packages/durable/src/documents.ts | 16 +- packages/durable/src/ids.ts | 11 + packages/durable/src/index.ts | 7 +- packages/durable/src/session/forks.ts | 14 +- packages/durable/src/session/session.ts | 32 +- packages/durable/src/session/transaction.ts | 134 +++++-- packages/durable/src/storage/jsonl/storage.ts | 96 +++-- packages/durable/src/storage/memory.ts | 147 +++---- .../durable/src/storage/sqlite/storage.ts | 100 +++-- .../durable/src/testing/storage-benchmark.ts | 66 ++-- .../src/testing/storage-conformance.ts | 231 ++++++----- packages/durable/src/types.ts | 187 +++++---- packages/durable/test/jsonl-storage.test.ts | 102 +++-- packages/durable/test/memory-storage.test.ts | 12 +- packages/durable/test/scratch.ts | 95 +++++ .../session-checkpoints-migrations.test.ts | 23 +- .../durable/test/session-definitions.test.ts | 22 +- .../durable/test/session-documents.test.ts | 15 +- packages/durable/test/session-forks.test.ts | 157 +++++--- packages/durable/test/session-support.ts | 12 +- packages/durable/test/session-tables.test.ts | 163 ++++++-- packages/durable/test/sqlite-facade.test.ts | 49 ++- .../durable/test/sqlite-migrations.test.ts | 5 +- packages/durable/test/sqlite-storage.test.ts | 84 ++-- packages/durable/test/storage.bench.ts | 6 +- packages/durable/test/types.test.ts | 154 ++++---- 28 files changed, 1473 insertions(+), 845 deletions(-) create mode 100644 packages/durable/src/ids.ts create mode 100644 packages/durable/test/scratch.ts diff --git a/packages/durable/CHANGELOG.md b/packages/durable/CHANGELOG.md index c3e5076ed..5a4ecb1dd 100644 --- a/packages/durable/CHANGELOG.md +++ b/packages/durable/CHANGELOG.md @@ -6,7 +6,9 @@ - Reordered Storage scan arguments so the limit precedes the cursor. - Added the required conversation-visible `Storage.entry(conversationId, id, context)` overload. -- Split independent `Tx.createConversation()` from `Tx.forkConversation()` and replaced raw conversation-record input. +- Split `Tx.createConversation()` from `Tx.forkConversation()`, replaced raw conversation-record input, and require explicit ownerless or task ownership. +- Replaced untyped numeric record IDs and the `TaskRef` wrapper with erased branded numeric ID types, including result-typed `TaskId`, separately branded commit sequences, and generic `Storage.mintId()`. +- Made task conversation membership immutable after task creation. - Added `ConversationQuery` to Storage and transaction conversation scans. ### Added diff --git a/packages/durable/docs/pico-v5.md b/packages/durable/docs/pico-v5.md index 25b5b6763..b05932bdb 100644 --- a/packages/durable/docs/pico-v5.md +++ b/packages/durable/docs/pico-v5.md @@ -70,20 +70,37 @@ The concrete JSON representations may add bookkeeping fields, but must preserve these contracts. ```ts -type Id = number; +declare const idBrand: unique symbol; +type Id = number & { + readonly [idBrand]: { + readonly kind: Kind; + readonly type: Type; + }; +}; +type ConversationId = Id<"conversation">; +type EntryId = Id<"entry">; +type TaskId = Id<"task", Result>; +type SubmissionId = Id<"submission">; +type DocumentId = Id<"document">; + +declare const seqBrand: unique symbol; /** Strictly increases between commits; gaps are permitted. */ -type Seq = number; -const ROOT_CONVERSATION_ID: Id = 1; +type Seq = number & { readonly [seqBrand]: "sequence" }; +const ROOT_CONVERSATION_ID = 1 as ConversationId; + +type ConversationOwnership = + | { readonly kind: "ownerless" } + | { readonly kind: "task"; readonly taskId: TaskId }; type ConversationRecord = { - readonly id: Id; + readonly id: ConversationId; readonly parent?: { - readonly conversationId: Id; - readonly at: Id; + readonly conversationId: ConversationId; + readonly at: EntryId; }; readonly owner?: { - readonly conversationId: Id; - readonly taskId: Id; + readonly conversationId: ConversationId; + readonly taskId: TaskId; }; }; ``` @@ -109,30 +126,30 @@ order yields the effective prompt and tool set. ```ts type ContextEdit = { - readonly target: Id; + readonly target: EntryId; } & ( | { readonly action: "omit"; readonly messages?: never } | { readonly action: "replace"; readonly messages: readonly Message[] } ); type EntryRecord = { - readonly id: Id; - readonly conversationId: Id; + readonly id: EntryId; + readonly conversationId: ConversationId; readonly kind: string; readonly model?: readonly Message[]; readonly data?: JsonValue; - readonly head?: Id; + readonly head?: EntryId; readonly edits?: readonly ContextEdit[]; - readonly byTaskId?: Id; + readonly byTaskId?: TaskId; }; type EntryDraft = Omit & { - readonly head?: Id | "self"; + readonly head?: EntryId | "self"; }; type SubmissionRecordBase = { - readonly id: Id; - readonly conversationId: Id; + readonly id: SubmissionId; + readonly conversationId: ConversationId; readonly requestId?: string; }; @@ -149,21 +166,21 @@ type SubmissionRecord = } | { readonly status: "placed"; - readonly entry: Id; + readonly entry: EntryId; readonly answer?: never; readonly reason?: never; readonly detail?: never; } | { readonly status: "done"; - readonly entry: Id; - readonly answer: Id; + readonly entry: EntryId; + readonly answer: EntryId; readonly reason?: never; readonly detail?: never; } | { readonly status: "unanswered"; - readonly entry?: Id; + readonly entry?: EntryId; readonly answer?: never; readonly reason: string; readonly detail?: JsonValue; @@ -181,7 +198,7 @@ type SubmissionRecord = } | { readonly status: "done"; - readonly entry: Id; + readonly entry: EntryId; readonly answer?: never; readonly reason?: never; readonly detail?: never; @@ -202,10 +219,18 @@ type SubmissionCreate = SubmissionRecord extends infer Record : never; ``` +ID brands are compile-time only. IDs and sequences remain ordinary numbers in +memory, JSON, JSONL, and SQLite. Code applies a brand only at a trusted creation +or decoding boundary. The distinct sequence brand prevents an entity ID from +being used as a document commit point. + Conversation history parenting and task ownership are separate: - `parent` controls inherited entries and historical documents. - `owner` records task attribution and connects scopes for subtree abort and idle traversal. It is not an access-control capability. +- Creation always supplies `ConversationOwnership` explicitly. The Session + derives the persisted owner's conversation from the named task; callers never + construct the persisted owner pair. A conversation's owner remains recorded after the owning task becomes terminal. @@ -282,6 +307,10 @@ type ConversationSpec = { readonly activeTools?: readonly string[]; }; +type ConversationCreateSpec = ConversationSpec & { + readonly ownership: ConversationOwnership; +}; + type AnyTask = { readonly definition: { readonly name: string; @@ -322,7 +351,7 @@ type SettledSubmissionRecord = SubmissionRecord & { }; interface Submission { - readonly id: Id; + readonly id: SubmissionId; status(context: Context): Promise; wait(context: Context): Promise; abort(context: Context): Promise<"aborted" | "already_placed" | "settled">; @@ -339,7 +368,7 @@ type HooksOf = K extends Task : never; interface Conversation { - readonly id: Id; + readonly id: ConversationId; submit(submission: SubmissionDraft, context: Context): Promise; getModel(context: Context): Promise; @@ -370,11 +399,11 @@ interface Conversation { context: Context, ): Promise>; fork( - at: Id, - spec: ConversationSpec, + at: EntryId, + spec: ConversationCreateSpec, context: Context, ): Promise; - collapse(instructions: string | undefined, context: Context): Promise; + collapse(instructions: string | undefined, context: Context): Promise; reset(handoff: string | undefined, context: Context): Promise; abort(context: Context): Promise; waitForIdle(context: Context): Promise; @@ -404,22 +433,22 @@ interface Harness extends Session { root(context: Context): Promise; onConversation(listener: (conversation: Conversation) => void): () => void; - conversation(id: Id, context: Context): Promise; + conversation(id: ConversationId, context: Context): Promise; createConversation( - spec: ConversationSpec & { readonly input?: UserInput }, + spec: ConversationCreateSpec & { readonly input?: UserInput }, context: Context, ): Promise; - getTask(id: Id, context: Context): Promise | undefined>; - submission(id: Id, context: Context): Promise; + getTask(id: TaskId, context: Context): Promise | undefined>; + submission(id: SubmissionId, context: Context): Promise; abortSubmission( - id: Id, + id: SubmissionId, context: Context, - conversationId?: Id, + conversationId?: ConversationId, ): Promise<"aborted" | "already_placed" | "settled" | "not_found">; - abortTask(id: Id, context: Context): Promise<"marked" | "terminal">; - markTask(id: Id, context: Context): Promise<"marked" | "terminal">; - waitForTask(ref: TaskRef, context: Context): Promise>; + abortTask(id: TaskId, context: Context): Promise<"marked" | "terminal">; + markTask(id: TaskId, context: Context): Promise<"marked" | "terminal">; + waitForTask(id: TaskId, context: Context): Promise>; waitForIdle(context: Context): Promise; } @@ -449,8 +478,8 @@ handler dispatches during open. Dynamic registration is available after open and does not resurrect a task already settled by that pass. The root conversation always has reserved ID `ROOT_CONVERSATION_ID` (`1`). Empty -storage creates that conversation from `options.root`; reopen looks it up by the -reserved ID. `options.root` never overwrites existing state. `root()` returns +storage creates that conversation from `options.root` as ownerless; reopen looks +it up by the reserved ID. `options.root` never overwrites existing state. `root()` returns that handle. A conversation with no configured model produces a durable `no_model` generation failure. @@ -466,11 +495,14 @@ function that unregisters only that exact token. It supports declaration movement during normal operation, not replacement of executing extension code; section 7.4 governs code reload. -Conversation creation atomically commits the conversation, built-in -configuration and section/tool seeds, and an optional input submission. For a fork, -omitted model, section, and active-tool values follow their document definition's -fork policy; provided values override those forked built-in values in the same -commit. Other documents follow their own definitions without special handling. +Conversation creation atomically commits the conversation, its explicitly +selected ownership, built-in configuration and section/tool seeds, and an +optional input submission. Host callers must choose ownerless or task ownership +in `ConversationCreateSpec`; neither the Harness nor a conversation handle +infers ownership from call context. For a fork, omitted model, section, and +active-tool values follow their document definition's fork policy; provided +values override those forked built-in values in the same commit. Other documents +follow their own definitions without special handling. The built-in conversation configuration document contains the selected model, thinking level, an ordered array of section key/value records, and active tool @@ -517,7 +549,8 @@ fork-aware stored history; callers cannot substitute another conversation ID. Generic Session-wide document operations remain available directly on `Harness` because `Harness extends Session`. -`fork()` requires a concrete visible parent entry and applies section 3.7. +`fork()` requires a concrete visible parent entry and explicit ownership, then +applies section 3.7. `collapse()` returns the newly admitted background collapse task ID, not its future summary entry. `reset()` durably admits a passive self-head reset or handoff write and then resolves; while busy, placement follows section 6 and may @@ -736,21 +769,21 @@ A persisted document instance has one `DocumentRecord`: ```ts type DocumentRecord = { - readonly id: Id; // unique incarnation + readonly id: DocumentId; // unique incarnation readonly kind: string; // stable 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 } } & ( + | ({ readonly scope: { readonly kind: "conversation"; readonly conversationId: ConversationId } } & ( | { readonly history: "latest"; readonly fork: "current" | "initial" } | { readonly history: "rewindable"; readonly fork: "asOf" | "current" | "initial"; } )) - | { readonly scope: { readonly kind: "task"; readonly taskId: Id } } + | { readonly scope: { readonly kind: "task"; readonly taskId: TaskId } } ); type DocumentCreate = DocumentRecord extends infer Record @@ -783,56 +816,72 @@ interface Session extends DocumentObserver { close(context: Context): Promise; snapshot(token: SessionDocToken, context: Context): Promise | undefined>; - snapshot(token: ConversationDocToken, conversationId: Id, context: Context): Promise | undefined>; - snapshot(token: TaskDocToken, taskId: Id, context: Context): Promise | undefined>; + snapshot(token: ConversationDocToken, conversationId: ConversationId, context: Context): Promise | undefined>; + snapshot(token: TaskDocToken, taskId: TaskId, context: Context): Promise | undefined>; snapshot(token: SessionDocFamilyToken, key: string, context: Context): Promise | undefined>; - snapshot(token: ConversationDocFamilyToken, conversationId: Id, key: string, context: Context): Promise | undefined>; - snapshot(token: TaskDocFamilyToken, taskId: Id, key: string, context: Context): Promise | undefined>; + snapshot(token: ConversationDocFamilyToken, conversationId: ConversationId, key: string, context: Context): Promise | undefined>; + snapshot(token: TaskDocFamilyToken, taskId: TaskId, key: string, context: Context): Promise | undefined>; - snapshotAsOf(token: RewindableConversationDocToken, conversationId: Id, at: Id, context: Context): Promise | undefined>; - snapshotAsOf(token: RewindableConversationDocFamilyToken, conversationId: Id, key: string, at: Id, context: Context): Promise | undefined>; + snapshotAsOf(token: RewindableConversationDocToken, conversationId: ConversationId, at: EntryId, context: Context): Promise | undefined>; + snapshotAsOf(token: RewindableConversationDocFamilyToken, conversationId: ConversationId, key: string, at: EntryId, context: Context): Promise | undefined>; documentSource(token: SessionDocToken, context: Context): Promise | undefined>; - documentSource(token: ConversationDocToken, conversationId: Id, context: Context): Promise | undefined>; - documentSource(token: TaskDocToken, taskId: Id, context: Context): Promise | undefined>; + documentSource(token: ConversationDocToken, conversationId: ConversationId, context: Context): Promise | undefined>; + documentSource(token: TaskDocToken, taskId: TaskId, context: Context): Promise | undefined>; documentSource(token: SessionDocFamilyToken, key: string, context: Context): Promise | undefined>; - documentSource(token: ConversationDocFamilyToken, conversationId: Id, key: string, context: Context): Promise | undefined>; - documentSource(token: TaskDocFamilyToken, taskId: Id, key: string, context: Context): Promise | undefined>; + documentSource(token: ConversationDocFamilyToken, conversationId: ConversationId, key: string, context: Context): Promise | undefined>; + documentSource(token: TaskDocFamilyToken, taskId: TaskId, key: string, context: Context): Promise | undefined>; } interface Tx { - conversation(id: Id): Promise; - entry(id: Id): Promise; - task(id: Id): Promise | undefined>; + conversation(id: ConversationId): Promise; + entry(id: EntryId): Promise; + task(id: TaskId): Promise | undefined>; scanConversations(query: ConversationQuery, limit: number, cursor?: Cursor): Promise>; scanEntries(query: EntryQuery, limit: number, cursor?: Cursor): Promise>; scanTasks(query: TaskQuery, limit: number, cursor?: Cursor): Promise, Cursor>>; - createConversation(): Promise; - forkConversation(parentConversationId: Id, at: Id): Promise; - appendEntry(conversationId: Id, value: EntryDraft): Promise; + createConversation(options: { readonly ownership: ConversationOwnership }): Promise; + forkConversation( + parentConversationId: ConversationId, + at: EntryId, + options: { readonly ownership: ConversationOwnership }, + ): Promise; + appendEntry(conversationId: ConversationId, value: EntryDraft): Promise; createTask( task: Task, input: I, options?: TaskOptions, - ): Promise>; + ): Promise>; setTask(value: TaskRecord): void; doc(token: SessionDocToken): Promise>; - doc(token: ConversationDocToken, conversationId: Id): Promise>; - doc(token: TaskDocToken, taskId: Id): Promise>; + doc(token: ConversationDocToken, conversationId: ConversationId): Promise>; + doc(token: TaskDocToken, taskId: TaskId): Promise>; doc(token: SessionDocFamilyToken, key: string, seed: I): Promise>; - doc(token: ConversationDocFamilyToken, conversationId: Id, key: string, seed: I): Promise>; - doc(token: TaskDocFamilyToken, taskId: Id, key: string, seed: I): Promise>; + doc(token: ConversationDocFamilyToken, conversationId: ConversationId, key: string, seed: I): Promise>; + doc(token: TaskDocFamilyToken, taskId: TaskId, key: string, seed: I): Promise>; retireDoc(token: SessionDocToken): Promise; - retireDoc(token: ConversationDocToken, conversationId: Id): Promise; - retireDoc(token: TaskDocToken, taskId: Id): Promise; + retireDoc(token: ConversationDocToken, conversationId: ConversationId): Promise; + retireDoc(token: TaskDocToken, taskId: TaskId): Promise; retireDoc(token: SessionDocFamilyToken, key: string): Promise; - retireDoc(token: ConversationDocFamilyToken, conversationId: Id, key: string): Promise; - retireDoc(token: TaskDocFamilyToken, taskId: Id, key: string): Promise; + retireDoc(token: ConversationDocFamilyToken, conversationId: ConversationId, key: string): Promise; + retireDoc(token: TaskDocFamilyToken, taskId: TaskId, key: string): Promise; } ``` -ID-creating transaction methods are asynchronous because remote storage may allocate globally unique IDs durably. `createConversation()` creates an independent ownerless scope; `forkConversation()` validates one visible entry and applies section 3.7. Task-bound transactions later inject their executing task as owner without accepting raw owner IDs from callers. Only public typed `tx.doc()` is get-or-create. Internal fork copying may create +ID-creating transaction methods are asynchronous because remote storage may +allocate globally unique numeric IDs durably. Conversation creation always +requires explicit ownership; no transaction wrapper injects the executing task. +For task ownership, the caller supplies only a typed task ID. The Session derives +the persisted owner conversation from the task's final candidate record. + +An owner task may be committed or staged earlier in the same transaction. Before +Storage admission, the Session rejects a missing, terminal, or abort-marked owner, +including one made terminal or abort-marked later in that transaction. Existing +owner edges remain valid when their owners terminalize afterward. Conversation +creation returns an inert record, never an operational handle. +`forkConversation()` additionally validates one visible entry and applies section +3.7. Only public typed `tx.doc()` is get-or-create. Internal fork copying may create new incarnations directly from stored values without a definition. `tx.doc()` receives the definition token that supplies its static type, initializer, migration, and checkpoint policy. Scope-preserving @@ -1179,12 +1228,12 @@ type TaskState = | { readonly status: "terminal"; readonly outcome: TaskOutcome }; type TaskRecord = { - readonly id: Id; - readonly conversationId: Id; + readonly id: TaskId; + readonly conversationId: ConversationId; readonly kind: string; readonly version: number; readonly input: I; - readonly after: readonly Id[]; + readonly after: readonly TaskId[]; readonly background: boolean; readonly abortRequested: boolean; } & ( @@ -1213,8 +1262,8 @@ type PhaseHandler = ( ) => Promise; interface TaskRuntime extends DocumentObserver { - readonly taskId: Id; - readonly conversationId: Id; + readonly taskId: TaskId; + readonly conversationId: ConversationId; readonly signal: AbortSignal; readonly hooks: HookRunner; @@ -1225,6 +1274,7 @@ interface TaskRuntime extends DocumentObserver { memo(name: string, context: Context): Promise; memo(name: string, candidate: T, context: Context): Promise; + conversation(id: ConversationId, context: Context): Promise; sleep(until: number, context: Context): Promise; } @@ -1247,11 +1297,9 @@ interface Task { readonly definition: TaskDefinition; } -declare const taskResultType: unique symbol; -type TaskRef = { readonly id: Id; readonly [taskResultType]?: R }; type TaskOptions = { - readonly conversationId?: Id; - readonly after?: readonly Id[]; + readonly conversationId?: ConversationId; + readonly after?: readonly TaskId[]; readonly background?: boolean; }; @@ -1260,6 +1308,13 @@ function defineTask( ): Task; ``` +`TaskId` is the numeric task ID itself with an erased result-type brand. It +replaces a separate task-reference wrapper: `createTask()` returns `TaskId`, +typed waits infer `R` from that ID, and result-agnostic operations accept +`TaskId`. A task's `conversationId` is immutable after creation; a +replacement that moves an existing task to another conversation rejects before +Storage admission so persisted conversation-owner edges cannot become stale. + The phase map is exhaustive and phase-narrowed. A handler may perform several commits around one effect, but each durable checkpoint is a full replacement. `TaskRuntime.commit()` rereads and gates the current durable task on the Session @@ -1403,8 +1458,8 @@ itself is an ordered conversation document containing tagged items: ```ts type InboxItem = - | { readonly id: Id; readonly mode: "steer" | "followUp"; readonly message: Message } - | { readonly id: Id; readonly mode: "write"; readonly entry: EntryDraft }; + | { readonly id: SubmissionId; readonly mode: "steer" | "followUp"; readonly message: Message } + | { readonly id: SubmissionId; readonly mode: "write"; readonly entry: EntryDraft }; ``` A built-in turn-control document has an optional `active` value naming the task @@ -1499,16 +1554,16 @@ type ToolExecutionResult = { readonly control?: ToolControl; }; -interface OwnedConversation { - readonly id: Id; +interface ConversationHandle { + readonly id: ConversationId; submit(submission: InputSubmissionDraft, context: Context): Promise; abort(context: Context): Promise; waitForIdle(context: Context): Promise; } interface ToolExecutionApi extends DocumentObserver { - readonly taskId: Id; - readonly conversationId: Id; + readonly taskId: TaskId; + readonly conversationId: ConversationId; readonly callId: string; stream(chunk: string | Uint8Array): void; commit(change: (tx: Tx) => T | Promise, context: Context): Promise; @@ -1520,14 +1575,10 @@ interface ToolExecutionApi extends DocumentObserver { input: I, options: Omit, context: Context, - ): Promise>; - getTask(ref: TaskRef, context: Context): Promise | undefined>; - waitForTask(ref: TaskRef, context: Context): Promise>; - createConversation( - spec: ConversationSpec & { readonly inherit?: boolean }, - context: Context, - ): Promise; - conversation(id: Id, context: Context): Promise; + ): Promise>; + getTask(id: TaskId, context: Context): Promise | undefined>; + waitForTask(id: TaskId, context: Context): Promise>; + conversation(id: ConversationId, context: Context): Promise; } type ToolRegistration = Tool & { @@ -1576,59 +1627,75 @@ validated before and after `beforeTool` hooks. After hooks and validation, the tool task durably records the final call and resolved replay policy before execution. Recovery does not rerun `beforeTool` -and does not let a changed registry declaration alter that stored policy. +and passes the same stored arguments to `execute()`. A replay-safe tool may +reconstruct a submission from those arguments when that transformation is pure. +Random values, timestamps, mutable document/configuration reads, or other derived +inputs that must remain stable are first captured in a durable memo, checkpoint, +or task input. A background supervisor receives the final submission draft in +its own durable input so it can finish independently. Recovery does not let a +changed registry declaration alter the stored replay policy. -An owned conversation accepts only input submissions; tools use ordinary -transaction writes for passive entries. +A tool-acquired conversation handle accepts only input submissions; tools use +ordinary transaction writes for passive entries. A tool executes in a durable task. It may: - write bounded progress/output to a presentation or task-scoped document; - commit memos; - create and wait for tasks; -- create owned conversations; +- atomically create or fork explicitly owned conversations through `commit()`; - observe documents for which it has a token/reference; - mutate authorized documents through `commit()`; - return bounded model content and separate diagnostic details. -Tool operations use the invoking task's admission and -invocation-lifetime gates. Task creation defaults to that task's conversation. -Trusted document and conversation access follows sections 3.3 and 9.2; there is -no additional subtree authorization layer. Possession of a Session-global ID is -sufficient in trusted code. `createConversation()` records the invoking task as -owner and returns an invocation-bound handle. `conversation(id)` reacquires the -same handle shape for status, steering, explicit abort, and idle waits without -changing ownership. `inherit` defaults -to `false`. When true, creation atomically selects the invoking conversation's -newest committed visible entry as `parent.at`; if no entry exists, it creates no -history parent. Conversation documents then apply section 3.7 at that entry, and -explicit model/section seeds override their inherited built-in values. With no -parent, documents initialize normally. That handle's -operations reject after the invocation ends. A `Submission` returned by its -`submit()` is invocation-bound in the same way; the admitted submission remains -durable after those methods reject. Invocation-owned document watches stop when -the invocation ends. +Tool operations use the invoking task's admission and invocation-lifetime gates. +Task creation defaults to that task's conversation. Trusted document and +conversation access follows sections 3.3 and 9.2; there is no additional subtree +authorization layer. Possession of a Session-global typed ID is sufficient in +trusted code. -A foreground subagent conversation is created directly by its tool task; the -tool waits for the admitted submission/result, and aborting or abnormally -terminalizing that task cascades through the owned scope. A background subagent -is created by a background provisioning task. The tool may explicitly -`waitForTask()` only until that task durably returns the child conversation and -submission IDs, then return while the child continues. The provisioner may -complete after setup: its terminal record retains `background`, so ancestor -ordinary abort and idle traversal continue to stop at its owned scope. Provision -the conversation and its durable name-to-conversation/request mapping atomically, -then call `Conversation.submit()` with that application-minted request ID. A -crash before submission retries the durable request; a crash after admission -recovers the existing deduplicated `Submission`. A replay-safe tool must never -create a child in one commit and record its identity only in a later memo commit. +Tools have no conversation-creation convenience method. They create or fork a +conversation inside `commit()`, state its ownership explicitly, atomically stage +any related document/task writes, and receive only the inert record. After that +commit settles, `conversation(id)` acquires an invocation-bound operational +handle for submission, explicit abort, and idle waits without changing +ownership. A commit callback must not call `conversation()` or use a previously +acquired handle: nested Session operations and external effects are forbidden +while the mutation line is held. A handle's operations reject after the +invocation ends. A `Submission` returned by its `submit()` is invocation-bound in +the same way; the admitted submission remains durable after those methods reject. +Invocation-owned document watches stop when the invocation ends. + +A foreground subagent conversation is explicitly owned by its tool task. One +transaction creates or forks the child and records its durable registry mapping; +after settlement the tool reacquires the child, submits with the registered +request ID, and waits for that submission's result. Aborting or abnormally +terminalizing the tool task cascades through the owned scope. + +A background subagent is provisioned in one transaction. After deduplicating by +its durable registry key, the tool stages a background supervisor task `B`, a +child conversation `C` explicitly owned by `B`, and the registry mapping from the +semantic name to `C` plus its stable request ID. `B` may be staged earlier in the +same transaction and used immediately as `C`'s owner. Its durable input contains +the exact submission draft and the registry location/key; it need not contain +`C`'s not-yet-created ID. Once scheduled, `B` resolves `C` from the mapping, +verifies the immutable owner edge, and performs normal `Conversation.submit()`. +A crash before admission makes `B` retry; a crash after admission returns the +existing request-ID-deduplicated `Submission`. The initiating tool may race the +same submit for lower latency and wait only for the durable admission receipt. +The supervisor may complete after setup: its terminal record retains +`background`, so ancestor ordinary abort and idle traversal continue to stop at +its owned scope. Applications may maintain a conversation document mapping semantic subagent names to durable conversation IDs and application-minted submission request IDs. Such a live registry uses `fork: "initial"` so children do not inherit the parent's agent list and its update is not a selected fork source. Conversation -creation/forking and its registry mapping commit atomically. Initial or later -input then uses the registered stable request ID with the full +creation/forking, supervisor creation when applicable, and the registry mapping +commit atomically. The supervisor input, tool's durable final arguments, or a +durable checkpoint must retain enough information to reconstruct the exact +submission draft; the registry itself need not duplicate that payload. Initial +or later input then uses the registered stable request ID with the full `Conversation.submit()` state machine. A crash before admission leaves a durable request to submit; a crash after admission retries the same request and receives the existing `Submission`. A read-only lookup by conversation/request ID can @@ -1866,11 +1933,11 @@ type DocumentWatch = WatchHandle | null>; interface DocumentObserver { watchDoc(token: SessionDocToken, context: Context): Promise | undefined>; - watchDoc(token: ConversationDocToken, conversationId: Id, context: Context): Promise | undefined>; - watchDoc(token: TaskDocToken, taskId: Id, context: Context): Promise | undefined>; + watchDoc(token: ConversationDocToken, conversationId: ConversationId, context: Context): Promise | undefined>; + watchDoc(token: TaskDocToken, taskId: TaskId, context: Context): Promise | undefined>; watchDoc(token: SessionDocFamilyToken, key: string, context: Context): Promise | undefined>; - watchDoc(token: ConversationDocFamilyToken, conversationId: Id, key: string, context: Context): Promise | undefined>; - watchDoc(token: TaskDocFamilyToken, taskId: Id, key: string, context: Context): Promise | undefined>; + watchDoc(token: ConversationDocFamilyToken, conversationId: ConversationId, key: string, context: Context): Promise | undefined>; + watchDoc(token: TaskDocFamilyToken, taskId: TaskId, key: string, context: Context): Promise | undefined>; } ``` @@ -2058,18 +2125,18 @@ type Page = { type Cursor = Readonly>; type ConversationQuery = { - readonly ownerConversationId?: Id; - readonly ownerTaskId?: Id; + readonly ownerConversationId?: ConversationId; + readonly ownerTaskId?: TaskId; }; type EntryQuery = { - readonly conversationId: Id; - readonly minEntryId?: Id; // inclusive - readonly maxEntryId?: Id; // inclusive + readonly conversationId: ConversationId; + readonly minEntryId?: EntryId; // inclusive + readonly maxEntryId?: EntryId; // inclusive }; type TaskQuery = { - readonly conversationId?: Id; + readonly conversationId?: ConversationId; readonly kind?: string; readonly status?: "pending" | "running" | "terminal"; readonly abortRequested?: boolean; @@ -2113,14 +2180,14 @@ type StorageWrite = | { readonly type: "document.copy"; readonly record: DocumentCreate; - readonly source: { readonly id: Id; readonly at: DocumentPoint }; + readonly source: { readonly id: DocumentId; readonly at: DocumentPoint }; } | { readonly type: "document.change"; - readonly id: Id; + readonly id: DocumentId; readonly content: DocumentContent; } - | { readonly type: "document.retire"; readonly id: Id }; + | { readonly type: "document.retire"; readonly id: DocumentId }; /** * Trusts the owning Session to supply semantically valid records, references, @@ -2131,24 +2198,25 @@ type StorageWrite = */ interface Storage { commit(writes: readonly StorageWrite[], context: Context): Promise; - mintId(): Promise; + /** Allocate from the one global numeric namespace; the generic brand is compile-time only. */ + mintId>(): Promise; - conversation(id: Id, context: Context): Promise; + conversation(id: ConversationId, context: Context): Promise; scanConversations(query: ConversationQuery, limit: number, cursor: Cursor | undefined, context: Context): Promise>; - entry(id: Id, context: Context): Promise<{ readonly entry: EntryRecord; readonly commitSeq: Seq } | undefined>; - entry(conversationId: Id, 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>; + entry(id: EntryId, context: Context): Promise<{ readonly entry: EntryRecord; readonly commitSeq: Seq } | undefined>; + entry(conversationId: ConversationId, id: EntryId, context: Context): Promise<{ readonly entry: EntryRecord; readonly commitSeq: Seq } | undefined>; + findLatestHeadMarker(conversationId: ConversationId, atOrBeforeEntryId: EntryId | undefined, context: Context): Promise<(EntryRecord & { readonly head: EntryId }) | undefined>; scanEntries(query: EntryQuery, limit: number, cursor: Cursor | undefined, context: Context): Promise>; - task(id: Id, context: Context): Promise | undefined>; + task(id: TaskId, context: Context): Promise | undefined>; scanTasks(query: TaskQuery, limit: number, cursor: Cursor | undefined, context: Context): Promise, Cursor>>; - submission(id: Id, context: Context): Promise; - submissionByRequest(conversationId: Id, requestId: string, context: Context): Promise; + submission(id: SubmissionId, context: Context): Promise; + submissionByRequest(conversationId: ConversationId, requestId: string, context: Context): Promise; findDocument(address: DocumentAddress, at: DocumentPoint, context: Context): Promise; - document(id: Id, at: DocumentPoint, context: Context): Promise; + document(id: DocumentId, at: DocumentPoint, context: Context): Promise; scanDocuments(query: DocumentQuery, limit: number, cursor: Cursor | undefined, context: Context): Promise>; close(context: Context): Promise; diff --git a/packages/durable/src/documents.ts b/packages/durable/src/documents.ts index 421bb4899..09eca262a 100644 --- a/packages/durable/src/documents.ts +++ b/packages/durable/src/documents.ts @@ -1,15 +1,18 @@ import { copyJson, type JsonValue } from "@earendil-works/chord"; import type { Op } from "@earendil-works/chord/delta"; +import { idFromNumber } from "./ids.ts"; import type { CommonDocDefinition, ConversationDocFamilyToken, ConversationDocToken, + ConversationId, DocDefinition, DocFamilyDefinition, DocFamilyToken, DocToken, DocumentAddress, DocumentCreate, + DocumentId, DocumentRecord, DocumentSemantics, Id, @@ -23,6 +26,7 @@ import type { StoredDocument, TaskDocFamilyToken, TaskDocToken, + TaskId, } from "./types.ts"; type FamilyInput = Omit, "initial"> & { @@ -109,10 +113,10 @@ export function resolveAddress(definition: AnyDocDefinition, args: readonly unkn scope = { kind: "session" }; break; case "conversation": - scope = { kind: "conversation", conversationId: ownerId(args[index++], definition) }; + scope = { kind: "conversation", conversationId: ownerId(args[index++], definition) }; break; case "task": - scope = { kind: "task", taskId: ownerId(args[index++], definition) }; + scope = { kind: "task", taskId: ownerId(args[index++], definition) }; break; } const key = definition.family === true ? (args[index++] as string) : undefined; @@ -121,11 +125,11 @@ export function resolveAddress(definition: AnyDocDefinition, args: readonly unkn return { address, id: addressId(address), nextArgument: index }; } -function ownerId(value: unknown, definition: AnyDocDefinition): Id { - if (!Number.isSafeInteger(value as number)) { +function ownerId>(value: unknown, definition: AnyDocDefinition): I { + if (typeof value !== "number" || !Number.isSafeInteger(value)) { throw new TypeError(`Document ${definition.kind} requires a ${definition.scope} ID`); } - return value as Id; + return idFromNumber(value); } /** Stable string identity of one logical address. */ @@ -140,7 +144,7 @@ export function addressId(address: DocumentAddress): string { } /** Build the storage create record for a new incarnation at an address. */ -export function documentCreate(definition: AnyDocDefinition, address: DocumentAddress, id: Id): DocumentCreate { +export function documentCreate(definition: AnyDocDefinition, address: DocumentAddress, id: DocumentId): DocumentCreate { const key = address.key === undefined ? {} : { key: address.key }; switch (address.scope.kind) { case "session": diff --git a/packages/durable/src/ids.ts b/packages/durable/src/ids.ts new file mode 100644 index 000000000..f69ffebd1 --- /dev/null +++ b/packages/durable/src/ids.ts @@ -0,0 +1,11 @@ +import type { Id, Seq } from "./types.ts"; + +/** Apply an erased ID brand at a trusted numeric allocation or decoding boundary. */ +export function idFromNumber>(value: number): I { + return value as I; +} + +/** Apply the erased commit-sequence brand at a trusted storage boundary. */ +export function seqFromNumber(value: number): Seq { + return value as Seq; +} diff --git a/packages/durable/src/index.ts b/packages/durable/src/index.ts index 92f67e104..d807c9d90 100644 --- a/packages/durable/src/index.ts +++ b/packages/durable/src/index.ts @@ -7,6 +7,8 @@ export type { ContextEdit, ConversationDocFamilyToken, ConversationDocToken, + ConversationId, + ConversationOwnership, ConversationQuery, ConversationRecord, Cursor, @@ -18,11 +20,13 @@ export type { DocumentContent, DocumentCopySource, DocumentCreate, + DocumentId, DocumentPoint, DocumentQuery, DocumentRecord, DocumentSemantics, EntryDraft, + EntryId, EntryQuery, EntryRecord, Id, @@ -40,17 +44,18 @@ export type { StorageWrite, StoredDocument, SubmissionCreate, + SubmissionId, SubmissionRecord, Task, TaskDefinition, TaskDocFamilyToken, TaskDocToken, + TaskId, TaskOptions, TaskOutcome, TaskOutcomeError, TaskQuery, TaskRecord, - TaskRef, TaskState, Tx, } from "./types.ts"; diff --git a/packages/durable/src/session/forks.ts b/packages/durable/src/session/forks.ts index b76d2c5d4..102be4679 100644 --- a/packages/durable/src/session/forks.ts +++ b/packages/durable/src/session/forks.ts @@ -1,12 +1,14 @@ import type { Context } from "@earendil-works/chord"; import { addressId } from "../documents.ts"; import type { + ConversationId, Cursor, DocumentCopySource, DocumentCreate, + DocumentId, DocumentPoint, DocumentRecord, - Id, + EntryId, Storage, } from "../types.ts"; @@ -23,9 +25,9 @@ export type ForkDocumentCopy = { /** Select every persisted conversation document copied by one fork. */ export async function prepareForkDocumentCopies( storage: Storage, - parentConversationId: Id, - at: Id, - childConversationId: Id, + parentConversationId: ConversationId, + at: EntryId, + childConversationId: ConversationId, context: Context, ): Promise { const entry = await storage.entry(parentConversationId, at, context); @@ -61,7 +63,7 @@ async function collectCopies( scope: Extract, at: DocumentPoint, policy: ForkPolicy, - childConversationId: Id, + childConversationId: ConversationId, copies: ForkDocumentCopy[], copiedAddresses: Set, context: Context, @@ -71,7 +73,7 @@ async function collectCopies( const page = await storage.scanDocuments({ scope, at }, SCAN_PAGE_SIZE, cursor, context); for (const source of page.items) { if (source.scope.kind !== "conversation" || source.fork !== policy) continue; - const id = await storage.mintId(); + const id = await storage.mintId(); const identity = { id, kind: source.kind, diff --git a/packages/durable/src/session/session.ts b/packages/durable/src/session/session.ts index 731d43fa8..8fa945779 100644 --- a/packages/durable/src/session/session.ts +++ b/packages/durable/src/session/session.ts @@ -9,14 +9,17 @@ import { resolveAddress, } from "../documents.ts"; import { StorageRejected } from "../errors.ts"; +import { idFromNumber } from "../ids.ts"; import type { ConversationDocFamilyToken, ConversationDocToken, + ConversationId, DocumentAddress, - Id, + EntryId, JsonObject, RewindableConversationDocFamilyToken, RewindableConversationDocToken, + Seq, Session, SessionDocFamilyToken, SessionDocToken, @@ -24,6 +27,7 @@ import type { StorageWrite, TaskDocFamilyToken, TaskDocToken, + TaskId, Tx, } from "../types.ts"; import type { CommitChange, CommitPublication } from "./publications.ts"; @@ -76,12 +80,12 @@ export class SessionKernel implements Session { snapshot(token: SessionDocToken, context: Context): Promise | undefined>; snapshot( token: ConversationDocToken, - conversationId: Id, + conversationId: ConversationId, context: Context, ): Promise | undefined>; snapshot( token: TaskDocToken, - taskId: Id, + taskId: TaskId, context: Context, ): Promise | undefined>; snapshot( @@ -91,13 +95,13 @@ export class SessionKernel implements Session { ): Promise | undefined>; snapshot( token: ConversationDocFamilyToken, - conversationId: Id, + conversationId: ConversationId, key: string, context: Context, ): Promise | undefined>; snapshot( token: TaskDocFamilyToken, - taskId: Id, + taskId: TaskId, key: string, context: Context, ): Promise | undefined>; @@ -120,15 +124,15 @@ export class SessionKernel implements Session { snapshotAsOf( token: RewindableConversationDocToken, - conversationId: Id, - at: Id, + conversationId: ConversationId, + at: EntryId, context: Context, ): Promise | undefined>; snapshotAsOf( token: RewindableConversationDocFamilyToken, - conversationId: Id, + conversationId: ConversationId, key: string, - at: Id, + at: EntryId, context: Context, ): Promise | undefined>; async snapshotAsOf(token: AnyDocToken, ...args: readonly unknown[]): Promise { @@ -139,7 +143,11 @@ export class SessionKernel implements Session { throw new TypeError("Session.snapshotAsOf() requires a conversation document"); } const conversationId = resolved.address.scope.conversationId; - const at = args[resolved.nextArgument] as Id; + const atValue = args[resolved.nextArgument]; + if (typeof atValue !== "number" || !Number.isSafeInteger(atValue)) { + throw new TypeError("Session.snapshotAsOf() requires an entry ID"); + } + const at = idFromNumber(atValue); const context = args[resolved.nextArgument + 1] as Context; return this.#enqueue(async () => { this.#assertHealthy(); @@ -203,7 +211,7 @@ export class SessionKernel implements Session { tx.discard(); return result; } - let seq: number; + let seq: Seq; try { // Once admitted, caller cancellation does not interrupt Storage settlement. seq = await this.#storage.commit(writes, withoutAbortSignal(context)); @@ -226,7 +234,7 @@ export class SessionKernel implements Session { } #publish( - seq: number, + seq: Seq, writes: readonly StorageWrite[], documents: readonly DocumentCommitChange[], context: Context, diff --git a/packages/durable/src/session/transaction.ts b/packages/durable/src/session/transaction.ts index ccdca2571..79d6e0d06 100644 --- a/packages/durable/src/session/transaction.ts +++ b/packages/durable/src/session/transaction.ts @@ -15,17 +15,20 @@ import { ReadAfterWrite } from "../errors.ts"; import type { ConversationDocFamilyToken, ConversationDocToken, + ConversationId, + ConversationOwnership, ConversationQuery, ConversationRecord, Cursor, DocumentAddress, DocumentCopySource, DocumentCreate, + DocumentId, DocumentRecord, EntryDraft, + EntryId, EntryQuery, EntryRecord, - Id, JsonObject, Seq, SessionDocFamilyToken, @@ -35,10 +38,10 @@ import type { Task, TaskDocFamilyToken, TaskDocToken, + TaskId, TaskOptions, TaskQuery, TaskRecord, - TaskRef, Tx, } from "../types.ts"; import { prepareForkDocumentCopies } from "./forks.ts"; @@ -55,7 +58,7 @@ export type DocumentCommitChange = readonly type: "document"; readonly record: DocumentRecord; /** Conversation owning the document; task documents derive it from their task record. Undefined only for Session documents. */ - readonly conversationId: Id | undefined; + readonly conversationId: ConversationId | undefined; /** Definition version of `value`; absent when this commit retired the incarnation. */ readonly version: number | undefined; /** Exact adopted immutable revision, or `null` when this commit retired the incarnation. */ @@ -67,7 +70,7 @@ export type DocumentCommitChange = /** Definition-free child initialization; consumers hydrate through a source or watch. */ readonly type: "document.copy"; readonly record: DocumentRecord; - readonly conversationId: Id; + readonly conversationId: ConversationId; readonly source: DocumentCopySource; }; @@ -95,14 +98,14 @@ export interface TransactionHost { /** 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: Id): void; + evict(addressId: string, recordId: DocumentId): void; } /** Committed and candidate state for one task touched by this transaction. */ type TransactionTask = { committedRead?: Promise; write?: { readonly kind: "create" | "replace"; readonly record: AnyTaskRecord }; - publicationConversationId?: Id; + publicationConversationId?: ConversationId; }; /** Storage/cache provenance of one staged document incarnation. */ @@ -135,7 +138,7 @@ type DocumentEntry = { prepared?: Prepared; retireOnCommit: boolean; /** Resolved before Storage admission so adoption performs no reads. */ - conversationId?: Id; + conversationId?: ConversationId; }; /** @@ -153,11 +156,11 @@ export class Transaction implements Tx { /** Atomic batch; conversation and entry writes stage eagerly, while task and document writes assemble later. */ readonly #writes: StorageWrite[] = []; - readonly #createdConversationIds = new Set(); - readonly #forkSourceConversationIds = new Set(); - readonly #forkSourceDocumentIds = new Set(); + readonly #createdConversationIds = new Set(); + readonly #forkSourceConversationIds = new Set(); + readonly #forkSourceDocumentIds = new Set(); /** One entry per task touched by a public read, candidate write, or document-owner lookup. */ - readonly #tasksById = new Map(); + readonly #tasksById = new Map(); /** Every document acquisition or retirement marker in staging order. */ readonly #documents: DocumentEntry[] = []; @@ -171,15 +174,15 @@ export class Transaction implements Tx { // ─── Table reads ──────────────────────────────────────────────────────── - conversation(id: Id): Promise { + conversation(id: ConversationId): Promise { return this.#read("conversation", () => this.#host.storage.conversation(id, this.#context)); } - entry(id: Id): Promise { + entry(id: EntryId): Promise { return this.#read("entry", async () => (await this.#host.storage.entry(id, this.#context))?.entry); } - task(id: Id): Promise { + task(id: TaskId): Promise { return this.#read("task", () => this.#committedTask(id)); } @@ -199,18 +202,39 @@ export class Transaction implements Tx { // ─── Table writes ─────────────────────────────────────────────────────── - createConversation(): Promise { - return this.#write(() => this.#stageConversation()); + createConversation(options: { readonly ownership: ConversationOwnership }): Promise { + return this.#write(() => this.#stageConversation(undefined, options.ownership)); } - forkConversation(parentConversationId: Id, at: Id): Promise { - return this.#write(() => this.#stageConversation({ conversationId: parentConversationId, at })); + forkConversation( + parentConversationId: ConversationId, + at: EntryId, + options: { readonly ownership: ConversationOwnership }, + ): Promise { + return this.#write(() => + this.#stageConversation({ conversationId: parentConversationId, at }, options.ownership), + ); } - async #stageConversation(parent?: NonNullable): Promise { - const id = await this.#host.storage.mintId(); + async #stageConversation( + parent: NonNullable | undefined, + ownership: ConversationOwnership, + ): Promise { + const ownerTaskId = ownership.kind === "task" ? ownership.taskId : undefined; + const id = await this.#host.storage.mintId(); this.#assertOpen(); - const record: ConversationRecord = parent === undefined ? { id } : { id, parent }; + 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 ? [] @@ -233,11 +257,11 @@ export class Transaction implements Tx { return record; } - appendEntry(conversationId: Id, value: EntryDraft): Promise { + appendEntry(conversationId: ConversationId, value: EntryDraft): Promise { return this.#write(async () => { await this.#requireConversation(conversationId); this.#assertOpen(); - const id = await this.#host.storage.mintId(); + const id = await this.#host.storage.mintId(); this.#assertOpen(); const { head, ...rest } = value; const record = copyJson( @@ -255,7 +279,7 @@ export class Transaction implements Tx { task: Task, input: I, options?: TaskOptions, - ): Promise> { + ): Promise> { return this.#write(async () => { const conversationId = options?.conversationId; if (conversationId === undefined) throw new TypeError("Tx.createTask() requires options.conversationId"); @@ -263,7 +287,7 @@ export class Transaction implements Tx { this.#assertOpen(); const definition = task.definition; const checkpoint = definition.initial(input); - const id = await this.#host.storage.mintId(); + const id = await this.#host.storage.mintId>(); this.#assertOpen(); const record = copyJson( { @@ -280,7 +304,7 @@ export class Transaction implements Tx { TABLE_JSON_COPY_OPTIONS, ) as unknown as AnyTaskRecord; this.#tasksById.set(id, { write: { kind: "create", record } }); - return { id }; + return id; }); } @@ -288,9 +312,13 @@ export class Transaction implements Tx { this.#assertOpen(); this.#hasTableWrite = true; const task = this.#taskEntry(value.id); - if (task.write?.record.state.status === "terminal") { + 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(value, TABLE_JSON_COPY_OPTIONS) as unknown as AnyTaskRecord, @@ -299,8 +327,8 @@ export class Transaction implements Tx { // ─── Documents ────────────────────────────────────────────────────────── doc(token: SessionDocToken): Promise>; - doc(token: ConversationDocToken, conversationId: Id): Promise>; - doc(token: TaskDocToken, taskId: Id): Promise>; + doc(token: ConversationDocToken, conversationId: ConversationId): Promise>; + doc(token: TaskDocToken, taskId: TaskId): Promise>; doc( token: SessionDocFamilyToken, key: string, @@ -308,13 +336,13 @@ export class Transaction implements Tx { ): Promise>; doc( token: ConversationDocFamilyToken, - conversationId: Id, + conversationId: ConversationId, key: string, seed: I, ): Promise>; doc( token: TaskDocFamilyToken, - taskId: Id, + taskId: TaskId, key: string, seed: I, ): Promise>; @@ -350,17 +378,17 @@ export class Transaction implements Tx { } retireDoc(token: SessionDocToken): Promise; - retireDoc(token: ConversationDocToken, conversationId: Id): Promise; - retireDoc(token: TaskDocToken, taskId: Id): Promise; + retireDoc(token: ConversationDocToken, conversationId: ConversationId): Promise; + retireDoc(token: TaskDocToken, taskId: TaskId): Promise; retireDoc(token: SessionDocFamilyToken, key: string): Promise; retireDoc( token: ConversationDocFamilyToken, - conversationId: Id, + conversationId: ConversationId, key: string, ): Promise; retireDoc( token: TaskDocFamilyToken, - taskId: Id, + taskId: TaskId, key: string, ): Promise; retireDoc(token: AnyDocToken, ...args: readonly unknown[]): Promise { @@ -418,7 +446,7 @@ export class Transaction implements Tx { const value = copyJson( definition.family === true ? definition.initial(seed) : definition.initial(), ) as JsonObject; - const id = await this.#host.storage.mintId(); + const id = await this.#host.storage.mintId(); this.#assertOpen(); const tracker = track(value); entry.target = { @@ -603,15 +631,19 @@ export class Transaction implements Tx { async #assemble(): Promise { const storage = this.#host.storage; this.#rejectForkSourceWrites(); + await this.#validateConversationOwners(); 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. - let terminalTaskIds: Set | undefined; + let terminalTaskIds: Set | undefined; for (const task of this.#tasksById.values()) { const candidate = task.write?.record; if (candidate?.state.status !== "terminal") continue; @@ -619,7 +651,7 @@ export class Transaction implements Tx { terminalTaskIds.add(candidate.id); } if (terminalTaskIds !== undefined) { - const targetedDocumentIds = new Set(); + const targetedDocumentIds = new Set(); for (const document of this.#documents) { const scope = document.address.scope; if (scope.kind !== "task" || !terminalTaskIds.has(scope.taskId)) continue; @@ -723,6 +755,24 @@ export class Transaction implements Tx { // ─── Helpers ──────────────────────────────────────────────────────────── + async #validateConversationOwners(): Promise { + for (const write of this.#writes) { + if (write.type !== "conversation" || write.value.owner === undefined) continue; + const owner = write.value.owner; + const task = await this.#currentTask(owner.taskId); + if (task === undefined) throw new Error(`Conversation owner task ${owner.taskId} does not exist`); + if (task.conversationId !== owner.conversationId) { + throw new Error(`Conversation owner task ${owner.taskId} changed conversations`); + } + if (task.state.status === "terminal") { + throw new Error(`Conversation owner task ${owner.taskId} is terminal`); + } + if (task.abortRequested) { + throw new Error(`Conversation owner task ${owner.taskId} is abort-marked`); + } + } + } + #rejectForkSourceWrites(): void { for (const document of this.#documents) { const target = document.target; @@ -800,14 +850,14 @@ export class Transaction implements Tx { } } - async #requireConversation(id: Id): Promise { + async #requireConversation(id: ConversationId): Promise { if (this.#createdConversationIds.has(id)) return; if ((await this.#host.storage.conversation(id, this.#context)) === undefined) { throw new Error(`Conversation ${id} does not exist`); } } - #taskEntry(id: Id): TransactionTask { + #taskEntry(id: TaskId): TransactionTask { let task = this.#tasksById.get(id); if (task === undefined) { task = {}; @@ -817,11 +867,11 @@ export class Transaction implements Tx { } /** Latest candidate task record, falling back to committed state; not a caller table read. */ - async #currentTask(id: Id): Promise { + async #currentTask(id: TaskId): Promise { return this.#tasksById.get(id)?.write?.record ?? (await this.#committedTask(id)); } - #committedTask(id: Id): Promise { + #committedTask(id: TaskId): Promise { const task = this.#taskEntry(id); task.committedRead ??= this.#host.storage.task(id, this.#context); return task.committedRead; diff --git a/packages/durable/src/storage/jsonl/storage.ts b/packages/durable/src/storage/jsonl/storage.ts index 434d2afa2..2fc4272ca 100644 --- a/packages/durable/src/storage/jsonl/storage.ts +++ b/packages/durable/src/storage/jsonl/storage.ts @@ -1,18 +1,24 @@ import type { Context, JsonValue } from "@earendil-works/chord"; import type { FileError, FileSystem } from "../../env/index.ts"; +import { idFromNumber, seqFromNumber } from "../../ids.ts"; import type { + ConversationId, ConversationQuery, Cursor, DocumentAddress, DocumentContent, DocumentCreate, + DocumentId, DocumentPoint, DocumentQuery, + EntryId, EntryQuery, Id, Seq, Storage, StorageWrite, + SubmissionId, + TaskId, TaskQuery, TaskRecord, } from "../../types.ts"; @@ -28,9 +34,9 @@ type StoredTask = TaskRecord; type MainOperation = | Extract | { readonly type: "task"; readonly value: StoredTask } - | { readonly type: "task.sidecar"; readonly id: Id; readonly ordinal: number } + | { readonly type: "task.sidecar"; readonly id: TaskId; readonly ordinal: number } | { readonly type: "document.create"; readonly record: DocumentCreate; readonly ordinal: number } - | { readonly type: "document.change"; readonly id: Id; readonly ordinal: number }; + | { readonly type: "document.change"; readonly id: DocumentId; readonly ordinal: number }; type MainMarker = { readonly format: typeof FORMAT_VERSION; @@ -41,7 +47,7 @@ type MainMarker = { type SidecarPayload = | { readonly type: "task"; readonly value: StoredTask } - | { readonly type: "document"; readonly id: Id; readonly content: DocumentContent }; + | { readonly type: "document"; readonly id: DocumentId; readonly content: DocumentContent }; type SidecarRecord = { readonly format: typeof FORMAT_VERSION; @@ -90,7 +96,7 @@ const isObject = (value: unknown): value is Record => const isSafeInteger = (value: unknown): value is number => Number.isSafeInteger(value); -const sidecarFileName = (kind: "doc" | "task", id: Id): string => `${kind}-${id}.jsonl`; +const sidecarFileName = (kind: "doc" | "task", id: DocumentId | TaskId): string => `${kind}-${id}.jsonl`; const isSidecarFileName = (name: string): boolean => /^(?:doc|task)-(?:0|[1-9]\d*)\.jsonl$/.test(name); @@ -180,7 +186,7 @@ const parseMainMarker = (text: string, line: number): MainMarker => { return { format: FORMAT_VERSION, type: "commit", - seq: value.seq, + seq: seqFromNumber(value.seq), writes: value.writes.map((write) => validateMainOperation(write, description)), }; }; @@ -237,8 +243,8 @@ export class JsonlStorage implements Storage { private readonly mainPath: string; private readonly fsync: boolean; private readonly memory = new MemoryStorage(); - private readonly currentOnlyDocuments = new Set(); - private readonly liveTaskSidecars = new Set(); + private readonly currentOnlyDocuments = new Set(); + private readonly liveTaskSidecars = new Set(); private closed = false; private poisonError: JsonlStoragePoisonedError | undefined; @@ -298,11 +304,11 @@ export class JsonlStorage implements Storage { return seq; } - async mintId() { - return this.store.mintId(); + async mintId>(): Promise { + return this.store.mintId(); } - async conversation(id: Id, context: Context) { + async conversation(id: ConversationId, context: Context) { return this.store.conversation(id, context); } @@ -310,15 +316,24 @@ export class JsonlStorage implements Storage { return this.store.scanConversations(query, limit, cursor, context); } - entry(id: Id, context: Context): ReturnType; - entry(conversationId: Id, id: Id, context: Context): ReturnType; - async entry(idOrConversationId: Id, idOrContext: Id | Context, context?: Context) { - return context === undefined - ? this.store.entry(idOrConversationId, idOrContext as Context) - : this.store.entry(idOrConversationId, idOrContext as Id, context); + entry(id: EntryId, context: Context): ReturnType; + entry(conversationId: ConversationId, id: EntryId, context: Context): ReturnType; + async entry(idOrConversationId: EntryId | ConversationId, idOrContext: EntryId | Context, context?: Context) { + if (context === undefined) + return this.store.entry(idFromNumber(idOrConversationId), idOrContext as Context); + if (typeof idOrContext !== "number") throw new TypeError("Storage.entry() requires an entry ID"); + return this.store.entry( + idFromNumber(idOrConversationId), + idFromNumber(idOrContext), + context, + ); } - async findLatestHeadMarker(conversationId: Id, atOrBeforeEntryId: Id | undefined, context: Context) { + async findLatestHeadMarker( + conversationId: ConversationId, + atOrBeforeEntryId: EntryId | undefined, + context: Context, + ) { return this.store.findLatestHeadMarker(conversationId, atOrBeforeEntryId, context); } @@ -326,7 +341,7 @@ export class JsonlStorage implements Storage { return this.store.scanEntries(query, limit, cursor, context); } - async task(id: Id, context: Context) { + async task(id: TaskId, context: Context) { return this.store.task(id, context); } @@ -334,11 +349,11 @@ export class JsonlStorage implements Storage { return this.store.scanTasks(query, limit, cursor, context); } - async submission(id: Id, context: Context) { + async submission(id: SubmissionId, context: Context) { return this.store.submission(id, context); } - async submissionByRequest(conversationId: Id, requestId: string, context: Context) { + async submissionByRequest(conversationId: ConversationId, requestId: string, context: Context) { return this.store.submissionByRequest(conversationId, requestId, context); } @@ -346,7 +361,7 @@ export class JsonlStorage implements Storage { return this.store.findDocument(address, at, context); } - async document(id: Id, at: DocumentPoint, context: Context) { + async document(id: DocumentId, at: DocumentPoint, context: Context) { return this.store.document(id, at, context); } @@ -428,10 +443,10 @@ export class JsonlStorage implements Storage { } private planReclamations(writes: readonly StorageWrite[], encoded: EncodedCommit): ReadonlyMap { - const createdCurrentOnlyDocuments = new Set(); - const retiredDocuments = new Set(); - const baseDocuments = new Set(); - const finalTasks = new Map(); + const createdCurrentOnlyDocuments = new Set(); + const retiredDocuments = new Set(); + const baseDocuments = new Set(); + const finalTasks = new Map(); for (const write of writes) { switch (write.type) { case "document.create": @@ -450,7 +465,7 @@ export class JsonlStorage implements Storage { } const replacements = new Map(); - const isCurrentOnlyDocument = (id: Id): boolean => + const isCurrentOnlyDocument = (id: DocumentId): boolean => this.currentOnlyDocuments.has(id) || createdCurrentOnlyDocuments.has(id); for (const id of retiredDocuments) { if (isCurrentOnlyDocument(id)) replacements.set(sidecarFileName("doc", id), ""); @@ -560,9 +575,9 @@ export class JsonlStorage implements Storage { } } - const currentOnlyDocuments = new Set(); - const retiredDocuments = new Set(); - const finalTaskIsLive = new Map(); + const currentOnlyDocuments = new Set(); + const retiredDocuments = new Set(); + const finalTaskIsLive = new Map(); for (const { value: marker } of main.lines) { for (const operation of marker.writes) { if (operation.type === "document.create") { @@ -578,7 +593,7 @@ export class JsonlStorage implements Storage { } const retiredCurrentOnlyDocuments = new Set([...retiredDocuments].filter((id) => currentOnlyDocuments.has(id))); - const latestBases = new Map(); + const latestBases = new Map(); for (const { value: marker } of main.lines) { for (const operation of marker.writes) { if (operation.type !== "document.create" && operation.type !== "document.change") continue; @@ -605,7 +620,7 @@ export class JsonlStorage implements Storage { } } - const isBeforeLatestBase = (id: Id, seq: Seq, ordinal: number): boolean => { + const isBeforeLatestBase = (id: DocumentId, seq: Seq, ordinal: number): boolean => { const base = latestBases.get(id); return base !== undefined && (seq < base.seq || (seq === base.seq && ordinal < base.ordinal)); }; @@ -711,19 +726,22 @@ export class JsonlStorage implements Storage { if (!truncated.ok) throw errorFromFile(`tail truncation of ${file}`, truncated.error); } - const id = Number(file.slice(file.indexOf("-") + 1, -".jsonl".length)); + const numericId = Number(file.slice(file.indexOf("-") + 1, -".jsonl".length)); const confirmedLines = parsed.lines.filter((line) => confirmed.has(sidecarKey(file, line.value.seq, line.value.ordinal)), ); let retainedLines: readonly ParsedLine[] | undefined; - if (file.startsWith("task-") && terminalTasks.has(id)) { + if (file.startsWith("task-") && terminalTasks.has(idFromNumber(numericId))) { retainedLines = []; - } else if (file.startsWith("doc-") && retiredCurrentOnlyDocuments.has(id)) { - retainedLines = []; - } else if (file.startsWith("doc-") && latestBases.has(id)) { - retainedLines = confirmedLines.filter( - (line) => !isBeforeLatestBase(id, line.value.seq, line.value.ordinal), - ); + } else if (file.startsWith("doc-")) { + const documentId = idFromNumber(numericId); + if (retiredCurrentOnlyDocuments.has(documentId)) { + retainedLines = []; + } else if (latestBases.has(documentId)) { + retainedLines = confirmedLines.filter( + (line) => !isBeforeLatestBase(documentId, line.value.seq, line.value.ordinal), + ); + } } if ( retainedLines !== undefined && diff --git a/packages/durable/src/storage/memory.ts b/packages/durable/src/storage/memory.ts index 04befd67c..9a5aaa608 100644 --- a/packages/durable/src/storage/memory.ts +++ b/packages/durable/src/storage/memory.ts @@ -1,16 +1,20 @@ import type { Context, JsonValue } from "@earendil-works/chord"; import { applyImmutableBatches, type Op } from "@earendil-works/chord/delta"; import { StorageRejected } from "../errors.ts"; +import { idFromNumber, seqFromNumber } from "../ids.ts"; import type { + ConversationId, ConversationQuery, ConversationRecord, Cursor, DocumentAddress, DocumentContent, DocumentCreate, + DocumentId, DocumentPoint, DocumentQuery, DocumentRecord, + EntryId, EntryQuery, EntryRecord, Id, @@ -20,7 +24,9 @@ import type { Storage, StorageWrite, StoredDocument, + SubmissionId, SubmissionRecord, + TaskId, TaskQuery, TaskRecord, } from "../types.ts"; @@ -40,7 +46,7 @@ type DocumentAction = { }; function* documentDeltaBatches( - id: Id, + id: DocumentId, version: number, revisions: readonly DocumentRevision[], start: number, @@ -55,27 +61,28 @@ function* documentDeltaBatches( } type DocumentAddressIndex = { - ids: Id[]; - currentId?: Id; + ids: DocumentId[]; + currentId?: DocumentId; }; type State = { - conversations: Map; - conversationIds: Id[]; - conversationIdsByOwnerConversation: Map; - conversationIdsByOwnerTask: Map; - entries: Map; - entryIds: Map; - headEntryIds: Map; - entryCommitSeqs: Map; - tasks: Map; - taskIds: Id[]; - taskIdsByStatus: Record; - submissions: Map; - submissionIdsByRequest: Map>; - documents: Map; + recordTypes: Map, TableName>; + conversations: Map; + conversationIds: ConversationId[]; + conversationIdsByOwnerConversation: Map; + conversationIdsByOwnerTask: Map; + entries: Map; + entryIds: Map; + headEntryIds: Map; + entryCommitSeqs: Map; + tasks: Map; + taskIds: TaskId[]; + taskIdsByStatus: Record; + submissions: Map; + submissionIdsByRequest: Map>; + documents: Map; documentAddresses: Map; - documentIdsByScope: Map; + documentIdsByScope: Map; }; const clone = (value: T): T => { @@ -106,10 +113,14 @@ const freeze = (value: T): T => { return Object.freeze(value); }; -const cursorId = (cursor: Readonly> | undefined): Id | undefined => - cursor?.after as Id | undefined; +const cursorId = >(cursor: Readonly> | undefined): I | undefined => { + const after = cursor?.after; + if (after === undefined) return undefined; + if (typeof after !== "number" || !Number.isSafeInteger(after)) throw new TypeError("Invalid storage cursor"); + return idFromNumber(after); +}; -const lowerBound = (ids: readonly Id[], target: Id): number => { +const lowerBound = >(ids: readonly I[], target: number): number => { let low = 0; let high = ids.length; while (low < high) { @@ -120,7 +131,7 @@ const lowerBound = (ids: readonly Id[], target: Id): number => { return low; }; -const upperBound = (ids: readonly Id[], target: Id): number => { +const upperBound = >(ids: readonly I[], target: number): number => { let low = 0; let high = ids.length; while (low < high) { @@ -131,17 +142,17 @@ const upperBound = (ids: readonly Id[], target: Id): number => { return low; }; -const insertSorted = (ids: Id[], id: Id): void => { +const insertSorted = >(ids: I[], id: I): void => { if (ids.length === 0 || ids[ids.length - 1] < id) ids.push(id); else ids.splice(lowerBound(ids, id), 0, id); }; -const removeSorted = (ids: Id[], id: Id): void => { +const removeSorted = >(ids: I[], id: I): void => { const index = lowerBound(ids, id); if (ids[index] === id) ids.splice(index, 1); }; -const insertMapId = (index: Map, key: Id, id: Id): void => { +const insertMapId = >(index: Map, key: K, id: I): void => { let ids = index.get(key); if (ids === undefined) { ids = []; @@ -179,16 +190,9 @@ const isAliveAt = (record: DocumentRecord, at: DocumentPoint): boolean => { const isCurrentOnly = (record: DocumentRecord): boolean => record.scope.kind !== "conversation" || record.history === "latest"; -const tableContaining = (state: State, id: Id): TableName | undefined => { - if (state.conversations.has(id)) return "conversation"; - if (state.entries.has(id)) return "entry"; - if (state.tasks.has(id)) return "task"; - if (state.submissions.has(id)) return "submission"; - if (state.documents.has(id)) return "document"; - return undefined; -}; +const tableContaining = (state: State, id: Id): TableName | undefined => state.recordTypes.get(id); -const page = (values: readonly T[], limit: number): Page => { +const page = }>(values: readonly T[], limit: number): Page => { const items = values.slice(0, limit); if (values.length <= limit) return { items: clone(items) }; return { items: clone(items), next: { after: items.at(-1)!.id } }; @@ -210,6 +214,7 @@ export interface PreparedMemoryCommit { */ export class MemoryStorage implements Storage { private readonly state: State = { + recordTypes: new Map(), conversations: new Map(), conversationIds: [], conversationIdsByOwnerConversation: new Map(), @@ -236,7 +241,7 @@ export class MemoryStorage implements Storage { } /** Validate and detach one commit without changing observable state. */ - prepareCommit(writes: readonly StorageWrite[], seq: Seq = this.nextSeq): PreparedMemoryCommit { + prepareCommit(writes: readonly StorageWrite[], seq: Seq = seqFromNumber(this.nextSeq)): PreparedMemoryCommit { this.assertOpen(); if (!Number.isSafeInteger(seq) || seq < this.nextSeq) { throw new Error(`Commit sequence ${seq} does not strictly increase`); @@ -261,7 +266,7 @@ export class MemoryStorage implements Storage { private resolveDocumentCopies(writes: StorageWrite[]): StorageWrite[] { if (!writes.some((write) => write.type === "document.copy")) return writes; - const changedDocumentIds = new Set(); + const changedDocumentIds = new Set(); for (const write of writes) { if (write.type === "document.create" || write.type === "document.copy") { changedDocumentIds.add(write.record.id); @@ -301,12 +306,13 @@ export class MemoryStorage implements Storage { private applyPreparedCommit( prepared: readonly StorageWrite[], - documentActions: ReadonlyMap, + documentActions: ReadonlyMap, seq: Seq, ): Seq { for (const write of prepared) { switch (write.type) { case "conversation": { + this.state.recordTypes.set(write.value.id, "conversation"); this.state.conversations.set(write.value.id, write.value); insertSorted(this.state.conversationIds, write.value.id); const owner = write.value.owner; @@ -318,6 +324,7 @@ export class MemoryStorage implements Storage { break; } case "entry": { + this.state.recordTypes.set(write.value.id, "entry"); this.state.entries.set(write.value.id, write.value); this.state.entryCommitSeqs.set(write.value.id, seq); let ids = this.state.entryIds.get(write.value.conversationId); @@ -338,6 +345,7 @@ export class MemoryStorage implements Storage { break; } case "task": { + this.state.recordTypes.set(write.value.id, "task"); const previous = this.state.tasks.get(write.value.id); if (previous === undefined) { insertSorted(this.state.taskIds, write.value.id); @@ -351,6 +359,7 @@ export class MemoryStorage implements Storage { break; } case "submission": { + this.state.recordTypes.set(write.value.id, "submission"); const previous = this.state.submissions.get(write.value.id); if (previous?.requestId !== undefined) { const previousRequests = this.state.submissionIdsByRequest.get(previous.conversationId); @@ -385,13 +394,13 @@ export class MemoryStorage implements Storage { return seq; } - async mintId(): Promise { + async mintId>(): Promise { this.assertOpen(); if (!Number.isSafeInteger(this.nextId)) throw new Error("ID space is exhausted"); - return this.nextId++; + return idFromNumber(this.nextId++); } - async conversation(id: Id, _context: Context): Promise { + async conversation(id: ConversationId, _context: Context): Promise { this.assertOpen(); const value = this.state.conversations.get(id); return value === undefined ? undefined : clone(value); @@ -423,32 +432,37 @@ export class MemoryStorage implements Storage { return page(values, limit); } - entry(id: Id, context: Context): Promise<{ readonly entry: EntryRecord; readonly commitSeq: Seq } | undefined>; + entry(id: EntryId, context: Context): Promise<{ readonly entry: EntryRecord; readonly commitSeq: Seq } | undefined>; entry( - conversationId: Id, - id: Id, + conversationId: ConversationId, + id: EntryId, context: Context, ): Promise<{ readonly entry: EntryRecord; readonly commitSeq: Seq } | undefined>; async entry( - idOrConversationId: Id, - idOrContext: Id | Context, + idOrConversationId: EntryId | ConversationId, + idOrContext: EntryId | Context, context?: Context, ): Promise<{ readonly entry: EntryRecord; readonly commitSeq: Seq } | undefined> { this.assertOpen(); - const id = context === undefined ? idOrConversationId : (idOrContext as Id); - const entry = - context === undefined - ? this.state.entries.get(id) - : this.visibleEntries(idOrConversationId, id, id).next().value; + if (context === undefined) { + const id = idFromNumber(idOrConversationId); + const entry = this.state.entries.get(id); + if (entry === undefined) return undefined; + return { entry: clone(entry), commitSeq: this.state.entryCommitSeqs.get(id)! }; + } + if (typeof idOrContext !== "number") throw new TypeError("Storage.entry() requires an entry ID"); + const conversationId = idFromNumber(idOrConversationId); + const id = idFromNumber(idOrContext); + const entry = this.visibleEntries(conversationId, id, id).next().value; if (entry === undefined) return undefined; return { entry: clone(entry), commitSeq: this.state.entryCommitSeqs.get(id)! }; } async findLatestHeadMarker( - conversationId: Id, - atOrBeforeEntryId: Id | undefined, + conversationId: ConversationId, + atOrBeforeEntryId: EntryId | undefined, _context: Context, - ): Promise<(EntryRecord & { readonly head: Id }) | undefined> { + ): Promise<(EntryRecord & { readonly head: EntryId }) | undefined> { this.assertOpen(); if (!this.state.conversations.has(conversationId)) { throw new Error(`Unknown conversation: ${conversationId}`); @@ -487,7 +501,7 @@ export class MemoryStorage implements Storage { return page(visible, limit); } - async task(id: Id, _context: Context): Promise { + async task(id: TaskId, _context: Context): Promise { this.assertOpen(); const value = this.state.tasks.get(id); return value === undefined ? undefined : clone(value); @@ -515,14 +529,14 @@ export class MemoryStorage implements Storage { return page(values, limit); } - async submission(id: Id, _context: Context): Promise { + async submission(id: SubmissionId, _context: Context): Promise { this.assertOpen(); const value = this.state.submissions.get(id); return value === undefined ? undefined : clone(value); } async submissionByRequest( - conversationId: Id, + conversationId: ConversationId, requestId: string, _context: Context, ): Promise { @@ -549,7 +563,7 @@ export class MemoryStorage implements Storage { return undefined; } - async document(id: Id, at: DocumentPoint, _context: Context): Promise { + async document(id: DocumentId, at: DocumentPoint, _context: Context): Promise { this.assertOpen(); const stored = this.materializeDocument(id, at); return stored === undefined @@ -580,7 +594,7 @@ export class MemoryStorage implements Storage { this.closed = true; } - private materializeDocument(id: Id, at: DocumentPoint): StoredDocument | undefined { + private materializeDocument(id: DocumentId, at: DocumentPoint): StoredDocument | undefined { const stored = this.state.documents.get(id); if (stored === undefined) return undefined; if (at !== "current" && isCurrentOnly(stored.record)) { @@ -600,9 +614,9 @@ export class MemoryStorage implements Storage { } private *visibleEntries( - conversationId: Id, - minEntryId = Number.NEGATIVE_INFINITY, - maxEntryId = Number.POSITIVE_INFINITY, + conversationId: ConversationId, + minEntryId: number = Number.NEGATIVE_INFINITY, + maxEntryId: number = Number.POSITIVE_INFINITY, ): Generator { if (!this.state.conversations.has(conversationId)) { throw new Error(`Unknown conversation: ${conversationId}`); @@ -625,7 +639,7 @@ export class MemoryStorage implements Storage { } private checkGlobalIds(writes: readonly StorageWrite[]): void { - const claimed = new Map(); + const claimed = new Map, TableName>(); for (const write of writes) { if (write.type === "document.change" || write.type === "document.retire") continue; const document = write.type === "document.create" || write.type === "document.copy"; @@ -646,8 +660,8 @@ export class MemoryStorage implements Storage { } } - private prepareDocumentActions(writes: readonly StorageWrite[]): Map { - const actions = new Map(); + private prepareDocumentActions(writes: readonly StorageWrite[]): Map { + const actions = new Map(); for (const write of writes) { if (write.type !== "document.create" && write.type !== "document.change" && write.type !== "document.retire") { continue; @@ -679,7 +693,7 @@ export class MemoryStorage implements Storage { return actions; } - private checkDocumentActions(actions: ReadonlyMap): void { + private checkDocumentActions(actions: ReadonlyMap): void { const liveCounts = new Map(); for (const [id, action] of actions) { const existing = this.state.documents.get(id); @@ -708,7 +722,7 @@ export class MemoryStorage implements Storage { } } - private applyDocumentActions(actions: ReadonlyMap, seq: Seq): void { + private applyDocumentActions(actions: ReadonlyMap, seq: Seq): void { for (const [id, action] of actions) { let stored = this.state.documents.get(id); if (action.create !== undefined) { @@ -718,6 +732,7 @@ export class MemoryStorage implements Storage { ...(action.retire ? { retiredAt: seq } : {}), }; stored = { record, revisions: [{ ...action.content!, seq }] }; + this.state.recordTypes.set(id, "document"); this.state.documents.set(id, stored); const key = recordAddressKey(record); diff --git a/packages/durable/src/storage/sqlite/storage.ts b/packages/durable/src/storage/sqlite/storage.ts index 9fffd6914..a9a35108e 100644 --- a/packages/durable/src/storage/sqlite/storage.ts +++ b/packages/durable/src/storage/sqlite/storage.ts @@ -1,16 +1,20 @@ import type { Context, JsonValue } from "@earendil-works/chord"; import { apply, type Op } from "@earendil-works/chord/delta"; import { StorageRejected } from "../../errors.ts"; +import { idFromNumber, seqFromNumber } from "../../ids.ts"; import type { + ConversationId, ConversationQuery, ConversationRecord, Cursor, DocumentAddress, DocumentContent, DocumentCreate, + DocumentId, DocumentPoint, DocumentQuery, DocumentRecord, + EntryId, EntryQuery, EntryRecord, Id, @@ -20,7 +24,9 @@ import type { Storage, StorageWrite, StoredDocument, + SubmissionId, SubmissionRecord, + TaskId, TaskQuery, TaskRecord, } from "../../types.ts"; @@ -62,9 +68,14 @@ const getRow = (statement: SqliteStatement, ...params: SqliteV const allRows = (statement: SqliteStatement, ...params: SqliteValue[]): T[] => statement.all(...params); -const cursorId = (cursor: Cursor | undefined): Id | undefined => cursor?.after as Id | undefined; +const cursorId = >(cursor: Cursor | undefined): I | undefined => { + const after = cursor?.after; + if (after === undefined) return undefined; + if (typeof after !== "number" || !Number.isSafeInteger(after)) throw new TypeError("Invalid storage cursor"); + return idFromNumber(after); +}; -const page = (values: readonly T[], limit: number): Page => { +const page = }>(values: readonly T[], limit: number): Page => { const items = values.slice(0, limit); if (values.length <= limit) return { items }; return { items, next: { after: items.at(-1)!.id } }; @@ -104,7 +115,7 @@ const isAliveAt = (record: DocumentRecord, at: DocumentPoint): boolean => { const isCurrentOnly = (record: DocumentRecord): boolean => record.scope.kind !== "conversation" || record.history === "latest"; -const writeId = (write: StorageWrite): Id | undefined => { +const writeId = (write: StorageWrite): Id | undefined => { switch (write.type) { case "conversation": case "entry": @@ -154,10 +165,10 @@ class StatementCachingDatabase implements SqliteDatabase { /** Portable SQLite implementation of the Pico storage contract. */ export class SqliteStorage implements Storage { private readonly db: SqliteDatabase; - private nextId: Id; + private nextId: number; private closed = false; - private constructor(db: SqliteDatabase, nextId: Id) { + private constructor(db: SqliteDatabase, nextId: number) { this.db = new StatementCachingDatabase(db); this.nextId = nextId; } @@ -190,7 +201,7 @@ export class SqliteStorage implements Storage { this.db.prepare("SELECT next_id, next_seq FROM durable_metadata WHERE singleton = 1"), ); if (metadata === undefined) throw new Error("Durable SQLite metadata is missing"); - const committedSeq = metadata.next_seq; + const committedSeq = seqFromNumber(metadata.next_seq); this.checkGlobalIds(writes); this.checkDocumentActions(documentActions); for (const write of writes) this.applyTableWrite(write, committedSeq); @@ -204,13 +215,13 @@ export class SqliteStorage implements Storage { return seq; } - async mintId(): Promise { + async mintId>(): Promise { this.assertOpen(); if (!Number.isSafeInteger(this.nextId)) throw new Error("ID space is exhausted"); - return this.nextId++; + return idFromNumber(this.nextId++); } - async conversation(id: Id, _context: Context): Promise { + async conversation(id: ConversationId, _context: Context): Promise { this.assertOpen(); const row = getRow(this.db.prepare("SELECT record FROM conversations WHERE id = ?"), id); return row === undefined ? undefined : parseJson(row.record); @@ -244,23 +255,31 @@ export class SqliteStorage implements Storage { ); } - entry(id: Id, context: Context): Promise<{ readonly entry: EntryRecord; readonly commitSeq: Seq } | undefined>; + entry(id: EntryId, context: Context): Promise<{ readonly entry: EntryRecord; readonly commitSeq: Seq } | undefined>; entry( - conversationId: Id, - id: Id, + conversationId: ConversationId, + id: EntryId, context: Context, ): Promise<{ readonly entry: EntryRecord; readonly commitSeq: Seq } | undefined>; async entry( - idOrConversationId: Id, - idOrContext: Id | Context, + idOrConversationId: EntryId | ConversationId, + idOrContext: EntryId | Context, context?: Context, ): Promise<{ readonly entry: EntryRecord; readonly commitSeq: Seq } | undefined> { this.assertOpen(); - let conversation = context === undefined ? undefined : this.readConversation(idOrConversationId); - if (context !== undefined && conversation === undefined) { - throw new Error(`Unknown conversation: ${idOrConversationId}`); + const id = + context === undefined + ? idFromNumber(idOrConversationId) + : typeof idOrContext === "number" + ? idFromNumber(idOrContext) + : undefined; + if (id === undefined) throw new TypeError("Storage.entry() requires an entry ID"); + let conversation: ConversationRecord | undefined; + if (context !== undefined) { + const conversationId = idFromNumber(idOrConversationId); + conversation = this.readConversation(conversationId); + if (conversation === undefined) throw new Error(`Unknown conversation: ${conversationId}`); } - const id = context === undefined ? idOrConversationId : (idOrContext as Id); const row = getRow(this.db.prepare("SELECT record, commit_seq FROM entries WHERE id = ?"), id); if (row === undefined) return undefined; const entry = parseJson(row.record); @@ -273,18 +292,18 @@ export class SqliteStorage implements Storage { } if (entry.id > upperEntryId) return undefined; } - return { entry, commitSeq: row.commit_seq }; + return { entry, commitSeq: seqFromNumber(row.commit_seq) }; } async findLatestHeadMarker( - conversationId: Id, - atOrBeforeEntryId: Id | undefined, + conversationId: ConversationId, + atOrBeforeEntryId: EntryId | undefined, _context: Context, - ): Promise<(EntryRecord & { readonly head: Id }) | undefined> { + ): Promise<(EntryRecord & { readonly head: EntryId }) | undefined> { this.assertOpen(); let conversation = this.readConversation(conversationId); if (conversation === undefined) throw new Error(`Unknown conversation: ${conversationId}`); - let upper = atOrBeforeEntryId; + let upper: number | undefined = atOrBeforeEntryId; while (true) { const row = upper === undefined @@ -301,7 +320,7 @@ export class SqliteStorage implements Storage { conversation.id, upper, ); - if (row !== undefined) return parseJson(row.record); + if (row !== undefined) return parseJson(row.record); if (conversation.parent === undefined) return undefined; upper = upper === undefined ? conversation.parent.at : Math.min(upper, conversation.parent.at); conversation = this.readConversation(conversation.parent.conversationId)!; @@ -318,7 +337,7 @@ export class SqliteStorage implements Storage { let conversation = this.readConversation(query.conversationId); if (conversation === undefined) throw new Error(`Unknown conversation: ${query.conversationId}`); const after = cursorId(cursor); - let upper = query.maxEntryId; + let upper: number | undefined = query.maxEntryId; if (after !== undefined) upper = Math.min(upper ?? Number.MAX_SAFE_INTEGER, after - 1); const values: EntryRecord[] = []; while (true) { @@ -346,7 +365,7 @@ export class SqliteStorage implements Storage { return page(values, limit); } - async task(id: Id, _context: Context): Promise { + async task(id: TaskId, _context: Context): Promise { this.assertOpen(); const row = getRow(this.db.prepare("SELECT record FROM tasks WHERE id = ?"), id); return row === undefined ? undefined : parseJson(row.record); @@ -392,14 +411,14 @@ export class SqliteStorage implements Storage { ); } - async submission(id: Id, _context: Context): Promise { + async submission(id: SubmissionId, _context: Context): Promise { this.assertOpen(); const row = getRow(this.db.prepare("SELECT record FROM submissions WHERE id = ?"), id); return row === undefined ? undefined : parseJson(row.record); } async submissionByRequest( - conversationId: Id, + conversationId: ConversationId, requestId: string, _context: Context, ): Promise { @@ -434,7 +453,7 @@ export class SqliteStorage implements Storage { return row === undefined ? undefined : parseJson(row.record); } - async document(id: Id, at: DocumentPoint, _context: Context): Promise { + async document(id: DocumentId, at: DocumentPoint, _context: Context): Promise { this.assertOpen(); return this.materializeDocument(id, at); } @@ -476,12 +495,12 @@ export class SqliteStorage implements Storage { await this.db.close(); } - private readConversation(id: Id): ConversationRecord | undefined { + private readConversation(id: ConversationId): ConversationRecord | undefined { const row = getRow(this.db.prepare("SELECT record FROM conversations WHERE id = ?"), id); return row === undefined ? undefined : parseJson(row.record); } - private materializeDocument(id: Id, at: DocumentPoint): StoredDocument | undefined { + private materializeDocument(id: DocumentId, at: DocumentPoint): StoredDocument | undefined { const row = getRow(this.db.prepare("SELECT record FROM documents WHERE id = ?"), id); if (row === undefined) return undefined; const record = parseJson(row.record); @@ -514,7 +533,7 @@ export class SqliteStorage implements Storage { return { record, version: base.version, value }; } - private candidateNextId(writes: readonly StorageWrite[]): Id { + private candidateNextId(writes: readonly StorageWrite[]): number { let nextId = this.nextId; for (const write of writes) { const id = writeId(write); @@ -524,7 +543,7 @@ export class SqliteStorage implements Storage { } private checkGlobalIds(writes: readonly StorageWrite[]): void { - const claimed = new Map(); + const claimed = new Map, TableName>(); const lookup = this.db.prepare("SELECT record_type FROM record_ids WHERE id = ?"); for (const write of writes) { if (write.type === "document.change" || write.type === "document.retire") continue; @@ -545,8 +564,8 @@ export class SqliteStorage implements Storage { } } - private prepareDocumentActions(writes: readonly StorageWrite[]): Map { - const actions = new Map(); + private prepareDocumentActions(writes: readonly StorageWrite[]): Map { + const actions = new Map(); for (const write of writes) { if ( write.type !== "document.create" && @@ -592,7 +611,7 @@ export class SqliteStorage implements Storage { return actions; } - private checkDocumentActions(actions: ReadonlyMap): void { + private checkDocumentActions(actions: ReadonlyMap): void { const liveCounts = new Map(); for (const [id, action] of actions) { if (action.copy !== undefined && actions.has(action.copy.id)) { @@ -628,9 +647,9 @@ export class SqliteStorage implements Storage { } } - private currentDocumentId(address: DocumentAddress | DocumentCreate | DocumentRecord): Id | undefined { + private currentDocumentId(address: DocumentAddress | DocumentCreate | DocumentRecord): DocumentId | undefined { const parts = addressParts(address); - return getRow( + const id = getRow( this.db.prepare(`SELECT id FROM documents WHERE kind = ? AND scope_kind = ? AND owner_id = ? AND family = ? AND key_value = ? AND retired_at IS NULL LIMIT 1`), @@ -640,6 +659,7 @@ export class SqliteStorage implements Storage { parts.family, parts.keyValue, )?.id; + return id === undefined ? undefined : idFromNumber(id); } private applyTableWrite(write: StorageWrite, seq: Seq): void { @@ -702,11 +722,11 @@ export class SqliteStorage implements Storage { } } - private claimId(id: Id, table: TableName): void { + private claimId(id: Id, table: TableName): void { this.db.prepare("INSERT OR IGNORE INTO record_ids (id, record_type) VALUES (?, ?)").run(id, table); } - private applyDocumentActions(actions: ReadonlyMap, seq: Seq): void { + private applyDocumentActions(actions: ReadonlyMap, seq: Seq): void { for (const [id, action] of actions) { let content = action.content; if (action.copy !== undefined) { diff --git a/packages/durable/src/testing/storage-benchmark.ts b/packages/durable/src/testing/storage-benchmark.ts index b6e462ca0..44278a1b2 100644 --- a/packages/durable/src/testing/storage-benchmark.ts +++ b/packages/durable/src/testing/storage-benchmark.ts @@ -1,6 +1,17 @@ import type { JsonValue } from "@earendil-works/chord"; import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; -import type { DocumentCreate, Id, Seq, Storage, StorageWrite, TaskRecord } from "../types.ts"; +import type { + ConversationId, + DocumentCreate, + DocumentId, + EntryId, + Seq, + Storage, + StorageWrite, + SubmissionId, + TaskId, + TaskRecord, +} from "../types.ts"; import { ROOT_CONVERSATION_ID } from "../types.ts"; export type StorageBenchmarkScale = { @@ -43,19 +54,19 @@ export function storageBenchmarkPrimaryRecordCount(scale: StorageBenchmarkScale) type StoredTask = TaskRecord; export type StorageBenchmarkDataset = { - readonly firstEntryId: Id; + readonly firstEntryId: EntryId; readonly filteredTaskCount: number; - readonly exactDocumentId: Id; + readonly exactDocumentId: DocumentId; readonly exactDocumentKey: string; - readonly replayDocumentIds: Readonly>; - readonly historicalDocumentId: Id; + readonly replayDocumentIds: Readonly>; + readonly historicalDocumentId: DocumentId; readonly ancientAt: Seq; readonly recentAt: Seq; - readonly deepestConversationId: Id; - readonly ancestorHeadEntryId: Id; + readonly deepestConversationId: ConversationId; + readonly ancestorHeadEntryId: EntryId; }; -function task(id: Id, index: number): StoredTask { +function task(id: TaskId, index: number): StoredTask { const statuses = ["pending", "running", "terminal"] as const; const status = statuses[index % statuses.length]; const common = { @@ -81,11 +92,11 @@ export async function seedStorageBenchmark( ): Promise { await storage.commit([{ type: "conversation", value: { id: ROOT_CONVERSATION_ID } }], BACKGROUND_CONTEXT); - let firstEntryId = 0; + let firstEntryId: EntryId | undefined; for (let start = 0; start < scale.entryCount; start += BATCH_SIZE) { const writes: StorageWrite[] = []; for (let index = start; index < Math.min(start + BATCH_SIZE, scale.entryCount); index++) { - const id = await storage.mintId(); + const id = await storage.mintId(); if (index === 0) firstEntryId = id; writes.push({ type: "entry", @@ -104,16 +115,16 @@ export async function seedStorageBenchmark( for (let start = 0; start < scale.taskCount; start += BATCH_SIZE) { const writes: StorageWrite[] = []; for (let index = start; index < Math.min(start + BATCH_SIZE, scale.taskCount); index++) { - writes.push({ type: "task", value: task(await storage.mintId(), index) }); + writes.push({ type: "task", value: task(await storage.mintId>(), index) }); } await storage.commit(writes, BACKGROUND_CONTEXT); } - let exactDocumentId = 0; + let exactDocumentId: DocumentId | undefined; for (let start = 0; start < scale.documentCount; start += BATCH_SIZE) { const writes: StorageWrite[] = []; for (let index = start; index < Math.min(start + BATCH_SIZE, scale.documentCount); index++) { - const id = await storage.mintId(); + const id = await storage.mintId(); exactDocumentId = id; writes.push({ type: "document.create", @@ -126,7 +137,7 @@ export async function seedStorageBenchmark( const replayEntries = await Promise.all( REPLAY_TAILS.map(async (tail) => { - const id = await storage.mintId(); + const id = await storage.mintId(); return { tail, id }; }), ); @@ -158,7 +169,7 @@ export async function seedStorageBenchmark( ); } - const historicalDocumentId = await storage.mintId(); + const historicalDocumentId = await storage.mintId(); const historicalRecord = { id: historicalDocumentId, kind: "benchmark.history", @@ -176,7 +187,7 @@ export async function seedStorageBenchmark( ], BACKGROUND_CONTEXT, ); - let ancientAt = 0; + let ancientAt: Seq | undefined; for (let count = 1; count <= HISTORY_SEGMENT_LENGTH; count++) { ancientAt = await storage.commit( [ @@ -199,6 +210,7 @@ export async function seedStorageBenchmark( ], BACKGROUND_CONTEXT, ); + if (ancientAt === undefined) throw new Error("Benchmark history seed produced no commits"); let recentAt = ancientAt; for (let count = HISTORY_SEGMENT_LENGTH + 1; count <= HISTORY_SEGMENT_LENGTH * 2; count++) { recentAt = await storage.commit( @@ -213,11 +225,12 @@ export async function seedStorageBenchmark( ); } + if (firstEntryId === undefined) throw new Error("Benchmark scale must create entries"); let parentConversationId = ROOT_CONVERSATION_ID; let parentAt = firstEntryId; let deepestConversationId = ROOT_CONVERSATION_ID; for (let depth = 0; depth < FORK_DEPTH; depth++) { - const conversationId = await storage.mintId(); + const conversationId = await storage.mintId(); await storage.commit( [ { @@ -227,7 +240,7 @@ export async function seedStorageBenchmark( ], BACKGROUND_CONTEXT, ); - const ids = await Promise.all(Array.from({ length: ENTRIES_PER_FORK }, () => storage.mintId())); + const ids = await Promise.all(Array.from({ length: ENTRIES_PER_FORK }, () => storage.mintId())); await storage.commit( ids.map((id, index) => ({ type: "entry" as const, @@ -245,6 +258,7 @@ export async function seedStorageBenchmark( deepestConversationId = conversationId; } + if (exactDocumentId === undefined) throw new Error("Benchmark scale must create documents"); return { firstEntryId, filteredTaskCount: Math.min(50, Math.ceil(scale.taskCount / 60)), @@ -252,7 +266,7 @@ export async function seedStorageBenchmark( exactDocumentKey: `key-${scale.documentCount - 1}`, replayDocumentIds: Object.fromEntries(replayEntries.map(({ tail, id }) => [tail, id])) as Record< (typeof REPLAY_TAILS)[number], - Id + DocumentId >, historicalDocumentId, ancientAt, @@ -382,7 +396,7 @@ export async function seedStorageWriteBenchmark(storage: Storage): Promise Array.from({ length: 100 }, async (_, index) => ({ type: "entry" as const, value: { - id: await storage.mintId(), + id: await storage.mintId(), conversationId: ROOT_CONVERSATION_ID, kind: "benchmark.baseline", data: { index }, @@ -398,7 +412,7 @@ export const STORAGE_WRITE_BENCHMARKS: readonly StorageWriteBenchmark[] = [ name: "commit one entry", expected: 1, async run(storage) { - const id = await storage.mintId(); + const id = await storage.mintId(); await storage.commit( [ { @@ -426,7 +440,7 @@ export const STORAGE_WRITE_BENCHMARKS: readonly StorageWriteBenchmark[] = [ async (_, index): Promise => ({ type: "entry", value: { - id: await storage.mintId(), + id: await storage.mintId(), conversationId: ROOT_CONVERSATION_ID, kind: "benchmark.write", data: { index, text: "x".repeat(128) }, @@ -442,10 +456,10 @@ export const STORAGE_WRITE_BENCHMARKS: readonly StorageWriteBenchmark[] = [ name: "commit mixed entry/task/submission/document", expected: 4, async run(storage) { - const entryId = await storage.mintId(); - const taskId = await storage.mintId(); - const submissionId = await storage.mintId(); - const documentId = await storage.mintId(); + const entryId = await storage.mintId(); + const taskId = await storage.mintId>(); + const submissionId = await storage.mintId(); + const documentId = await storage.mintId(); const writes: readonly StorageWrite[] = [ { type: "entry", diff --git a/packages/durable/src/testing/storage-conformance.ts b/packages/durable/src/testing/storage-conformance.ts index 76004a13d..b9ae776d8 100644 --- a/packages/durable/src/testing/storage-conformance.ts +++ b/packages/durable/src/testing/storage-conformance.ts @@ -1,15 +1,20 @@ import type { JsonValue } from "@earendil-works/chord"; import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; import type { Op } from "@earendil-works/chord/delta"; +import { idFromNumber } from "../ids.ts"; import { + type ConversationId, type DocumentCreate, + type DocumentId, + type EntryId, type EntryRecord, - type Id, type JsonObject, ROOT_CONVERSATION_ID, type Storage, type StorageWrite, + type SubmissionId, type SubmissionRecord, + type TaskId, type TaskRecord, } from "../types.ts"; import type { StorageConformanceAssertions, StorageConformanceCase, StorageConformanceOptions } from "./types.ts"; @@ -17,12 +22,12 @@ import type { StorageConformanceAssertions, StorageConformanceCase, StorageConfo const context = BACKGROUND_CONTEXT; type StoredTask = TaskRecord; -async function createRoot(storage: Storage): Promise { +async function createRoot(storage: Storage): Promise { await storage.commit([{ type: "conversation", value: { id: ROOT_CONVERSATION_ID } }], context); return ROOT_CONVERSATION_ID; } -function pendingTask(id: Id, conversationId: Id, phase = "ready") { +function pendingTask(id: TaskId, conversationId: ConversationId, phase = "ready") { return { id, conversationId, @@ -36,7 +41,12 @@ function pendingTask(id: Id, conversationId: Id, phase = "ready") { } satisfies StoredTask; } -function entry(id: Id, conversationId: Id, kind = "message", extra: Partial = {}): EntryRecord { +function entry( + id: EntryId, + conversationId: ConversationId, + kind = "message", + extra: Partial = {}, +): EntryRecord { return { id, conversationId, kind, ...extra }; } @@ -81,7 +91,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re const expect = assertionFacade(options.assertions); return [ createCase(options, "reserves ID 1 for the immutable root conversation", async (storage) => { - expect(await storage.mintId()).toBe(2); + expect(await storage.mintId()).toBe(2); await expect(createRoot(storage)).resolves.toBe(ROOT_CONVERSATION_ID); expect(await storage.conversation(ROOT_CONVERSATION_ID, context)).toEqual({ id: ROOT_CONVERSATION_ID }); await expect( @@ -94,9 +104,9 @@ export function createStorageConformance(options: StorageConformanceOptions): re "commits mixed table writes atomically and rolls all of them back on failure", async (storage) => { const rootId = await createRoot(storage); - const entryId = await storage.mintId(); - const taskId = await storage.mintId(); - const submissionId = await storage.mintId(); + const entryId = await storage.mintId(); + const taskId = await storage.mintId>(); + const submissionId = await storage.mintId(); const task = pendingTask(taskId, rootId); const input: SubmissionRecord = { id: submissionId, @@ -122,7 +132,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re expect(await storage.task(taskId, context)).toEqual(task); expect(await storage.submission(submissionId, context)).toEqual(input); - const transientEntryId = await storage.mintId(); + const transientEntryId = await storage.mintId(); const runningTask: StoredTask = { ...task, state: { status: "running", checkpoint: { phase: "effect" } }, @@ -144,7 +154,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re expect(await storage.submission(submissionId, context)).toEqual(input); expect(await storage.entry(transientEntryId, context)).toBeUndefined(); const afterRollbackSeq = await storage.commit( - [{ type: "entry", value: entry(await storage.mintId(), rootId, "after-rollback") }], + [{ type: "entry", value: entry(await storage.mintId(), rootId, "after-rollback") }], context, ); expect(afterRollbackSeq).toBeGreaterThan(initialSeq); @@ -153,9 +163,9 @@ export function createStorageConformance(options: StorageConformanceOptions): re createCase(options, "detaches retained writes and every returned record", async (storage) => { const rootId = await createRoot(storage); - const entryId = await storage.mintId(); - const taskId = await storage.mintId(); - const submissionId = await storage.mintId(); + const entryId = await storage.mintId(); + const taskId = await storage.mintId>(); + const submissionId = await storage.mintId(); const entryData = { nested: [1, 2] }; const checkpoint = { phase: "ready", nested: { count: 1 } }; const detail = { codes: ["initial"] }; @@ -210,7 +220,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re createCase(options, "detaches prototype-like JSON keys without changing object prototypes", async (storage) => { const rootId = await createRoot(storage); - const entryId = await storage.mintId(); + const entryId = await storage.mintId(); const data = JSON.parse( '{"__proto__":{"polluted":false},"constructor":{"label":"stored"},"toString":"value"}', ) as Record; @@ -237,9 +247,14 @@ export function createStorageConformance(options: StorageConformanceOptions): re const rootId = await createRoot(storage); await storage.commit( [ - { type: "entry", value: entry(30, rootId) }, - { type: "entry", value: entry(10, rootId) }, - { type: "entry", value: entry(20, rootId, "marker", { head: 10 }) }, + { type: "entry", value: entry(idFromNumber(30), rootId) }, + { type: "entry", value: entry(idFromNumber(10), rootId) }, + { + type: "entry", + value: entry(idFromNumber(20), rootId, "marker", { + head: idFromNumber(10), + }), + }, ], context, ); @@ -252,9 +267,9 @@ export function createStorageConformance(options: StorageConformanceOptions): re createCase(options, "continues an entry cursor below its last item after a newer commit", async (storage) => { const rootId = await createRoot(storage); - const oldestId = await storage.mintId(); - const middleId = await storage.mintId(); - const newestId = await storage.mintId(); + const oldestId = await storage.mintId(); + const middleId = await storage.mintId(); + const newestId = await storage.mintId(); await storage.commit( [ { type: "entry", value: entry(oldestId, rootId) }, @@ -266,7 +281,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re const first = await storage.scanEntries({ conversationId: rootId }, 2, undefined, context); expect(first.items.map(({ id }) => id)).toEqual([newestId, middleId]); - const appendedId = await storage.mintId(); + const appendedId = await storage.mintId(); await storage.commit([{ type: "entry", value: entry(appendedId, rootId) }], context); const second = await storage.scanEntries({ conversationId: rootId }, 2, first.next, context); expect(second.items.map(({ id }) => id)).toEqual([oldestId]); @@ -275,8 +290,8 @@ export function createStorageConformance(options: StorageConformanceOptions): re createCase(options, "paginates conversations by opaque cursor in ascending ID order", async (storage) => { const rootId = await createRoot(storage); - const secondId = await storage.mintId(); - const thirdId = await storage.mintId(); + const secondId = await storage.mintId(); + const thirdId = await storage.mintId(); await storage.commit( [ { type: "conversation", value: { id: thirdId } }, @@ -296,12 +311,12 @@ export function createStorageConformance(options: StorageConformanceOptions): re createCase(options, "filters and pages conversations by durable owner edges", async (storage) => { const rootId = await createRoot(storage); - const otherOwnerId = await storage.mintId(); - const firstTaskId = await storage.mintId(); - const secondTaskId = await storage.mintId(); - const firstId = await storage.mintId(); - const secondId = await storage.mintId(); - const thirdId = await storage.mintId(); + const otherOwnerId = await storage.mintId(); + const firstTaskId = await storage.mintId>(); + const secondTaskId = await storage.mintId>(); + const firstId = await storage.mintId(); + const secondId = await storage.mintId(); + const thirdId = await storage.mintId(); await storage.commit( [ { type: "conversation", value: { id: otherOwnerId } }, @@ -346,9 +361,9 @@ export function createStorageConformance(options: StorageConformanceOptions): re createCase(options, "scans deep fork history newest-first through every ancestor cap", async (storage) => { const rootId = await createRoot(storage); - const rootFirst = await storage.mintId(); - const rootForkPoint = await storage.mintId(); - const rootExcludedSameCommit = await storage.mintId(); + const rootFirst = await storage.mintId(); + const rootForkPoint = await storage.mintId(); + const rootExcludedSameCommit = await storage.mintId(); const rootEntriesSeq = await storage.commit( [ { type: "entry", value: entry(rootFirst, rootId) }, @@ -360,7 +375,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re ], context, ); - const childId = await storage.mintId(); + const childId = await storage.mintId(); await storage.commit( [ { @@ -370,8 +385,8 @@ export function createStorageConformance(options: StorageConformanceOptions): re ], context, ); - const childForkPoint = await storage.mintId(); - const childExcluded = await storage.mintId(); + const childForkPoint = await storage.mintId(); + const childExcluded = await storage.mintId(); await storage.commit( [ { type: "entry", value: entry(childForkPoint, childId, "note") }, @@ -379,9 +394,9 @@ export function createStorageConformance(options: StorageConformanceOptions): re ], context, ); - const rootExcludedLater = await storage.mintId(); + const rootExcludedLater = await storage.mintId(); await storage.commit([{ type: "entry", value: entry(rootExcludedLater, rootId) }], context); - const grandchildId = await storage.mintId(); + const grandchildId = await storage.mintId(); await storage.commit( [ { @@ -391,8 +406,8 @@ export function createStorageConformance(options: StorageConformanceOptions): re ], context, ); - const grandchildHead = await storage.mintId(); - const grandchildTail = await storage.mintId(); + const grandchildHead = await storage.mintId(); + const grandchildTail = await storage.mintId(); const grandchildEntriesSeq = await storage.commit( [ { @@ -403,7 +418,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re ], context, ); - const childExcludedLater = await storage.mintId(); + const childExcludedLater = await storage.mintId(); await storage.commit([{ type: "entry", value: entry(childExcludedLater, childId) }], context); const first = await storage.scanEntries({ conversationId: grandchildId }, 2, undefined, context); @@ -461,7 +476,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re 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(); + expect(await storage.entry(idFromNumber(999_999), context)).toBeUndefined(); expect(await storage.entry(grandchildId, rootFirst, context)).toEqual({ entry: entry(rootFirst, rootId), @@ -474,18 +489,20 @@ export function createStorageConformance(options: StorageConformanceOptions): re expect(await storage.entry(grandchildId, childExcluded, context)).toBeUndefined(); expect(await storage.entry(grandchildId, childExcludedLater, context)).toBeUndefined(); expect(await storage.entry(rootId, grandchildHead, context)).toBeUndefined(); - expect(await storage.entry(grandchildId, 999_999, context)).toBeUndefined(); - await expect(storage.entry(999_999, rootFirst, context)).rejects.toThrow("Unknown conversation"); - await expect(storage.scanEntries({ conversationId: 999_999 }, 10, undefined, context)).rejects.toThrow( + expect(await storage.entry(grandchildId, idFromNumber(999_999), context)).toBeUndefined(); + await expect(storage.entry(idFromNumber(999_999), rootFirst, context)).rejects.toThrow( "Unknown conversation", ); + await expect( + storage.scanEntries({ conversationId: idFromNumber(999_999) }, 10, undefined, context), + ).rejects.toThrow("Unknown conversation"); }), createCase(options, "replaces complete task records and pages filtered task scans", async (storage) => { const rootId = await createRoot(storage); - const firstId = await storage.mintId(); - const secondId = await storage.mintId(); - const thirdId = await storage.mintId(); + const firstId = await storage.mintId>(); + const secondId = await storage.mintId>(); + const thirdId = await storage.mintId>(); const first = { ...pendingTask(firstId, rootId), memos: { winner: "first" } } satisfies StoredTask; const second = { ...pendingTask(secondId, rootId), background: true } satisfies StoredTask; const third = { ...pendingTask(thirdId, rootId), abortRequested: true } satisfies StoredTask; @@ -538,11 +555,11 @@ export function createStorageConformance(options: StorageConformanceOptions): re "indexes request IDs per conversation and replaces complete submission records", async (storage) => { const rootId = await createRoot(storage); - const secondConversationId = await storage.mintId(); + const secondConversationId = await storage.mintId(); await storage.commit([{ type: "conversation", value: { id: secondConversationId } }], context); - const firstId = await storage.mintId(); - const secondId = await storage.mintId(); - const otherConversationId = await storage.mintId(); + const firstId = await storage.mintId(); + const secondId = await storage.mintId(); + const otherConversationId = await storage.mintId(); const first: SubmissionRecord = { id: firstId, conversationId: rootId, @@ -575,7 +592,11 @@ export function createStorageConformance(options: StorageConformanceOptions): re expect(await storage.submissionByRequest(rootId, "same", context)).toEqual(first); expect(await storage.submissionByRequest(secondConversationId, "same", context)).toEqual(otherConversation); - const placedSecond: SubmissionRecord = { ...second, status: "placed", entry: await storage.mintId() }; + const placedSecond: SubmissionRecord = { + ...second, + status: "placed", + entry: await storage.mintId(), + }; await storage.commit([{ type: "submission", value: placedSecond }], context); expect(await storage.submission(secondId, context)).toEqual(placedSecond); expect(await storage.submissionByRequest(rootId, "other", context)).toEqual(placedSecond); @@ -584,8 +605,8 @@ export function createStorageConformance(options: StorageConformanceOptions): re createCase(options, "stores passive write submissions without input-only lifecycle states", async (storage) => { const rootId = await createRoot(storage); - const doneId = await storage.mintId(); - const failedId = await storage.mintId(); + const doneId = await storage.mintId(); + const failedId = await storage.mintId(); const queuedDone: SubmissionRecord = { id: doneId, conversationId: rootId, @@ -608,7 +629,11 @@ export function createStorageConformance(options: StorageConformanceOptions): re context, ); - const done: SubmissionRecord = { ...queuedDone, status: "done", entry: await storage.mintId() }; + const done: SubmissionRecord = { + ...queuedDone, + status: "done", + entry: await storage.mintId(), + }; const unanswered: SubmissionRecord = { ...queuedFailed, status: "unanswered", @@ -630,7 +655,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re createCase(options, "reconstructs rewindable documents and preserves half-open incarnations", async (storage) => { const rootId = await createRoot(storage); - const firstId = await storage.mintId(); + const firstId = await storage.mintId(); const firstRecord = { id: firstId, kind: "conversation.notes", @@ -710,7 +735,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re nested: { count: 4 }, }); - const secondId = await storage.mintId(); + const secondId = await storage.mintId(); const retiredAt = await storage.commit( [ { @@ -749,7 +774,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re createCase(options, "streams long document tails across root replacement deltas", async (storage) => { const rootId = await createRoot(storage); - const id = await storage.mintId(); + const id = await storage.mintId(); const record = { id, kind: "conversation.long-tail", @@ -845,8 +870,8 @@ export function createStorageConformance(options: StorageConformanceOptions): re "copies stored document bases independently and rejects ambiguous sources", async (storage) => { const rootId = await createRoot(storage); - const childId = await storage.mintId(); - const secondChildId = await storage.mintId(); + const childId = await storage.mintId(); + const secondChildId = await storage.mintId(); await storage.commit( [ { type: "conversation", value: { id: childId } }, @@ -854,7 +879,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re ], context, ); - const sourceId = await storage.mintId(); + const sourceId = await storage.mintId(); const sourceRecord = { id: sourceId, kind: "copy.source", @@ -889,10 +914,10 @@ export function createStorageConformance(options: StorageConformanceOptions): re ], context, ); - const historicalCopyId = await storage.mintId(); - const currentCopyId = await storage.mintId(); - const retiredCopyId = await storage.mintId(); - const childRecord = (id: Id, conversationId: Id): DocumentCreate => ({ + const historicalCopyId = await storage.mintId(); + const currentCopyId = await storage.mintId(); + const retiredCopyId = await storage.mintId(); + const childRecord = (id: DocumentId, conversationId: ConversationId): DocumentCreate => ({ id, kind: sourceRecord.kind, scope: { kind: "conversation", conversationId }, @@ -946,8 +971,8 @@ export function createStorageConformance(options: StorageConformanceOptions): re rows: [{ value: "base" }, { value: "current" }], }); - const latestSourceId = await storage.mintId(); - const latestCopyId = await storage.mintId(); + const latestSourceId = await storage.mintId(); + const latestCopyId = await storage.mintId(); const latestSource = { id: latestSourceId, kind: "copy.latest", @@ -995,7 +1020,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re value: { retained: "copy" }, }); - const conflictId = await storage.mintId(); + const conflictId = await storage.mintId(); let conflictError: unknown; try { await storage.commit( @@ -1019,7 +1044,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re rows: [{ value: "base" }, { value: "current" }], }); - const mismatchId = await storage.mintId(); + const mismatchId = await storage.mintId(); let mismatchError: unknown; try { await storage.commit( @@ -1045,7 +1070,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re "uses bases for version transitions and rejects historical reads of current-only documents", async (storage) => { await createRoot(storage); - const id = await storage.mintId(); + const id = await storage.mintId(); const record = { id, kind: "session.settings", @@ -1082,13 +1107,13 @@ export function createStorageConformance(options: StorageConformanceOptions): re createCase(options, "indexes logical addresses and exact-scope scans independently", async (storage) => { const rootId = await createRoot(storage); - const firstId = await storage.mintId(); - const secondId = await storage.mintId(); - const conversationId = await storage.mintId(); - const taskId = await storage.mintId(); - const taskSingletonId = await storage.mintId(); - const taskFamilyId = await storage.mintId(); - const taskOtherKindId = await storage.mintId(); + const firstId = await storage.mintId(); + const secondId = await storage.mintId(); + const conversationId = await storage.mintId(); + const taskId = await storage.mintId>(); + const taskSingletonId = await storage.mintId(); + const taskFamilyId = await storage.mintId(); + const taskOtherKindId = await storage.mintId(); const createdAt = await storage.commit( [ { type: "task", value: pendingTask(taskId, rootId) }, @@ -1224,8 +1249,8 @@ export function createStorageConformance(options: StorageConformanceOptions): re "keeps document lifecycle failures atomic and gives create-plus-retire an empty lifetime", async (storage) => { const rootId = await createRoot(storage); - const firstId = await storage.mintId(); - const secondId = await storage.mintId(); + const firstId = await storage.mintId(); + const secondId = await storage.mintId(); const record = { id: firstId, kind: "singleton", @@ -1251,7 +1276,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re expect((await storage.document(firstId, "current", context))?.value).toEqual({ value: 1 }); expect(await storage.document(secondId, "current", context)).toBeUndefined(); - const emptyId = await storage.mintId(); + const emptyId = await storage.mintId(); const emptyAt = await storage.commit( [ { @@ -1291,9 +1316,9 @@ export function createStorageConformance(options: StorageConformanceOptions): re "rolls back record tables and secondary indexes when a document command fails", async (storage) => { const rootId = await createRoot(storage); - const taskId = await storage.mintId(); - const submissionId = await storage.mintId(); - const documentId = await storage.mintId(); + const taskId = await storage.mintId>(); + const submissionId = await storage.mintId(); + const documentId = await storage.mintId(); const task = pendingTask(taskId, rootId); const submission: SubmissionRecord = { id: submissionId, @@ -1316,8 +1341,8 @@ export function createStorageConformance(options: StorageConformanceOptions): re context, ); - const entryId = await storage.mintId(); - const conflictingDocumentId = await storage.mintId(); + const entryId = await storage.mintId(); + const conflictingDocumentId = await storage.mintId(); await expect( storage.commit( [ @@ -1366,14 +1391,14 @@ export function createStorageConformance(options: StorageConformanceOptions): re const rootId = await createRoot(storage); const first = "\ud800"; const second = "\ud801"; - const firstTaskId = await storage.mintId(); - const secondTaskId = await storage.mintId(); - const firstSubmissionId = await storage.mintId(); - const secondSubmissionId = await storage.mintId(); - const firstKindDocumentId = await storage.mintId(); - const secondKindDocumentId = await storage.mintId(); - const firstKeyDocumentId = await storage.mintId(); - const secondKeyDocumentId = await storage.mintId(); + const firstTaskId = await storage.mintId>(); + const secondTaskId = await storage.mintId>(); + const firstSubmissionId = await storage.mintId(); + const secondSubmissionId = await storage.mintId(); + const firstKindDocumentId = await storage.mintId(); + const secondKindDocumentId = await storage.mintId(); + const firstKeyDocumentId = await storage.mintId(); + const secondKeyDocumentId = await storage.mintId(); await storage.commit( [ { type: "task", value: { ...pendingTask(firstTaskId, rootId), kind: first } }, @@ -1466,16 +1491,22 @@ export function createStorageConformance(options: StorageConformanceOptions): re createCase(options, "keeps one global record ID namespace and rejects exhausted ID minting", async (storage) => { const rootId = await createRoot(storage); - const explicitEntryId = 100; + const explicitEntryId = idFromNumber(100); await storage.commit([{ type: "entry", value: entry(explicitEntryId, rootId) }], context); - expect(await storage.mintId()).toBe(101); + expect(await storage.mintId()).toBe(101); await expect( - storage.commit([{ type: "task", value: pendingTask(explicitEntryId, rootId) }], context), + storage.commit( + [{ type: "task", value: pendingTask(idFromNumber>(explicitEntryId), rootId) }], + context, + ), ).rejects.toThrow(`ID ${explicitEntryId} already belongs to entry`); - await storage.commit([{ type: "entry", value: entry(Number.MAX_SAFE_INTEGER, rootId, "last-id") }], context); - await expect(storage.mintId()).rejects.toThrow("ID space is exhausted"); - await expect(storage.mintId()).rejects.toThrow("ID space is exhausted"); + await storage.commit( + [{ type: "entry", value: entry(idFromNumber(Number.MAX_SAFE_INTEGER), rootId, "last-id") }], + context, + ); + await expect(storage.mintId()).rejects.toThrow("ID space is exhausted"); + await expect(storage.mintId()).rejects.toThrow("ID space is exhausted"); }), createCase(options, "rejects every operation after close", async (storage) => { @@ -1483,7 +1514,7 @@ export function createStorageConformance(options: StorageConformanceOptions): re await storage.close(context); await expect(storage.conversation(ROOT_CONVERSATION_ID, context)).rejects.toThrow("closed"); await expect(storage.commit([] satisfies StorageWrite[], context)).rejects.toThrow("closed"); - await expect(storage.mintId()).rejects.toThrow("closed"); + await expect(storage.mintId()).rejects.toThrow("closed"); }), ]; } diff --git a/packages/durable/src/types.ts b/packages/durable/src/types.ts index e5f0a3c94..af303fcf6 100644 --- a/packages/durable/src/types.ts +++ b/packages/durable/src/types.ts @@ -5,14 +5,29 @@ import type { Message } from "@earendil-works/pi-ai"; /** JSON object used as the root of every durable document. */ export type JsonObject = { [key: string]: JsonValue }; -/** Session-global identifier shared by every durable record table. */ -export type Id = number; +declare const idBrand: unique symbol; + +/** Erased nominal number identifying one durable record kind. */ +export type Id = number & { + readonly [idBrand]: { + readonly kind: Kind; + readonly type: Type; + }; +}; + +export type ConversationId = Id<"conversation">; +export type EntryId = Id<"entry">; +export type TaskId = Id<"task", Result>; +export type SubmissionId = Id<"submission">; +export type DocumentId = Id<"document">; + +declare const seqBrand: unique symbol; /** Strictly increasing sequence assigned to one atomic storage commit; gaps are permitted. */ -export type Seq = number; +export type Seq = number & { readonly [seqBrand]: "sequence" }; /** The root conversation always uses this reserved ID. */ -export const ROOT_CONVERSATION_ID: Id = 1; +export const ROOT_CONVERSATION_ID = 1 as ConversationId; /** Conversation document that retains only its current state. */ export type LatestConversationSemantics = { @@ -122,38 +137,38 @@ export interface Task { readonly definition: TaskDefinition; } -/** Task ID carrying its result type for typed waits. */ -export type TaskRef = { readonly id: Id; readonly [taskResultType]?: R }; - /** Creation options for a durable task. */ export type TaskOptions = { /** Owning conversation; required for Session commits that are not bound to a conversation. */ - readonly conversationId?: Id; + readonly conversationId?: ConversationId; /** Tasks that must be terminal before ordinary execution may begin. */ - readonly after?: readonly Id[]; + readonly after?: readonly TaskId[]; /** Excluded from ordinary idle waits and conversation aborts. */ readonly background?: boolean; }; +/** Ownership selected explicitly whenever a conversation is created. */ +export type ConversationOwnership = { readonly kind: "ownerless" } | { readonly kind: "task"; readonly taskId: TaskId }; + /** Immutable identity, history ancestry, and task ownership of a transcript scope. */ export type ConversationRecord = { - readonly id: Id; + readonly id: ConversationId; /** Fork source and inclusive parent entry through which history is inherited. */ readonly parent?: { - readonly conversationId: Id; - readonly at: Id; + readonly conversationId: ConversationId; + readonly at: EntryId; }; /** Creator edge used for attribution, subtree abort, and subtree idle waits. */ readonly owner?: { - readonly conversationId: Id; - readonly taskId: Id; + readonly conversationId: ConversationId; + readonly taskId: TaskId; }; }; /** 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 target: EntryId; } & ( | { readonly action: "omit"; @@ -168,8 +183,8 @@ export type ContextEdit = { /** Immutable transcript event with separate model-facing and application-facing payloads. */ export type EntryRecord = { - readonly id: Id; - readonly conversationId: Id; + readonly id: EntryId; + readonly conversationId: ConversationId; /** Application-defined entry discriminator. */ readonly kind: string; /** Messages contributed to model context; absent for display or bookkeeping entries. */ @@ -177,23 +192,23 @@ export type EntryRecord = { /** JSON payload consumed by views, extensions, or bookkeeping logic. */ readonly data?: JsonValue; /** First entry in the active context selected by this entry. */ - readonly head?: Id; + readonly head?: EntryId; /** 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; + readonly byTaskId?: TaskId; }; /** 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"; + readonly head?: EntryId | "self"; }; /** Identity fields shared by every durable submission state. */ type SubmissionRecordBase = { - readonly id: Id; - readonly conversationId: Id; + readonly id: SubmissionId; + readonly conversationId: ConversationId; /** Host-provided deduplication key, scoped to the conversation. */ readonly requestId?: string; }; @@ -214,7 +229,7 @@ export type SubmissionRecord = | { /** Added to the transcript and owned by an active turn. */ readonly status: "placed"; - readonly entry: Id; + readonly entry: EntryId; readonly answer?: never; readonly reason?: never; readonly detail?: never; @@ -222,15 +237,15 @@ export type SubmissionRecord = | { /** Successfully answered user input. */ readonly status: "done"; - readonly entry: Id; - readonly answer: Id; + readonly entry: EntryId; + readonly answer: EntryId; readonly reason?: never; readonly detail?: never; } | { /** Terminal input that can no longer receive an answer. */ readonly status: "unanswered"; - readonly entry?: Id; + readonly entry?: EntryId; readonly answer?: never; readonly reason: string; readonly detail?: JsonValue; @@ -250,7 +265,7 @@ export type SubmissionRecord = | { /** Successfully appended passive entry. */ readonly status: "done"; - readonly entry: Id; + readonly entry: EntryId; readonly answer?: never; readonly reason?: never; readonly detail?: never; @@ -340,9 +355,9 @@ export type TaskState = }; /** Identity, definition, and scheduling fields shared by every task state. */ -type TaskRecordBase = { - readonly id: Id; - readonly conversationId: Id; +type TaskRecordBase = { + readonly id: TaskId; + readonly conversationId: ConversationId; /** Registered task definition name. */ readonly kind: string; /** Definition version used to migrate live input and checkpoints. */ @@ -350,7 +365,7 @@ type TaskRecordBase = { /** Original task input retained while the task is live or terminal. */ readonly input: I; /** Tasks that must be terminal before ordinary execution may begin. */ - readonly after: readonly Id[]; + readonly after: readonly TaskId[]; /** 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. */ @@ -358,7 +373,7 @@ type TaskRecordBase = { }; /** Complete replacement record for one durable task state machine. */ -export type TaskRecord = TaskRecordBase & +export type TaskRecord = TaskRecordBase & ( | { readonly state: Extract, { readonly status: "pending" | "running" }>; @@ -374,7 +389,7 @@ export type TaskRecord = TaskRecordBase & /** 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 id: DocumentId; /** Stable document definition kind. */ readonly kind: string; /** Family member key; absent for singleton documents. */ @@ -389,7 +404,7 @@ export type DocumentRecord = { readonly history?: never; readonly fork?: never; } - | ({ readonly scope: { readonly kind: "conversation"; readonly conversationId: Id } } & ( + | ({ readonly scope: { readonly kind: "conversation"; readonly conversationId: ConversationId } } & ( | { /** Retain only current state. */ readonly history: "latest"; @@ -404,7 +419,7 @@ export type DocumentRecord = { } )) | { - readonly scope: { readonly kind: "task"; readonly taskId: Id }; + readonly scope: { readonly kind: "task"; readonly taskId: TaskId }; readonly history?: never; readonly fork?: never; } @@ -428,22 +443,22 @@ export type Cursor = Readonly>; /** Optional filters for an ordered conversation scan. */ export type ConversationQuery = { - readonly ownerConversationId?: Id; - readonly ownerTaskId?: Id; + readonly ownerConversationId?: ConversationId; + readonly ownerTaskId?: TaskId; }; /** Inclusive ID bounds for a newest-first scan of one conversation's fork-aware history. */ export type EntryQuery = { - readonly conversationId: Id; + readonly conversationId: ConversationId; /** Oldest entry ID that may be returned. */ - readonly minEntryId?: Id; + readonly minEntryId?: EntryId; /** Newest entry ID that may be returned. */ - readonly maxEntryId?: Id; + readonly maxEntryId?: EntryId; }; /** Optional filters for an ordered scan of durable task records. */ export type TaskQuery = { - readonly conversationId?: Id; + readonly conversationId?: ConversationId; readonly kind?: string; readonly status?: "pending" | "running" | "terminal"; readonly abortRequested?: boolean; @@ -483,7 +498,7 @@ export type DocumentContent = /** Exact persisted source selected for a definition-free document copy. */ export type DocumentCopySource = { - readonly id: Id; + readonly id: DocumentId; readonly at: DocumentPoint; }; @@ -508,19 +523,19 @@ export type StorageWrite = | { readonly type: "document.copy"; readonly record: DocumentCreate; readonly source: DocumentCopySource } | { readonly type: "document.change"; - readonly id: Id; + readonly id: DocumentId; readonly content: DocumentContent; } - | { readonly type: "document.retire"; readonly id: Id }; + | { readonly type: "document.retire"; readonly id: DocumentId }; /** * Transaction surface of one Session commit callback. * Table reads and creation results are trusted immutable values and may be shared with internal commit state. */ export interface Tx { - conversation(id: Id): Promise; - entry(id: Id): Promise; - task(id: Id): Promise | undefined>; + conversation(id: ConversationId): Promise; + entry(id: EntryId): Promise; + task(id: TaskId): Promise | undefined>; scanConversations( query: ConversationQuery, limit: number, @@ -533,23 +548,27 @@ export interface Tx { cursor?: Cursor, ): Promise, Cursor>>; - /** Create an independent ownerless conversation. */ - createConversation(): Promise; - /** Create an ownerless history fork at one concrete visible entry. */ - forkConversation(parentConversationId: Id, at: Id): Promise; + /** Create a conversation with explicitly selected ownership. */ + createConversation(options: { readonly ownership: ConversationOwnership }): Promise; + /** Create a history fork at one concrete visible entry with explicitly selected ownership. */ + forkConversation( + parentConversationId: ConversationId, + at: EntryId, + options: { readonly ownership: ConversationOwnership }, + ): Promise; /** Returned records are Session-owned immutable values and may be shared with commit listeners. */ - appendEntry(conversationId: Id, value: EntryDraft): Promise; + appendEntry(conversationId: ConversationId, value: EntryDraft): Promise; createTask( task: Task, input: I, options?: TaskOptions, - ): Promise>; + ): Promise>; /** Replace one task record completely. */ setTask(value: TaskRecord): void; doc(token: SessionDocToken): Promise>; - doc(token: ConversationDocToken, conversationId: Id): Promise>; - doc(token: TaskDocToken, taskId: Id): Promise>; + doc(token: ConversationDocToken, conversationId: ConversationId): Promise>; + doc(token: TaskDocToken, taskId: TaskId): Promise>; doc( token: SessionDocFamilyToken, key: string, @@ -557,29 +576,29 @@ export interface Tx { ): Promise>; doc( token: ConversationDocFamilyToken, - conversationId: Id, + conversationId: ConversationId, key: string, seed: I, ): Promise>; doc( token: TaskDocFamilyToken, - taskId: Id, + taskId: TaskId, key: string, seed: I, ): Promise>; retireDoc(token: SessionDocToken): Promise; - retireDoc(token: ConversationDocToken, conversationId: Id): Promise; - retireDoc(token: TaskDocToken, taskId: Id): Promise; + retireDoc(token: ConversationDocToken, conversationId: ConversationId): Promise; + retireDoc(token: TaskDocToken, taskId: TaskId): Promise; retireDoc(token: SessionDocFamilyToken, key: string): Promise; retireDoc( token: ConversationDocFamilyToken, - conversationId: Id, + conversationId: ConversationId, key: string, ): Promise; retireDoc( token: TaskDocFamilyToken, - taskId: Id, + taskId: TaskId, key: string, ): Promise; } @@ -594,12 +613,12 @@ export interface Session { snapshot(token: SessionDocToken, context: Context): Promise | undefined>; snapshot( token: ConversationDocToken, - conversationId: Id, + conversationId: ConversationId, context: Context, ): Promise | undefined>; snapshot( token: TaskDocToken, - taskId: Id, + taskId: TaskId, context: Context, ): Promise | undefined>; snapshot( @@ -609,28 +628,28 @@ export interface Session { ): Promise | undefined>; snapshot( token: ConversationDocFamilyToken, - conversationId: Id, + conversationId: ConversationId, key: string, context: Context, ): Promise | undefined>; snapshot( token: TaskDocFamilyToken, - taskId: Id, + taskId: TaskId, key: string, context: Context, ): Promise | undefined>; snapshotAsOf( token: RewindableConversationDocToken, - conversationId: Id, - at: Id, + conversationId: ConversationId, + at: EntryId, context: Context, ): Promise | undefined>; snapshotAsOf( token: RewindableConversationDocFamilyToken, - conversationId: Id, + conversationId: ConversationId, key: string, - at: Id, + at: EntryId, context: Context, ): Promise | undefined>; } @@ -649,11 +668,11 @@ export interface Storage { */ commit(writes: readonly StorageWrite[], context: Context): Promise; - /** Return a fresh candidate from the Session-global record ID namespace. */ - mintId(): Promise; + /** Return a fresh branded candidate from the Session-global numeric ID namespace. */ + mintId>(): Promise; /** Look up one conversation by exact ID. */ - conversation(id: Id, context: Context): Promise; + conversation(id: ConversationId, context: Context): Promise; /** Scan conversations in ascending ID order. */ scanConversations( @@ -664,11 +683,11 @@ export interface Storage { ): 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>; + entry(id: EntryId, context: Context): Promise<{ readonly entry: EntryRecord; readonly commitSeq: Seq } | undefined>; /** Look up one entry only when it is visible through the requested conversation's ancestry. */ entry( - conversationId: Id, - id: Id, + conversationId: ConversationId, + id: EntryId, context: Context, ): Promise<{ readonly entry: EntryRecord; readonly commitSeq: Seq } | undefined>; @@ -677,10 +696,10 @@ export interface Storage { * The returned entry is the marker; its `head` value is the range's actual lower bound. */ findLatestHeadMarker( - conversationId: Id, - atOrBeforeEntryId: Id | undefined, + conversationId: ConversationId, + atOrBeforeEntryId: EntryId | undefined, context: Context, - ): Promise<(EntryRecord & { readonly head: Id }) | undefined>; + ): Promise<(EntryRecord & { readonly head: EntryId }) | undefined>; /** Scan the inclusive visible range newest-first, returning at most `limit` entries. */ scanEntries( @@ -691,7 +710,7 @@ export interface Storage { ): Promise>; /** Look up the latest complete record for one task. */ - task(id: Id, context: Context): Promise | undefined>; + task(id: TaskId, context: Context): Promise | undefined>; /** Scan task records matching every supplied filter. */ scanTasks( @@ -702,16 +721,20 @@ export interface Storage { ): Promise, Cursor>>; /** Look up the latest complete record for one admitted submission. */ - submission(id: Id, context: Context): Promise; + submission(id: SubmissionId, context: Context): Promise; /** Find a submission by its conversation-scoped host deduplication key. */ - submissionByRequest(conversationId: Id, requestId: string, context: Context): Promise; + submissionByRequest( + conversationId: ConversationId, + requestId: string, + context: Context, + ): Promise; /** Resolve the incarnation occupying one exact logical address at the selected point. */ findDocument(address: DocumentAddress, at: DocumentPoint, context: Context): Promise; /** Materialize one specific incarnation by ID at the selected point without following a replacement at its address. */ - document(id: Id, at: DocumentPoint, context: Context): Promise; + document(id: DocumentId, at: DocumentPoint, context: Context): Promise; /** Scan incarnations alive in one exact scope at the selected point. */ scanDocuments( diff --git a/packages/durable/test/jsonl-storage.test.ts b/packages/durable/test/jsonl-storage.test.ts index 49d718649..e046b70f5 100644 --- a/packages/durable/test/jsonl-storage.test.ts +++ b/packages/durable/test/jsonl-storage.test.ts @@ -7,9 +7,21 @@ import { registerStorageConformance } from "@earendil-works/pi-durable/testing"; import { afterEach, describe, expect, it } from "vitest"; import { err, FileError, type FileSystem, type Result } from "../src/env/index.ts"; import { NodeExecutionEnv } from "../src/env/node.ts"; +import { idFromNumber } from "../src/ids.ts"; import { JsonlStorage } from "../src/storage/jsonl/index.ts"; import { openNodeJsonlStorage } from "../src/storage/jsonl/node.ts"; -import type { DocumentCreate, Seq, Storage, StorageWrite, TaskRecord } from "../src/types.ts"; +import type { + ConversationId, + DocumentCreate, + DocumentId, + EntryId, + Id, + Seq, + Storage, + StorageWrite, + TaskId, + TaskRecord, +} from "../src/types.ts"; import { ROOT_CONVERSATION_ID } from "../src/types.ts"; const context = BACKGROUND_CONTEXT; @@ -64,16 +76,24 @@ class ReopeningStorage implements Storage { } } - mintId: Storage["mintId"] = () => this.current.mintId(); + mintId>(): Promise { + return this.current.mintId(); + } conversation: Storage["conversation"] = (id, readContext) => this.current.conversation(id, readContext); scanConversations: Storage["scanConversations"] = (query, limit, cursor, readContext) => this.current.scanConversations(query, limit, cursor, readContext); - entry(id: number, readContext: Context): ReturnType; - entry(conversationId: number, id: number, readContext: Context): ReturnType; - entry(idOrConversationId: number, idOrContext: number | Context, readContext?: Context) { - return readContext === undefined - ? this.current.entry(idOrConversationId, idOrContext as Context) - : this.current.entry(idOrConversationId, idOrContext as number, readContext); + entry(id: EntryId, readContext: Context): ReturnType; + entry(conversationId: ConversationId, id: EntryId, readContext: Context): ReturnType; + entry(idOrConversationId: EntryId | ConversationId, idOrContext: EntryId | Context, readContext?: Context) { + if (readContext === undefined) { + return this.current.entry(idFromNumber(idOrConversationId), idOrContext as Context); + } + if (typeof idOrContext !== "number") throw new TypeError("Storage.entry() requires an entry ID"); + return this.current.entry( + idFromNumber(idOrConversationId), + idFromNumber(idOrContext), + readContext, + ); } findLatestHeadMarker: Storage["findLatestHeadMarker"] = (conversationId, at, readContext) => this.current.findLatestHeadMarker(conversationId, at, readContext); @@ -111,7 +131,7 @@ registerStorageConformance({ describe, expect, it }, "JsonlStorage across reopen } }); -function pendingTask(id: number, phase = "ready"): StoredTask { +function pendingTask(id: TaskId, phase = "ready"): StoredTask { return { id, conversationId: ROOT_CONVERSATION_ID, @@ -125,7 +145,7 @@ function pendingTask(id: number, phase = "ready"): StoredTask { }; } -function terminalTask(id: number): StoredTask { +function terminalTask(id: TaskId): StoredTask { return { id, conversationId: ROOT_CONVERSATION_ID, @@ -143,7 +163,7 @@ async function createRoot(storage: Storage): Promise { await storage.commit([{ type: "conversation", value: { id: ROOT_CONVERSATION_ID } }], context); } -function sessionDocument(id: number, kind = "test.document"): DocumentCreate { +function sessionDocument(id: DocumentId, kind = "test.document"): DocumentCreate { return { id, kind, scope: { kind: "session" } }; } @@ -307,8 +327,8 @@ describe("Pico JsonlStorage publication and recovery", () => { const directory = await tempDirectory(); const storage = await openStorage(directory); await createRoot(storage); - const taskId = await storage.mintId(); - const documentId = await storage.mintId(); + const taskId = await storage.mintId>(); + const documentId = await storage.mintId(); await storage.commit([{ type: "task", value: pendingTask(taskId) }], context); await storage.commit( [ @@ -353,7 +373,7 @@ describe("Pico JsonlStorage publication and recovery", () => { const directory = await tempDirectory(); const storage = await openStorage(directory); await createRoot(storage); - const taskId = await storage.mintId(); + const taskId = await storage.mintId>(); await storage.commit( [ { type: "task", value: pendingTask(taskId, "first") }, @@ -372,7 +392,7 @@ describe("Pico JsonlStorage publication and recovery", () => { const env = new InstrumentedEnv({ cwd: directory }); const storage = await openStorage(directory, env); await createRoot(storage); - const taskId = await storage.mintId(); + const taskId = await storage.mintId>(); env.fail({ operation: "append", call: 1, mode: "short" }); await expect( storage.commit( @@ -398,7 +418,7 @@ describe("Pico JsonlStorage publication and recovery", () => { const storage = await openStorage(directory, env); await createRoot(storage); env.clear(); - const id = await storage.mintId(); + const id = await storage.mintId(); await expect( storage.commit( [ @@ -440,8 +460,8 @@ describe("Pico JsonlStorage publication and recovery", () => { const env = new InstrumentedEnv({ cwd: directory }); const storage = await openStorage(directory, env); await createRoot(storage); - const firstId = await storage.mintId(); - const secondId = await storage.mintId(); + const firstId = await storage.mintId(); + const secondId = await storage.mintId(); env.fail(failure); await expect( storage.commit( @@ -484,8 +504,8 @@ describe("Pico JsonlStorage publication and recovery", () => { const env = new InstrumentedEnv({ cwd: directory }); const storage = await openStorage(directory, env, { fsync: true }); await createRoot(storage); - const firstId = await storage.mintId(); - const secondId = await storage.mintId(); + const firstId = await storage.mintId(); + const secondId = await storage.mintId(); env.fail(failure); await expect( storage.commit( @@ -516,8 +536,8 @@ describe("Pico JsonlStorage publication and recovery", () => { const env = new InstrumentedEnv({ cwd: directory }); const storage = await openStorage(directory, env, { fsync }); await createRoot(storage); - const firstId = await storage.mintId(); - const secondId = await storage.mintId(); + const firstId = await storage.mintId(); + const secondId = await storage.mintId(); env.clear(); await storage.commit( [ @@ -569,7 +589,7 @@ describe("Pico JsonlStorage publication and recovery", () => { { type: "entry", value: { - id: await storage.mintId(), + id: await storage.mintId(), conversationId: ROOT_CONVERSATION_ID, kind: "main-only", }, @@ -579,7 +599,7 @@ describe("Pico JsonlStorage publication and recovery", () => { ); expect(env.operations).toEqual(["append:main.jsonl"]); - const taskId = await storage.mintId(); + const taskId = await storage.mintId>(); await storage.commit([{ type: "task", value: pendingTask(taskId) }], context); env.clear(); await storage.commit([{ type: "task", value: terminalTask(taskId) }], context); @@ -603,7 +623,7 @@ describe("Pico JsonlStorage publication and recovery", () => { const env = new InstrumentedEnv({ cwd: directory }); const storage = await openStorage(directory, env); await createRoot(storage); - const id = await storage.mintId(); + const id = await storage.mintId(); await storage.commit( [ { @@ -654,8 +674,8 @@ describe("Pico JsonlStorage publication and recovery", () => { const env = new InstrumentedEnv({ cwd: directory }); const storage = await openStorage(directory, env); await createRoot(storage); - const taskId = await storage.mintId(); - const id = await storage.mintId(); + const taskId = await storage.mintId>(); + const id = await storage.mintId(); await storage.commit( [ { type: "task", value: pendingTask(taskId) }, @@ -686,7 +706,7 @@ describe("Pico JsonlStorage publication and recovery", () => { const env = new InstrumentedEnv({ cwd: directory }); const storage = await openStorage(directory, env, { fsync: true }); await createRoot(storage); - const id = await storage.mintId(); + const id = await storage.mintId(); await storage.commit( [ { @@ -740,7 +760,7 @@ describe("Pico JsonlStorage publication and recovery", () => { const env = new InstrumentedEnv({ cwd: directory }); const storage = await openStorage(directory, env, { fsync: true }); await createRoot(storage); - const id = await storage.mintId(); + const id = await storage.mintId(); await storage.commit( [ { @@ -789,7 +809,7 @@ describe("Pico JsonlStorage publication and recovery", () => { const env = new InstrumentedEnv({ cwd: directory }); const storage = await openStorage(directory, env); await createRoot(storage); - const id = await storage.mintId(); + const id = await storage.mintId>(); await storage.commit([{ type: "task", value: pendingTask(id) }], context); env.fail({ operation: "remove", call: 1, mode }); await expect(storage.commit([{ type: "task", value: terminalTask(id) }], context)).resolves.toBe(3); @@ -807,7 +827,7 @@ describe("Pico JsonlStorage publication and recovery", () => { const directory = await tempDirectory(); const storage = await openStorage(directory); await createRoot(storage); - const id = await storage.mintId(); + const id = await storage.mintId(); await storage.commit( [ { @@ -847,7 +867,7 @@ describe("Pico JsonlStorage publication and recovery", () => { const directory = await tempDirectory(); const storage = await openStorage(directory); await createRoot(storage); - const id = await storage.mintId(); + const id = await storage.mintId(); const record = { id, kind: "rewindable", @@ -893,10 +913,10 @@ describe("Pico JsonlStorage publication and recovery", () => { const directory = await tempDirectory(); const storage = await openStorage(directory); await createRoot(storage); - const taskId = await storage.mintId(); - const sessionId = await storage.mintId(); - const latestId = await storage.mintId(); - const taskDocumentId = await storage.mintId(); + const taskId = await storage.mintId>(); + const sessionId = await storage.mintId(); + const latestId = await storage.mintId(); + const taskDocumentId = await storage.mintId(); const createdAt = await storage.commit( [ { type: "task", value: pendingTask(taskId) }, @@ -959,7 +979,7 @@ describe("Pico JsonlStorage publication and recovery", () => { const directory = await tempDirectory(); const storage = await openStorage(directory); await createRoot(storage); - const documentId = await storage.mintId(); + const documentId = await storage.mintId(); await storage.commit( [ { @@ -993,7 +1013,7 @@ describe("Pico JsonlStorage publication and recovery", () => { const directory = await tempDirectory(); const storage = await openStorage(directory); await createRoot(storage); - const taskId = await storage.mintId(); + const taskId = await storage.mintId>(); await storage.commit([{ type: "task", value: pendingTask(taskId) }], context); await storage.commit([{ type: "task", value: terminalTask(taskId) }], context); const sidecarPath = join(directory, `task-${taskId}.jsonl`); @@ -1020,7 +1040,7 @@ describe("Pico JsonlStorage publication and recovery", () => { const directory = await tempDirectory(); const storage = await openStorage(directory); await createRoot(storage); - const documentId = await storage.mintId(); + const documentId = await storage.mintId(); await storage.commit( [ { @@ -1041,7 +1061,7 @@ describe("Pico JsonlStorage publication and recovery", () => { const directory = await tempDirectory(); const storage = await openStorage(directory); await createRoot(storage); - const documentId = await storage.mintId(); + const documentId = await storage.mintId(); await storage.commit( [ { @@ -1093,7 +1113,7 @@ describe("Pico JsonlStorage publication and recovery", () => { const directory = await tempDirectory(); const storage = await openStorage(directory); await createRoot(storage); - const documentId = await storage.mintId(); + const documentId = await storage.mintId(); await storage.commit( [ { diff --git a/packages/durable/test/memory-storage.test.ts b/packages/durable/test/memory-storage.test.ts index ee9622316..f01af3ed3 100644 --- a/packages/durable/test/memory-storage.test.ts +++ b/packages/durable/test/memory-storage.test.ts @@ -1,19 +1,21 @@ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; import { registerStorageConformance } from "@earendil-works/pi-durable/testing"; import { describe, expect, it } from "vitest"; +import { idFromNumber, seqFromNumber } from "../src/ids.ts"; import { MemoryStorage } from "../src/storage/memory.ts"; -import { ROOT_CONVERSATION_ID } from "../src/types.ts"; +import { type EntryId, ROOT_CONVERSATION_ID } from "../src/types.ts"; registerStorageConformance({ describe, expect, it }, "MemoryStorage", (use) => use(new MemoryStorage())); it("does not expose retained state through a prepared commit", async () => { const storage = new MemoryStorage(); await storage.commit([{ type: "conversation", value: { id: ROOT_CONVERSATION_ID } }], BACKGROUND_CONTEXT); + const entryId = idFromNumber(2); const prepared = storage.prepareCommit([ { type: "entry", value: { - id: 2, + id: entryId, conversationId: ROOT_CONVERSATION_ID, kind: "test", data: { nested: [1] }, @@ -24,7 +26,7 @@ it("does not expose retained state through a prepared commit", async () => { if (exposed.type !== "entry") throw new Error("Expected an entry write"); expect(() => (exposed.value.data as { nested: number[] }).nested.push(2)).toThrow(); - expect(prepared.apply()).toBe(2); - expect(prepared.apply()).toBe(2); - expect((await storage.entry(2, BACKGROUND_CONTEXT))?.entry.data).toEqual({ nested: [1] }); + expect(prepared.apply()).toBe(seqFromNumber(2)); + expect(prepared.apply()).toBe(seqFromNumber(2)); + expect((await storage.entry(entryId, BACKGROUND_CONTEXT))?.entry.data).toEqual({ nested: [1] }); }); diff --git a/packages/durable/test/scratch.ts b/packages/durable/test/scratch.ts new file mode 100644 index 000000000..1a5b93de8 --- /dev/null +++ b/packages/durable/test/scratch.ts @@ -0,0 +1,95 @@ +// Human-facing smoke test for the public API. Extend this after each package so +// design changes are exercised as ordinary application code, not only fixtures. +import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; +import { type ConversationId, createSession, defineDoc, MemoryStorage, type Task } from "../src/index.ts"; + +const session = createSession(new MemoryStorage()); + +// Example 1: create an explicitly ownerless conversation. +// ID brands are erased, so the returned ConversationId is still a plain number. +const conversation = await session.commit( + (tx) => tx.createConversation({ ownership: { kind: "ownerless" } }), + BACKGROUND_CONTEXT, +); +console.log("ownerless conversation:", conversation); +console.log("conversation ID runtime type:", typeof conversation.id); + +// Example 2: keep rewindable document state aligned with transcript entries. +// A historical read selects the document value committed with that entry. +const ConversationDoc = defineDoc<{ value: string }>({ + kind: "my-conversation-doc", + version: 1, + scope: "conversation", + history: "rewindable", + fork: "asOf", + initial: () => ({ value: "initial" }), +}); + +const firstEntryId = await session.commit(async (tx) => { + const entry = await tx.appendEntry(conversation.id, { kind: "first" }); + (await tx.doc(ConversationDoc, conversation.id)).value = "first value"; + return entry.id; +}, BACKGROUND_CONTEXT); + +const secondEntryId = await session.commit(async (tx) => { + const entry = await tx.appendEntry(conversation.id, { kind: "second" }); + (await tx.doc(ConversationDoc, conversation.id)).value = "second value"; + return entry.id; +}, BACKGROUND_CONTEXT); + +console.log("current value:", await session.snapshot(ConversationDoc, conversation.id, BACKGROUND_CONTEXT)); +console.log( + "value at first entry:", + await session.snapshotAsOf(ConversationDoc, conversation.id, firstEntryId, BACKGROUND_CONTEXT), +); +console.log( + "value at second entry:", + await session.snapshotAsOf(ConversationDoc, conversation.id, secondEntryId, BACKGROUND_CONTEXT), +); + +// Example 3: atomically provision a background-owned fork and registry mapping. +// The task is the durable ownership boundary. The registry stores application +// lookup and request identities without duplicating task lifecycle state. +const AgentRegistry = defineDoc<{ + agents: Record; +}>({ + kind: "my-agent-registry", + version: 1, + scope: "conversation", + history: "latest", + fork: "initial", + initial: () => ({ agents: {} }), +}); + +const SupervisorTask: Task = { + definition: { + name: "my-supervisor", + version: 1, + initial: () => ({ phase: "ready" }), + }, +}; + +const provisioned = await session.commit(async (tx) => { + const supervisorId = await tx.createTask(SupervisorTask, null, { + conversationId: conversation.id, + background: true, + }); + const child = await tx.forkConversation(conversation.id, secondEntryId, { + ownership: { kind: "task", taskId: supervisorId }, + }); + const registry = await tx.doc(AgentRegistry, conversation.id); + registry.agents.researcher = { + conversationId: child.id, + requestId: `initial:${supervisorId}`, + }; + return { supervisorId, child }; +}, BACKGROUND_CONTEXT); + +console.log("background supervisor ID:", provisioned.supervisorId); +console.log("owned child:", provisioned.child); +console.log("forked child value:", await session.snapshot(ConversationDoc, provisioned.child.id, BACKGROUND_CONTEXT)); +console.log("parent registry:", await session.snapshot(AgentRegistry, conversation.id, BACKGROUND_CONTEXT)); +// fork: initial leaves the child registry absent until its first typed transaction access. +console.log("child registry:", await session.snapshot(AgentRegistry, provisioned.child.id, BACKGROUND_CONTEXT)); + +await session.close(BACKGROUND_CONTEXT); diff --git a/packages/durable/test/session-checkpoints-migrations.test.ts b/packages/durable/test/session-checkpoints-migrations.test.ts index 2a3a996b0..ab3348073 100644 --- a/packages/durable/test/session-checkpoints-migrations.test.ts +++ b/packages/durable/test/session-checkpoints-migrations.test.ts @@ -1,5 +1,11 @@ import type { Op } from "@earendil-works/chord/delta"; -import { defineDoc, defineDocFamily, type Id, type JsonObject, type StorageWrite } from "@earendil-works/pi-durable"; +import { + defineDoc, + defineDocFamily, + type EntryId, + type JsonObject, + type StorageWrite, +} from "@earendil-works/pi-durable"; import { describe, expect, it } from "vitest"; import { context, createConversation, documentChanges, flush, openTestSession } from "./session-support.ts"; @@ -585,9 +591,9 @@ describe("Session historical document snapshots", () => { }); const { session, storage } = openTestSession(); const conversationId = await createConversation(session); - let firstEntry!: Id; - let secondEntry!: Id; - let thirdEntry!: Id; + let firstEntry!: EntryId; + let secondEntry!: EntryId; + let thirdEntry!: EntryId; await session.commit(async (tx) => { firstEntry = (await tx.appendEntry(conversationId, { kind: "first" })).id; (await tx.doc(V1, conversationId)).count = 1; @@ -624,7 +630,8 @@ describe("Session historical document snapshots", () => { }); const childId = await session.commit( - async (tx) => (await tx.forkConversation(conversationId, secondEntry)).id, + async (tx) => + (await tx.forkConversation(conversationId, secondEntry, { ownership: { kind: "ownerless" } })).id, context, ); expect(await session.snapshotAsOf(V3, childId, firstEntry, context)).toEqual({ count: 1, version: 3 }); @@ -653,9 +660,9 @@ describe("Session historical document snapshots", () => { async (tx) => (await tx.appendEntry(conversationId, { kind: "before" })).id, context, ); - let createdAt!: Id; - let retiredAt!: Id; - let recreatedAt!: Id; + let createdAt!: EntryId; + let retiredAt!: EntryId; + let recreatedAt!: EntryId; await session.commit(async (tx) => { createdAt = (await tx.appendEntry(conversationId, { kind: "create" })).id; (await tx.doc(Doc, conversationId)).value = "old"; diff --git a/packages/durable/test/session-definitions.test.ts b/packages/durable/test/session-definitions.test.ts index 249bbd0a8..789be0ada 100644 --- a/packages/durable/test/session-definitions.test.ts +++ b/packages/durable/test/session-definitions.test.ts @@ -1,14 +1,17 @@ import type { Draft } from "@earendil-works/chord"; import { + type ConversationId, createSession, defineDoc, defineDocFamily, - type Id, MemoryStorage, + ROOT_CONVERSATION_ID, type Session, + type TaskId, type Tx, } from "@earendil-works/pi-durable"; import { describe, expect, expectTypeOf, it } from "vitest"; +import { idFromNumber } from "../src/ids.ts"; import { context } from "./session-support.ts"; type State = { value: number }; @@ -68,7 +71,7 @@ describe("document definitions", () => { it("types every owner, key, and seed overload", async () => { const session: Session = createSession(new MemoryStorage()); - const check = async (tx: Tx, conversationId: Id, taskId: Id): Promise => { + const check = async (tx: Tx, conversationId: ConversationId, taskId: TaskId): Promise => { expectTypeOf(await tx.doc(SessionDoc)).toEqualTypeOf>(); expectTypeOf(await tx.doc(LatestDoc, conversationId)).toEqualTypeOf>(); expectTypeOf(await tx.doc(RewindableDoc, conversationId)).toEqualTypeOf>(); @@ -97,13 +100,18 @@ describe("document definitions", () => { expect(check).toBeTypeOf("function"); expectTypeOf(await session.snapshot(SessionDoc, context)).toEqualTypeOf | undefined>(); - expectTypeOf(await session.snapshot(LatestDoc, 1, context)).toEqualTypeOf | undefined>(); - expectTypeOf(await session.snapshot(TaskDoc, 1, context)).toEqualTypeOf | undefined>(); - expectTypeOf(await session.snapshot(SessionFamily, "k", context)).toEqualTypeOf | undefined>(); - expectTypeOf(await session.snapshot(ConversationFamily, 1, "k", context)).toEqualTypeOf< + const taskId = idFromNumber(1); + expectTypeOf(await session.snapshot(LatestDoc, ROOT_CONVERSATION_ID, context)).toEqualTypeOf< + Readonly | undefined + >(); + expectTypeOf(await session.snapshot(TaskDoc, taskId, context)).toEqualTypeOf | undefined>(); + expectTypeOf(await session.snapshot(SessionFamily, "k", context)).toEqualTypeOf | undefined>(); + expectTypeOf(await session.snapshot(ConversationFamily, ROOT_CONVERSATION_ID, "k", context)).toEqualTypeOf< + Readonly | undefined + >(); + expectTypeOf(await session.snapshot(TaskFamily, taskId, "k", context)).toEqualTypeOf< Readonly | undefined >(); - expectTypeOf(await session.snapshot(TaskFamily, 1, "k", context)).toEqualTypeOf | undefined>(); // @ts-expect-error snapshots never take a creation seed await session.snapshot(SessionFamily, "k", 1, context).catch(() => undefined); await session.close(context); diff --git a/packages/durable/test/session-documents.test.ts b/packages/durable/test/session-documents.test.ts index 249bdf3fe..9c6dfd382 100644 --- a/packages/durable/test/session-documents.test.ts +++ b/packages/durable/test/session-documents.test.ts @@ -1,6 +1,13 @@ -import type { Draft } from "@earendil-works/chord"; -import { defineDoc, defineDocFamily, type Id, type JsonObject } from "@earendil-works/pi-durable"; +import type { Draft, JsonValue } from "@earendil-works/chord"; +import { + type ConversationId, + defineDoc, + defineDocFamily, + type JsonObject, + type TaskId, +} from "@earendil-works/pi-durable"; import { describe, expect, it } from "vitest"; +import { idFromNumber } from "../src/ids.ts"; import { context, createConversation, documentChanges, flush, openTestSession } from "./session-support.ts"; type Live = { message?: string; items: string[]; nested: { count: number }; other: { label: string } }; @@ -57,7 +64,7 @@ const MemberDoc = defineDocFamily({ }, }); -async function setupLive(): Promise & { readonly conversationId: Id }> { +async function setupLive(): Promise & { readonly conversationId: ConversationId }> { const harness = openTestSession(); const conversationId = await createConversation(harness.session); await harness.session.commit(async (tx) => { @@ -409,7 +416,7 @@ describe("Session document transactions", () => { (await tx.doc(CounterDoc)).count = 2; // Replacing a missing task fails during assembly, after every change was prepared. tx.setTask({ - id: 999, + id: idFromNumber>(999), conversationId, kind: "missing", version: 1, diff --git a/packages/durable/test/session-forks.test.ts b/packages/durable/test/session-forks.test.ts index a490793dd..03140fdd7 100644 --- a/packages/durable/test/session-forks.test.ts +++ b/packages/durable/test/session-forks.test.ts @@ -1,4 +1,12 @@ -import { defineDoc, defineDocFamily, type Id, StorageRejected, type StorageWrite } from "@earendil-works/pi-durable"; +import { + type ConversationId, + defineDoc, + defineDocFamily, + type EntryId, + StorageRejected, + type StorageWrite, + type TaskId, +} from "@earendil-works/pi-durable"; import { describe, expect, it } from "vitest"; import { context, @@ -63,7 +71,7 @@ describe("Session conversation document forks", () => { }); const { session, storage, publications } = openTestSession(); const parentId = await createConversation(session); - let forkAt!: Id; + let forkAt!: EntryId; await session.commit(async (tx) => { forkAt = (await tx.appendEntry(parentId, { kind: "fork-point" })).id; (await tx.doc(AsOf, parentId)).value = "as-at-fork"; @@ -82,7 +90,10 @@ describe("Session conversation document forks", () => { await flush(); const documentReads = storage.documentReadCount; - const child = await session.commit((tx) => tx.forkConversation(parentId, forkAt), context); + const child = await session.commit( + (tx) => tx.forkConversation(parentId, forkAt, { ownership: { kind: "ownerless" } }), + context, + ); await flush(); expect(storage.documentReadCount).toBe(documentReads); @@ -148,14 +159,17 @@ describe("Session conversation document forks", () => { }); const { session } = openTestSession(); const parentId = await createConversation(session); - let forkAt!: Id; - let excluded!: Id; + let forkAt!: EntryId; + let excluded!: EntryId; await session.commit(async (tx) => { forkAt = (await tx.appendEntry(parentId, { kind: "included" })).id; (await tx.doc(Doc, parentId)).value = "final-state-of-commit"; excluded = (await tx.appendEntry(parentId, { kind: "excluded" })).id; }, context); - const child = await session.commit((tx) => tx.forkConversation(parentId, forkAt), context); + const child = await session.commit( + (tx) => tx.forkConversation(parentId, forkAt, { ownership: { kind: "ownerless" } }), + context, + ); expect(await session.snapshot(Doc, child.id, context)).toEqual({ value: "final-state-of-commit" }); const visible = await session.commit((tx) => tx.scanEntries({ conversationId: child.id }, 10), context); expect(visible.items.map(({ id }) => id)).toContain(forkAt); @@ -181,25 +195,34 @@ describe("Session conversation document forks", () => { }); const { session } = openTestSession(); const rootId = await createConversation(session); - let inherited!: Id; + let inherited!: EntryId; await session.commit(async (tx) => { inherited = (await tx.appendEntry(rootId, { kind: "root" })).id; (await tx.doc(AsOf, rootId)).value = "root-at-entry"; (await tx.doc(Current, rootId)).value = "root-current"; }, context); - const parent = await session.commit((tx) => tx.forkConversation(rootId, inherited), context); - let parentEntry!: Id; + const parent = await session.commit( + (tx) => tx.forkConversation(rootId, inherited, { ownership: { kind: "ownerless" } }), + context, + ); + let parentEntry!: EntryId; await session.commit(async (tx) => { parentEntry = (await tx.appendEntry(parent.id, { kind: "parent" })).id; (await tx.doc(AsOf, parent.id)).value = "parent-at-own-entry"; (await tx.doc(Current, parent.id)).value = "parent-current"; }, context); - const inheritedFork = await session.commit((tx) => tx.forkConversation(parent.id, inherited), context); + const inheritedFork = await session.commit( + (tx) => tx.forkConversation(parent.id, inherited, { ownership: { kind: "ownerless" } }), + context, + ); expect(await session.snapshot(AsOf, inheritedFork.id, context)).toEqual({ value: "root-at-entry" }); expect(await session.snapshot(Current, inheritedFork.id, context)).toEqual({ value: "parent-current" }); - const ownEntryFork = await session.commit((tx) => tx.forkConversation(parent.id, parentEntry), context); + const ownEntryFork = await session.commit( + (tx) => tx.forkConversation(parent.id, parentEntry, { ownership: { kind: "ownerless" } }), + context, + ); expect(await session.snapshot(AsOf, ownEntryFork.id, context)).toEqual({ value: "parent-at-own-entry" }); expect(await session.snapshot(Current, ownEntryFork.id, context)).toEqual({ value: "parent-current" }); }); @@ -229,7 +252,7 @@ describe("Session conversation document forks", () => { }); const { session, storage } = openTestSession(); const parentId = await createConversation(session); - let forkAt!: Id; + let forkAt!: EntryId; await session.commit(async (tx) => { forkAt = (await tx.appendEntry(parentId, { kind: "point" })).id; await tx.doc(V1, parentId); @@ -238,7 +261,10 @@ describe("Session conversation document forks", () => { expect(await session.snapshot(V3, parentId, context)).toEqual({ count: 1, migrated: true }); expect(migrations).toBe(1); - const child = await session.commit((tx) => tx.forkConversation(parentId, forkAt), context); + const child = await session.commit( + (tx) => tx.forkConversation(parentId, forkAt, { ownership: { kind: "ownerless" } }), + context, + ); expect(migrations).toBe(1); const childRecord = await storage.findDocument( { kind: V1.definition.kind, scope: { kind: "conversation", conversationId: child.id } }, @@ -276,7 +302,7 @@ describe("Session conversation document forks", () => { }); const { session, storage, publications } = openTestSession(); const parentId = await createConversation(session); - let forkAt!: Id; + let forkAt!: EntryId; await session.commit(async (tx) => { forkAt = (await tx.appendEntry(parentId, { kind: "point" })).id; await tx.doc(V1, parentId); @@ -284,7 +310,7 @@ describe("Session conversation document forks", () => { const documentReads = storage.documentReadCount; const child = await session.commit(async (tx) => { - const created = await tx.forkConversation(parentId, forkAt); + const created = await tx.forkConversation(parentId, forkAt, { ownership: { kind: "ownerless" } }); const value = await tx.doc(V2, created.id); value.count = 9; return created; @@ -320,9 +346,9 @@ describe("Session conversation document forks", () => { }); const { session } = openTestSession(); const parentId = await createConversation(session); - let oldAt!: Id; - let retiredAt!: Id; - let newAt!: Id; + let oldAt!: EntryId; + let retiredAt!: EntryId; + let newAt!: EntryId; await session.commit(async (tx) => { oldAt = (await tx.appendEntry(parentId, { kind: "old" })).id; (await tx.doc(Doc, parentId)).value = "old"; @@ -336,9 +362,18 @@ describe("Session conversation document forks", () => { (await tx.doc(Doc, parentId)).value = "new"; }, context); - const oldChild = await session.commit((tx) => tx.forkConversation(parentId, oldAt), context); - const emptyChild = await session.commit((tx) => tx.forkConversation(parentId, retiredAt), context); - const newChild = await session.commit((tx) => tx.forkConversation(parentId, newAt), context); + const oldChild = await session.commit( + (tx) => tx.forkConversation(parentId, oldAt, { ownership: { kind: "ownerless" } }), + context, + ); + const emptyChild = await session.commit( + (tx) => tx.forkConversation(parentId, retiredAt, { ownership: { kind: "ownerless" } }), + context, + ); + const newChild = await session.commit( + (tx) => tx.forkConversation(parentId, newAt, { ownership: { kind: "ownerless" } }), + context, + ); expect(await session.snapshot(Doc, oldChild.id, context)).toEqual({ value: "old" }); expect(await session.snapshot(Doc, emptyChild.id, context)).toBeUndefined(); expect(await session.snapshot(Doc, newChild.id, context)).toEqual({ value: "new" }); @@ -347,23 +382,29 @@ describe("Session conversation document forks", () => { it("rejects invisible fork points before admission and remains usable", async () => { const { session, storage, publications } = openTestSession(); const rootId = await createConversation(session); - let visible!: Id; - let hidden!: Id; + let visible!: EntryId; + let hidden!: EntryId; await session.commit(async (tx) => { visible = (await tx.appendEntry(rootId, { kind: "visible" })).id; hidden = (await tx.appendEntry(rootId, { kind: "hidden" })).id; }, context); - const parent = await session.commit((tx) => tx.forkConversation(rootId, visible), context); + const parent = await session.commit( + (tx) => tx.forkConversation(rootId, visible, { ownership: { kind: "ownerless" } }), + context, + ); await flush(); const commits = storage.commits.length; const published = publications.length; - await expect(session.commit((tx) => tx.forkConversation(parent.id, hidden), context)).rejects.toThrow( - `Entry ${hidden} is not visible`, - ); + await expect( + session.commit((tx) => tx.forkConversation(parent.id, hidden, { ownership: { kind: "ownerless" } }), context), + ).rejects.toThrow(`Entry ${hidden} is not visible`); await flush(); expect(storage.commits).toHaveLength(commits); expect(publications).toHaveLength(published); - const independent = await session.commit((tx) => tx.createConversation(), context); + const independent = await session.commit( + (tx) => tx.createConversation({ ownership: { kind: "ownerless" } }), + context, + ); expect(await storage.conversation(independent.id, context)).toEqual(independent); }); @@ -386,7 +427,7 @@ describe("Session conversation document forks", () => { }); const { session, storage } = openTestSession(); const parentId = await createConversation(session); - let oldAt!: Id; + let oldAt!: EntryId; await session.commit(async (tx) => { oldAt = (await tx.appendEntry(parentId, { kind: "old" })).id; await tx.doc(AsOf, parentId); @@ -394,11 +435,14 @@ describe("Session conversation document forks", () => { await session.commit((tx) => tx.retireDoc(AsOf, parentId), context); await session.commit((tx) => tx.doc(Current, parentId).then(() => undefined), context); const commits = storage.commits.length; - await expect(session.commit((tx) => tx.forkConversation(parentId, oldAt), context)).rejects.toThrow( - "Fork selects multiple source documents", - ); + await expect( + session.commit((tx) => tx.forkConversation(parentId, oldAt, { ownership: { kind: "ownerless" } }), context), + ).rejects.toThrow("Fork selects multiple source documents"); expect(storage.commits).toHaveLength(commits); - const independent = await session.commit((tx) => tx.createConversation(), context); + const independent = await session.commit( + (tx) => tx.createConversation({ ownership: { kind: "ownerless" } }), + context, + ); expect(await storage.conversation(independent.id, context)).toEqual(independent); }); @@ -421,7 +465,7 @@ describe("Session conversation document forks", () => { }); const { session, storage } = openTestSession(); const parentId = await createConversation(session); - let forkAt!: Id; + let forkAt!: EntryId; await session.commit(async (tx) => { forkAt = (await tx.appendEntry(parentId, { kind: "point" })).id; await tx.doc(Current, parentId); @@ -431,24 +475,27 @@ describe("Session conversation document forks", () => { await expect( session.commit(async (tx) => { (await tx.doc(Current, parentId)).value = "before-fork"; - await tx.forkConversation(parentId, forkAt); + await tx.forkConversation(parentId, forkAt, { ownership: { kind: "ownerless" } }); }, context), ).rejects.toThrow("Cannot change fork source document"); await expect( session.commit(async (tx) => { - await tx.forkConversation(parentId, forkAt); + await tx.forkConversation(parentId, forkAt, { ownership: { kind: "ownerless" } }); (await tx.doc(Current, parentId)).value = "after-fork"; }, context), ).rejects.toThrow("Cannot change fork source document"); await expect( session.commit(async (tx) => { (await tx.doc(AsOf, parentId)).value = "as-of-write"; - await tx.forkConversation(parentId, forkAt); + await tx.forkConversation(parentId, forkAt, { ownership: { kind: "ownerless" } }); }, context), ).rejects.toThrow("Cannot change fork source document"); expect(storage.commits).toHaveLength(commits); expect(await session.snapshot(Current, parentId, context)).toEqual({ value: "committed" }); - const child = await session.commit((tx) => tx.forkConversation(parentId, forkAt), context); + const child = await session.commit( + (tx) => tx.forkConversation(parentId, forkAt, { ownership: { kind: "ownerless" } }), + context, + ); expect(await session.snapshot(Current, child.id, context)).toEqual({ value: "committed" }); }); @@ -474,7 +521,7 @@ describe("Session conversation document forks", () => { }); const { session, storage, publications } = openTestSession(); const parentId = await createConversation(session); - let forkAt!: Id; + let forkAt!: EntryId; await session.commit(async (tx) => { forkAt = (await tx.appendEntry(parentId, { kind: "point" })).id; await tx.doc(Copied, parentId); @@ -483,10 +530,10 @@ describe("Session conversation document forks", () => { await flush(); const commits = storage.commits.length; const published = publications.length; - let childId!: Id; + let childId!: ConversationId; await expect( session.commit(async (tx) => { - childId = (await tx.forkConversation(parentId, forkAt)).id; + childId = (await tx.forkConversation(parentId, forkAt, { ownership: { kind: "ownerless" } })).id; (await tx.doc(Failure)).count = 1; }, context), ).rejects.toThrow("checkpoint failed"); @@ -513,20 +560,20 @@ describe("Session conversation document forks", () => { }); const { session, storage } = openTestSession(); const parentId = await createConversation(session); - let forkAt!: Id; + let forkAt!: EntryId; await session.commit(async (tx) => { forkAt = (await tx.appendEntry(parentId, { kind: "point" })).id; await tx.doc(Doc, parentId); }, context); - let rejectedChildId!: Id; + let rejectedChildId!: ConversationId; storage.failNextCommit(new StorageRejected("copy rejected")); await expect( session.commit(async (tx) => { - rejectedChildId = (await tx.forkConversation(parentId, forkAt)).id; + rejectedChildId = (await tx.forkConversation(parentId, forkAt, { ownership: { kind: "ownerless" } })).id; }, context), ).rejects.toThrow("copy rejected"); expect(await storage.conversation(rejectedChildId, context)).toBeUndefined(); - const next = await session.commit((tx) => tx.createConversation(), context); + const next = await session.commit((tx) => tx.createConversation({ ownership: { kind: "ownerless" } }), context); expect(await storage.conversation(next.id, context)).toEqual(next); }); @@ -541,13 +588,13 @@ describe("Session conversation document forks", () => { }); const { session, storage } = openTestSession(); const parentId = await createConversation(session); - let forkAt!: Id; + let forkAt!: EntryId; await session.commit(async (tx) => { forkAt = (await tx.appendEntry(parentId, { kind: "point" })).id; (await tx.doc(Doc, parentId)).value = "copied"; }, context); const child = await session.commit(async (tx) => { - const created = await tx.forkConversation(parentId, forkAt); + const created = await tx.forkConversation(parentId, forkAt, { ownership: { kind: "ownerless" } }); await tx.retireDoc(Doc, created.id); (await tx.doc(Doc, created.id)).value = "replacement"; return created; @@ -589,16 +636,19 @@ describe("Session conversation document forks", () => { }; const { session, storage, publications } = openTestSession(); const parentId = await createConversation(session); - let forkAt!: Id; - let taskId!: Id; + let forkAt!: EntryId; + let taskId!: TaskId; await session.commit(async (tx) => { forkAt = (await tx.appendEntry(parentId, { kind: "point" })).id; - taskId = (await tx.createTask(Work, null, { conversationId: parentId })).id; + taskId = await tx.createTask(Work, null, { conversationId: parentId }); await tx.doc(Copied, parentId); await tx.doc(SessionOnly); await tx.doc(TaskOnly, taskId); }, context); - const child = await session.commit((tx) => tx.forkConversation(parentId, forkAt), context); + const child = await session.commit( + (tx) => tx.forkConversation(parentId, forkAt, { ownership: { kind: "ownerless" } }), + context, + ); await flush(); const copies = documentCopies(storage.commits.at(-1)!); expect(copies.map((write) => write.record.kind)).toEqual([Copied.definition.kind]); @@ -633,7 +683,10 @@ describe("Session conversation document forks", () => { }), ); }, context); - const child = await session.commit((tx) => tx.forkConversation(parentId, forkAt), context); + const child = await session.commit( + (tx) => tx.forkConversation(parentId, forkAt, { ownership: { kind: "ownerless" } }), + context, + ); expect(documentCopies(storage.commits.at(-1)!)).toHaveLength(260); expect(await session.snapshot(Family, child.id, "member-0", context)).toEqual({ value: 0 }); expect(await session.snapshot(Family, child.id, "member-259", context)).toEqual({ value: 259 }); diff --git a/packages/durable/test/session-support.ts b/packages/durable/test/session-support.ts index aecf7b7b8..2ab66d770 100644 --- a/packages/durable/test/session-support.ts +++ b/packages/durable/test/session-support.ts @@ -1,7 +1,9 @@ import type { Context } from "@earendil-works/chord"; import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; import { + type ConversationId, type DocumentAddress, + type DocumentId, type DocumentPoint, type DocumentRecord, type Id, @@ -81,12 +83,12 @@ export class ControlledStorage extends MemoryStorage { return super.commit(writes, commitContext); } - override mintId(): Promise { + override mintId>(): Promise { this.mintCount++; - return super.mintId(); + return super.mintId(); } - override document(id: Id, at: DocumentPoint, callContext: Context) { + override document(id: DocumentId, at: DocumentPoint, callContext: Context) { this.documentReadCount++; return super.document(id, at, callContext); } @@ -138,8 +140,8 @@ export function documentCopyChanges( } /** Create one conversation and return its ID. */ -export async function createConversation(session: SessionKernel): Promise { - return session.commit(async (tx) => (await tx.createConversation()).id, context); +export async function createConversation(session: SessionKernel): Promise { + return session.commit(async (tx) => (await tx.createConversation({ ownership: { kind: "ownerless" } })).id, context); } /** Resolve after pending microtasks and one macrotask turn. */ diff --git a/packages/durable/test/session-tables.test.ts b/packages/durable/test/session-tables.test.ts index eddfb742d..104441172 100644 --- a/packages/durable/test/session-tables.test.ts +++ b/packages/durable/test/session-tables.test.ts @@ -1,13 +1,16 @@ import type { JsonValue } from "@earendil-works/chord"; import { + type ConversationId, defineDoc, defineDocFamily, - type Id, + type EntryId, ReadAfterWrite, type Task, + type TaskId, type TaskRecord, } from "@earendil-works/pi-durable"; import { describe, expect, it } from "vitest"; +import { idFromNumber } from "../src/ids.ts"; import { context, createConversation, documentChanges, flush, openTestSession } from "./session-support.ts"; type Checkpoint = { phase: "start" } | { phase: "next"; step: number }; @@ -58,13 +61,13 @@ function terminal(task: AnyTask): AnyTask { async function createTask( session: ReturnType["session"], - conversationId: Id, + conversationId: ConversationId, withDocument = false, -): Promise { +): Promise> { return session.commit(async (tx) => { - const ref = await tx.createTask(WorkTask, { path: "a" }, { conversationId }); - if (withDocument) (await tx.doc(ProgressDoc, ref.id)).lines.push("started"); - return ref.id; + const taskId = await tx.createTask(WorkTask, { path: "a" }, { conversationId }); + if (withDocument) (await tx.doc(ProgressDoc, taskId)).lines.push("started"); + return taskId; }, context); } @@ -79,8 +82,10 @@ describe("Session transaction tables", () => { expect(await tx.scanEntries({ conversationId }, 10)).toEqual({ items: [] }); await tx.appendEntry(conversationId, { kind: "note" }); await expect(tx.conversation(conversationId)).rejects.toBeInstanceOf(ReadAfterWrite); - await expect(tx.task(1)).rejects.toThrow("Tx.task() cannot read tables after the first table write"); - await expect(tx.entry(1)).rejects.toBeInstanceOf(ReadAfterWrite); + await expect(tx.task(idFromNumber(1))).rejects.toThrow( + "Tx.task() cannot read tables after the first table write", + ); + await expect(tx.entry(idFromNumber(1))).rejects.toBeInstanceOf(ReadAfterWrite); await expect(tx.scanConversations({}, 10)).rejects.toBeInstanceOf(ReadAfterWrite); await expect(tx.scanEntries({ conversationId }, 10)).rejects.toBeInstanceOf(ReadAfterWrite); // Document access remains available after table writes. @@ -120,7 +125,7 @@ describe("Session transaction tables", () => { it("creates conversations, entries, and tasks with minted IDs", async () => { const { session, storage, publications } = openTestSession(); const created = await session.commit(async (tx) => { - const conversation = await tx.createConversation(); + const conversation = await tx.createConversation({ ownership: { kind: "ownerless" } }); const first = await tx.appendEntry(conversation.id, { kind: "note", data: "one" }); const headed = await tx.appendEntry(conversation.id, { kind: "summary", head: "self" }); const task = await tx.createTask( @@ -130,7 +135,7 @@ describe("Session transaction tables", () => { ); return { conversation, first, headed, task }; }, context); - const ids = [created.conversation.id, created.first.id, created.headed.id, created.task.id]; + const ids = [created.conversation.id, created.first.id, created.headed.id, created.task]; expect(new Set(ids).size).toBe(4); expect(created.headed.head).toBe(created.headed.id); expect(created.first).toEqual({ @@ -140,8 +145,8 @@ describe("Session transaction tables", () => { data: "one", }); expect((await storage.entry(created.headed.id, context))!.entry).toEqual(created.headed); - expect(await storage.task(created.task.id, context)).toEqual({ - id: created.task.id, + expect(await storage.task(created.task, context)).toEqual({ + id: created.task, conversationId: created.conversation.id, kind: "test.work", version: 1, @@ -166,9 +171,115 @@ describe("Session transaction tables", () => { await expect(session.commit((tx) => tx.createTask(WorkTask, { path: "x" }), context)).rejects.toThrow( "requires options.conversationId", ); - await expect(session.commit((tx) => tx.appendEntry(12345, { kind: "note" }), context)).rejects.toThrow( - "Conversation 12345 does not exist", - ); + await expect( + session.commit((tx) => tx.appendEntry(idFromNumber(12345), { kind: "note" }), context), + ).rejects.toThrow("Conversation 12345 does not exist"); + }); + + it("creates a conversation with an explicitly staged task owner", async () => { + const { session, storage } = openTestSession(); + const parentId = await createConversation(session); + const created = await session.commit(async (tx) => { + const supervisorId = await tx.createTask( + WorkTask, + { path: "background" }, + { conversationId: parentId, background: true }, + ); + const child = await tx.createConversation({ ownership: { kind: "task", taskId: supervisorId } }); + return { supervisorId, child }; + }, context); + + expect(created.child.owner).toEqual({ conversationId: parentId, taskId: created.supervisorId }); + expect(await storage.conversation(created.child.id, context)).toEqual(created.child); + + await expect( + session.commit(async (tx) => { + const supervisor = (await tx.task(created.supervisorId))!; + tx.setTask({ ...supervisor, conversationId: created.child.id }); + }, context), + ).rejects.toThrow(`Task ${created.supervisorId} cannot change conversations`); + expect((await storage.task(created.supervisorId, context))?.conversationId).toBe(parentId); + }); + + it("rejects missing, terminal, and abort-marked conversation owners atomically", async () => { + const { session, storage } = openTestSession(); + const parentId = await createConversation(session); + let movedStagedTaskId: TaskId<{ ok: boolean }> | undefined; + await expect( + session.commit(async (tx) => { + const taskId = await tx.createTask(WorkTask, { path: "move" }, { conversationId: parentId }); + movedStagedTaskId = taskId; + tx.setTask({ + id: taskId, + conversationId: idFromNumber(998), + kind: WorkTask.definition.name, + version: WorkTask.definition.version, + input: { path: "move" }, + after: [], + background: false, + abortRequested: false, + state: { status: "pending", checkpoint: { phase: "start" } }, + }); + }, context), + ).rejects.toThrow("cannot change conversations"); + if (movedStagedTaskId === undefined) throw new Error("Expected a staged task ID"); + expect(await storage.task(movedStagedTaskId, context)).toBeUndefined(); + + const missingTaskId = idFromNumber(999); + await expect( + session.commit((tx) => tx.createConversation({ ownership: { kind: "task", taskId: missingTaskId } }), context), + ).rejects.toThrow("Conversation owner task 999 does not exist"); + + let rejectedChildId: ConversationId | undefined; + await expect( + session.commit(async (tx) => { + const supervisorId = await tx.createTask(WorkTask, { path: "aborting" }, { conversationId: parentId }); + rejectedChildId = (await tx.createConversation({ ownership: { kind: "task", taskId: supervisorId } })).id; + tx.setTask({ + id: supervisorId, + conversationId: parentId, + kind: WorkTask.definition.name, + version: WorkTask.definition.version, + input: { path: "aborting" }, + after: [], + background: false, + abortRequested: true, + state: { status: "pending", checkpoint: { phase: "start" } }, + }); + }, context), + ).rejects.toThrow("is abort-marked"); + expect(rejectedChildId).toBeDefined(); + if (rejectedChildId === undefined) throw new Error("Expected a rejected child ID"); + expect(await storage.conversation(rejectedChildId, context)).toBeUndefined(); + + await expect( + session.commit(async (tx) => { + const supervisorId = await tx.createTask(WorkTask, { path: "terminal" }, { conversationId: parentId }); + await tx.createConversation({ ownership: { kind: "task", taskId: supervisorId } }); + tx.setTask({ + id: supervisorId, + conversationId: parentId, + kind: WorkTask.definition.name, + version: WorkTask.definition.version, + input: { path: "terminal" }, + after: [], + background: false, + abortRequested: false, + state: { status: "terminal", outcome: { status: "completed", result: { ok: true } } }, + }); + }, context), + ).rejects.toThrow("is terminal"); + + const terminalOwnerId = await createTask(session, parentId); + await session.commit(async (tx) => { + tx.setTask(terminal((await tx.task(terminalOwnerId))!)); + }, context); + await expect( + session.commit( + (tx) => tx.createConversation({ ownership: { kind: "task", taskId: terminalOwnerId } }), + context, + ), + ).rejects.toThrow("is terminal"); }); it("takes ownership of table JSON and rejects non-strict values", async () => { @@ -227,11 +338,11 @@ describe("Session transaction tables", () => { const { session, publications } = openTestSession(); const conversationId = await createConversation(session); const taskId = await session.commit(async (tx) => { - const ref = await tx.createTask(WorkTask, { path: "a" }, { conversationId }); + const createdTaskId = await tx.createTask(WorkTask, { path: "a" }, { conversationId }); // Validation uses the candidate task record, not a caller table read. - (await tx.doc(ProgressDoc, ref.id)).lines.push("created"); - (await tx.doc(StepDoc, ref.id, "one", null)).lines.push("step"); - return ref.id; + (await tx.doc(ProgressDoc, createdTaskId)).lines.push("created"); + (await tx.doc(StepDoc, createdTaskId, "one", null)).lines.push("step"); + return createdTaskId; }, context); expect(await session.snapshot(ProgressDoc, taskId, context)).toEqual({ lines: ["created"] }); await flush(); @@ -245,7 +356,7 @@ describe("Session transaction tables", () => { for (const document of documents) expect(document.conversationId).toBe(conversationId); await session.commit(async (tx) => { - await tx.createConversation(); + await tx.createConversation({ ownership: { kind: "ownerless" } }); (await tx.doc(ProgressDoc, taskId)).lines.push("committed task"); }, context); await flush(); @@ -318,11 +429,11 @@ describe("Session transaction tables", () => { it("validates document owners", async () => { const { session } = openTestSession(); - await expect(session.commit((tx) => tx.doc(ProgressDoc, 4242).then(() => undefined), context)).rejects.toThrow( - "Task 4242 does not exist", - ); - await expect(session.commit((tx) => tx.doc(NotesDoc, 4242).then(() => undefined), context)).rejects.toThrow( - "Conversation 4242 does not exist", - ); + await expect( + session.commit((tx) => tx.doc(ProgressDoc, idFromNumber(4242)).then(() => undefined), context), + ).rejects.toThrow("Task 4242 does not exist"); + await expect( + session.commit((tx) => tx.doc(NotesDoc, idFromNumber(4242)).then(() => undefined), context), + ).rejects.toThrow("Conversation 4242 does not exist"); }); }); diff --git a/packages/durable/test/sqlite-facade.test.ts b/packages/durable/test/sqlite-facade.test.ts index a908174b4..fe24eeaf2 100644 --- a/packages/durable/test/sqlite-facade.test.ts +++ b/packages/durable/test/sqlite-facade.test.ts @@ -1,9 +1,11 @@ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; import { describe, expect, it } from "vitest"; import { StorageRejected } from "../src/errors.ts"; +import { idFromNumber } from "../src/ids.ts"; import type { SqliteDatabase, SqliteStatement } from "../src/storage/sqlite/index.ts"; import { SqliteStorage } from "../src/storage/sqlite/index.ts"; import { type NodeSqliteDatabase, openNodeSqliteDatabase } from "../src/storage/sqlite/node.ts"; +import { type EntryId, ROOT_CONVERSATION_ID } from "../src/types.ts"; type SettlementMode = "immediate" | "delay" | "reject"; @@ -71,24 +73,29 @@ describe("portable SQLite facade settlement", () => { it("prepares each storage statement once and rebinds it across commits", async () => { const database = new ControlledSettlementDatabase(await openNodeSqliteDatabase(":memory:")); const storage = await SqliteStorage.open(database); - await storage.commit([{ type: "conversation", value: { id: 1 } }], BACKGROUND_CONTEXT); + await storage.commit([{ type: "conversation", value: { id: ROOT_CONVERSATION_ID } }], BACKGROUND_CONTEXT); await storage.commit( Array.from({ length: 100 }, (_, index) => ({ type: "entry" as const, - value: { id: index + 2, conversationId: 1, kind: "cached" }, + value: { id: idFromNumber(index + 2), conversationId: ROOT_CONVERSATION_ID, kind: "cached" }, })), BACKGROUND_CONTEXT, ); await storage.commit( - [{ type: "entry", value: { id: 102, conversationId: 1, kind: "cached-again" } }], + [ + { + type: "entry", + value: { id: idFromNumber(102), conversationId: ROOT_CONVERSATION_ID, kind: "cached-again" }, + }, + ], BACKGROUND_CONTEXT, ); - expect((await storage.entry(2, BACKGROUND_CONTEXT))?.entry.kind).toBe("cached"); - expect((await storage.entry(102, BACKGROUND_CONTEXT))?.entry.kind).toBe("cached-again"); - await expect(storage.commit([{ type: "conversation", value: { id: 1 } }], BACKGROUND_CONTEXT)).rejects.toThrow( - "ID 1 already belongs to conversation", - ); - expect((await storage.entry(2, BACKGROUND_CONTEXT))?.entry.kind).toBe("cached"); + expect((await storage.entry(idFromNumber(2), BACKGROUND_CONTEXT))?.entry.kind).toBe("cached"); + expect((await storage.entry(idFromNumber(102), BACKGROUND_CONTEXT))?.entry.kind).toBe("cached-again"); + await expect( + storage.commit([{ type: "conversation", value: { id: ROOT_CONVERSATION_ID } }], BACKGROUND_CONTEXT), + ).rejects.toThrow("ID 1 already belongs to conversation"); + expect((await storage.entry(idFromNumber(2), BACKGROUND_CONTEXT))?.entry.kind).toBe("cached"); expect(database.prepareCount("SELECT record, commit_seq FROM entries WHERE id = ?")).toBe(1); expect(database.prepareCount("INSERT OR IGNORE INTO record_ids (id, record_type) VALUES (?, ?)")).toBe(1); expect( @@ -137,7 +144,12 @@ describe("portable SQLite facade settlement", () => { database.controlNextSettlement("delay"); const committing = storage.commit( - [{ type: "entry", value: { id: 100, conversationId: 1, kind: "settled" } }], + [ + { + type: "entry", + value: { id: idFromNumber(100), conversationId: ROOT_CONVERSATION_ID, kind: "settled" }, + }, + ], BACKGROUND_CONTEXT, ); let committed = false; @@ -146,21 +158,26 @@ describe("portable SQLite facade settlement", () => { }); await Promise.resolve(); expect(committed).toBe(false); - expect(await storage.mintId()).toBe(2); + expect(await storage.mintId()).toBe(2); database.settle(); await expect(committing).resolves.toBe(1); - expect(await storage.mintId()).toBe(101); + expect(await storage.mintId()).toBe(101); database.controlNextSettlement("reject"); const rejected = storage.commit( - [{ type: "entry", value: { id: 200, conversationId: 1, kind: "rejected" } }], + [ + { + type: "entry", + value: { id: idFromNumber(200), conversationId: ROOT_CONVERSATION_ID, kind: "rejected" }, + }, + ], BACKGROUND_CONTEXT, ); - expect(await storage.mintId()).toBe(102); + expect(await storage.mintId()).toBe(102); database.settle(); await expect(rejected).rejects.toThrow("controlled settlement rejection"); - expect(await storage.mintId()).toBe(103); - expect(await storage.entry(200, BACKGROUND_CONTEXT)).toBeUndefined(); + expect(await storage.mintId()).toBe(103); + expect(await storage.entry(idFromNumber(200), BACKGROUND_CONTEXT)).toBeUndefined(); await storage.close(BACKGROUND_CONTEXT); }); }); diff --git a/packages/durable/test/sqlite-migrations.test.ts b/packages/durable/test/sqlite-migrations.test.ts index c49bdf58d..0e2e0665e 100644 --- a/packages/durable/test/sqlite-migrations.test.ts +++ b/packages/durable/test/sqlite-migrations.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; import { afterEach, describe, expect, it } from "vitest"; +import { idFromNumber } from "../src/ids.ts"; import { applySqliteMigrations, CURRENT_SQLITE_SCHEMA_VERSION, @@ -10,7 +11,7 @@ import { type SqliteMigration, } from "../src/storage/sqlite/index.ts"; import { openNodeSqliteDatabase, openNodeSqliteStorage } from "../src/storage/sqlite/node.ts"; -import { ROOT_CONVERSATION_ID } from "../src/types.ts"; +import { type EntryId, ROOT_CONVERSATION_ID } from "../src/types.ts"; const directories = new Set(); @@ -102,7 +103,7 @@ describe("durable SQLite migrations", () => { { type: "entry", value: { - id: 2, + id: idFromNumber(2), conversationId: ROOT_CONVERSATION_ID, kind: "retained", data: { retained: true }, diff --git a/packages/durable/test/sqlite-storage.test.ts b/packages/durable/test/sqlite-storage.test.ts index 47441211a..ca81dfd3c 100644 --- a/packages/durable/test/sqlite-storage.test.ts +++ b/packages/durable/test/sqlite-storage.test.ts @@ -6,9 +6,22 @@ import type { Context, JsonValue } from "@earendil-works/chord"; import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; import { registerStorageConformance } from "@earendil-works/pi-durable/testing"; import { afterEach, describe, expect, it } from "vitest"; +import { idFromNumber } from "../src/ids.ts"; import type { SqliteStorage } from "../src/storage/sqlite/index.ts"; import { type NodeSqliteStorageOptions, openNodeSqliteStorage } from "../src/storage/sqlite/node.ts"; -import type { DocumentCreate, EntryRecord, Seq, Storage, StorageWrite, TaskRecord } from "../src/types.ts"; +import type { + ConversationId, + DocumentCreate, + DocumentId, + EntryId, + EntryRecord, + Id, + Seq, + Storage, + StorageWrite, + TaskId, + TaskRecord, +} from "../src/types.ts"; import { ROOT_CONVERSATION_ID } from "../src/types.ts"; const context = BACKGROUND_CONTEXT; @@ -53,16 +66,24 @@ class ReopeningStorage implements Storage { } } - mintId: Storage["mintId"] = () => this.current.mintId(); + mintId>(): Promise { + return this.current.mintId(); + } conversation: Storage["conversation"] = (id, readContext) => this.current.conversation(id, readContext); scanConversations: Storage["scanConversations"] = (query, limit, cursor, readContext) => this.current.scanConversations(query, limit, cursor, readContext); - entry(id: number, readContext: Context): ReturnType; - entry(conversationId: number, id: number, readContext: Context): ReturnType; - entry(idOrConversationId: number, idOrContext: number | Context, readContext?: Context) { - return readContext === undefined - ? this.current.entry(idOrConversationId, idOrContext as Context) - : this.current.entry(idOrConversationId, idOrContext as number, readContext); + entry(id: EntryId, readContext: Context): ReturnType; + entry(conversationId: ConversationId, id: EntryId, readContext: Context): ReturnType; + entry(idOrConversationId: EntryId | ConversationId, idOrContext: EntryId | Context, readContext?: Context) { + if (readContext === undefined) { + return this.current.entry(idFromNumber(idOrConversationId), idOrContext as Context); + } + if (typeof idOrContext !== "number") throw new TypeError("Storage.entry() requires an entry ID"); + return this.current.entry( + idFromNumber(idOrConversationId), + idFromNumber(idOrContext), + readContext, + ); } findLatestHeadMarker: Storage["findLatestHeadMarker"] = (conversationId, at, readContext) => this.current.findLatestHeadMarker(conversationId, at, readContext); @@ -105,15 +126,15 @@ registerStorageConformance({ describe, expect, it }, "SqliteStorage across reope } }); -function entry(id: number, conversationId: number, data?: JsonValue): EntryRecord { +function entry(id: EntryId, conversationId: ConversationId, data?: JsonValue): EntryRecord { return { id, conversationId, kind: "message", ...(data === undefined ? {} : { data }) }; } -async function createRoot(storage: Storage): Promise { +async function createRoot(storage: Storage): Promise { return storage.commit([{ type: "conversation", value: { id: ROOT_CONVERSATION_ID } }], context); } -function pendingTask(id: number): TaskRecord { +function pendingTask(id: TaskId): TaskRecord { return { id, conversationId: ROOT_CONVERSATION_ID, @@ -132,7 +153,7 @@ function scalar(db: DatabaseSync, sql: string): number { return row.value; } -function revisionCount(path: string, documentId: number): number { +function revisionCount(path: string, documentId: DocumentId): number { const db = new DatabaseSync(path, { readOnly: true }); try { const row = db @@ -148,7 +169,7 @@ describe("Pico SqliteStorage", () => { it("persists records, sequence allocation, and global ID allocation across reopen", async () => { const { storage, path } = await createSqliteStorage(); expect(await createRoot(storage)).toBe(1); - const entryId = await storage.mintId(); + const entryId = await storage.mintId(); expect(await storage.commit([{ type: "entry", value: entry(entryId, ROOT_CONVERSATION_ID) }], context)).toBe(2); await storage.close(context); openStorages.delete(storage); @@ -159,8 +180,13 @@ describe("Pico SqliteStorage", () => { entry: entry(entryId, ROOT_CONVERSATION_ID), commitSeq: 2, }); - expect(await reopened.mintId()).toBe(entryId + 1); - expect(await reopened.commit([{ type: "task", value: pendingTask(await reopened.mintId()) }], context)).toBe(3); + expect(await reopened.mintId()).toBe(entryId + 1); + expect( + await reopened.commit( + [{ type: "task", value: pendingTask(await reopened.mintId>()) }], + context, + ), + ).toBe(3); }); it("rejects persisted metadata corruption on reopen", async () => { @@ -179,7 +205,7 @@ describe("Pico SqliteStorage", () => { it("rejects a document whose required base is missing", async () => { const { storage, path } = await createSqliteStorage(); await createRoot(storage); - const id = await storage.mintId(); + const id = await storage.mintId(); await storage.commit( [ { @@ -204,7 +230,7 @@ describe("Pico SqliteStorage", () => { it("replays detached root replacements and follow-up edits while rejecting corrupt operations", async () => { const { storage, path } = await createSqliteStorage(); await createRoot(storage); - const id = await storage.mintId(); + const id = await storage.mintId(); await storage.commit( [ { @@ -280,8 +306,8 @@ describe("Pico SqliteStorage", () => { it("rolls SQL rows and sequence allocation back as one transaction", async () => { const { storage, path } = await createSqliteStorage(); await createRoot(storage); - const transientId = await storage.mintId(); - const transientTaskId = await storage.mintId(); + const transientId = await storage.mintId(); + const transientTaskId = await storage.mintId>(); const circular: { self?: unknown } = {}; circular.self = circular; await expect( @@ -298,7 +324,7 @@ describe("Pico SqliteStorage", () => { ).rejects.toThrow("circular structure"); expect(await storage.entry(transientId, context)).toBeUndefined(); expect(await storage.task(transientTaskId, context)).toBeUndefined(); - const committedId = await storage.mintId(); + const committedId = await storage.mintId(); expect(await storage.commit([{ type: "entry", value: entry(committedId, ROOT_CONVERSATION_ID) }], context)).toBe( 2, ); @@ -315,7 +341,7 @@ describe("Pico SqliteStorage", () => { it("reconstructs recent and ancient rewindable points after reopen", async () => { const { storage, path } = await createSqliteStorage(); await createRoot(storage); - const id = await storage.mintId(); + const id = await storage.mintId(); const record = { id, kind: "history", @@ -414,7 +440,7 @@ describe("Pico SqliteStorage", () => { it("reclaims current-only revisions only after a base or retirement", async () => { const { storage, path } = await createSqliteStorage(); await createRoot(storage); - const id = await storage.mintId(); + const id = await storage.mintId(); await storage.commit( [ { @@ -471,7 +497,10 @@ describe("Pico SqliteStorage", () => { [ { type: "entry", - value: entry(await storage.mintId(), ROOT_CONVERSATION_ID, { text: "x".repeat(32 * 1024), index }), + value: entry(await storage.mintId(), ROOT_CONVERSATION_ID, { + text: "x".repeat(32 * 1024), + index, + }), }, ], context, @@ -493,7 +522,7 @@ describe("Pico SqliteStorage", () => { it("reuses pages released by current-only checkpoints", async () => { const { storage, path } = await createSqliteStorage(); await createRoot(storage); - const id = await storage.mintId(); + const id = await storage.mintId(); const large = "x".repeat(512 * 1024); await storage.commit( [ @@ -542,13 +571,16 @@ describe("Pico SqliteStorage", () => { [ { type: "entry", - value: entry(await storage.mintId(), ROOT_CONVERSATION_ID, { index, text: "x".repeat(1_024) }), + value: entry(await storage.mintId(), ROOT_CONVERSATION_ID, { + index, + text: "x".repeat(1_024), + }), }, ], context, ); } - const documentId = await storage.mintId(); + const documentId = await storage.mintId(); await storage.commit( [ { diff --git a/packages/durable/test/storage.bench.ts b/packages/durable/test/storage.bench.ts index f518ca227..f24e915eb 100644 --- a/packages/durable/test/storage.bench.ts +++ b/packages/durable/test/storage.bench.ts @@ -13,7 +13,7 @@ import { afterAll, bench, describe } from "vitest"; import { openNodeJsonlStorage } from "../src/storage/jsonl/node.ts"; import { MemoryStorage } from "../src/storage/memory.ts"; import { openNodeSqliteStorage } from "../src/storage/sqlite/node.ts"; -import type { Storage } from "../src/types.ts"; +import type { EntryId, Storage } from "../src/types.ts"; const STORAGE_BENCHMARK_BACKENDS = ["memory", "sqlite", "jsonl"] as const; type StorageBenchmarkBackend = (typeof STORAGE_BENCHMARK_BACKENDS)[number]; @@ -118,7 +118,7 @@ type PersistentBackend = Exclude; type ReopenFixture = { readonly backend: PersistentBackend; readonly path: string; - readonly firstEntryId: number; + readonly firstEntryId: EntryId; readonly samples: string[]; }; @@ -156,7 +156,7 @@ const reopenedStorages: Storage[] = []; async function reopenAndRead( backend: PersistentBackend, path: string, - firstEntryId: number, + firstEntryId: EntryId, ): Promise<{ readonly id: number; readonly storage: Storage }> { const storage = await openPersistentStorage(backend, path); const id = (await storage.entry(firstEntryId, BACKGROUND_CONTEXT))?.entry.id ?? -1; diff --git a/packages/durable/test/types.test.ts b/packages/durable/test/types.test.ts index 16d2ac5fe..e79b29666 100644 --- a/packages/durable/test/types.test.ts +++ b/packages/durable/test/types.test.ts @@ -1,20 +1,59 @@ -import { expectTypeOf, it } from "vitest"; +import { expect, expectTypeOf, it } from "vitest"; +import { idFromNumber, seqFromNumber } from "../src/ids.ts"; import type { ContextEdit, + ConversationId, DocumentContent, DocumentCreate, + DocumentId, DocumentRecord, + EntryId, StorageWrite, SubmissionCreate, + SubmissionId, SubmissionRecord, + TaskId, TaskOutcome, TaskRecord, TaskState, } from "../src/index.ts"; +const conversationId = idFromNumber(1); +const entryId = idFromNumber(2); +const answerId = idFromNumber(3); +const taskId = idFromNumber>(4); +const submissionId = idFromNumber(5); +const documentId = idFromNumber(6); +const seq = seqFromNumber(1); + +type TaskResult = I extends TaskId ? R : never; + +it("brands numeric IDs by record kind and carries task result types", () => { + expect(typeof conversationId).toBe("number"); + expect(JSON.stringify(taskId)).toBe("4"); + expectTypeOf(conversationId).toMatchTypeOf(); + expectTypeOf>().toEqualTypeOf(); + + const compileTimeFailures = () => { + const widenedTask: TaskId = idFromNumber>(7); + // @ts-expect-error an erased task result cannot be narrowed without a typed source + const narrowedTask: TaskId = widenedTask; + // @ts-expect-error conversation IDs are not task IDs + const wrongTask: TaskId = conversationId; + // @ts-expect-error task IDs are not conversation IDs + const wrongConversation: ConversationId = taskId; + // @ts-expect-error entry IDs are not document IDs + const wrongDocument: DocumentId = entryId; + // @ts-expect-error entity IDs are not commit sequences + const wrongSequence: typeof seq = entryId; + void [narrowedTask, wrongTask, wrongConversation, wrongDocument, wrongSequence]; + }; + expectTypeOf(compileTimeFailures).toBeFunction(); +}); + it("encodes discriminator-dependent fields", () => { - const omit = { target: 1, action: "omit" } satisfies ContextEdit; - const replace = { target: 1, action: "replace", messages: [] } satisfies ContextEdit; + const omit = { target: entryId, action: "omit" } satisfies ContextEdit; + const replace = { target: entryId, action: "replace", messages: [] } satisfies ContextEdit; const pending = { status: "pending", checkpoint: { phase: "ready" } } satisfies TaskState< { phase: string }, { value: number } @@ -24,31 +63,31 @@ it("encodes discriminator-dependent fields", () => { outcome: { status: "completed", result: { value: 1 } }, } satisfies TaskState<{ phase: string }, { value: number }>; const completedInput = { - id: 1, - conversationId: 1, + id: submissionId, + conversationId, type: "input", status: "done", - entry: 2, - answer: 3, + entry: entryId, + answer: answerId, } satisfies SubmissionRecord; const completedWrite = { - id: 4, - conversationId: 1, + id: submissionId, + conversationId, type: "write", status: "done", - entry: 5, + entry: entryId, } satisfies SubmissionRecord; const queuedWriteCreate = { - conversationId: 1, + conversationId, type: "write", status: "queued", } satisfies SubmissionCreate; const baseContent = { kind: "base", version: 1, value: { count: 1 } } satisfies DocumentContent; const deltaContent = { kind: "delta", version: 1, ops: [["s", ["count"], 2]] } satisfies DocumentContent; const conversationDocument = { - id: 1, + id: documentId, kind: "test", - scope: { kind: "conversation", conversationId: 1 }, + scope: { kind: "conversation", conversationId }, history: "rewindable", fork: "asOf", } satisfies DocumentCreate; @@ -57,7 +96,7 @@ it("encodes discriminator-dependent fields", () => { expectTypeOf(replace.action).toEqualTypeOf<"replace">(); expectTypeOf(pending.status).toEqualTypeOf<"pending">(); expectTypeOf(terminal.status).toEqualTypeOf<"terminal">(); - expectTypeOf(completedInput.answer).toEqualTypeOf(); + expectTypeOf(completedInput.answer).toEqualTypeOf(); expectTypeOf(completedWrite.type).toEqualTypeOf<"write">(); expectTypeOf(queuedWriteCreate.status).toEqualTypeOf<"queued">(); expectTypeOf(baseContent.kind).toEqualTypeOf<"base">(); @@ -66,9 +105,9 @@ it("encodes discriminator-dependent fields", () => { const compileTimeFailures = () => { // @ts-expect-error replacement edits require replacement messages - const missingReplacement: ContextEdit = { target: 1, action: "replace" }; + const missingReplacement: ContextEdit = { target: entryId, action: "replace" }; // @ts-expect-error omission edits cannot carry replacement messages - const omissionWithMessages: ContextEdit = { target: 1, action: "omit", messages: [] }; + const omissionWithMessages: ContextEdit = { target: entryId, action: "omit", messages: [] }; const pendingWithOutcome: TaskState<{ phase: string }, number> = { status: "pending", checkpoint: { phase: "ready" }, @@ -83,8 +122,8 @@ it("encodes discriminator-dependent fields", () => { }; // @ts-expect-error terminal task records cannot retain live memos const terminalWithMemos: TaskRecord = { - id: 1, - conversationId: 1, + id: taskId, + conversationId, kind: "test", version: 1, input: null, @@ -96,50 +135,34 @@ it("encodes discriminator-dependent fields", () => { }; // @ts-expect-error session documents do not declare conversation history behavior const sessionWithHistory: DocumentRecord = { - id: 1, + id: documentId, kind: "test", - createdAt: 1, + createdAt: seq, scope: { kind: "session" }, history: "latest", fork: "current", }; // @ts-expect-error conversation document creation requires history and fork policies const conversationWithoutPolicy: DocumentCreate = { - id: 1, + id: documentId, 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", + scope: { kind: "conversation", conversationId }, }; const taskWithPolicy = { - id: 1, + id: documentId, kind: "test", - scope: { kind: "task", taskId: 1 }, + scope: { kind: "task", taskId }, 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, + id: documentId, kind: "test", scope: { kind: "session" }, // @ts-expect-error storage, not the create command, supplies createdAt - createdAt: 1, + createdAt: seq, }; // @ts-expect-error document bases cannot carry operation batches const baseWithOps: DocumentContent = { kind: "base", version: 1, value: {}, ops: [] }; @@ -147,7 +170,7 @@ it("encodes discriminator-dependent fields", () => { const deltaWithValue: DocumentContent = { kind: "delta", version: 1, ops: [], value: {} }; const createWithDelta: StorageWrite = { type: "document.create", - record: { id: 1, kind: "test", scope: { kind: "session" } }, + record: { id: documentId, kind: "test", scope: { kind: "session" } }, // @ts-expect-error document creation always starts from a complete base content: { kind: "delta", version: 1, ops: [] }, }; @@ -159,50 +182,33 @@ it("encodes discriminator-dependent fields", () => { }; // @ts-expect-error queued submissions cannot reference transcript entries const queuedWithEntry: SubmissionRecord = { - id: 1, - conversationId: 1, + id: submissionId, + conversationId, type: "input", status: "queued", - entry: 2, + entry: entryId, }; // @ts-expect-error successful input submissions require an answer entry const inputWithoutAnswer: SubmissionRecord = { - id: 2, - conversationId: 1, + id: submissionId, + conversationId, type: "input", status: "done", - entry: 3, + entry: entryId, }; // @ts-expect-error passive write submissions never carry an answer const writeWithAnswer: SubmissionRecord = { - id: 4, - conversationId: 1, + id: submissionId, + conversationId, type: "write", status: "done", - entry: 5, - answer: 6, - }; - // @ts-expect-error passive writes have no placed intermediate state - const placedWrite: SubmissionRecord = { - id: 7, - conversationId: 1, - type: "write", - status: "placed", - entry: 8, - }; - // @ts-expect-error failed passive writes cannot reference an entry - const unansweredWriteWithEntry: SubmissionRecord = { - id: 9, - conversationId: 1, - type: "write", - status: "unanswered", - reason: "failed", - entry: 10, + entry: entryId, + answer: answerId, }; const submissionCreateWithId: SubmissionCreate = { // @ts-expect-error Session, not the submission create value, assigns its ID - id: 11, - conversationId: 1, + id: submissionId, + conversationId, type: "write", status: "queued", }; @@ -214,9 +220,7 @@ it("encodes discriminator-dependent fields", () => { terminalWithMemos, sessionWithHistory, conversationWithoutPolicy, - sessionCreateWithPolicy, taskCreateWithPolicy, - latestCreateWithAsOf, createWithSequence, baseWithOps, deltaWithValue, @@ -225,8 +229,6 @@ it("encodes discriminator-dependent fields", () => { queuedWithEntry, inputWithoutAnswer, writeWithAnswer, - placedWrite, - unansweredWriteWithEntry, submissionCreateWithId, ]; };