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