feat(durable): add typed IDs and explicit ownership

This commit is contained in:
Mario Zechner 2026-09-25 17:46:13 +02:00
parent a6ca861024
commit 04b5bad666
28 changed files with 1473 additions and 845 deletions

View file

@ -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<R>`, 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

View file

@ -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<Kind extends string, Type = unknown> = number & {
readonly [idBrand]: {
readonly kind: Kind;
readonly type: Type;
};
};
type ConversationId = Id<"conversation">;
type EntryId = Id<"entry">;
type TaskId<Result = unknown> = 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<EntryRecord, "id" | "conversationId" | "byTaskId" | "head"> & {
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<SubmissionRecord>;
wait(context: Context): Promise<SettledSubmissionRecord>;
abort(context: Context): Promise<"aborted" | "already_placed" | "settled">;
@ -339,7 +368,7 @@ type HooksOf<K> = K extends Task<infer _I, infer _S, infer _R, infer H>
: never;
interface Conversation {
readonly id: Id;
readonly id: ConversationId;
submit(submission: SubmissionDraft, context: Context): Promise<Submission>;
getModel(context: Context): Promise<ModelRef | undefined>;
@ -370,11 +399,11 @@ interface Conversation {
context: Context,
): Promise<Page<EntryRecord, Cursor>>;
fork(
at: Id,
spec: ConversationSpec,
at: EntryId,
spec: ConversationCreateSpec,
context: Context,
): Promise<Conversation>;
collapse(instructions: string | undefined, context: Context): Promise<Id>;
collapse(instructions: string | undefined, context: Context): Promise<TaskId>;
reset(handoff: string | undefined, context: Context): Promise<void>;
abort(context: Context): Promise<void>;
waitForIdle(context: Context): Promise<void>;
@ -404,22 +433,22 @@ interface Harness extends Session {
root(context: Context): Promise<Conversation>;
onConversation(listener: (conversation: Conversation) => void): () => void;
conversation(id: Id, context: Context): Promise<Conversation | undefined>;
conversation(id: ConversationId, context: Context): Promise<Conversation | undefined>;
createConversation(
spec: ConversationSpec & { readonly input?: UserInput },
spec: ConversationCreateSpec & { readonly input?: UserInput },
context: Context,
): Promise<Conversation>;
getTask(id: Id, context: Context): Promise<TaskRecord<JsonValue, JsonValue, JsonValue> | undefined>;
submission(id: Id, context: Context): Promise<Submission | undefined>;
getTask<R>(id: TaskId<R>, context: Context): Promise<TaskRecord<JsonValue, JsonValue, R> | undefined>;
submission(id: SubmissionId, context: Context): Promise<Submission | undefined>;
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<R>(ref: TaskRef<R>, context: Context): Promise<SettledTask<R>>;
abortTask(id: TaskId, context: Context): Promise<"marked" | "terminal">;
markTask(id: TaskId, context: Context): Promise<"marked" | "terminal">;
waitForTask<R>(id: TaskId<R>, context: Context): Promise<SettledTask<R>>;
waitForIdle(context: Context): Promise<void>;
}
@ -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<void>;
snapshot<T extends JsonObject>(token: SessionDocToken<T>, context: Context): Promise<Readonly<T> | undefined>;
snapshot<T extends JsonObject>(token: ConversationDocToken<T>, conversationId: Id, context: Context): Promise<Readonly<T> | undefined>;
snapshot<T extends JsonObject>(token: TaskDocToken<T>, taskId: Id, context: Context): Promise<Readonly<T> | undefined>;
snapshot<T extends JsonObject>(token: ConversationDocToken<T>, conversationId: ConversationId, context: Context): Promise<Readonly<T> | undefined>;
snapshot<T extends JsonObject>(token: TaskDocToken<T>, taskId: TaskId, context: Context): Promise<Readonly<T> | undefined>;
snapshot<T extends JsonObject, I extends JsonValue>(token: SessionDocFamilyToken<T, I>, key: string, context: Context): Promise<Readonly<T> | undefined>;
snapshot<T extends JsonObject, I extends JsonValue>(token: ConversationDocFamilyToken<T, I>, conversationId: Id, key: string, context: Context): Promise<Readonly<T> | undefined>;
snapshot<T extends JsonObject, I extends JsonValue>(token: TaskDocFamilyToken<T, I>, taskId: Id, key: string, context: Context): Promise<Readonly<T> | undefined>;
snapshot<T extends JsonObject, I extends JsonValue>(token: ConversationDocFamilyToken<T, I>, conversationId: ConversationId, key: string, context: Context): Promise<Readonly<T> | undefined>;
snapshot<T extends JsonObject, I extends JsonValue>(token: TaskDocFamilyToken<T, I>, taskId: TaskId, key: string, context: Context): Promise<Readonly<T> | undefined>;
snapshotAsOf<T extends JsonObject>(token: RewindableConversationDocToken<T>, conversationId: Id, at: Id, context: Context): Promise<Readonly<T> | undefined>;
snapshotAsOf<T extends JsonObject, I extends JsonValue>(token: RewindableConversationDocFamilyToken<T, I>, conversationId: Id, key: string, at: Id, context: Context): Promise<Readonly<T> | undefined>;
snapshotAsOf<T extends JsonObject>(token: RewindableConversationDocToken<T>, conversationId: ConversationId, at: EntryId, context: Context): Promise<Readonly<T> | undefined>;
snapshotAsOf<T extends JsonObject, I extends JsonValue>(token: RewindableConversationDocFamilyToken<T, I>, conversationId: ConversationId, key: string, at: EntryId, context: Context): Promise<Readonly<T> | undefined>;
documentSource<T extends JsonObject>(token: SessionDocToken<T>, context: Context): Promise<DocumentSource<T> | undefined>;
documentSource<T extends JsonObject>(token: ConversationDocToken<T>, conversationId: Id, context: Context): Promise<DocumentSource<T> | undefined>;
documentSource<T extends JsonObject>(token: TaskDocToken<T>, taskId: Id, context: Context): Promise<DocumentSource<T> | undefined>;
documentSource<T extends JsonObject>(token: ConversationDocToken<T>, conversationId: ConversationId, context: Context): Promise<DocumentSource<T> | undefined>;
documentSource<T extends JsonObject>(token: TaskDocToken<T>, taskId: TaskId, context: Context): Promise<DocumentSource<T> | undefined>;
documentSource<T extends JsonObject, I extends JsonValue>(token: SessionDocFamilyToken<T, I>, key: string, context: Context): Promise<DocumentSource<T> | undefined>;
documentSource<T extends JsonObject, I extends JsonValue>(token: ConversationDocFamilyToken<T, I>, conversationId: Id, key: string, context: Context): Promise<DocumentSource<T> | undefined>;
documentSource<T extends JsonObject, I extends JsonValue>(token: TaskDocFamilyToken<T, I>, taskId: Id, key: string, context: Context): Promise<DocumentSource<T> | undefined>;
documentSource<T extends JsonObject, I extends JsonValue>(token: ConversationDocFamilyToken<T, I>, conversationId: ConversationId, key: string, context: Context): Promise<DocumentSource<T> | undefined>;
documentSource<T extends JsonObject, I extends JsonValue>(token: TaskDocFamilyToken<T, I>, taskId: TaskId, key: string, context: Context): Promise<DocumentSource<T> | undefined>;
}
interface Tx {
conversation(id: Id): Promise<ConversationRecord | undefined>;
entry(id: Id): Promise<EntryRecord | undefined>;
task(id: Id): Promise<TaskRecord<JsonValue, JsonValue, JsonValue> | undefined>;
conversation(id: ConversationId): Promise<ConversationRecord | undefined>;
entry(id: EntryId): Promise<EntryRecord | undefined>;
task(id: TaskId): Promise<TaskRecord<JsonValue, JsonValue, JsonValue> | undefined>;
scanConversations(query: ConversationQuery, limit: number, cursor?: Cursor): Promise<Page<ConversationRecord, Cursor>>;
scanEntries(query: EntryQuery, limit: number, cursor?: Cursor): Promise<Page<EntryRecord, Cursor>>;
scanTasks(query: TaskQuery, limit: number, cursor?: Cursor): Promise<Page<TaskRecord<JsonValue, JsonValue, JsonValue>, Cursor>>;
createConversation(): Promise<ConversationRecord>;
forkConversation(parentConversationId: Id, at: Id): Promise<ConversationRecord>;
appendEntry(conversationId: Id, value: EntryDraft): Promise<EntryRecord>;
createConversation(options: { readonly ownership: ConversationOwnership }): Promise<ConversationRecord>;
forkConversation(
parentConversationId: ConversationId,
at: EntryId,
options: { readonly ownership: ConversationOwnership },
): Promise<ConversationRecord>;
appendEntry(conversationId: ConversationId, value: EntryDraft): Promise<EntryRecord>;
createTask<I, S extends { phase: string }, R, H extends object>(
task: Task<I, S, R, H>, input: I, options?: TaskOptions,
): Promise<TaskRef<R>>;
): Promise<TaskId<R>>;
setTask(value: TaskRecord<JsonValue, JsonValue, JsonValue>): void;
doc<T extends JsonObject>(token: SessionDocToken<T>): Promise<Draft<T>>;
doc<T extends JsonObject>(token: ConversationDocToken<T>, conversationId: Id): Promise<Draft<T>>;
doc<T extends JsonObject>(token: TaskDocToken<T>, taskId: Id): Promise<Draft<T>>;
doc<T extends JsonObject>(token: ConversationDocToken<T>, conversationId: ConversationId): Promise<Draft<T>>;
doc<T extends JsonObject>(token: TaskDocToken<T>, taskId: TaskId): Promise<Draft<T>>;
doc<T extends JsonObject, I extends JsonValue>(token: SessionDocFamilyToken<T, I>, key: string, seed: I): Promise<Draft<T>>;
doc<T extends JsonObject, I extends JsonValue>(token: ConversationDocFamilyToken<T, I>, conversationId: Id, key: string, seed: I): Promise<Draft<T>>;
doc<T extends JsonObject, I extends JsonValue>(token: TaskDocFamilyToken<T, I>, taskId: Id, key: string, seed: I): Promise<Draft<T>>;
doc<T extends JsonObject, I extends JsonValue>(token: ConversationDocFamilyToken<T, I>, conversationId: ConversationId, key: string, seed: I): Promise<Draft<T>>;
doc<T extends JsonObject, I extends JsonValue>(token: TaskDocFamilyToken<T, I>, taskId: TaskId, key: string, seed: I): Promise<Draft<T>>;
retireDoc<T extends JsonObject>(token: SessionDocToken<T>): Promise<void>;
retireDoc<T extends JsonObject>(token: ConversationDocToken<T>, conversationId: Id): Promise<void>;
retireDoc<T extends JsonObject>(token: TaskDocToken<T>, taskId: Id): Promise<void>;
retireDoc<T extends JsonObject>(token: ConversationDocToken<T>, conversationId: ConversationId): Promise<void>;
retireDoc<T extends JsonObject>(token: TaskDocToken<T>, taskId: TaskId): Promise<void>;
retireDoc<T extends JsonObject, I extends JsonValue>(token: SessionDocFamilyToken<T, I>, key: string): Promise<void>;
retireDoc<T extends JsonObject, I extends JsonValue>(token: ConversationDocFamilyToken<T, I>, conversationId: Id, key: string): Promise<void>;
retireDoc<T extends JsonObject, I extends JsonValue>(token: TaskDocFamilyToken<T, I>, taskId: Id, key: string): Promise<void>;
retireDoc<T extends JsonObject, I extends JsonValue>(token: ConversationDocFamilyToken<T, I>, conversationId: ConversationId, key: string): Promise<void>;
retireDoc<T extends JsonObject, I extends JsonValue>(token: TaskDocFamilyToken<T, I>, taskId: TaskId, key: string): Promise<void>;
}
```
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<S, R> =
| { readonly status: "terminal"; readonly outcome: TaskOutcome<R> };
type TaskRecord<I, S, R> = {
readonly id: Id;
readonly conversationId: Id;
readonly id: TaskId<R>;
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<I, P, S, R, H extends object> = (
) => Promise<void>;
interface TaskRuntime<I, S, R, H extends object> extends DocumentObserver {
readonly taskId: Id;
readonly conversationId: Id;
readonly taskId: TaskId<R>;
readonly conversationId: ConversationId;
readonly signal: AbortSignal;
readonly hooks: HookRunner<H>;
@ -1225,6 +1274,7 @@ interface TaskRuntime<I, S, R, H extends object> extends DocumentObserver {
memo<T extends JsonValue>(name: string, context: Context): Promise<T | undefined>;
memo<T extends JsonValue>(name: string, candidate: T, context: Context): Promise<T>;
conversation(id: ConversationId, context: Context): Promise<ConversationHandle | undefined>;
sleep(until: number, context: Context): Promise<void>;
}
@ -1247,11 +1297,9 @@ interface Task<I, S extends { phase: string }, R, H extends object> {
readonly definition: TaskDefinition<I, S, R, H>;
}
declare const taskResultType: unique symbol;
type TaskRef<R> = { 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<I, S extends { phase: string }, R, H extends object = {}>(
): Task<I, S, R, H>;
```
`TaskId<R>` is the numeric task ID itself with an erased result-type brand. It
replaces a separate task-reference wrapper: `createTask()` returns `TaskId<R>`,
typed waits infer `R` from that ID, and result-agnostic operations accept
`TaskId<unknown>`. 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<Submission>;
abort(context: Context): Promise<void>;
waitForIdle(context: Context): Promise<void>;
}
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<T>(change: (tx: Tx) => T | Promise<T>, context: Context): Promise<T>;
@ -1520,14 +1575,10 @@ interface ToolExecutionApi extends DocumentObserver {
input: I,
options: Omit<TaskOptions, "conversationId">,
context: Context,
): Promise<TaskRef<R>>;
getTask<R>(ref: TaskRef<R>, context: Context): Promise<TaskRecord<JsonValue, JsonValue, R> | undefined>;
waitForTask<R>(ref: TaskRef<R>, context: Context): Promise<SettledTask<R>>;
createConversation(
spec: ConversationSpec & { readonly inherit?: boolean },
context: Context,
): Promise<OwnedConversation>;
conversation(id: Id, context: Context): Promise<OwnedConversation | undefined>;
): Promise<TaskId<R>>;
getTask<R>(id: TaskId<R>, context: Context): Promise<TaskRecord<JsonValue, JsonValue, R> | undefined>;
waitForTask<R>(id: TaskId<R>, context: Context): Promise<SettledTask<R>>;
conversation(id: ConversationId, context: Context): Promise<ConversationHandle | undefined>;
}
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<T extends JsonObject> = WatchHandle<Readonly<T> | null>;
interface DocumentObserver {
watchDoc<T extends JsonObject>(token: SessionDocToken<T>, context: Context): Promise<DocumentWatch<T> | undefined>;
watchDoc<T extends JsonObject>(token: ConversationDocToken<T>, conversationId: Id, context: Context): Promise<DocumentWatch<T> | undefined>;
watchDoc<T extends JsonObject>(token: TaskDocToken<T>, taskId: Id, context: Context): Promise<DocumentWatch<T> | undefined>;
watchDoc<T extends JsonObject>(token: ConversationDocToken<T>, conversationId: ConversationId, context: Context): Promise<DocumentWatch<T> | undefined>;
watchDoc<T extends JsonObject>(token: TaskDocToken<T>, taskId: TaskId, context: Context): Promise<DocumentWatch<T> | undefined>;
watchDoc<T extends JsonObject, I extends JsonValue>(token: SessionDocFamilyToken<T, I>, key: string, context: Context): Promise<DocumentWatch<T> | undefined>;
watchDoc<T extends JsonObject, I extends JsonValue>(token: ConversationDocFamilyToken<T, I>, conversationId: Id, key: string, context: Context): Promise<DocumentWatch<T> | undefined>;
watchDoc<T extends JsonObject, I extends JsonValue>(token: TaskDocFamilyToken<T, I>, taskId: Id, key: string, context: Context): Promise<DocumentWatch<T> | undefined>;
watchDoc<T extends JsonObject, I extends JsonValue>(token: ConversationDocFamilyToken<T, I>, conversationId: ConversationId, key: string, context: Context): Promise<DocumentWatch<T> | undefined>;
watchDoc<T extends JsonObject, I extends JsonValue>(token: TaskDocFamilyToken<T, I>, taskId: TaskId, key: string, context: Context): Promise<DocumentWatch<T> | undefined>;
}
```
@ -2058,18 +2125,18 @@ type Page<T, C> = {
type Cursor = Readonly<Record<string, JsonValue>>;
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<Seq>;
mintId(): Promise<Id>;
/** Allocate from the one global numeric namespace; the generic brand is compile-time only. */
mintId<I extends Id<string>>(): Promise<I>;
conversation(id: Id, context: Context): Promise<ConversationRecord | undefined>;
conversation(id: ConversationId, context: Context): Promise<ConversationRecord | undefined>;
scanConversations(query: ConversationQuery, limit: number, cursor: Cursor | undefined, context: Context): Promise<Page<ConversationRecord, Cursor>>;
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<Page<EntryRecord, Cursor>>;
task(id: Id, context: Context): Promise<TaskRecord<JsonValue, JsonValue, JsonValue> | undefined>;
task(id: TaskId, context: Context): Promise<TaskRecord<JsonValue, JsonValue, JsonValue> | undefined>;
scanTasks(query: TaskQuery, limit: number, cursor: Cursor | undefined, context: Context): Promise<Page<TaskRecord<JsonValue, JsonValue, JsonValue>, Cursor>>;
submission(id: Id, context: Context): Promise<SubmissionRecord | undefined>;
submissionByRequest(conversationId: Id, requestId: string, context: Context): Promise<SubmissionRecord | undefined>;
submission(id: SubmissionId, context: Context): Promise<SubmissionRecord | undefined>;
submissionByRequest(conversationId: ConversationId, requestId: string, context: Context): Promise<SubmissionRecord | undefined>;
findDocument(address: DocumentAddress, at: DocumentPoint, context: Context): Promise<DocumentRecord | undefined>;
document(id: Id, at: DocumentPoint, context: Context): Promise<StoredDocument | undefined>;
document(id: DocumentId, at: DocumentPoint, context: Context): Promise<StoredDocument | undefined>;
scanDocuments(query: DocumentQuery, limit: number, cursor: Cursor | undefined, context: Context): Promise<Page<DocumentRecord, Cursor>>;
close(context: Context): Promise<void>;

View file

@ -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<T extends JsonObject, I extends JsonValue> = Omit<CommonDocDefinition<T>, "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<ConversationId>(args[index++], definition) };
break;
case "task":
scope = { kind: "task", taskId: ownerId(args[index++], definition) };
scope = { kind: "task", taskId: ownerId<TaskId>(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<I extends Id<string>>(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<I>(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":

View file

@ -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<I extends Id<string>>(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;
}

View file

@ -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";

View file

@ -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<readonly ForkDocumentCopy[]> {
const entry = await storage.entry(parentConversationId, at, context);
@ -61,7 +63,7 @@ async function collectCopies(
scope: Extract<DocumentRecord["scope"], { readonly kind: "conversation" }>,
at: DocumentPoint,
policy: ForkPolicy,
childConversationId: Id,
childConversationId: ConversationId,
copies: ForkDocumentCopy[],
copiedAddresses: Set<string>,
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<DocumentId>();
const identity = {
id,
kind: source.kind,

View file

@ -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<T extends JsonObject>(token: SessionDocToken<T>, context: Context): Promise<Readonly<T> | undefined>;
snapshot<T extends JsonObject>(
token: ConversationDocToken<T>,
conversationId: Id,
conversationId: ConversationId,
context: Context,
): Promise<Readonly<T> | undefined>;
snapshot<T extends JsonObject>(
token: TaskDocToken<T>,
taskId: Id,
taskId: TaskId,
context: Context,
): Promise<Readonly<T> | undefined>;
snapshot<T extends JsonObject, I extends JsonValue>(
@ -91,13 +95,13 @@ export class SessionKernel implements Session {
): Promise<Readonly<T> | undefined>;
snapshot<T extends JsonObject, I extends JsonValue>(
token: ConversationDocFamilyToken<T, I>,
conversationId: Id,
conversationId: ConversationId,
key: string,
context: Context,
): Promise<Readonly<T> | undefined>;
snapshot<T extends JsonObject, I extends JsonValue>(
token: TaskDocFamilyToken<T, I>,
taskId: Id,
taskId: TaskId,
key: string,
context: Context,
): Promise<Readonly<T> | undefined>;
@ -120,15 +124,15 @@ export class SessionKernel implements Session {
snapshotAsOf<T extends JsonObject>(
token: RewindableConversationDocToken<T>,
conversationId: Id,
at: Id,
conversationId: ConversationId,
at: EntryId,
context: Context,
): Promise<Readonly<T> | undefined>;
snapshotAsOf<T extends JsonObject, I extends JsonValue>(
token: RewindableConversationDocFamilyToken<T, I>,
conversationId: Id,
conversationId: ConversationId,
key: string,
at: Id,
at: EntryId,
context: Context,
): Promise<Readonly<T> | undefined>;
async snapshotAsOf(token: AnyDocToken, ...args: readonly unknown[]): Promise<JsonObject | undefined> {
@ -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<EntryId>(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,

View file

@ -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<AnyTaskRecord | undefined>;
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<JsonObject>;
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<Id>();
readonly #forkSourceConversationIds = new Set<Id>();
readonly #forkSourceDocumentIds = new Set<Id>();
readonly #createdConversationIds = new Set<ConversationId>();
readonly #forkSourceConversationIds = new Set<ConversationId>();
readonly #forkSourceDocumentIds = new Set<DocumentId>();
/** One entry per task touched by a public read, candidate write, or document-owner lookup. */
readonly #tasksById = new Map<Id, TransactionTask>();
readonly #tasksById = new Map<TaskId, TransactionTask>();
/** 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<ConversationRecord | undefined> {
conversation(id: ConversationId): Promise<ConversationRecord | undefined> {
return this.#read("conversation", () => this.#host.storage.conversation(id, this.#context));
}
entry(id: Id): Promise<EntryRecord | undefined> {
entry(id: EntryId): Promise<EntryRecord | undefined> {
return this.#read("entry", async () => (await this.#host.storage.entry(id, this.#context))?.entry);
}
task(id: Id): Promise<AnyTaskRecord | undefined> {
task(id: TaskId): Promise<AnyTaskRecord | undefined> {
return this.#read("task", () => this.#committedTask(id));
}
@ -199,18 +202,39 @@ export class Transaction implements Tx {
// ─── Table writes ───────────────────────────────────────────────────────
createConversation(): Promise<ConversationRecord> {
return this.#write(() => this.#stageConversation());
createConversation(options: { readonly ownership: ConversationOwnership }): Promise<ConversationRecord> {
return this.#write(() => this.#stageConversation(undefined, options.ownership));
}
forkConversation(parentConversationId: Id, at: Id): Promise<ConversationRecord> {
return this.#write(() => this.#stageConversation({ conversationId: parentConversationId, at }));
forkConversation(
parentConversationId: ConversationId,
at: EntryId,
options: { readonly ownership: ConversationOwnership },
): Promise<ConversationRecord> {
return this.#write(() =>
this.#stageConversation({ conversationId: parentConversationId, at }, options.ownership),
);
}
async #stageConversation(parent?: NonNullable<ConversationRecord["parent"]>): Promise<ConversationRecord> {
const id = await this.#host.storage.mintId();
async #stageConversation(
parent: NonNullable<ConversationRecord["parent"]> | undefined,
ownership: ConversationOwnership,
): Promise<ConversationRecord> {
const ownerTaskId = ownership.kind === "task" ? ownership.taskId : undefined;
const id = await this.#host.storage.mintId<ConversationId>();
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<EntryRecord> {
appendEntry(conversationId: ConversationId, value: EntryDraft): Promise<EntryRecord> {
return this.#write(async () => {
await this.#requireConversation(conversationId);
this.#assertOpen();
const id = await this.#host.storage.mintId();
const id = await this.#host.storage.mintId<EntryId>();
this.#assertOpen();
const { head, ...rest } = value;
const record = copyJson(
@ -255,7 +279,7 @@ export class Transaction implements Tx {
task: Task<I, S, R, H>,
input: I,
options?: TaskOptions,
): Promise<TaskRef<R>> {
): Promise<TaskId<R>> {
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<TaskId<R>>();
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<T extends JsonObject>(token: SessionDocToken<T>): Promise<Draft<T>>;
doc<T extends JsonObject>(token: ConversationDocToken<T>, conversationId: Id): Promise<Draft<T>>;
doc<T extends JsonObject>(token: TaskDocToken<T>, taskId: Id): Promise<Draft<T>>;
doc<T extends JsonObject>(token: ConversationDocToken<T>, conversationId: ConversationId): Promise<Draft<T>>;
doc<T extends JsonObject>(token: TaskDocToken<T>, taskId: TaskId): Promise<Draft<T>>;
doc<T extends JsonObject, I extends JsonValue>(
token: SessionDocFamilyToken<T, I>,
key: string,
@ -308,13 +336,13 @@ export class Transaction implements Tx {
): Promise<Draft<T>>;
doc<T extends JsonObject, I extends JsonValue>(
token: ConversationDocFamilyToken<T, I>,
conversationId: Id,
conversationId: ConversationId,
key: string,
seed: I,
): Promise<Draft<T>>;
doc<T extends JsonObject, I extends JsonValue>(
token: TaskDocFamilyToken<T, I>,
taskId: Id,
taskId: TaskId,
key: string,
seed: I,
): Promise<Draft<T>>;
@ -350,17 +378,17 @@ export class Transaction implements Tx {
}
retireDoc<T extends JsonObject>(token: SessionDocToken<T>): Promise<void>;
retireDoc<T extends JsonObject>(token: ConversationDocToken<T>, conversationId: Id): Promise<void>;
retireDoc<T extends JsonObject>(token: TaskDocToken<T>, taskId: Id): Promise<void>;
retireDoc<T extends JsonObject>(token: ConversationDocToken<T>, conversationId: ConversationId): Promise<void>;
retireDoc<T extends JsonObject>(token: TaskDocToken<T>, taskId: TaskId): Promise<void>;
retireDoc<T extends JsonObject, I extends JsonValue>(token: SessionDocFamilyToken<T, I>, key: string): Promise<void>;
retireDoc<T extends JsonObject, I extends JsonValue>(
token: ConversationDocFamilyToken<T, I>,
conversationId: Id,
conversationId: ConversationId,
key: string,
): Promise<void>;
retireDoc<T extends JsonObject, I extends JsonValue>(
token: TaskDocFamilyToken<T, I>,
taskId: Id,
taskId: TaskId,
key: string,
): Promise<void>;
retireDoc(token: AnyDocToken, ...args: readonly unknown[]): Promise<void> {
@ -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<DocumentId>();
this.#assertOpen();
const tracker = track(value);
entry.target = {
@ -603,15 +631,19 @@ export class Transaction implements Tx {
async #assemble(): Promise<StorageWrite[]> {
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<Id> | undefined;
let terminalTaskIds: Set<TaskId> | 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<Id>();
const targetedDocumentIds = new Set<DocumentId>();
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<void> {
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<void> {
async #requireConversation(id: ConversationId): Promise<void> {
if (this.#createdConversationIds.has(id)) return;
if ((await this.#host.storage.conversation(id, this.#context)) === undefined) {
throw new Error(`Conversation ${id} does not exist`);
}
}
#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<AnyTaskRecord | undefined> {
async #currentTask(id: TaskId): Promise<AnyTaskRecord | undefined> {
return this.#tasksById.get(id)?.write?.record ?? (await this.#committedTask(id));
}
#committedTask(id: Id): Promise<AnyTaskRecord | undefined> {
#committedTask(id: TaskId): Promise<AnyTaskRecord | undefined> {
const task = this.#taskEntry(id);
task.committedRead ??= this.#host.storage.task(id, this.#context);
return task.committedRead;

View file

@ -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<JsonValue, JsonValue, JsonValue>;
type MainOperation =
| Extract<StorageWrite, { readonly type: "conversation" | "entry" | "submission" | "document.retire" }>
| { 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<string, unknown> =>
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<Id>();
private readonly liveTaskSidecars = new Set<Id>();
private readonly currentOnlyDocuments = new Set<DocumentId>();
private readonly liveTaskSidecars = new Set<TaskId>();
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<I extends Id<string>>(): Promise<I> {
return this.store.mintId<I>();
}
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<Storage["entry"]>;
entry(conversationId: Id, id: Id, context: Context): ReturnType<Storage["entry"]>;
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<Storage["entry"]>;
entry(conversationId: ConversationId, id: EntryId, context: Context): ReturnType<Storage["entry"]>;
async entry(idOrConversationId: EntryId | ConversationId, idOrContext: EntryId | Context, context?: Context) {
if (context === undefined)
return this.store.entry(idFromNumber<EntryId>(idOrConversationId), idOrContext as Context);
if (typeof idOrContext !== "number") throw new TypeError("Storage.entry() requires an entry ID");
return this.store.entry(
idFromNumber<ConversationId>(idOrConversationId),
idFromNumber<EntryId>(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<string, string> {
const createdCurrentOnlyDocuments = new Set<Id>();
const retiredDocuments = new Set<Id>();
const baseDocuments = new Set<Id>();
const finalTasks = new Map<Id, StoredTask>();
const createdCurrentOnlyDocuments = new Set<DocumentId>();
const retiredDocuments = new Set<DocumentId>();
const baseDocuments = new Set<DocumentId>();
const finalTasks = new Map<TaskId, StoredTask>();
for (const write of writes) {
switch (write.type) {
case "document.create":
@ -450,7 +465,7 @@ export class JsonlStorage implements Storage {
}
const replacements = new Map<string, string>();
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<Id>();
const retiredDocuments = new Set<Id>();
const finalTaskIsLive = new Map<Id, boolean>();
const currentOnlyDocuments = new Set<DocumentId>();
const retiredDocuments = new Set<DocumentId>();
const finalTaskIsLive = new Map<TaskId, boolean>();
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<Id, SidecarRecord>();
const latestBases = new Map<DocumentId, SidecarRecord>();
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<SidecarRecord>[] | undefined;
if (file.startsWith("task-") && terminalTasks.has(id)) {
if (file.startsWith("task-") && terminalTasks.has(idFromNumber<TaskId>(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<DocumentId>(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 &&

View file

@ -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<Id, ConversationRecord>;
conversationIds: Id[];
conversationIdsByOwnerConversation: Map<Id, Id[]>;
conversationIdsByOwnerTask: Map<Id, Id[]>;
entries: Map<Id, EntryRecord>;
entryIds: Map<Id, Id[]>;
headEntryIds: Map<Id, Id[]>;
entryCommitSeqs: Map<Id, Seq>;
tasks: Map<Id, StoredTask>;
taskIds: Id[];
taskIdsByStatus: Record<TaskStatus, Id[]>;
submissions: Map<Id, SubmissionRecord>;
submissionIdsByRequest: Map<Id, Map<string, Id>>;
documents: Map<Id, StoredDocumentState>;
recordTypes: Map<Id<string>, TableName>;
conversations: Map<ConversationId, ConversationRecord>;
conversationIds: ConversationId[];
conversationIdsByOwnerConversation: Map<ConversationId, ConversationId[]>;
conversationIdsByOwnerTask: Map<TaskId, ConversationId[]>;
entries: Map<EntryId, EntryRecord>;
entryIds: Map<ConversationId, EntryId[]>;
headEntryIds: Map<ConversationId, EntryId[]>;
entryCommitSeqs: Map<EntryId, Seq>;
tasks: Map<TaskId, StoredTask>;
taskIds: TaskId[];
taskIdsByStatus: Record<TaskStatus, TaskId[]>;
submissions: Map<SubmissionId, SubmissionRecord>;
submissionIdsByRequest: Map<ConversationId, Map<string, SubmissionId>>;
documents: Map<DocumentId, StoredDocumentState>;
documentAddresses: Map<string, DocumentAddressIndex>;
documentIdsByScope: Map<string, Id[]>;
documentIdsByScope: Map<string, DocumentId[]>;
};
const clone = <T>(value: T): T => {
@ -106,10 +113,14 @@ const freeze = <T>(value: T): T => {
return Object.freeze(value);
};
const cursorId = (cursor: Readonly<Record<string, JsonValue>> | undefined): Id | undefined =>
cursor?.after as Id | undefined;
const cursorId = <I extends Id<string>>(cursor: Readonly<Record<string, JsonValue>> | 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<I>(after);
};
const lowerBound = (ids: readonly Id[], target: Id): number => {
const lowerBound = <I extends Id<string>>(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 = <I extends Id<string>>(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 = <I extends Id<string>>(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 = <I extends Id<string>>(ids: I[], id: I): void => {
const index = lowerBound(ids, id);
if (ids[index] === id) ids.splice(index, 1);
};
const insertMapId = (index: Map<Id, Id[]>, key: Id, id: Id): void => {
const insertMapId = <K, I extends Id<string>>(index: Map<K, I[]>, 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<string>): TableName | undefined => state.recordTypes.get(id);
const page = <T extends { readonly id: Id }>(values: readonly T[], limit: number): Page<T, Cursor> => {
const page = <T extends { readonly id: Id<string> }>(values: readonly T[], limit: number): Page<T, Cursor> => {
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<Id>();
const changedDocumentIds = new Set<DocumentId>();
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<Id, DocumentAction>,
documentActions: ReadonlyMap<DocumentId, DocumentAction>,
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<Id> {
async mintId<I extends Id<string>>(): Promise<I> {
this.assertOpen();
if (!Number.isSafeInteger(this.nextId)) throw new Error("ID space is exhausted");
return this.nextId++;
return idFromNumber<I>(this.nextId++);
}
async conversation(id: Id, _context: Context): Promise<ConversationRecord | undefined> {
async conversation(id: ConversationId, _context: Context): Promise<ConversationRecord | undefined> {
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<EntryId>(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<ConversationId>(idOrConversationId);
const id = idFromNumber<EntryId>(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<StoredTask | undefined> {
async task(id: TaskId, _context: Context): Promise<StoredTask | undefined> {
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<SubmissionRecord | undefined> {
async submission(id: SubmissionId, _context: Context): Promise<SubmissionRecord | undefined> {
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<SubmissionRecord | undefined> {
@ -549,7 +563,7 @@ export class MemoryStorage implements Storage {
return undefined;
}
async document(id: Id, at: DocumentPoint, _context: Context): Promise<StoredDocument | undefined> {
async document(id: DocumentId, at: DocumentPoint, _context: Context): Promise<StoredDocument | undefined> {
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<EntryRecord> {
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<Id, TableName>();
const claimed = new Map<Id<string>, 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<Id, DocumentAction> {
const actions = new Map<Id, DocumentAction>();
private prepareDocumentActions(writes: readonly StorageWrite[]): Map<DocumentId, DocumentAction> {
const actions = new Map<DocumentId, DocumentAction>();
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<Id, DocumentAction>): void {
private checkDocumentActions(actions: ReadonlyMap<DocumentId, DocumentAction>): void {
const liveCounts = new Map<string, number>();
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<Id, DocumentAction>, seq: Seq): void {
private applyDocumentActions(actions: ReadonlyMap<DocumentId, DocumentAction>, 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);

View file

@ -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 = <T extends object>(statement: SqliteStatement, ...params: SqliteV
const allRows = <T extends object>(statement: SqliteStatement, ...params: SqliteValue[]): T[] =>
statement.all<T>(...params);
const cursorId = (cursor: Cursor | undefined): Id | undefined => cursor?.after as Id | undefined;
const cursorId = <I extends Id<string>>(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<I>(after);
};
const page = <T extends { readonly id: Id }>(values: readonly T[], limit: number): Page<T, Cursor> => {
const page = <T extends { readonly id: Id<string> }>(values: readonly T[], limit: number): Page<T, Cursor> => {
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<string> | 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<Id> {
async mintId<I extends Id<string>>(): Promise<I> {
this.assertOpen();
if (!Number.isSafeInteger(this.nextId)) throw new Error("ID space is exhausted");
return this.nextId++;
return idFromNumber<I>(this.nextId++);
}
async conversation(id: Id, _context: Context): Promise<ConversationRecord | undefined> {
async conversation(id: ConversationId, _context: Context): Promise<ConversationRecord | undefined> {
this.assertOpen();
const row = getRow<JsonRow>(this.db.prepare("SELECT record FROM conversations WHERE id = ?"), id);
return row === undefined ? undefined : parseJson<ConversationRecord>(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<EntryId>(idOrConversationId)
: typeof idOrContext === "number"
? idFromNumber<EntryId>(idOrContext)
: undefined;
if (id === undefined) throw new TypeError("Storage.entry() requires an entry ID");
let conversation: ConversationRecord | undefined;
if (context !== undefined) {
const conversationId = idFromNumber<ConversationId>(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<EntryJsonRow>(this.db.prepare("SELECT record, commit_seq FROM entries WHERE id = ?"), id);
if (row === undefined) return undefined;
const entry = parseJson<EntryRecord>(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<EntryRecord & { readonly head: Id }>(row.record);
if (row !== undefined) return parseJson<EntryRecord & { readonly head: EntryId }>(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<StoredTask | undefined> {
async task(id: TaskId, _context: Context): Promise<StoredTask | undefined> {
this.assertOpen();
const row = getRow<JsonRow>(this.db.prepare("SELECT record FROM tasks WHERE id = ?"), id);
return row === undefined ? undefined : parseJson<StoredTask>(row.record);
@ -392,14 +411,14 @@ export class SqliteStorage implements Storage {
);
}
async submission(id: Id, _context: Context): Promise<SubmissionRecord | undefined> {
async submission(id: SubmissionId, _context: Context): Promise<SubmissionRecord | undefined> {
this.assertOpen();
const row = getRow<JsonRow>(this.db.prepare("SELECT record FROM submissions WHERE id = ?"), id);
return row === undefined ? undefined : parseJson<SubmissionRecord>(row.record);
}
async submissionByRequest(
conversationId: Id,
conversationId: ConversationId,
requestId: string,
_context: Context,
): Promise<SubmissionRecord | undefined> {
@ -434,7 +453,7 @@ export class SqliteStorage implements Storage {
return row === undefined ? undefined : parseJson<DocumentRecord>(row.record);
}
async document(id: Id, at: DocumentPoint, _context: Context): Promise<StoredDocument | undefined> {
async document(id: DocumentId, at: DocumentPoint, _context: Context): Promise<StoredDocument | undefined> {
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<JsonRow>(this.db.prepare("SELECT record FROM conversations WHERE id = ?"), id);
return row === undefined ? undefined : parseJson<ConversationRecord>(row.record);
}
private materializeDocument(id: Id, at: DocumentPoint): StoredDocument | undefined {
private materializeDocument(id: DocumentId, at: DocumentPoint): StoredDocument | undefined {
const row = getRow<JsonRow>(this.db.prepare("SELECT record FROM documents WHERE id = ?"), id);
if (row === undefined) return undefined;
const record = parseJson<DocumentRecord>(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<Id, TableName>();
const claimed = new Map<Id<string>, 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<Id, DocumentAction> {
const actions = new Map<Id, DocumentAction>();
private prepareDocumentActions(writes: readonly StorageWrite[]): Map<DocumentId, DocumentAction> {
const actions = new Map<DocumentId, DocumentAction>();
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<Id, DocumentAction>): void {
private checkDocumentActions(actions: ReadonlyMap<DocumentId, DocumentAction>): void {
const liveCounts = new Map<string, number>();
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<IdRow>(
const id = getRow<IdRow>(
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<DocumentId>(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<string>, table: TableName): void {
this.db.prepare("INSERT OR IGNORE INTO record_ids (id, record_type) VALUES (?, ?)").run(id, table);
}
private applyDocumentActions(actions: ReadonlyMap<Id, DocumentAction>, seq: Seq): void {
private applyDocumentActions(actions: ReadonlyMap<DocumentId, DocumentAction>, seq: Seq): void {
for (const [id, action] of actions) {
let content = action.content;
if (action.copy !== undefined) {

View file

@ -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<JsonValue, JsonValue, JsonValue>;
export type StorageBenchmarkDataset = {
readonly firstEntryId: Id;
readonly firstEntryId: EntryId;
readonly filteredTaskCount: number;
readonly exactDocumentId: Id;
readonly exactDocumentId: DocumentId;
readonly exactDocumentKey: string;
readonly replayDocumentIds: Readonly<Record<(typeof REPLAY_TAILS)[number], Id>>;
readonly historicalDocumentId: Id;
readonly replayDocumentIds: Readonly<Record<(typeof REPLAY_TAILS)[number], DocumentId>>;
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<JsonValue>, 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<StorageBenchmarkDataset> {
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<EntryId>();
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<TaskId<JsonValue>>(), 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<DocumentId>();
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<DocumentId>();
return { tail, id };
}),
);
@ -158,7 +169,7 @@ export async function seedStorageBenchmark(
);
}
const historicalDocumentId = await storage.mintId();
const historicalDocumentId = await storage.mintId<DocumentId>();
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<ConversationId>();
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<EntryId>()));
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<void>
Array.from({ length: 100 }, async (_, index) => ({
type: "entry" as const,
value: {
id: await storage.mintId(),
id: await storage.mintId<EntryId>(),
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<EntryId>();
await storage.commit(
[
{
@ -426,7 +440,7 @@ export const STORAGE_WRITE_BENCHMARKS: readonly StorageWriteBenchmark[] = [
async (_, index): Promise<StorageWrite> => ({
type: "entry",
value: {
id: await storage.mintId(),
id: await storage.mintId<EntryId>(),
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<EntryId>();
const taskId = await storage.mintId<TaskId<JsonValue>>();
const submissionId = await storage.mintId<SubmissionId>();
const documentId = await storage.mintId<DocumentId>();
const writes: readonly StorageWrite[] = [
{
type: "entry",

View file

@ -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<JsonValue, JsonValue, JsonValue>;
async function createRoot(storage: Storage): Promise<Id> {
async function createRoot(storage: Storage): Promise<ConversationId> {
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<JsonValue>, 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> = {}): EntryRecord {
function entry(
id: EntryId,
conversationId: ConversationId,
kind = "message",
extra: Partial<EntryRecord> = {},
): 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<ConversationId>()).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<EntryId>();
const taskId = await storage.mintId<TaskId<JsonValue>>();
const submissionId = await storage.mintId<SubmissionId>();
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<EntryId>();
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<EntryId>(), 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<EntryId>();
const taskId = await storage.mintId<TaskId<JsonValue>>();
const submissionId = await storage.mintId<SubmissionId>();
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<EntryId>();
const data = JSON.parse(
'{"__proto__":{"polluted":false},"constructor":{"label":"stored"},"toString":"value"}',
) as Record<string, JsonValue>;
@ -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<EntryId>(30), rootId) },
{ type: "entry", value: entry(idFromNumber<EntryId>(10), rootId) },
{
type: "entry",
value: entry(idFromNumber<EntryId>(20), rootId, "marker", {
head: idFromNumber<EntryId>(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<EntryId>();
const middleId = await storage.mintId<EntryId>();
const newestId = await storage.mintId<EntryId>();
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<EntryId>();
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<ConversationId>();
const thirdId = await storage.mintId<ConversationId>();
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<ConversationId>();
const firstTaskId = await storage.mintId<TaskId<JsonValue>>();
const secondTaskId = await storage.mintId<TaskId<JsonValue>>();
const firstId = await storage.mintId<ConversationId>();
const secondId = await storage.mintId<ConversationId>();
const thirdId = await storage.mintId<ConversationId>();
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<EntryId>();
const rootForkPoint = await storage.mintId<EntryId>();
const rootExcludedSameCommit = await storage.mintId<EntryId>();
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<ConversationId>();
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<EntryId>();
const childExcluded = await storage.mintId<EntryId>();
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<EntryId>();
await storage.commit([{ type: "entry", value: entry(rootExcludedLater, rootId) }], context);
const grandchildId = await storage.mintId();
const grandchildId = await storage.mintId<ConversationId>();
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<EntryId>();
const grandchildTail = await storage.mintId<EntryId>();
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<EntryId>();
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<EntryId>(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<EntryId>(999_999), context)).toBeUndefined();
await expect(storage.entry(idFromNumber<ConversationId>(999_999), rootFirst, context)).rejects.toThrow(
"Unknown conversation",
);
await expect(
storage.scanEntries({ conversationId: idFromNumber<ConversationId>(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<TaskId<JsonValue>>();
const secondId = await storage.mintId<TaskId<JsonValue>>();
const thirdId = await storage.mintId<TaskId<JsonValue>>();
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<ConversationId>();
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<SubmissionId>();
const secondId = await storage.mintId<SubmissionId>();
const otherConversationId = await storage.mintId<SubmissionId>();
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<EntryId>(),
};
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<SubmissionId>();
const failedId = await storage.mintId<SubmissionId>();
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<EntryId>(),
};
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<DocumentId>();
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<DocumentId>();
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<DocumentId>();
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<ConversationId>();
const secondChildId = await storage.mintId<ConversationId>();
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<DocumentId>();
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<DocumentId>();
const currentCopyId = await storage.mintId<DocumentId>();
const retiredCopyId = await storage.mintId<DocumentId>();
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<DocumentId>();
const latestCopyId = await storage.mintId<DocumentId>();
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<DocumentId>();
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<DocumentId>();
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<DocumentId>();
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<DocumentId>();
const secondId = await storage.mintId<DocumentId>();
const conversationId = await storage.mintId<DocumentId>();
const taskId = await storage.mintId<TaskId<JsonValue>>();
const taskSingletonId = await storage.mintId<DocumentId>();
const taskFamilyId = await storage.mintId<DocumentId>();
const taskOtherKindId = await storage.mintId<DocumentId>();
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<DocumentId>();
const secondId = await storage.mintId<DocumentId>();
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<DocumentId>();
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<TaskId<JsonValue>>();
const submissionId = await storage.mintId<SubmissionId>();
const documentId = await storage.mintId<DocumentId>();
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<EntryId>();
const conflictingDocumentId = await storage.mintId<DocumentId>();
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<TaskId<JsonValue>>();
const secondTaskId = await storage.mintId<TaskId<JsonValue>>();
const firstSubmissionId = await storage.mintId<SubmissionId>();
const secondSubmissionId = await storage.mintId<SubmissionId>();
const firstKindDocumentId = await storage.mintId<DocumentId>();
const secondKindDocumentId = await storage.mintId<DocumentId>();
const firstKeyDocumentId = await storage.mintId<DocumentId>();
const secondKeyDocumentId = await storage.mintId<DocumentId>();
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<EntryId>(100);
await storage.commit([{ type: "entry", value: entry(explicitEntryId, rootId) }], context);
expect(await storage.mintId()).toBe(101);
expect(await storage.mintId<EntryId>()).toBe(101);
await expect(
storage.commit([{ type: "task", value: pendingTask(explicitEntryId, rootId) }], context),
storage.commit(
[{ type: "task", value: pendingTask(idFromNumber<TaskId<JsonValue>>(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<EntryId>(Number.MAX_SAFE_INTEGER), rootId, "last-id") }],
context,
);
await expect(storage.mintId<EntryId>()).rejects.toThrow("ID space is exhausted");
await expect(storage.mintId<EntryId>()).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<ConversationId>()).rejects.toThrow("closed");
}),
];
}

View file

@ -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<Kind extends string, Type = unknown> = number & {
readonly [idBrand]: {
readonly kind: Kind;
readonly type: Type;
};
};
export type ConversationId = Id<"conversation">;
export type EntryId = Id<"entry">;
export type TaskId<Result = unknown> = 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<I, S extends { phase: string }, R, H extends object> {
readonly definition: TaskDefinition<I, S, R, H>;
}
/** Task ID carrying its result type for typed waits. */
export type TaskRef<R> = { 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<EntryRecord, "id" | "conversationId" | "byTaskId" | "head"> & {
/** `"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<S, R> =
};
/** Identity, definition, and scheduling fields shared by every task state. */
type TaskRecordBase<I> = {
readonly id: Id;
readonly conversationId: Id;
type TaskRecordBase<I, R> = {
readonly id: TaskId<R>;
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<I> = {
/** 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<I> = {
};
/** Complete replacement record for one durable task state machine. */
export type TaskRecord<I, S, R> = TaskRecordBase<I> &
export type TaskRecord<I, S, R> = TaskRecordBase<I, R> &
(
| {
readonly state: Extract<TaskState<S, R>, { readonly status: "pending" | "running" }>;
@ -374,7 +389,7 @@ export type TaskRecord<I, S, R> = TaskRecordBase<I> &
/** 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<Record<string, JsonValue>>;
/** 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<ConversationRecord | undefined>;
entry(id: Id): Promise<EntryRecord | undefined>;
task(id: Id): Promise<TaskRecord<JsonValue, JsonValue, JsonValue> | undefined>;
conversation(id: ConversationId): Promise<ConversationRecord | undefined>;
entry(id: EntryId): Promise<EntryRecord | undefined>;
task(id: TaskId): Promise<TaskRecord<JsonValue, JsonValue, JsonValue> | undefined>;
scanConversations(
query: ConversationQuery,
limit: number,
@ -533,23 +548,27 @@ export interface Tx {
cursor?: Cursor,
): Promise<Page<TaskRecord<JsonValue, JsonValue, JsonValue>, Cursor>>;
/** Create an independent ownerless conversation. */
createConversation(): Promise<ConversationRecord>;
/** Create an ownerless history fork at one concrete visible entry. */
forkConversation(parentConversationId: Id, at: Id): Promise<ConversationRecord>;
/** Create a conversation with explicitly selected ownership. */
createConversation(options: { readonly ownership: ConversationOwnership }): Promise<ConversationRecord>;
/** Create a history fork at one concrete visible entry with explicitly selected ownership. */
forkConversation(
parentConversationId: ConversationId,
at: EntryId,
options: { readonly ownership: ConversationOwnership },
): Promise<ConversationRecord>;
/** Returned records are Session-owned immutable values and may be shared with commit listeners. */
appendEntry(conversationId: Id, value: EntryDraft): Promise<EntryRecord>;
appendEntry(conversationId: ConversationId, value: EntryDraft): Promise<EntryRecord>;
createTask<I, S extends { phase: string }, R, H extends object>(
task: Task<I, S, R, H>,
input: I,
options?: TaskOptions,
): Promise<TaskRef<R>>;
): Promise<TaskId<R>>;
/** Replace one task record completely. */
setTask(value: TaskRecord<JsonValue, JsonValue, JsonValue>): void;
doc<T extends JsonObject>(token: SessionDocToken<T>): Promise<Draft<T>>;
doc<T extends JsonObject>(token: ConversationDocToken<T>, conversationId: Id): Promise<Draft<T>>;
doc<T extends JsonObject>(token: TaskDocToken<T>, taskId: Id): Promise<Draft<T>>;
doc<T extends JsonObject>(token: ConversationDocToken<T>, conversationId: ConversationId): Promise<Draft<T>>;
doc<T extends JsonObject>(token: TaskDocToken<T>, taskId: TaskId): Promise<Draft<T>>;
doc<T extends JsonObject, I extends JsonValue>(
token: SessionDocFamilyToken<T, I>,
key: string,
@ -557,29 +576,29 @@ export interface Tx {
): Promise<Draft<T>>;
doc<T extends JsonObject, I extends JsonValue>(
token: ConversationDocFamilyToken<T, I>,
conversationId: Id,
conversationId: ConversationId,
key: string,
seed: I,
): Promise<Draft<T>>;
doc<T extends JsonObject, I extends JsonValue>(
token: TaskDocFamilyToken<T, I>,
taskId: Id,
taskId: TaskId,
key: string,
seed: I,
): Promise<Draft<T>>;
retireDoc<T extends JsonObject>(token: SessionDocToken<T>): Promise<void>;
retireDoc<T extends JsonObject>(token: ConversationDocToken<T>, conversationId: Id): Promise<void>;
retireDoc<T extends JsonObject>(token: TaskDocToken<T>, taskId: Id): Promise<void>;
retireDoc<T extends JsonObject>(token: ConversationDocToken<T>, conversationId: ConversationId): Promise<void>;
retireDoc<T extends JsonObject>(token: TaskDocToken<T>, taskId: TaskId): Promise<void>;
retireDoc<T extends JsonObject, I extends JsonValue>(token: SessionDocFamilyToken<T, I>, key: string): Promise<void>;
retireDoc<T extends JsonObject, I extends JsonValue>(
token: ConversationDocFamilyToken<T, I>,
conversationId: Id,
conversationId: ConversationId,
key: string,
): Promise<void>;
retireDoc<T extends JsonObject, I extends JsonValue>(
token: TaskDocFamilyToken<T, I>,
taskId: Id,
taskId: TaskId,
key: string,
): Promise<void>;
}
@ -594,12 +613,12 @@ export interface Session {
snapshot<T extends JsonObject>(token: SessionDocToken<T>, context: Context): Promise<Readonly<T> | undefined>;
snapshot<T extends JsonObject>(
token: ConversationDocToken<T>,
conversationId: Id,
conversationId: ConversationId,
context: Context,
): Promise<Readonly<T> | undefined>;
snapshot<T extends JsonObject>(
token: TaskDocToken<T>,
taskId: Id,
taskId: TaskId,
context: Context,
): Promise<Readonly<T> | undefined>;
snapshot<T extends JsonObject, I extends JsonValue>(
@ -609,28 +628,28 @@ export interface Session {
): Promise<Readonly<T> | undefined>;
snapshot<T extends JsonObject, I extends JsonValue>(
token: ConversationDocFamilyToken<T, I>,
conversationId: Id,
conversationId: ConversationId,
key: string,
context: Context,
): Promise<Readonly<T> | undefined>;
snapshot<T extends JsonObject, I extends JsonValue>(
token: TaskDocFamilyToken<T, I>,
taskId: Id,
taskId: TaskId,
key: string,
context: Context,
): Promise<Readonly<T> | undefined>;
snapshotAsOf<T extends JsonObject>(
token: RewindableConversationDocToken<T>,
conversationId: Id,
at: Id,
conversationId: ConversationId,
at: EntryId,
context: Context,
): Promise<Readonly<T> | undefined>;
snapshotAsOf<T extends JsonObject, I extends JsonValue>(
token: RewindableConversationDocFamilyToken<T, I>,
conversationId: Id,
conversationId: ConversationId,
key: string,
at: Id,
at: EntryId,
context: Context,
): Promise<Readonly<T> | undefined>;
}
@ -649,11 +668,11 @@ export interface Storage {
*/
commit(writes: readonly StorageWrite[], context: Context): Promise<Seq>;
/** Return a fresh candidate from the Session-global record ID namespace. */
mintId(): Promise<Id>;
/** Return a fresh branded candidate from the Session-global numeric ID namespace. */
mintId<I extends Id<string>>(): Promise<I>;
/** Look up one conversation by exact ID. */
conversation(id: Id, context: Context): Promise<ConversationRecord | undefined>;
conversation(id: ConversationId, context: Context): Promise<ConversationRecord | undefined>;
/** Scan conversations in ascending ID order. */
scanConversations(
@ -664,11 +683,11 @@ export interface Storage {
): Promise<Page<ConversationRecord, Cursor>>;
/** 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<Page<EntryRecord, Cursor>>;
/** Look up the latest complete record for one task. */
task(id: Id, context: Context): Promise<TaskRecord<JsonValue, JsonValue, JsonValue> | undefined>;
task(id: TaskId, context: Context): Promise<TaskRecord<JsonValue, JsonValue, JsonValue> | undefined>;
/** Scan task records matching every supplied filter. */
scanTasks(
@ -702,16 +721,20 @@ export interface Storage {
): Promise<Page<TaskRecord<JsonValue, JsonValue, JsonValue>, Cursor>>;
/** Look up the latest complete record for one admitted submission. */
submission(id: Id, context: Context): Promise<SubmissionRecord | undefined>;
submission(id: SubmissionId, context: Context): Promise<SubmissionRecord | undefined>;
/** Find a submission by its conversation-scoped host deduplication key. */
submissionByRequest(conversationId: Id, requestId: string, context: Context): Promise<SubmissionRecord | undefined>;
submissionByRequest(
conversationId: ConversationId,
requestId: string,
context: Context,
): Promise<SubmissionRecord | undefined>;
/** Resolve the incarnation occupying one exact logical address at the selected point. */
findDocument(address: DocumentAddress, at: DocumentPoint, context: Context): Promise<DocumentRecord | undefined>;
/** 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<StoredDocument | undefined>;
document(id: DocumentId, at: DocumentPoint, context: Context): Promise<StoredDocument | undefined>;
/** Scan incarnations alive in one exact scope at the selected point. */
scanDocuments(

View file

@ -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<I extends Id<string>>(): Promise<I> {
return this.current.mintId<I>();
}
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<Storage["entry"]>;
entry(conversationId: number, id: number, readContext: Context): ReturnType<Storage["entry"]>;
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<Storage["entry"]>;
entry(conversationId: ConversationId, id: EntryId, readContext: Context): ReturnType<Storage["entry"]>;
entry(idOrConversationId: EntryId | ConversationId, idOrContext: EntryId | Context, readContext?: Context) {
if (readContext === undefined) {
return this.current.entry(idFromNumber<EntryId>(idOrConversationId), idOrContext as Context);
}
if (typeof idOrContext !== "number") throw new TypeError("Storage.entry() requires an entry ID");
return this.current.entry(
idFromNumber<ConversationId>(idOrConversationId),
idFromNumber<EntryId>(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<JsonValue>, 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<JsonValue>): StoredTask {
return {
id,
conversationId: ROOT_CONVERSATION_ID,
@ -143,7 +163,7 @@ async function createRoot(storage: Storage): Promise<void> {
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<TaskId<JsonValue>>();
const documentId = await storage.mintId<DocumentId>();
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<TaskId<JsonValue>>();
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<TaskId<JsonValue>>();
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<EntryId>();
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<DocumentId>();
const secondId = await storage.mintId<DocumentId>();
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<DocumentId>();
const secondId = await storage.mintId<DocumentId>();
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<DocumentId>();
const secondId = await storage.mintId<DocumentId>();
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<EntryId>(),
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<TaskId<JsonValue>>();
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<DocumentId>();
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<TaskId<JsonValue>>();
const id = await storage.mintId<DocumentId>();
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<DocumentId>();
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<DocumentId>();
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<TaskId<JsonValue>>();
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<DocumentId>();
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<DocumentId>();
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<TaskId<JsonValue>>();
const sessionId = await storage.mintId<DocumentId>();
const latestId = await storage.mintId<DocumentId>();
const taskDocumentId = await storage.mintId<DocumentId>();
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<DocumentId>();
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<TaskId<JsonValue>>();
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<DocumentId>();
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<DocumentId>();
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<DocumentId>();
await storage.commit(
[
{

View file

@ -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<EntryId>(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] });
});

View file

@ -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<string, { conversationId: ConversationId; requestId: string }>;
}>({
kind: "my-agent-registry",
version: 1,
scope: "conversation",
history: "latest",
fork: "initial",
initial: () => ({ agents: {} }),
});
const SupervisorTask: Task<null, { phase: "ready" }, null, object> = {
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);

View file

@ -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";

View file

@ -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<void> => {
const check = async (tx: Tx, conversationId: ConversationId, taskId: TaskId): Promise<void> => {
expectTypeOf(await tx.doc(SessionDoc)).toEqualTypeOf<Draft<State>>();
expectTypeOf(await tx.doc(LatestDoc, conversationId)).toEqualTypeOf<Draft<State>>();
expectTypeOf(await tx.doc(RewindableDoc, conversationId)).toEqualTypeOf<Draft<State>>();
@ -97,13 +100,18 @@ describe("document definitions", () => {
expect(check).toBeTypeOf("function");
expectTypeOf(await session.snapshot(SessionDoc, context)).toEqualTypeOf<Readonly<State> | undefined>();
expectTypeOf(await session.snapshot(LatestDoc, 1, context)).toEqualTypeOf<Readonly<State> | undefined>();
expectTypeOf(await session.snapshot(TaskDoc, 1, context)).toEqualTypeOf<Readonly<State> | undefined>();
expectTypeOf(await session.snapshot(SessionFamily, "k", context)).toEqualTypeOf<Readonly<State> | undefined>();
expectTypeOf(await session.snapshot(ConversationFamily, 1, "k", context)).toEqualTypeOf<
const taskId = idFromNumber<TaskId>(1);
expectTypeOf(await session.snapshot(LatestDoc, ROOT_CONVERSATION_ID, context)).toEqualTypeOf<
Readonly<State> | undefined
>();
expectTypeOf(await session.snapshot(TaskDoc, taskId, context)).toEqualTypeOf<Readonly<State> | undefined>();
expectTypeOf(await session.snapshot(SessionFamily, "k", context)).toEqualTypeOf<Readonly<State> | undefined>();
expectTypeOf(await session.snapshot(ConversationFamily, ROOT_CONVERSATION_ID, "k", context)).toEqualTypeOf<
Readonly<State> | undefined
>();
expectTypeOf(await session.snapshot(TaskFamily, taskId, "k", context)).toEqualTypeOf<
Readonly<State> | undefined
>();
expectTypeOf(await session.snapshot(TaskFamily, 1, "k", context)).toEqualTypeOf<Readonly<State> | undefined>();
// @ts-expect-error snapshots never take a creation seed
await session.snapshot(SessionFamily, "k", 1, context).catch(() => undefined);
await session.close(context);

View file

@ -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<Member, string>({
},
});
async function setupLive(): Promise<ReturnType<typeof openTestSession> & { readonly conversationId: Id }> {
async function setupLive(): Promise<ReturnType<typeof openTestSession> & { 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<TaskId<JsonValue>>(999),
conversationId,
kind: "missing",
version: 1,

View file

@ -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 });

View file

@ -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<Id> {
override mintId<I extends Id<string>>(): Promise<I> {
this.mintCount++;
return super.mintId();
return super.mintId<I>();
}
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<Id> {
return session.commit(async (tx) => (await tx.createConversation()).id, context);
export async function createConversation(session: SessionKernel): Promise<ConversationId> {
return session.commit(async (tx) => (await tx.createConversation({ ownership: { kind: "ownerless" } })).id, context);
}
/** Resolve after pending microtasks and one macrotask turn. */

View file

@ -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<typeof openTestSession>["session"],
conversationId: Id,
conversationId: ConversationId,
withDocument = false,
): Promise<Id> {
): Promise<TaskId<{ ok: boolean }>> {
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<TaskId>(1))).rejects.toThrow(
"Tx.task() cannot read tables after the first table write",
);
await expect(tx.entry(idFromNumber<EntryId>(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<ConversationId>(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<ConversationId>(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<TaskId>(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<TaskId>(4242)).then(() => undefined), context),
).rejects.toThrow("Task 4242 does not exist");
await expect(
session.commit((tx) => tx.doc(NotesDoc, idFromNumber<ConversationId>(4242)).then(() => undefined), context),
).rejects.toThrow("Conversation 4242 does not exist");
});
});

View file

@ -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<EntryId>(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<EntryId>(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<EntryId>(2), BACKGROUND_CONTEXT))?.entry.kind).toBe("cached");
expect((await storage.entry(idFromNumber<EntryId>(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<EntryId>(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<EntryId>(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<EntryId>()).toBe(2);
database.settle();
await expect(committing).resolves.toBe(1);
expect(await storage.mintId()).toBe(101);
expect(await storage.mintId<EntryId>()).toBe(101);
database.controlNextSettlement("reject");
const rejected = storage.commit(
[{ type: "entry", value: { id: 200, conversationId: 1, kind: "rejected" } }],
[
{
type: "entry",
value: { id: idFromNumber<EntryId>(200), conversationId: ROOT_CONVERSATION_ID, kind: "rejected" },
},
],
BACKGROUND_CONTEXT,
);
expect(await storage.mintId()).toBe(102);
expect(await storage.mintId<EntryId>()).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<EntryId>()).toBe(103);
expect(await storage.entry(idFromNumber<EntryId>(200), BACKGROUND_CONTEXT)).toBeUndefined();
await storage.close(BACKGROUND_CONTEXT);
});
});

View file

@ -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<string>();
@ -102,7 +103,7 @@ describe("durable SQLite migrations", () => {
{
type: "entry",
value: {
id: 2,
id: idFromNumber<EntryId>(2),
conversationId: ROOT_CONVERSATION_ID,
kind: "retained",
data: { retained: true },

View file

@ -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<I extends Id<string>>(): Promise<I> {
return this.current.mintId<I>();
}
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<Storage["entry"]>;
entry(conversationId: number, id: number, readContext: Context): ReturnType<Storage["entry"]>;
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<Storage["entry"]>;
entry(conversationId: ConversationId, id: EntryId, readContext: Context): ReturnType<Storage["entry"]>;
entry(idOrConversationId: EntryId | ConversationId, idOrContext: EntryId | Context, readContext?: Context) {
if (readContext === undefined) {
return this.current.entry(idFromNumber<EntryId>(idOrConversationId), idOrContext as Context);
}
if (typeof idOrContext !== "number") throw new TypeError("Storage.entry() requires an entry ID");
return this.current.entry(
idFromNumber<ConversationId>(idOrConversationId),
idFromNumber<EntryId>(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<number> {
async function createRoot(storage: Storage): Promise<Seq> {
return storage.commit([{ type: "conversation", value: { id: ROOT_CONVERSATION_ID } }], context);
}
function pendingTask(id: number): TaskRecord<JsonValue, JsonValue, JsonValue> {
function pendingTask(id: TaskId<JsonValue>): TaskRecord<JsonValue, JsonValue, JsonValue> {
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<EntryId>();
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<EntryId>()).toBe(entryId + 1);
expect(
await reopened.commit(
[{ type: "task", value: pendingTask(await reopened.mintId<TaskId<JsonValue>>()) }],
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<DocumentId>();
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<DocumentId>();
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<EntryId>();
const transientTaskId = await storage.mintId<TaskId<JsonValue>>();
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<EntryId>();
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<DocumentId>();
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<DocumentId>();
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<EntryId>(), 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<DocumentId>();
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<EntryId>(), ROOT_CONVERSATION_ID, {
index,
text: "x".repeat(1_024),
}),
},
],
context,
);
}
const documentId = await storage.mintId();
const documentId = await storage.mintId<DocumentId>();
await storage.commit(
[
{

View file

@ -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<StorageBenchmarkBackend, "memory">;
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;

View file

@ -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<ConversationId>(1);
const entryId = idFromNumber<EntryId>(2);
const answerId = idFromNumber<EntryId>(3);
const taskId = idFromNumber<TaskId<number>>(4);
const submissionId = idFromNumber<SubmissionId>(5);
const documentId = idFromNumber<DocumentId>(6);
const seq = seqFromNumber(1);
type TaskResult<I> = I extends TaskId<infer R> ? 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<number>();
expectTypeOf<TaskResult<typeof taskId>>().toEqualTypeOf<number>();
const compileTimeFailures = () => {
const widenedTask: TaskId = idFromNumber<TaskId<{ ok: boolean }>>(7);
// @ts-expect-error an erased task result cannot be narrowed without a typed source
const narrowedTask: TaskId<string> = 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<number>();
expectTypeOf(completedInput.answer).toEqualTypeOf<EntryId>();
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<null, { phase: string }, number> = {
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,
];
};