mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(durable): add typed IDs and explicit ownership
This commit is contained in:
parent
a6ca861024
commit
04b5bad666
28 changed files with 1473 additions and 845 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
11
packages/durable/src/ids.ts
Normal file
11
packages/durable/src/ids.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
[
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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] });
|
||||
});
|
||||
|
|
|
|||
95
packages/durable/test/scratch.ts
Normal file
95
packages/durable/test/scratch.ts
Normal 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);
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
[
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue