@earendil-works/pi-durable 0.87.0 → 0.87.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +29 -0
  2. package/dist/index.d.ts +2 -2
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +1 -1
  5. package/dist/index.js.map +1 -1
  6. package/dist/{memory-storage.d.ts → storage/memory.d.ts} +12 -6
  7. package/dist/storage/memory.d.ts.map +1 -0
  8. package/dist/{memory-storage.js → storage/memory.js} +236 -24
  9. package/dist/storage/memory.js.map +1 -0
  10. package/dist/storage/sqlite/database.d.ts +26 -0
  11. package/dist/storage/sqlite/database.d.ts.map +1 -0
  12. package/dist/storage/sqlite/database.js +2 -0
  13. package/dist/storage/sqlite/database.js.map +1 -0
  14. package/dist/storage/sqlite/index.d.ts +4 -0
  15. package/dist/storage/sqlite/index.d.ts.map +1 -0
  16. package/dist/storage/sqlite/index.js +3 -0
  17. package/dist/storage/sqlite/index.js.map +1 -0
  18. package/dist/storage/sqlite/migrations.d.ts +11 -0
  19. package/dist/storage/sqlite/migrations.d.ts.map +1 -0
  20. package/dist/storage/sqlite/migrations.js +104 -0
  21. package/dist/storage/sqlite/migrations.js.map +1 -0
  22. package/dist/storage/sqlite/node.d.ts +25 -0
  23. package/dist/storage/sqlite/node.d.ts.map +1 -0
  24. package/dist/storage/sqlite/node.js +97 -0
  25. package/dist/storage/sqlite/node.js.map +1 -0
  26. package/dist/storage/sqlite/storage.d.ts +45 -0
  27. package/dist/storage/sqlite/storage.d.ts.map +1 -0
  28. package/dist/storage/sqlite/storage.js +524 -0
  29. package/dist/storage/sqlite/storage.js.map +1 -0
  30. package/dist/types.d.ts +113 -34
  31. package/dist/types.d.ts.map +1 -1
  32. package/dist/types.js.map +1 -1
  33. package/docs/pico-v5-chord-usage.md +64 -63
  34. package/docs/pico-v5-handoff.md +92 -57
  35. package/docs/pico-v5.md +519 -350
  36. package/package.json +20 -3
  37. package/dist/memory-storage.d.ts.map +0 -1
  38. package/dist/memory-storage.js.map +0 -1
package/README.md CHANGED
@@ -8,6 +8,35 @@ This package contains the Pico runtime. Its current public API provides the dura
8
8
  import { MemoryStorage, ROOT_CONVERSATION_ID } from "@earendil-works/pi-durable";
