@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.
- package/README.md +29 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/{memory-storage.d.ts → storage/memory.d.ts} +12 -6
- package/dist/storage/memory.d.ts.map +1 -0
- package/dist/{memory-storage.js → storage/memory.js} +236 -24
- package/dist/storage/memory.js.map +1 -0
- package/dist/storage/sqlite/database.d.ts +26 -0
- package/dist/storage/sqlite/database.d.ts.map +1 -0
- package/dist/storage/sqlite/database.js +2 -0
- package/dist/storage/sqlite/database.js.map +1 -0
- package/dist/storage/sqlite/index.d.ts +4 -0
- package/dist/storage/sqlite/index.d.ts.map +1 -0
- package/dist/storage/sqlite/index.js +3 -0
- package/dist/storage/sqlite/index.js.map +1 -0
- package/dist/storage/sqlite/migrations.d.ts +11 -0
- package/dist/storage/sqlite/migrations.d.ts.map +1 -0
- package/dist/storage/sqlite/migrations.js +104 -0
- package/dist/storage/sqlite/migrations.js.map +1 -0
- package/dist/storage/sqlite/node.d.ts +25 -0
- package/dist/storage/sqlite/node.d.ts.map +1 -0
- package/dist/storage/sqlite/node.js +97 -0
- package/dist/storage/sqlite/node.js.map +1 -0
- package/dist/storage/sqlite/storage.d.ts +45 -0
- package/dist/storage/sqlite/storage.d.ts.map +1 -0
- package/dist/storage/sqlite/storage.js +524 -0
- package/dist/storage/sqlite/storage.js.map +1 -0
- package/dist/types.d.ts +113 -34
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/docs/pico-v5-chord-usage.md +64 -63
- package/docs/pico-v5-handoff.md +92 -57
- package/docs/pico-v5.md +519 -350
- package/package.json +20 -3
- package/dist/memory-storage.d.ts.map +0 -1
- 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
|
|
2
|
-
export type { ContextEdit, ConversationRecord, Cursor, DocumentCreate, DocumentRecord, EntryDraft, EntryQuery, EntryRecord, Id,
|
|
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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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,
|
|
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
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;
|
|
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,
|
|
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
|
-
|
|
30
|
-
|
|
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
|
|
36
|
+
private checkGlobalIds;
|
|
37
|
+
private prepareDocumentActions;
|
|
38
|
+
private checkDocumentActions;
|
|
39
|
+
private applyDocumentActions;
|
|
34
40
|
private assertOpen;
|
|
35
41
|
}
|
|
36
42
|
export {};
|
|
37
|
-
//# sourceMappingURL=memory
|
|
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.
|
|
69
|
-
return "
|
|
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
|
-
|
|
96
|
-
|
|
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 "
|
|
145
|
-
const previous = this.state.
|
|
177
|
+
case "submission": {
|
|
178
|
+
const previous = this.state.submissions.get(write.value.id);
|
|
146
179
|
if (previous?.requestId !== undefined) {
|
|
147
|
-
const previousRequests = this.state.
|
|
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.
|
|
184
|
+
this.state.submissionIdsByRequest.delete(previous.conversationId);
|
|
152
185
|
}
|
|
153
186
|
}
|
|
154
|
-
this.state.
|
|
187
|
+
this.state.submissions.set(write.value.id, write.value);
|
|
155
188
|
if (write.value.requestId !== undefined) {
|
|
156
|
-
let requests = this.state.
|
|
189
|
+
let requests = this.state.submissionIdsByRequest.get(write.value.conversationId);
|
|
157
190
|
if (requests === undefined) {
|
|
158
191
|
requests = new Map();
|
|
159
|
-
this.state.
|
|
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
|
|
294
|
+
async submission(id, _context) {
|
|
257
295
|
this.assertOpen();
|
|
258
|
-
const value = this.state.
|
|
296
|
+
const value = this.state.submissions.get(id);
|
|
259
297
|
return value === undefined ? undefined : clone(value);
|
|
260
298
|
}
|
|
261
|
-
async
|
|
299
|
+
async submissionByRequest(conversationId, requestId, _context) {
|
|
262
300
|
this.assertOpen();
|
|
263
|
-
const id = this.state.
|
|
301
|
+
const id = this.state.submissionIdsByRequest.get(conversationId)?.get(requestId);
|
|
264
302
|
if (id === undefined)
|
|
265
303
|
return undefined;
|
|
266
|
-
return clone(this.state.
|
|
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
|
-
|
|
387
|
+
checkGlobalIds(writes) {
|
|
295
388
|
const claimed = new Map();
|
|
296
389
|
for (const write of writes) {
|
|
297
|
-
|
|
298
|
-
|
|
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
|
|
534
|
+
//# sourceMappingURL=memory.js.map
|