9
9
  ```
10
10
 
11
+ The root export is runtime-neutral. Storage implementations also have explicit subpaths:
12
+
13
+ ```ts
14
+ import { MemoryStorage } from "@earendil-works/pi-durable/storage/memory";
15
+ ```
16
+
17
+ Node applications can open file-backed SQLite through its Node-only subpath:
18
+
19
+ ```ts
20
+ import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node";
21
+
22
+ const storage = await openNodeSqliteStorage("./session.sqlite");
23
+ ```
24
+
25
+ The portable SQLite core, minimal database facade, and ordered schema migrations are exported from `@earendil-works/pi-durable/storage/sqlite`. Adapters for synchronous SQLite environments such as Bun and Cloudflare Durable Objects can implement that facade without importing Node APIs. Remote asynchronous APIs such as Cloudflare D1 cannot implement this synchronous facade; they require a dedicated `Storage` backend.
26
+
27
+ The Node adapter uses WAL mode with `synchronous = NORMAL` and checkpoints the WAL on close. Acknowledged commits survive process crashes, but the newest commits may be lost after a power or host failure. One `SqliteStorage` owner must serialize writes to a database file; cross-process ID allocation is not supported.
28
+
29
+ ## Storage benchmarks
30
+
31
+ From this package directory:
32
+
33
+ ```sh
34
+ npm run bench:storage
35
+ npm run bench:storage:memory
36
+ ```
37
+
38
+ The timing suite compares memory and file-backed SQLite across representative commits, indexed reads, pagination, fork traversal, document replay, historical reads, and SQLite reopen. The memory suite measures each backend in a separate process at 1k and 10k scales and reports heap, RSS, external memory, and SQLite file/page metrics. These deterministic synthetic workloads are baselines for regression analysis, not production capacity limits or CI pass/fail thresholds.
39
+
11
40
  The normative design and implementation sequence are in:
12
41
 
13
42
  - [`docs/pico-v5.md`](docs/pico-v5.md)
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { MemoryStorage } from "./memory-storage.ts";
2
- export type { ContextEdit, ConversationRecord, Cursor, DocumentCreate, DocumentRecord, EntryDraft, EntryQuery, EntryRecord, Id, Input, Page, Seq, Storage, StorageWrite, StoredError, TaskOutcome, TaskQuery, TaskRecord, TaskState, } from "./types.ts";
1
+ export { MemoryStorage } from "./storage/memory.ts";
2
+ export type { ContextEdit, ConversationRecord, Cursor, DocumentAddress, DocumentContent, DocumentCreate, DocumentPoint, DocumentQuery, DocumentRecord, EntryDraft, EntryQuery, EntryRecord, Id, JsonObject, Page, Seq, Storage, StorageWrite, StoredDocument, SubmissionCreate, SubmissionRecord, TaskOutcome, TaskOutcomeError, TaskQuery, TaskRecord, TaskState, } from "./types.ts";
3
3
  export { ROOT_CONVERSATION_ID } from "./types.ts";
4
4
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACpD,YAAY,EACX,WAAW,EACX,kBAAkB,EAClB,MAAM,EACN,cAAc,EACd,cAAc,EACd,UAAU,EACV,UAAU,EACV,WAAW,EACX,EAAE,EACF,KAAK,EACL,IAAI,EACJ,GAAG,EACH,OAAO,EACP,YAAY,EACZ,WAAW,EACX,WAAW,EACX,SAAS,EACT,UAAU,EACV,SAAS,GACT,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC","sourcesContent":["export { MemoryStorage } from \"./memory-storage.ts\";\nexport type {\n\tContextEdit,\n\tConversationRecord,\n\tCursor,\n\tDocumentCreate,\n\tDocumentRecord,\n\tEntryDraft,\n\tEntryQuery,\n\tEntryRecord,\n\tId,\n\tInput,\n\tPage,\n\tSeq,\n\tStorage,\n\tStorageWrite,\n\tStoredError,\n\tTaskOutcome,\n\tTaskQuery,\n\tTaskRecord,\n\tTaskState,\n} from \"./types.ts\";\nexport { ROOT_CONVERSATION_ID } from \"./types.ts\";\n"]}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACpD,YAAY,EACX,WAAW,EACX,kBAAkB,EAClB,MAAM,EACN,eAAe,EACf,eAAe,EACf,cAAc,EACd,aAAa,EACb,aAAa,EACb,cAAc,EACd,UAAU,EACV,UAAU,EACV,WAAW,EACX,EAAE,EACF,UAAU,EACV,IAAI,EACJ,GAAG,EACH,OAAO,EACP,YAAY,EACZ,cAAc,EACd,gBAAgB,EAChB,gBAAgB,EAChB,WAAW,EACX,gBAAgB,EAChB,SAAS,EACT,UAAU,EACV,SAAS,GACT,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC","sourcesContent":["export { MemoryStorage } from \"./storage/memory.ts\";\nexport type {\n\tContextEdit,\n\tConversationRecord,\n\tCursor,\n\tDocumentAddress,\n\tDocumentContent,\n\tDocumentCreate,\n\tDocumentPoint,\n\tDocumentQuery,\n\tDocumentRecord,\n\tEntryDraft,\n\tEntryQuery,\n\tEntryRecord,\n\tId,\n\tJsonObject,\n\tPage,\n\tSeq,\n\tStorage,\n\tStorageWrite,\n\tStoredDocument,\n\tSubmissionCreate,\n\tSubmissionRecord,\n\tTaskOutcome,\n\tTaskOutcomeError,\n\tTaskQuery,\n\tTaskRecord,\n\tTaskState,\n} from \"./types.ts\";\nexport { ROOT_CONVERSATION_ID } from \"./types.ts\";\n"]}
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
- export { MemoryStorage } from "./memory-storage.js";
1
+ export { MemoryStorage } from "./storage/memory.js";
2
2
  export { ROOT_CONVERSATION_ID } from "./types.js";
3
3
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAsBpD,OAAO,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC","sourcesContent":["export { MemoryStorage } from \"./memory-storage.ts\";\nexport type {\n\tContextEdit,\n\tConversationRecord,\n\tCursor,\n\tDocumentCreate,\n\tDocumentRecord,\n\tEntryDraft,\n\tEntryQuery,\n\tEntryRecord,\n\tId,\n\tInput,\n\tPage,\n\tSeq,\n\tStorage,\n\tStorageWrite,\n\tStoredError,\n\tTaskOutcome,\n\tTaskQuery,\n\tTaskRecord,\n\tTaskState,\n} from \"./types.ts\";\nexport { ROOT_CONVERSATION_ID } from \"./types.ts\";\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AA6BpD,OAAO,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC","sourcesContent":["export { MemoryStorage } from \"./storage/memory.ts\";\nexport type {\n\tContextEdit,\n\tConversationRecord,\n\tCursor,\n\tDocumentAddress,\n\tDocumentContent,\n\tDocumentCreate,\n\tDocumentPoint,\n\tDocumentQuery,\n\tDocumentRecord,\n\tEntryDraft,\n\tEntryQuery,\n\tEntryRecord,\n\tId,\n\tJsonObject,\n\tPage,\n\tSeq,\n\tStorage,\n\tStorageWrite,\n\tStoredDocument,\n\tSubmissionCreate,\n\tSubmissionRecord,\n\tTaskOutcome,\n\tTaskOutcomeError,\n\tTaskQuery,\n\tTaskRecord,\n\tTaskState,\n} from \"./types.ts\";\nexport { ROOT_CONVERSATION_ID } from \"./types.ts\";\n"]}
@@ -1,5 +1,5 @@
1
1
  import type { Context, JsonValue } from "@earendil-works/chord";
2
- import type { ConversationRecord, Cursor, EntryQuery, EntryRecord, Id, Input, Page, Seq, Storage, StorageWrite, TaskQuery, TaskRecord } from "./types.ts";
2
+ import type { ConversationRecord, Cursor, DocumentAddress, DocumentPoint, DocumentQuery, DocumentRecord, EntryQuery, EntryRecord, Id, Page, Seq, Storage, StorageWrite, StoredDocument, SubmissionRecord, TaskQuery, TaskRecord } from "../types.ts";
3
3
  type StoredTask = TaskRecord<JsonValue, JsonValue, JsonValue>;
4
4
  /**
5
5
  * Detached in-memory reference implementation of `Storage`.
@@ -13,7 +13,7 @@ export declare class MemoryStorage implements Storage {
13
13
  private nextSeq;
14
14
  private closed;
15
15
  commit(writes: readonly StorageWrite[], _context: Context): Promise<Seq>;
16
- mintId(): Id;
16
+ mintId(): Promise<Id>;
17
17
  conversation(id: Id, _context: Context): Promise<ConversationRecord | undefined>;
18
18
  scanConversations(cursor: Cursor | undefined, limit: number, _context: Context): Promise<Page<ConversationRecord, Cursor>>;
19
19
  entry(id: Id, _context: Context): Promise<{
@@ -26,12 +26,18 @@ export declare class MemoryStorage implements Storage {
26
26
  scanEntries(query: EntryQuery, cursor: Cursor | undefined, limit: number, _context: Context): Promise<Page<EntryRecord, Cursor>>;
27
27
  task(id: Id, _context: Context): Promise<StoredTask | undefined>;
28
28
  scanTasks(query: TaskQuery, cursor: Cursor | undefined, limit: number, _context: Context): Promise<Page<StoredTask, Cursor>>;
29
- input(id: Id, _context: Context): Promise<Input | undefined>;
30
- inputByRequest(conversationId: Id, requestId: string, _context: Context): Promise<Input | undefined>;
29
+ submission(id: Id, _context: Context): Promise<SubmissionRecord | undefined>;
30
+ submissionByRequest(conversationId: Id, requestId: string, _context: Context): Promise<SubmissionRecord | undefined>;
31
+ findDocument(address: DocumentAddress, at: DocumentPoint, _context: Context): Promise<DocumentRecord | undefined>;
32
+ document(id: Id, at: DocumentPoint, _context: Context): Promise<StoredDocument | undefined>;
33
+ scanDocuments(query: DocumentQuery, cursor: Cursor | undefined, limit: number, _context: Context): Promise<Page<DocumentRecord, Cursor>>;
31
34
  close(_context: Context): Promise<void>;
32
35
  private visibleEntries;
33
- private checkImmutableIds;
36
+ private checkGlobalIds;
37
+ private prepareDocumentActions;
38
+ private checkDocumentActions;
39
+ private applyDocumentActions;
34
40
  private assertOpen;
35
41
  }
36
42
  export {};
37
- //# sourceMappingURL=memory-storage.d.ts.map
43
+ //# sourceMappingURL=memory.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"memory.d.ts","sourceRoot":"","sources":["../../src/storage/memory.ts"],"names":[],"mappings":"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","sourcesContent":["import type { Context, JsonValue } from \"@earendil-works/chord\";\nimport { applyImmutable } from \"@earendil-works/chord/delta\";\nimport type {\n\tConversationRecord,\n\tCursor,\n\tDocumentAddress,\n\tDocumentContent,\n\tDocumentCreate,\n\tDocumentPoint,\n\tDocumentQuery,\n\tDocumentRecord,\n\tEntryQuery,\n\tEntryRecord,\n\tId,\n\tJsonObject,\n\tPage,\n\tSeq,\n\tStorage,\n\tStorageWrite,\n\tStoredDocument,\n\tSubmissionRecord,\n\tTaskQuery,\n\tTaskRecord,\n} from \"../types.ts\";\n\ntype StoredTask = TaskRecord<JsonValue, JsonValue, JsonValue>;\ntype TaskStatus = StoredTask[\"state\"][\"status\"];\ntype TableName = \"conversation\" | \"entry\" | \"task\" | \"submission\" | \"document\";\ntype DocumentRevision = DocumentContent & { readonly seq: Seq };\ntype StoredDocumentState = {\n\trecord: DocumentRecord;\n\trevisions: DocumentRevision[];\n};\ntype DocumentAction = {\n\tcreate?: DocumentCreate;\n\tcontent?: DocumentContent;\n\tretire: boolean;\n};\n\ntype DocumentAddressIndex = {\n\tids: Id[];\n\tcurrentId?: Id;\n};\n\ntype State = {\n\tconversations: Map<Id, ConversationRecord>;\n\tconversationIds: Id[];\n\tentries: Map<Id, EntryRecord>;\n\tentryIds: Map<Id, Id[]>;\n\theadEntryIds: Map<Id, Id[]>;\n\tentryCommitSeqs: Map<Id, Seq>;\n\ttasks: Map<Id, StoredTask>;\n\ttaskIds: Id[];\n\ttaskIdsByStatus: Record<TaskStatus, Id[]>;\n\tsubmissions: Map<Id, SubmissionRecord>;\n\tsubmissionIdsByRequest: Map<Id, Map<string, Id>>;\n\tdocuments: Map<Id, StoredDocumentState>;\n\tdocumentAddresses: Map<string, DocumentAddressIndex>;\n\tdocumentIdsByScope: Map<string, Id[]>;\n};\n\nconst clone = <T>(value: T): T => {\n\tif (value === null || typeof value !== \"object\") return value;\n\tif (Array.isArray(value)) return value.map((item) => clone(item)) as T;\n\tconst source = value as Record<string, unknown>;\n\tconst nullPrototype = Object.getPrototypeOf(value) === null;\n\tconst result = (nullPrototype ? Object.create(null) : {}) as Record<string, unknown>;\n\tfor (const key of Object.keys(source)) {\n\t\tconst copied = clone(source[key]);\n\t\tif (!nullPrototype && key in result) {\n\t\t\tObject.defineProperty(result, key, {\n\t\t\t\tvalue: copied,\n\t\t\t\twritable: true,\n\t\t\t\tenumerable: true,\n\t\t\t\tconfigurable: true,\n\t\t\t});\n\t\t} else {\n\t\t\tresult[key] = copied;\n\t\t}\n\t}\n\treturn result as T;\n};\n\nconst cursorId = (cursor: Readonly<Record<string, JsonValue>> | undefined): Id | undefined =>\n\tcursor?.after as Id | undefined;\n\nconst lowerBound = (ids: readonly Id[], target: Id): number => {\n\tlet low = 0;\n\tlet high = ids.length;\n\twhile (low < high) {\n\t\tconst middle = (low + high) >>> 1;\n\t\tif (ids[middle] < target) low = middle + 1;\n\t\telse high = middle;\n\t}\n\treturn low;\n};\n\nconst upperBound = (ids: readonly Id[], target: Id): number => {\n\tlet low = 0;\n\tlet high = ids.length;\n\twhile (low < high) {\n\t\tconst middle = (low + high) >>> 1;\n\t\tif (ids[middle] <= target) low = middle + 1;\n\t\telse high = middle;\n\t}\n\treturn low;\n};\n\nconst insertSorted = (ids: Id[], id: Id): void => {\n\tif (ids.length === 0 || ids[ids.length - 1] < id) ids.push(id);\n\telse ids.splice(lowerBound(ids, id), 0, id);\n};\n\nconst removeSorted = (ids: Id[], id: Id): void => {\n\tconst index = lowerBound(ids, id);\n\tif (ids[index] === id) ids.splice(index, 1);\n};\n\nconst scopeKey = (scope: DocumentRecord[\"scope\"]): string => {\n\tswitch (scope.kind) {\n\t\tcase \"session\":\n\t\t\treturn JSON.stringify([\"session\"]);\n\t\tcase \"conversation\":\n\t\t\treturn JSON.stringify([\"conversation\", scope.conversationId]);\n\t\tcase \"task\":\n\t\t\treturn JSON.stringify([\"task\", scope.taskId]);\n\t}\n};\n\nconst addressKey = (address: DocumentAddress): string =>\n\tJSON.stringify([\n\t\taddress.kind,\n\t\tscopeKey(address.scope),\n\t\taddress.key === undefined ? [\"singleton\"] : [\"family\", address.key],\n\t]);\n\nconst recordAddressKey = (record: DocumentRecord | DocumentCreate): string =>\n\taddressKey({ kind: record.kind, scope: record.scope, key: record.key });\n\nconst isAliveAt = (record: DocumentRecord, at: DocumentPoint): boolean => {\n\tif (at === \"current\") return record.retiredAt === undefined;\n\treturn record.createdAt <= at && (record.retiredAt === undefined || at < record.retiredAt);\n};\n\nconst isCurrentOnly = (record: DocumentRecord): boolean =>\n\trecord.scope.kind !== \"conversation\" || record.history === \"latest\";\n\nconst tableContaining = (state: State, id: Id): TableName | undefined => {\n\tif (state.conversations.has(id)) return \"conversation\";\n\tif (state.entries.has(id)) return \"entry\";\n\tif (state.tasks.has(id)) return \"task\";\n\tif (state.submissions.has(id)) return \"submission\";\n\tif (state.documents.has(id)) return \"document\";\n\treturn undefined;\n};\n\nconst page = <T extends { readonly id: Id }>(values: readonly T[], limit: number): Page<T, Cursor> => {\n\tconst items = values.slice(0, limit);\n\tif (values.length <= limit) return { items: clone(items) };\n\treturn { items: clone(items), next: { after: items.at(-1)!.id } };\n};\n\n/**\n * Detached in-memory reference implementation of `Storage`.\n *\n * Reads and retained writes are cloned intentionally to match the ownership boundary\n * of serialization-backed stores. This is backend conformance, not validation.\n */\nexport class MemoryStorage implements Storage {\n\tprivate readonly state: State = {\n\t\tconversations: new Map(),\n\t\tconversationIds: [],\n\t\tentries: new Map(),\n\t\tentryIds: new Map(),\n\t\theadEntryIds: new Map(),\n\t\tentryCommitSeqs: new Map(),\n\t\ttasks: new Map(),\n\t\ttaskIds: [],\n\t\ttaskIdsByStatus: { pending: [], running: [], terminal: [] },\n\t\tsubmissions: new Map(),\n\t\tsubmissionIdsByRequest: new Map(),\n\t\tdocuments: new Map(),\n\t\tdocumentAddresses: new Map(),\n\t\tdocumentIdsByScope: new Map(),\n\t};\n\tprivate nextId = 2;\n\tprivate nextSeq = 1;\n\tprivate closed = false;\n\n\tasync commit(writes: readonly StorageWrite[], _context: Context): Promise<Seq> {\n\t\tthis.assertOpen();\n\t\tconst prepared = writes.map((write) => clone(write));\n\t\tconst seq = this.nextSeq;\n\t\tthis.checkGlobalIds(prepared);\n\t\tconst documentActions = this.prepareDocumentActions(prepared);\n\t\tthis.checkDocumentActions(documentActions);\n\n\t\tfor (const write of prepared) {\n\t\t\tswitch (write.type) {\n\t\t\t\tcase \"conversation\":\n\t\t\t\t\tthis.state.conversations.set(write.value.id, write.value);\n\t\t\t\t\tinsertSorted(this.state.conversationIds, write.value.id);\n\t\t\t\t\tthis.nextId = Math.max(this.nextId, write.value.id + 1);\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"entry\": {\n\t\t\t\t\tthis.state.entries.set(write.value.id, write.value);\n\t\t\t\t\tthis.state.entryCommitSeqs.set(write.value.id, seq);\n\t\t\t\t\tlet ids = this.state.entryIds.get(write.value.conversationId);\n\t\t\t\t\tif (ids === undefined) {\n\t\t\t\t\t\tids = [];\n\t\t\t\t\t\tthis.state.entryIds.set(write.value.conversationId, ids);\n\t\t\t\t\t}\n\t\t\t\t\tinsertSorted(ids, write.value.id);\n\t\t\t\t\tif (write.value.head !== undefined) {\n\t\t\t\t\t\tlet headIds = this.state.headEntryIds.get(write.value.conversationId);\n\t\t\t\t\t\tif (headIds === undefined) {\n\t\t\t\t\t\t\theadIds = [];\n\t\t\t\t\t\t\tthis.state.headEntryIds.set(write.value.conversationId, headIds);\n\t\t\t\t\t\t}\n\t\t\t\t\t\tinsertSorted(headIds, write.value.id);\n\t\t\t\t\t}\n\t\t\t\t\tthis.nextId = Math.max(this.nextId, write.value.id + 1);\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tcase \"task\": {\n\t\t\t\t\tconst previous = this.state.tasks.get(write.value.id);\n\t\t\t\t\tif (previous === undefined) {\n\t\t\t\t\t\tinsertSorted(this.state.taskIds, write.value.id);\n\t\t\t\t\t\tinsertSorted(this.state.taskIdsByStatus[write.value.state.status], write.value.id);\n\t\t\t\t\t} else if (previous.state.status !== write.value.state.status) {\n\t\t\t\t\t\tremoveSorted(this.state.taskIdsByStatus[previous.state.status], write.value.id);\n\t\t\t\t\t\tinsertSorted(this.state.taskIdsByStatus[write.value.state.status], write.value.id);\n\t\t\t\t\t}\n\t\t\t\t\tthis.state.tasks.set(write.value.id, write.value);\n\t\t\t\t\tthis.nextId = Math.max(this.nextId, write.value.id + 1);\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tcase \"submission\": {\n\t\t\t\t\tconst previous = this.state.submissions.get(write.value.id);\n\t\t\t\t\tif (previous?.requestId !== undefined) {\n\t\t\t\t\t\tconst previousRequests = this.state.submissionIdsByRequest.get(previous.conversationId);\n\t\t\t\t\t\tif (previousRequests?.get(previous.requestId) === write.value.id) {\n\t\t\t\t\t\t\tpreviousRequests.delete(previous.requestId);\n\t\t\t\t\t\t\tif (previousRequests.size === 0) this.state.submissionIdsByRequest.delete(previous.conversationId);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tthis.state.submissions.set(write.value.id, write.value);\n\t\t\t\t\tif (write.value.requestId !== undefined) {\n\t\t\t\t\t\tlet requests = this.state.submissionIdsByRequest.get(write.value.conversationId);\n\t\t\t\t\t\tif (requests === undefined) {\n\t\t\t\t\t\t\trequests = new Map();\n\t\t\t\t\t\t\tthis.state.submissionIdsByRequest.set(write.value.conversationId, requests);\n\t\t\t\t\t\t}\n\t\t\t\t\t\trequests.set(write.value.requestId, write.value.id);\n\t\t\t\t\t}\n\t\t\t\t\tthis.nextId = Math.max(this.nextId, write.value.id + 1);\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tcase \"document.create\":\n\t\t\t\tcase \"document.change\":\n\t\t\t\tcase \"document.retire\":\n\t\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\n\t\tthis.applyDocumentActions(documentActions, seq);\n\t\tthis.nextSeq++;\n\t\treturn seq;\n\t}\n\n\tasync mintId(): Promise<Id> {\n\t\tthis.assertOpen();\n\t\tif (!Number.isSafeInteger(this.nextId)) throw new Error(\"ID space is exhausted\");\n\t\treturn this.nextId++;\n\t}\n\n\tasync conversation(id: Id, _context: Context): Promise<ConversationRecord | undefined> {\n\t\tthis.assertOpen();\n\t\tconst value = this.state.conversations.get(id);\n\t\treturn value === undefined ? undefined : clone(value);\n\t}\n\n\tasync scanConversations(\n\t\tcursor: Cursor | undefined,\n\t\tlimit: number,\n\t\t_context: Context,\n\t): Promise<Page<ConversationRecord, Cursor>> {\n\t\tthis.assertOpen();\n\t\tconst after = cursorId(cursor);\n\t\tconst start = after === undefined ? 0 : upperBound(this.state.conversationIds, after);\n\t\tconst values = this.state.conversationIds\n\t\t\t.slice(start, start + limit + 1)\n\t\t\t.map((id) => this.state.conversations.get(id)!);\n\t\treturn page(values, limit);\n\t}\n\n\tasync entry(\n\t\tid: Id,\n\t\t_context: Context,\n\t): Promise<{ readonly entry: EntryRecord; readonly commitSeq: Seq } | undefined> {\n\t\tthis.assertOpen();\n\t\tconst entry = this.state.entries.get(id);\n\t\tif (entry === undefined) return undefined;\n\t\treturn { entry: clone(entry), commitSeq: this.state.entryCommitSeqs.get(id)! };\n\t}\n\n\tasync findLatestHeadMarker(\n\t\tconversationId: Id,\n\t\tatOrBeforeEntryId: Id | undefined,\n\t\t_context: Context,\n\t): Promise<(EntryRecord & { readonly head: Id }) | undefined> {\n\t\tthis.assertOpen();\n\t\tif (!this.state.conversations.has(conversationId)) {\n\t\t\tthrow new Error(`Unknown conversation: ${conversationId}`);\n\t\t}\n\t\tlet currentId = conversationId;\n\t\tlet upperEntryId = atOrBeforeEntryId ?? Number.POSITIVE_INFINITY;\n\t\twhile (true) {\n\t\t\tconst ids = this.state.headEntryIds.get(currentId) ?? [];\n\t\t\tconst index = upperBound(ids, upperEntryId) - 1;\n\t\t\tif (index >= 0) {\n\t\t\t\tconst entry = this.state.entries.get(ids[index])!;\n\t\t\t\treturn clone({ ...entry, head: entry.head! });\n\t\t\t}\n\t\t\tconst conversation = this.state.conversations.get(currentId)!;\n\t\t\tif (conversation.parent === undefined) return undefined;\n\t\t\tupperEntryId = Math.min(upperEntryId, conversation.parent.at);\n\t\t\tcurrentId = conversation.parent.conversationId;\n\t\t}\n\t}\n\n\tasync scanEntries(\n\t\tquery: EntryQuery,\n\t\tcursor: Cursor | undefined,\n\t\tlimit: number,\n\t\t_context: Context,\n\t): Promise<Page<EntryRecord, Cursor>> {\n\t\tthis.assertOpen();\n\t\tconst after = cursorId(cursor);\n\t\tconst maxEntryId =\n\t\t\tafter === undefined ? query.maxEntryId : Math.min(query.maxEntryId ?? Number.POSITIVE_INFINITY, after - 1);\n\t\tconst visible: EntryRecord[] = [];\n\t\tfor (const entry of this.visibleEntries(query.conversationId, query.minEntryId, maxEntryId)) {\n\t\t\tvisible.push(entry);\n\t\t\tif (visible.length > limit) break;\n\t\t}\n\t\treturn page(visible, limit);\n\t}\n\n\tasync task(id: Id, _context: Context): Promise<StoredTask | undefined> {\n\t\tthis.assertOpen();\n\t\tconst value = this.state.tasks.get(id);\n\t\treturn value === undefined ? undefined : clone(value);\n\t}\n\n\tasync scanTasks(\n\t\tquery: TaskQuery,\n\t\tcursor: Cursor | undefined,\n\t\tlimit: number,\n\t\t_context: Context,\n\t): Promise<Page<StoredTask, Cursor>> {\n\t\tthis.assertOpen();\n\t\tconst after = cursorId(cursor);\n\t\tconst ids = query.status === undefined ? this.state.taskIds : this.state.taskIdsByStatus[query.status];\n\t\tconst start = after === undefined ? 0 : upperBound(ids, after);\n\t\tconst values: StoredTask[] = [];\n\t\tfor (let index = start; index < ids.length && values.length <= limit; index++) {\n\t\t\tconst value = this.state.tasks.get(ids[index])!;\n\t\t\tif (query.conversationId !== undefined && value.conversationId !== query.conversationId) continue;\n\t\t\tif (query.kind !== undefined && value.kind !== query.kind) continue;\n\t\t\tif (query.abortRequested !== undefined && value.abortRequested !== query.abortRequested) continue;\n\t\t\tif (query.background !== undefined && value.background !== query.background) continue;\n\t\t\tvalues.push(value);\n\t\t}\n\t\treturn page(values, limit);\n\t}\n\n\tasync submission(id: Id, _context: Context): Promise<SubmissionRecord | undefined> {\n\t\tthis.assertOpen();\n\t\tconst value = this.state.submissions.get(id);\n\t\treturn value === undefined ? undefined : clone(value);\n\t}\n\n\tasync submissionByRequest(\n\t\tconversationId: Id,\n\t\trequestId: string,\n\t\t_context: Context,\n\t): Promise<SubmissionRecord | undefined> {\n\t\tthis.assertOpen();\n\t\tconst id = this.state.submissionIdsByRequest.get(conversationId)?.get(requestId);\n\t\tif (id === undefined) return undefined;\n\t\treturn clone(this.state.submissions.get(id)!);\n\t}\n\n\tasync findDocument(\n\t\taddress: DocumentAddress,\n\t\tat: DocumentPoint,\n\t\t_context: Context,\n\t): Promise<DocumentRecord | undefined> {\n\t\tthis.assertOpen();\n\t\tconst index = this.state.documentAddresses.get(addressKey(address));\n\t\tif (at === \"current\") {\n\t\t\treturn index?.currentId === undefined ? undefined : clone(this.state.documents.get(index.currentId)!.record);\n\t\t}\n\t\tfor (const id of index?.ids ?? []) {\n\t\t\tconst record = this.state.documents.get(id)!.record;\n\t\t\tif (isAliveAt(record, at)) return clone(record);\n\t\t}\n\t\treturn undefined;\n\t}\n\n\tasync document(id: Id, at: DocumentPoint, _context: Context): Promise<StoredDocument | undefined> {\n\t\tthis.assertOpen();\n\t\tconst stored = this.state.documents.get(id);\n\t\tif (stored === undefined) return undefined;\n\t\tif (at !== \"current\" && isCurrentOnly(stored.record)) {\n\t\t\tthrow new Error(`Document ${id} does not retain historical content`);\n\t\t}\n\t\tif (!isAliveAt(stored.record, at)) return undefined;\n\t\tconst revisions = at === \"current\" ? stored.revisions : stored.revisions.filter((revision) => revision.seq <= at);\n\t\tlet baseIndex = revisions.length - 1;\n\t\twhile (baseIndex >= 0 && revisions[baseIndex]!.kind !== \"base\") baseIndex--;\n\t\tconst base = revisions[baseIndex];\n\t\tif (base?.kind !== \"base\") throw new Error(`Document ${id} is missing a required base`);\n\t\tlet value = base.value;\n\t\tfor (let index = baseIndex + 1; index < revisions.length; index++) {\n\t\t\tconst revision = revisions[index]!;\n\t\t\tif (revision.kind !== \"delta\" || revision.version !== base.version) {\n\t\t\t\tthrow new Error(`Document ${id} crosses a stored version boundary without a base`);\n\t\t\t}\n\t\t\tvalue = applyImmutable(value, revision.ops) as JsonObject;\n\t\t}\n\t\treturn { record: clone(stored.record), version: base.version, value: clone(value) };\n\t}\n\n\tasync scanDocuments(\n\t\tquery: DocumentQuery,\n\t\tcursor: Cursor | undefined,\n\t\tlimit: number,\n\t\t_context: Context,\n\t): Promise<Page<DocumentRecord, Cursor>> {\n\t\tthis.assertOpen();\n\t\tconst ids = this.state.documentIdsByScope.get(scopeKey(query.scope)) ?? [];\n\t\tconst after = cursorId(cursor);\n\t\tconst start = after === undefined ? 0 : upperBound(ids, after);\n\t\tconst values: DocumentRecord[] = [];\n\t\tfor (let index = start; index < ids.length && values.length <= limit; index++) {\n\t\t\tconst record = this.state.documents.get(ids[index]!)!.record;\n\t\t\tif (query.kind !== undefined && record.kind !== query.kind) continue;\n\t\t\tif (isAliveAt(record, query.at)) values.push(record);\n\t\t}\n\t\treturn page(values, limit);\n\t}\n\n\tasync close(_context: Context): Promise<void> {\n\t\tthis.closed = true;\n\t}\n\n\tprivate *visibleEntries(\n\t\tconversationId: Id,\n\t\tminEntryId = Number.NEGATIVE_INFINITY,\n\t\tmaxEntryId = Number.POSITIVE_INFINITY,\n\t): Generator<EntryRecord> {\n\t\tif (!this.state.conversations.has(conversationId)) {\n\t\t\tthrow new Error(`Unknown conversation: ${conversationId}`);\n\t\t}\n\t\tlet currentId = conversationId;\n\t\tlet upperEntryId = maxEntryId;\n\t\twhile (true) {\n\t\t\tconst ids = this.state.entryIds.get(currentId) ?? [];\n\t\t\tfor (let index = upperBound(ids, upperEntryId) - 1; index >= 0; index--) {\n\t\t\t\tconst id = ids[index];\n\t\t\t\tif (id < minEntryId) break;\n\t\t\t\tyield this.state.entries.get(id)!;\n\t\t\t}\n\t\t\tconst conversation = this.state.conversations.get(currentId)!;\n\t\t\tif (conversation.parent === undefined) break;\n\t\t\tupperEntryId = Math.min(upperEntryId, conversation.parent.at);\n\t\t\tif (upperEntryId < minEntryId) break;\n\t\t\tcurrentId = conversation.parent.conversationId;\n\t\t}\n\t}\n\n\tprivate checkGlobalIds(writes: readonly StorageWrite[]): void {\n\t\tconst claimed = new Map<Id, TableName>();\n\t\tfor (const write of writes) {\n\t\t\tif (write.type === \"document.change\" || write.type === \"document.retire\") continue;\n\t\t\tconst table: TableName = write.type === \"document.create\" ? \"document\" : write.type;\n\t\t\tconst id = write.type === \"document.create\" ? write.record.id : write.value.id;\n\t\t\tconst existing = tableContaining(this.state, id);\n\t\t\tconst earlier = claimed.get(id);\n\t\t\tif (table === \"conversation\" || table === \"entry\" || table === \"document\") {\n\t\t\t\tif (existing !== undefined) throw new Error(`ID ${id} already belongs to ${existing}`);\n\t\t\t\tif (earlier !== undefined) throw new Error(`ID ${id} is written more than once`);\n\t\t\t} else {\n\t\t\t\tif (existing !== undefined && existing !== table) {\n\t\t\t\t\tthrow new Error(`ID ${id} already belongs to ${existing}`);\n\t\t\t\t}\n\t\t\t\tif (earlier !== undefined && earlier !== table) throw new Error(`ID ${id} is written as two record types`);\n\t\t\t}\n\t\t\tclaimed.set(id, table);\n\t\t}\n\t}\n\n\tprivate prepareDocumentActions(writes: readonly StorageWrite[]): Map<Id, DocumentAction> {\n\t\tconst actions = new Map<Id, DocumentAction>();\n\t\tfor (const write of writes) {\n\t\t\tif (write.type !== \"document.create\" && write.type !== \"document.change\" && write.type !== \"document.retire\") {\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tconst id = write.type === \"document.create\" ? write.record.id : write.id;\n\t\t\tlet action = actions.get(id);\n\t\t\tif (action === undefined) {\n\t\t\t\taction = { retire: false };\n\t\t\t\tactions.set(id, action);\n\t\t\t}\n\t\t\tswitch (write.type) {\n\t\t\t\tcase \"document.create\":\n\t\t\t\t\tif (action.create !== undefined || action.content !== undefined) {\n\t\t\t\t\t\tthrow new Error(`Document ${id} has more than one content command`);\n\t\t\t\t\t}\n\t\t\t\t\taction.create = write.record;\n\t\t\t\t\taction.content = write.content;\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"document.change\":\n\t\t\t\t\tif (action.content !== undefined) throw new Error(`Document ${id} has more than one content command`);\n\t\t\t\t\taction.content = write.content;\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"document.retire\":\n\t\t\t\t\tif (action.retire) throw new Error(`Document ${id} is retired more than once`);\n\t\t\t\t\taction.retire = true;\n\t\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t\treturn actions;\n\t}\n\n\tprivate checkDocumentActions(actions: ReadonlyMap<Id, DocumentAction>): void {\n\t\tconst liveCounts = new Map<string, number>();\n\t\tfor (const [id, action] of actions) {\n\t\t\tconst existing = this.state.documents.get(id);\n\t\t\tif (action.create === undefined && existing === undefined) throw new Error(`Unknown document: ${id}`);\n\t\t\tif (action.create !== undefined && existing !== undefined) throw new Error(`Document ${id} already exists`);\n\t\t\tif (existing?.record.retiredAt !== undefined) throw new Error(`Document ${id} is retired`);\n\t\t\tif (action.create !== undefined && action.content?.kind !== \"base\") {\n\t\t\t\tthrow new Error(`Document ${id} creation requires a base`);\n\t\t\t}\n\t\t\tconst previous = existing?.revisions.at(-1);\n\t\t\tif (action.content?.kind === \"delta\") {\n\t\t\t\tif (previous === undefined) throw new Error(`Document ${id} delta has no base`);\n\t\t\t\tif (previous.version !== action.content.version) {\n\t\t\t\t\tthrow new Error(`Document ${id} version transition requires a base`);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst key = action.create === undefined ? recordAddressKey(existing!.record) : recordAddressKey(action.create);\n\t\t\tconst currentId = this.state.documentAddresses.get(key)?.currentId;\n\t\t\tlet live = liveCounts.get(key);\n\t\t\tif (live === undefined) live = currentId === undefined ? 0 : 1;\n\t\t\tif (action.retire && currentId === id) live--;\n\t\t\tif (action.create !== undefined && !action.retire) live++;\n\t\t\tliveCounts.set(key, live);\n\t\t}\n\n\t\tfor (const live of liveCounts.values()) {\n\t\t\tif (live > 1) throw new Error(`Document address already has a current incarnation`);\n\t\t}\n\t}\n\n\tprivate applyDocumentActions(actions: ReadonlyMap<Id, DocumentAction>, seq: Seq): void {\n\t\tfor (const [id, action] of actions) {\n\t\t\tlet stored = this.state.documents.get(id);\n\t\t\tif (action.create !== undefined) {\n\t\t\t\tconst record: DocumentRecord = {\n\t\t\t\t\t...action.create,\n\t\t\t\t\tcreatedAt: seq,\n\t\t\t\t\t...(action.retire ? { retiredAt: seq } : {}),\n\t\t\t\t};\n\t\t\t\tstored = { record, revisions: [{ ...action.content!, seq }] };\n\t\t\t\tthis.state.documents.set(id, stored);\n\n\t\t\t\tconst key = recordAddressKey(record);\n\t\t\t\tlet address = this.state.documentAddresses.get(key);\n\t\t\t\tif (address === undefined) {\n\t\t\t\t\taddress = { ids: [] };\n\t\t\t\t\tthis.state.documentAddresses.set(key, address);\n\t\t\t\t}\n\t\t\t\tinsertSorted(address.ids, id);\n\n\t\t\t\tlet scopeIds = this.state.documentIdsByScope.get(scopeKey(record.scope));\n\t\t\t\tif (scopeIds === undefined) {\n\t\t\t\t\tscopeIds = [];\n\t\t\t\t\tthis.state.documentIdsByScope.set(scopeKey(record.scope), scopeIds);\n\t\t\t\t}\n\t\t\t\tinsertSorted(scopeIds, id);\n\t\t\t\tthis.nextId = Math.max(this.nextId, id + 1);\n\t\t\t} else if (action.content !== undefined) {\n\t\t\t\tconst revision = { ...action.content, seq } as DocumentRevision;\n\t\t\t\tif (revision.kind === \"base\" && isCurrentOnly(stored!.record)) stored!.revisions = [revision];\n\t\t\t\telse stored!.revisions.push(revision);\n\t\t\t}\n\n\t\t\tif (action.retire && action.create === undefined) stored!.record = { ...stored!.record, retiredAt: seq };\n\t\t\tif (action.retire && isCurrentOnly(stored!.record)) stored!.revisions = [];\n\t\t\tif (action.create !== undefined || action.retire) {\n\t\t\t\tconst address = this.state.documentAddresses.get(recordAddressKey(stored!.record))!;\n\t\t\t\tif (action.retire && address.currentId === id) delete address.currentId;\n\t\t\t\tif (action.create !== undefined && !action.retire) address.currentId = id;\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate assertOpen(): void {\n\t\tif (this.closed) throw new Error(\"MemoryStorage is closed\");\n\t}\n}\n"]}
@@ -1,3 +1,4 @@
1
+ import { applyImmutable } from "@earendil-works/chord/delta";
1
2
  const clone = (value) => {
2
3
  if (value === null || typeof value !== "object")
3
4
  return value;
@@ -58,6 +59,28 @@ const removeSorted = (ids, id) => {
58
59
  if (ids[index] === id)
59
60
  ids.splice(index, 1);
60
61
  };
62
+ const scopeKey = (scope) => {
63
+ switch (scope.kind) {
64
+ case "session":
65
+ return JSON.stringify(["session"]);
66
+ case "conversation":
67
+ return JSON.stringify(["conversation", scope.conversationId]);
68
+ case "task":
69
+ return JSON.stringify(["task", scope.taskId]);
70
+ }
71
+ };
72
+ const addressKey = (address) => JSON.stringify([
73
+ address.kind,
74
+ scopeKey(address.scope),
75
+ address.key === undefined ? ["singleton"] : ["family", address.key],
76
+ ]);
77
+ const recordAddressKey = (record) => addressKey({ kind: record.kind, scope: record.scope, key: record.key });
78
+ const isAliveAt = (record, at) => {
79
+ if (at === "current")
80
+ return record.retiredAt === undefined;
81
+ return record.createdAt <= at && (record.retiredAt === undefined || at < record.retiredAt);
82
+ };
83
+ const isCurrentOnly = (record) => record.scope.kind !== "conversation" || record.history === "latest";
61
84
  const tableContaining = (state, id) => {
62
85
  if (state.conversations.has(id))
63
86
  return "conversation";
@@ -65,8 +88,10 @@ const tableContaining = (state, id) => {
65
88
  return "entry";
66
89
  if (state.tasks.has(id))
67
90
  return "task";
68
- if (state.inputs.has(id))
69
- return "input";
91
+ if (state.submissions.has(id))
92
+ return "submission";
93
+ if (state.documents.has(id))
94
+ return "document";
70
95
  return undefined;
71
96
  };
72
97
  const page = (values, limit) => {
@@ -92,8 +117,11 @@ export class MemoryStorage {
92
117
  tasks: new Map(),
93
118
  taskIds: [],
94
119
  taskIdsByStatus: { pending: [], running: [], terminal: [] },
95
- inputs: new Map(),
96
- inputIdsByRequest: new Map(),
120
+ submissions: new Map(),
121
+ submissionIdsByRequest: new Map(),
122
+ documents: new Map(),
123
+ documentAddresses: new Map(),
124
+ documentIdsByScope: new Map(),
97
125
  };
98
126
  nextId = 2;
99
127
  nextSeq = 1;
@@ -101,13 +129,16 @@ export class MemoryStorage {
101
129
  async commit(writes, _context) {
102
130
  this.assertOpen();
103
131
  const prepared = writes.map((write) => clone(write));
104
- this.checkImmutableIds(prepared);
105
132
  const seq = this.nextSeq;
133
+ this.checkGlobalIds(prepared);
134
+ const documentActions = this.prepareDocumentActions(prepared);
135
+ this.checkDocumentActions(documentActions);
106
136
  for (const write of prepared) {
107
137
  switch (write.type) {
108
138
  case "conversation":
109
139
  this.state.conversations.set(write.value.id, write.value);
110
140
  insertSorted(this.state.conversationIds, write.value.id);
141
+ this.nextId = Math.max(this.nextId, write.value.id + 1);
111
142
  break;
112
143
  case "entry": {
113
144
  this.state.entries.set(write.value.id, write.value);
@@ -126,6 +157,7 @@ export class MemoryStorage {
126
157
  }
127
158
  insertSorted(headIds, write.value.id);
128
159
  }
160
+ this.nextId = Math.max(this.nextId, write.value.id + 1);
129
161
  break;
130
162
  }
131
163
  case "task": {
@@ -139,36 +171,42 @@ export class MemoryStorage {
139
171
  insertSorted(this.state.taskIdsByStatus[write.value.state.status], write.value.id);
140
172
  }
141
173
  this.state.tasks.set(write.value.id, write.value);
174
+ this.nextId = Math.max(this.nextId, write.value.id + 1);
142
175
  break;
143
176
  }
144
- case "input": {
145
- const previous = this.state.inputs.get(write.value.id);
177
+ case "submission": {
178
+ const previous = this.state.submissions.get(write.value.id);
146
179
  if (previous?.requestId !== undefined) {
147
- const previousRequests = this.state.inputIdsByRequest.get(previous.conversationId);
180
+ const previousRequests = this.state.submissionIdsByRequest.get(previous.conversationId);
148
181
  if (previousRequests?.get(previous.requestId) === write.value.id) {
149
182
  previousRequests.delete(previous.requestId);
150
183
  if (previousRequests.size === 0)
151
- this.state.inputIdsByRequest.delete(previous.conversationId);
184
+ this.state.submissionIdsByRequest.delete(previous.conversationId);
152
185
  }
153
186
  }
154
- this.state.inputs.set(write.value.id, write.value);
187
+ this.state.submissions.set(write.value.id, write.value);
155
188
  if (write.value.requestId !== undefined) {
156
- let requests = this.state.inputIdsByRequest.get(write.value.conversationId);
189
+ let requests = this.state.submissionIdsByRequest.get(write.value.conversationId);
157
190
  if (requests === undefined) {
158
191
  requests = new Map();
159
- this.state.inputIdsByRequest.set(write.value.conversationId, requests);
192
+ this.state.submissionIdsByRequest.set(write.value.conversationId, requests);
160
193
  }
161
194
  requests.set(write.value.requestId, write.value.id);
162
195
  }
196
+ this.nextId = Math.max(this.nextId, write.value.id + 1);
163
197
  break;
164
198
  }
199
+ case "document.create":
200
+ case "document.change":
201
+ case "document.retire":
202
+ break;
165
203
  }
166
- this.nextId = Math.max(this.nextId, write.value.id + 1);
167
204
  }
205
+ this.applyDocumentActions(documentActions, seq);
168
206
  this.nextSeq++;
169
207
  return seq;
170
208
  }
171
- mintId() {
209
+ async mintId() {
172
210
  this.assertOpen();
173
211
  if (!Number.isSafeInteger(this.nextId))
174
212
  throw new Error("ID space is exhausted");
@@ -253,17 +291,72 @@ export class MemoryStorage {
253
291
  }
254
292
  return page(values, limit);
255
293
  }
256
- async input(id, _context) {
294
+ async submission(id, _context) {
257
295
  this.assertOpen();
258
- const value = this.state.inputs.get(id);
296
+ const value = this.state.submissions.get(id);
259
297
  return value === undefined ? undefined : clone(value);
260
298
  }
261
- async inputByRequest(conversationId, requestId, _context) {
299
+ async submissionByRequest(conversationId, requestId, _context) {
262
300
  this.assertOpen();
263
- const id = this.state.inputIdsByRequest.get(conversationId)?.get(requestId);
301
+ const id = this.state.submissionIdsByRequest.get(conversationId)?.get(requestId);
264
302
  if (id === undefined)
265
303
  return undefined;
266
- return clone(this.state.inputs.get(id));
304
+ return clone(this.state.submissions.get(id));
305
+ }
306
+ async findDocument(address, at, _context) {
307
+ this.assertOpen();
308
+ const index = this.state.documentAddresses.get(addressKey(address));
309
+ if (at === "current") {
310
+ return index?.currentId === undefined ? undefined : clone(this.state.documents.get(index.currentId).record);
311
+ }
312
+ for (const id of index?.ids ?? []) {
313
+ const record = this.state.documents.get(id).record;
314
+ if (isAliveAt(record, at))
315
+ return clone(record);
316
+ }
317
+ return undefined;
318
+ }
319
+ async document(id, at, _context) {
320
+ this.assertOpen();
321
+ const stored = this.state.documents.get(id);
322
+ if (stored === undefined)
323
+ return undefined;
324
+ if (at !== "current" && isCurrentOnly(stored.record)) {
325
+ throw new Error(`Document ${id} does not retain historical content`);
326
+ }
327
+ if (!isAliveAt(stored.record, at))
328
+ return undefined;
329
+ const revisions = at === "current" ? stored.revisions : stored.revisions.filter((revision) => revision.seq <= at);
330
+ let baseIndex = revisions.length - 1;
331
+ while (baseIndex >= 0 && revisions[baseIndex].kind !== "base")
332
+ baseIndex--;
333
+ const base = revisions[baseIndex];
334
+ if (base?.kind !== "base")
335
+ throw new Error(`Document ${id} is missing a required base`);
336
+ let value = base.value;
337
+ for (let index = baseIndex + 1; index < revisions.length; index++) {
338
+ const revision = revisions[index];
339
+ if (revision.kind !== "delta" || revision.version !== base.version) {
340
+ throw new Error(`Document ${id} crosses a stored version boundary without a base`);
341
+ }
342
+ value = applyImmutable(value, revision.ops);
343
+ }
344
+ return { record: clone(stored.record), version: base.version, value: clone(value) };
345
+ }
346
+ async scanDocuments(query, cursor, limit, _context) {
347
+ this.assertOpen();
348
+ const ids = this.state.documentIdsByScope.get(scopeKey(query.scope)) ?? [];
349
+ const after = cursorId(cursor);
350
+ const start = after === undefined ? 0 : upperBound(ids, after);
351
+ const values = [];
352
+ for (let index = start; index < ids.length && values.length <= limit; index++) {
353
+ const record = this.state.documents.get(ids[index]).record;
354
+ if (query.kind !== undefined && record.kind !== query.kind)
355
+ continue;
356
+ if (isAliveAt(record, query.at))
357
+ values.push(record);
358
+ }
359
+ return page(values, limit);
267
360
  }
268
361
  async close(_context) {
269
362
  this.closed = true;
@@ -291,14 +384,16 @@ export class MemoryStorage {
291
384
  currentId = conversation.parent.conversationId;
292
385
  }
293
386
  }
294
- checkImmutableIds(writes) {
387
+ checkGlobalIds(writes) {
295
388
  const claimed = new Map();
296
389
  for (const write of writes) {
297
- const table = write.type;
298
- const id = write.value.id;
390
+ if (write.type === "document.change" || write.type === "document.retire")
391
+ continue;
392
+ const table = write.type === "document.create" ? "document" : write.type;
393
+ const id = write.type === "document.create" ? write.record.id : write.value.id;
299
394
  const existing = tableContaining(this.state, id);
300
395
  const earlier = claimed.get(id);
301
- if (table === "conversation" || table === "entry") {
396
+ if (table === "conversation" || table === "entry" || table === "document") {
302
397
  if (existing !== undefined)
303
398
  throw new Error(`ID ${id} already belongs to ${existing}`);
304
399
  if (earlier !== undefined)
@@ -314,9 +409,126 @@ export class MemoryStorage {
314
409
  claimed.set(id, table);
315
410
  }
316
411
  }
412
+ prepareDocumentActions(writes) {
413
+ const actions = new Map();
414
+ for (const write of writes) {
415
+ if (write.type !== "document.create" && write.type !== "document.change" && write.type !== "document.retire") {
416
+ continue;
417
+ }
418
+ const id = write.type === "document.create" ? write.record.id : write.id;
419
+ let action = actions.get(id);
420
+ if (action === undefined) {
421
+ action = { retire: false };
422
+ actions.set(id, action);
423
+ }
424
+ switch (write.type) {
425
+ case "document.create":
426
+ if (action.create !== undefined || action.content !== undefined) {
427
+ throw new Error(`Document ${id} has more than one content command`);
428
+ }
429
+ action.create = write.record;
430
+ action.content = write.content;
431
+ break;
432
+ case "document.change":
433
+ if (action.content !== undefined)
434
+ throw new Error(`Document ${id} has more than one content command`);
435
+ action.content = write.content;
436
+ break;
437
+ case "document.retire":
438
+ if (action.retire)
439
+ throw new Error(`Document ${id} is retired more than once`);
440
+ action.retire = true;
441
+ break;
442
+ }
443
+ }
444
+ return actions;
445
+ }
446
+ checkDocumentActions(actions) {
447
+ const liveCounts = new Map();
448
+ for (const [id, action] of actions) {
449
+ const existing = this.state.documents.get(id);
450
+ if (action.create === undefined && existing === undefined)
451
+ throw new Error(`Unknown document: ${id}`);
452
+ if (action.create !== undefined && existing !== undefined)
453
+ throw new Error(`Document ${id} already exists`);
454
+ if (existing?.record.retiredAt !== undefined)
455
+ throw new Error(`Document ${id} is retired`);
456
+ if (action.create !== undefined && action.content?.kind !== "base") {
457
+ throw new Error(`Document ${id} creation requires a base`);
458
+ }
459
+ const previous = existing?.revisions.at(-1);
460
+ if (action.content?.kind === "delta") {
461
+ if (previous === undefined)
462
+ throw new Error(`Document ${id} delta has no base`);
463
+ if (previous.version !== action.content.version) {
464
+ throw new Error(`Document ${id} version transition requires a base`);
465
+ }
466
+ }
467
+ const key = action.create === undefined ? recordAddressKey(existing.record) : recordAddressKey(action.create);
468
+ const currentId = this.state.documentAddresses.get(key)?.currentId;
469
+ let live = liveCounts.get(key);
470
+ if (live === undefined)
471
+ live = currentId === undefined ? 0 : 1;
472
+ if (action.retire && currentId === id)
473
+ live--;
474
+ if (action.create !== undefined && !action.retire)
475
+ live++;
476
+ liveCounts.set(key, live);
477
+ }
478
+ for (const live of liveCounts.values()) {
479
+ if (live > 1)
480
+ throw new Error(`Document address already has a current incarnation`);
481
+ }
482
+ }
483
+ applyDocumentActions(actions, seq) {
484
+ for (const [id, action] of actions) {
485
+ let stored = this.state.documents.get(id);
486
+ if (action.create !== undefined) {
487
+ const record = {
488
+ ...action.create,
489
+ createdAt: seq,
490
+ ...(action.retire ? { retiredAt: seq } : {}),
491
+ };
492
+ stored = { record, revisions: [{ ...action.content, seq }] };
493
+ this.state.documents.set(id, stored);
494
+ const key = recordAddressKey(record);
495
+ let address = this.state.documentAddresses.get(key);
496
+ if (address === undefined) {
497
+ address = { ids: [] };
498
+ this.state.documentAddresses.set(key, address);
499
+ }
500
+ insertSorted(address.ids, id);
501
+ let scopeIds = this.state.documentIdsByScope.get(scopeKey(record.scope));
502
+ if (scopeIds === undefined) {
503
+ scopeIds = [];
504
+ this.state.documentIdsByScope.set(scopeKey(record.scope), scopeIds);
505
+ }
506
+ insertSorted(scopeIds, id);
507
+ this.nextId = Math.max(this.nextId, id + 1);
508
+ }
509
+ else if (action.content !== undefined) {
510
+ const revision = { ...action.content, seq };
511
+ if (revision.kind === "base" && isCurrentOnly(stored.record))
512
+ stored.revisions = [revision];
513
+ else
514
+ stored.revisions.push(revision);
515
+ }
516
+ if (action.retire && action.create === undefined)
517
+ stored.record = { ...stored.record, retiredAt: seq };
518
+ if (action.retire && isCurrentOnly(stored.record))
519
+ stored.revisions = [];
520
+ if (action.create !== undefined || action.retire) {
521
+ const address = this.state.documentAddresses.get(recordAddressKey(stored.record));
522
+ if (action.retire && address.currentId === id)
523
+ delete address.currentId;
524
+ if (action.create !== undefined && !action.retire)
525
+ address.currentId = id;
526
+ }
527
+ }
528
+ }
317
529
  assertOpen() {
318
530
  if (this.closed)
319
531
  throw new Error("MemoryStorage is closed");
320
532
  }
321
533
  }
322
- //# sourceMappingURL=memory-storage.js.map
534
+ //# sourceMappingURL=memory.js.map