@opengeni/sdk 0.46.0 → 0.52.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 (103) hide show
  1. package/README.md +94 -2
  2. package/dist/artifact-client.d.ts +20 -2
  3. package/dist/artifacts.d.ts +4 -0
  4. package/dist/artifacts.js +11 -0
  5. package/dist/artifacts.js.map +1 -0
  6. package/dist/chunk-FHI4DFIG.js +128 -0
  7. package/dist/chunk-FHI4DFIG.js.map +1 -0
  8. package/dist/chunk-FRJFNDIR.js +218 -0
  9. package/dist/chunk-FRJFNDIR.js.map +1 -0
  10. package/dist/{chunk-QKV5OCLJ.js → chunk-ILQZR5N6.js} +1380 -595
  11. package/dist/chunk-ILQZR5N6.js.map +1 -0
  12. package/dist/chunk-MZWTWOX6.js +661 -0
  13. package/dist/chunk-MZWTWOX6.js.map +1 -0
  14. package/dist/chunk-VWE2QIVO.js +1162 -0
  15. package/dist/chunk-VWE2QIVO.js.map +1 -0
  16. package/dist/chunk-VYCTL62C.js +230 -0
  17. package/dist/chunk-VYCTL62C.js.map +1 -0
  18. package/dist/client.d.ts +176 -20
  19. package/dist/codex-realtime-controller.d.ts +6 -6
  20. package/dist/codex-realtime-lifecycle.d.ts +1 -1
  21. package/dist/codex-realtime-v3.d.ts +3 -3
  22. package/dist/codex-realtime.d.ts +1 -1
  23. package/dist/company-profile.d.ts +100 -0
  24. package/dist/core.d.ts +5 -5
  25. package/dist/core.js +9 -4
  26. package/dist/desktop.d.ts +1 -1
  27. package/dist/editable-artifact-resources.d.ts +96 -0
  28. package/dist/editable-artifacts/browser-session.d.ts +35 -0
  29. package/dist/editable-artifacts/controller.d.ts +60 -0
  30. package/dist/editable-artifacts/errors.d.ts +23 -0
  31. package/dist/editable-artifacts/http-live-transport.d.ts +46 -0
  32. package/dist/editable-artifacts/index.d.ts +19 -0
  33. package/dist/editable-artifacts/pool.d.ts +15 -0
  34. package/dist/editable-artifacts/session.d.ts +96 -0
  35. package/dist/editable-artifacts/storage.d.ts +72 -0
  36. package/dist/editable-artifacts/types.d.ts +416 -0
  37. package/dist/editable-artifacts/worker/browser-client.d.ts +80 -0
  38. package/dist/editable-artifacts/worker/browser-entry.d.ts +6 -0
  39. package/dist/editable-artifacts/worker/kernel-adapter.d.ts +45 -0
  40. package/dist/editable-artifacts/worker/rpc-protocol.d.ts +92 -0
  41. package/dist/editable-artifacts/worker/runtime.d.ts +66 -0
  42. package/dist/editable-artifacts/worker/wire-codec.d.ts +52 -0
  43. package/dist/editable-artifacts-worker.d.ts +8 -0
  44. package/dist/editable-artifacts-worker.js +1708 -0
  45. package/dist/editable-artifacts-worker.js.map +1 -0
  46. package/dist/editable-artifacts.d.ts +6 -0
  47. package/dist/editable-artifacts.js +5954 -0
  48. package/dist/editable-artifacts.js.map +1 -0
  49. package/dist/gateway-realtime-transport.d.ts +1 -1
  50. package/dist/index.d.ts +41 -35
  51. package/dist/index.js +104 -68
  52. package/dist/index.js.map +1 -1
  53. package/dist/interaction.d.ts +885 -0
  54. package/dist/interaction.js +55 -0
  55. package/dist/interaction.js.map +1 -0
  56. package/dist/memory-slack-client.d.ts +13 -0
  57. package/dist/memory-slack-delivery.d.ts +99 -0
  58. package/dist/memory-slack.d.ts +4 -0
  59. package/dist/memory-slack.js +44 -0
  60. package/dist/memory-slack.js.map +1 -0
  61. package/dist/proxy.d.ts +3 -3
  62. package/dist/realtime.d.ts +12 -12
  63. package/dist/retained-artifacts.d.ts +12 -0
  64. package/dist/stream.d.ts +16 -1
  65. package/dist/terminal.d.ts +1 -1
  66. package/dist/transcription.d.ts +2 -2
  67. package/dist/types.d.ts +1203 -23
  68. package/dist/workspace-control-stream.d.ts +2 -2
  69. package/dist/workspace-state.d.ts +1 -1
  70. package/package.json +29 -3
  71. package/src/artifact-client.ts +129 -0
  72. package/src/artifacts.ts +17 -0
  73. package/src/client.ts +1801 -206
  74. package/src/company-profile.ts +92 -0
  75. package/src/editable-artifact-resources.ts +119 -0
  76. package/src/editable-artifacts/browser-session.ts +176 -0
  77. package/src/editable-artifacts/controller.ts +2632 -0
  78. package/src/editable-artifacts/errors.ts +52 -0
  79. package/src/editable-artifacts/http-live-transport.ts +1449 -0
  80. package/src/editable-artifacts/index.ts +175 -0
  81. package/src/editable-artifacts/pool.ts +81 -0
  82. package/src/editable-artifacts/session.ts +778 -0
  83. package/src/editable-artifacts/storage.ts +1718 -0
  84. package/src/editable-artifacts/types.ts +499 -0
  85. package/src/editable-artifacts/worker/browser-client.ts +1167 -0
  86. package/src/editable-artifacts/worker/browser-entry.ts +57 -0
  87. package/src/editable-artifacts/worker/kernel-adapter.ts +1039 -0
  88. package/src/editable-artifacts/worker/rpc-protocol.ts +622 -0
  89. package/src/editable-artifacts/worker/runtime.ts +1204 -0
  90. package/src/editable-artifacts/worker/wire-codec.ts +852 -0
  91. package/src/editable-artifacts-worker.ts +8 -0
  92. package/src/editable-artifacts.ts +6 -0
  93. package/src/index.ts +194 -0
  94. package/src/interaction.ts +1830 -0
  95. package/src/memory-slack-client.ts +71 -0
  96. package/src/memory-slack-delivery.ts +128 -0
  97. package/src/memory-slack.ts +19 -0
  98. package/src/retained-artifacts.ts +326 -0
  99. package/src/stream.ts +102 -4
  100. package/src/transcription.ts +2 -2
  101. package/src/types.ts +1430 -33
  102. package/src/workspace-control-stream.ts +15 -5
  103. package/dist/chunk-QKV5OCLJ.js.map +0 -1
@@ -0,0 +1,1718 @@
1
+ import {
2
+ assertCanonicalSpreadsheetArtifactCommandBytes,
3
+ decodeEditableArtifactMutationIntent,
4
+ EDITABLE_ARTIFACT_COMMAND_MAX_BYTES,
5
+ EDITABLE_ARTIFACT_INTENT_MAX_BYTES,
6
+ EDITABLE_ARTIFACT_PRODUCT_MAX_SNAPSHOT_BYTES,
7
+ hashEditableArtifactMutationIntentBytes,
8
+ } from "@opengeni/contracts/editable-artifacts";
9
+ import { decodeCommittedTransactionSummary } from "@opengeni/contracts/editable-artifact-committed-transaction";
10
+ import { editableArtifactCodecFor } from "@opengeni/contracts/editable-artifact-codec-registry";
11
+ import { decodeEditableArtifactSerializedCommit } from "@opengeni/contracts/editable-artifact-serialized-commit";
12
+ import type {
13
+ EditableArtifactCommittedTransaction,
14
+ EditableArtifactCausalFrontier,
15
+ EditableArtifactId,
16
+ EditableArtifactModality,
17
+ EditableArtifactPendingTransaction,
18
+ EditableArtifactSnapshot,
19
+ EditableArtifactStoredReplica,
20
+ } from "./types";
21
+
22
+ /** Durable browser state required to resume one artifact without inventing state. */
23
+ export type EditableArtifactStoragePort = {
24
+ loadReplica: (
25
+ scope: EditableArtifactStorageScope,
26
+ ) => Promise<EditableArtifactStoredReplica | null>;
27
+ /** Rare full snapshot/resync replacement. Replaces the retained journal atomically. */
28
+ saveReplica: (
29
+ scope: EditableArtifactStorageScope,
30
+ replica: EditableArtifactStoredReplica,
31
+ expectedHead: EditableArtifactExpectedStoredHead,
32
+ ) => Promise<void>;
33
+ /** Hot path: exact-CAS one verified committed transaction onto the retained journal. */
34
+ appendCommitted: (
35
+ scope: EditableArtifactStorageScope,
36
+ input: EditableArtifactAppendCommittedInput,
37
+ ) => Promise<void>;
38
+ /** Clears the reconstructible server state but deliberately preserves local edits. */
39
+ clearReplica: (scope: EditableArtifactStorageScope) => Promise<void>;
40
+ listPending: (
41
+ scope: EditableArtifactStorageScope,
42
+ ) => Promise<EditableArtifactPendingTransaction[]>;
43
+ putPending: (
44
+ scope: EditableArtifactStorageScope,
45
+ transaction: EditableArtifactPendingTransaction,
46
+ ) => Promise<void>;
47
+ deletePending: (
48
+ scope: EditableArtifactStorageScope,
49
+ clientTransactionId: string,
50
+ ) => Promise<void>;
51
+ };
52
+
53
+ /** Stable authorization/cache partition supplied by the authenticated host. */
54
+ export type EditableArtifactStorageScope = {
55
+ namespace: string;
56
+ artifactId: EditableArtifactId;
57
+ modality: EditableArtifactModality;
58
+ };
59
+
60
+ export type EditableArtifactAppendCommittedInput = {
61
+ artifactId: EditableArtifactId;
62
+ expectedCursor: number;
63
+ expectedStateHash: string;
64
+ transaction: EditableArtifactCommittedTransaction;
65
+ updatedAt: number;
66
+ };
67
+
68
+ export type EditableArtifactExpectedStoredHead = Readonly<{
69
+ cursor: number;
70
+ stateHash: string;
71
+ }> | null;
72
+
73
+ /** A stable id was reused for different content. Retrying cannot resolve this. */
74
+ export class EditableArtifactStorageConflictError extends Error {
75
+ readonly code = "editable_artifact_storage_conflict" as const;
76
+
77
+ constructor(message: string) {
78
+ super(message);
79
+ this.name = "EditableArtifactStorageConflictError";
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Deterministic in-memory implementation for tests, SSR, and non-persistent
85
+ * hosts. All values cross the port by copy, matching IndexedDB structured-clone
86
+ * behavior and preventing callers from mutating retained authority by alias.
87
+ */
88
+ export class MemoryEditableArtifactStorage implements EditableArtifactStoragePort {
89
+ readonly #replicas = new Map<string, Map<EditableArtifactId, MemoryStoredReplica>>();
90
+ readonly #pending = new Map<
91
+ string,
92
+ Map<EditableArtifactId, Map<string, EditableArtifactPendingTransaction>>
93
+ >();
94
+
95
+ async loadReplica(
96
+ scope: EditableArtifactStorageScope,
97
+ ): Promise<EditableArtifactStoredReplica | null> {
98
+ assertScope(scope);
99
+ const replica = this.#replicas.get(scope.namespace)?.get(scope.artifactId);
100
+ return replica === undefined ? null : cloneMemoryStoredReplica(replica);
101
+ }
102
+
103
+ async saveReplica(
104
+ scope: EditableArtifactStorageScope,
105
+ replica: EditableArtifactStoredReplica,
106
+ expectedHead: EditableArtifactExpectedStoredHead,
107
+ ): Promise<void> {
108
+ assertScopeMatchesArtifact(scope, replica.artifactId);
109
+ assertScopeModality(scope, replica.modality);
110
+ assertStoredReplica(replica);
111
+ const namespace = getOrCreate(this.#replicas, scope.namespace);
112
+ const existing = namespace.get(scope.artifactId);
113
+ assertReplacementCanApply(existing?.head, expectedHead, replica.artifactId);
114
+ namespace.set(scope.artifactId, toMemoryStoredReplica(replica));
115
+ }
116
+
117
+ async appendCommitted(
118
+ scope: EditableArtifactStorageScope,
119
+ input: EditableArtifactAppendCommittedInput,
120
+ ): Promise<void> {
121
+ assertScopeMatchesArtifact(scope, input.artifactId);
122
+ assertScopeModality(scope, input.transaction.modality);
123
+ assertAppendInput(input);
124
+ const replica = this.#replicas.get(scope.namespace)?.get(scope.artifactId);
125
+ if (replica === undefined) throw missingReplicaConflict(input.artifactId);
126
+ if (
127
+ replica.head.cursor >= input.transaction.endSequence &&
128
+ replica.tail.some((transaction) => committedTransactionsEqual(transaction, input.transaction))
129
+ ) {
130
+ return;
131
+ }
132
+ assertAppendCanApply(
133
+ {
134
+ cursor: replica.head.cursor,
135
+ stateHash: replica.head.stateHash,
136
+ modality: replica.head.modality,
137
+ ...(replica.head.snapshot.modality === "spreadsheet"
138
+ ? { protocolVersion: replica.head.snapshot.protocolVersion }
139
+ : {}),
140
+ },
141
+ input,
142
+ );
143
+ const transactionBytes = committedStorageBytes(input.transaction);
144
+ if (replica.tail.length >= HARD_MAX_COMMITTED_TRANSACTIONS) {
145
+ throw new RangeError("committed transaction store exceeds its count bound");
146
+ }
147
+ const nextTailBytes = checkedAggregateBytes(
148
+ replica.tailBytes,
149
+ transactionBytes,
150
+ HARD_MAX_COMMITTED_BYTES,
151
+ "committed transaction store",
152
+ );
153
+ replica.tail.push(cloneCommittedTransaction(input.transaction));
154
+ replica.tailBytes = nextTailBytes;
155
+ replica.head.cursor = input.transaction.endSequence;
156
+ replica.head.stateHash = input.transaction.stateHash;
157
+ replica.head.updatedAt = input.updatedAt;
158
+ }
159
+
160
+ async clearReplica(scope: EditableArtifactStorageScope): Promise<void> {
161
+ assertScope(scope);
162
+ const namespace = this.#replicas.get(scope.namespace);
163
+ namespace?.delete(scope.artifactId);
164
+ if (namespace?.size === 0) this.#replicas.delete(scope.namespace);
165
+ }
166
+
167
+ async listPending(
168
+ scope: EditableArtifactStorageScope,
169
+ ): Promise<EditableArtifactPendingTransaction[]> {
170
+ assertScope(scope);
171
+ return [...(this.#pending.get(scope.namespace)?.get(scope.artifactId)?.values() ?? [])]
172
+ .sort(comparePendingTransactions)
173
+ .map(clonePendingTransaction);
174
+ }
175
+
176
+ async putPending(
177
+ scope: EditableArtifactStorageScope,
178
+ transaction: EditableArtifactPendingTransaction,
179
+ ): Promise<void> {
180
+ assertScopeMatchesArtifact(scope, transaction.artifactId);
181
+ assertScopeModality(scope, transaction.modality);
182
+ assertPendingTransaction(transaction);
183
+ const namespace = getOrCreate(this.#pending, scope.namespace);
184
+ const artifactPending = getOrCreate(namespace, scope.artifactId);
185
+ const existing = artifactPending.get(transaction.clientTransactionId);
186
+ assertPendingCanReplace(existing, transaction);
187
+ if (existing === undefined) {
188
+ if (artifactPending.size >= HARD_MAX_PENDING_TRANSACTIONS) {
189
+ throw new RangeError("pending transaction store exceeds its count bound");
190
+ }
191
+ let aggregateBytes = pendingStorageBytes(transaction);
192
+ if (aggregateBytes > HARD_MAX_PENDING_BYTES) {
193
+ throw new RangeError("pending transaction store exceeds its byte bound");
194
+ }
195
+ for (const retained of artifactPending.values()) {
196
+ aggregateBytes = checkedAggregateBytes(
197
+ aggregateBytes,
198
+ pendingStorageBytes(retained),
199
+ HARD_MAX_PENDING_BYTES,
200
+ "pending transaction store",
201
+ );
202
+ }
203
+ artifactPending.set(transaction.clientTransactionId, clonePendingTransaction(transaction));
204
+ }
205
+ }
206
+
207
+ async deletePending(
208
+ scope: EditableArtifactStorageScope,
209
+ clientTransactionId: string,
210
+ ): Promise<void> {
211
+ assertScope(scope);
212
+ const namespace = this.#pending.get(scope.namespace);
213
+ const artifactPending = namespace?.get(scope.artifactId);
214
+ artifactPending?.delete(clientTransactionId);
215
+ if (artifactPending?.size === 0) namespace?.delete(scope.artifactId);
216
+ if (namespace?.size === 0) this.#pending.delete(scope.namespace);
217
+ }
218
+ }
219
+
220
+ type StoredReplicaHead = Omit<EditableArtifactStoredReplica, "tail">;
221
+
222
+ type MemoryStoredReplica = {
223
+ head: StoredReplicaHead;
224
+ tail: EditableArtifactCommittedTransaction[];
225
+ tailBytes: number;
226
+ };
227
+
228
+ type IndexedDbStoredReplicaHead = Omit<StoredReplicaHead, "snapshot"> & {
229
+ namespace: string;
230
+ /** Absent only on legacy spreadsheet records. */
231
+ modality?: EditableArtifactModality;
232
+ /** Spreadsheet-only; absent for serialized modalities. */
233
+ protocolVersion?: number;
234
+ tailCount: number;
235
+ tailBytes: number;
236
+ };
237
+ type IndexedDbStoredSnapshot = EditableArtifactSnapshot & { namespace: string };
238
+ type IndexedDbCommittedTransaction = EditableArtifactCommittedTransaction & {
239
+ namespace: string;
240
+ };
241
+ type IndexedDbPendingTransaction = EditableArtifactPendingTransaction & {
242
+ namespace: string;
243
+ /** V3 IndexedDB key-path alias; always exactly clientTransactionId. */
244
+ transactionId: string;
245
+ };
246
+ const DATABASE_VERSION = 3;
247
+ const REPLICA_HEAD_STORE = "replicas";
248
+ const SNAPSHOT_STORE = "snapshots";
249
+ const COMMITTED_STORE = "committedTransactions";
250
+ const COMMITTED_BY_SCOPE_INDEX = "byScope";
251
+ const PENDING_STORE = "pendingTransactions";
252
+ const PENDING_BY_SCOPE_INDEX = "byScope";
253
+ const HARD_MAX_SNAPSHOT_BYTES = EDITABLE_ARTIFACT_PRODUCT_MAX_SNAPSHOT_BYTES;
254
+ const HARD_MAX_COMMITTED_TRANSACTIONS = 65_536;
255
+ const HARD_MAX_COMMITTED_BYTES = 256 * 1024 * 1024;
256
+ const HARD_MAX_COMMITTED_TRANSACTION_BYTES = 8 * 1024 * 1024;
257
+ const HARD_MAX_PENDING_TRANSACTIONS = 1_024;
258
+ const HARD_MAX_PENDING_BYTES = 128 * 1024 * 1024;
259
+ const HARD_MAX_COMMAND_BYTES = EDITABLE_ARTIFACT_COMMAND_MAX_BYTES;
260
+ const HARD_MAX_INTENT_BYTES = EDITABLE_ARTIFACT_INTENT_MAX_BYTES;
261
+
262
+ export type IndexedDbEditableArtifactStorageOptions = {
263
+ databaseName?: string;
264
+ indexedDB?: IDBFactory;
265
+ };
266
+
267
+ /**
268
+ * IndexedDB-backed browser storage. Writes that depend on an existing value do
269
+ * their comparison and mutation in the same IDB transaction, so tabs cannot
270
+ * silently replace a newer cursor or reuse an idempotency key for other bytes.
271
+ */
272
+ export class IndexedDbEditableArtifactStorage implements EditableArtifactStoragePort {
273
+ readonly #databaseName: string;
274
+ readonly #factory: IDBFactory;
275
+ #databasePromise: Promise<IDBDatabase> | null = null;
276
+
277
+ constructor(options: IndexedDbEditableArtifactStorageOptions = {}) {
278
+ const factory = options.indexedDB ?? globalThis.indexedDB;
279
+ if (factory === undefined) {
280
+ throw new Error("IndexedDB is unavailable; provide an indexedDB implementation");
281
+ }
282
+ this.#factory = factory;
283
+ this.#databaseName = options.databaseName ?? "opengeni-editable-artifacts";
284
+ }
285
+
286
+ async loadReplica(
287
+ scope: EditableArtifactStorageScope,
288
+ ): Promise<EditableArtifactStoredReplica | null> {
289
+ const ownedScope = cloneStorageScope(scope);
290
+ const database = await this.#database();
291
+ const transaction = database.transaction(
292
+ [REPLICA_HEAD_STORE, SNAPSHOT_STORE, COMMITTED_STORE],
293
+ "readonly",
294
+ );
295
+ const completed = transactionResult(transaction);
296
+ const headRequest = requestResult<IndexedDbStoredReplicaHead | undefined>(
297
+ transaction.objectStore(REPLICA_HEAD_STORE).get(scopeKey(ownedScope)),
298
+ );
299
+ const snapshotRequest = requestResult<IndexedDbStoredSnapshot | undefined>(
300
+ transaction.objectStore(SNAPSHOT_STORE).get(scopeKey(ownedScope)),
301
+ );
302
+ const tailRequest = readBoundedIndexValues<IndexedDbCommittedTransaction>(
303
+ transaction,
304
+ transaction.objectStore(COMMITTED_STORE).index(COMMITTED_BY_SCOPE_INDEX),
305
+ scopeKey(ownedScope),
306
+ HARD_MAX_COMMITTED_TRANSACTIONS,
307
+ HARD_MAX_COMMITTED_BYTES,
308
+ (value) => {
309
+ if (!(value.committedTransactionBytes instanceof Uint8Array)) {
310
+ throw new TypeError("retained committedTransactionBytes must be a Uint8Array");
311
+ }
312
+ return committedStorageBytes(value);
313
+ },
314
+ );
315
+ let head: IndexedDbStoredReplicaHead | undefined;
316
+ let snapshot: IndexedDbStoredSnapshot | undefined;
317
+ let tail: IndexedDbCommittedTransaction[];
318
+ try {
319
+ [head, snapshot, tail] = await Promise.all([headRequest, snapshotRequest, tailRequest]);
320
+ await completed;
321
+ } catch (error) {
322
+ await completed.catch(() => undefined);
323
+ throw error;
324
+ }
325
+ if (head === undefined) {
326
+ if (snapshot !== undefined || tail.length !== 0) {
327
+ throw new TypeError(`artifact ${ownedScope.artifactId} has retained state without a head`);
328
+ }
329
+ return null;
330
+ }
331
+ if (snapshot === undefined) {
332
+ throw new TypeError(`artifact ${ownedScope.artifactId} has no retained snapshot`);
333
+ }
334
+ if (head.namespace !== ownedScope.namespace) {
335
+ throw new TypeError("retained replica namespace does not match its storage key");
336
+ }
337
+ if (snapshot.namespace !== ownedScope.namespace) {
338
+ throw new TypeError("retained snapshot namespace does not match its storage key");
339
+ }
340
+ let retainedTailBytes = 0;
341
+ for (const committed of tail) {
342
+ if (committed.namespace !== ownedScope.namespace) {
343
+ throw new TypeError("retained journal namespace does not match its storage key");
344
+ }
345
+ retainedTailBytes = checkedAggregateBytes(
346
+ retainedTailBytes,
347
+ committedStorageBytes(committed),
348
+ HARD_MAX_COMMITTED_BYTES,
349
+ "retained committed transaction store",
350
+ );
351
+ }
352
+ assertStoredHeadAccounting(head, tail.length, retainedTailBytes);
353
+ const {
354
+ namespace: _headNamespace,
355
+ protocolVersion: retainedProtocolVersion,
356
+ tailCount: _tailCount,
357
+ tailBytes: _tailBytes,
358
+ ...storedHead
359
+ } = head;
360
+ const { namespace: _snapshotNamespace, ...rawStoredSnapshot } = snapshot;
361
+ const storedSnapshot = normalizeStoredSnapshot(rawStoredSnapshot, ownedScope.modality);
362
+ const normalizedTail = tail
363
+ .map(({ namespace: _transactionNamespace, ...value }) =>
364
+ normalizeStoredCommitted(value, ownedScope.modality),
365
+ )
366
+ .sort(compareCommittedTransactions);
367
+ const headModality = head.modality ?? "spreadsheet";
368
+ if (headModality !== ownedScope.modality) {
369
+ throw new TypeError("retained head modality does not match its storage scope");
370
+ }
371
+ if (
372
+ storedSnapshot.modality === "spreadsheet" &&
373
+ retainedProtocolVersion !== storedSnapshot.protocolVersion
374
+ )
375
+ throw new TypeError("retained head protocol does not match its snapshot");
376
+ const replica: EditableArtifactStoredReplica = {
377
+ ...storedHead,
378
+ modality: ownedScope.modality,
379
+ snapshot: storedSnapshot,
380
+ tail: normalizedTail,
381
+ };
382
+ assertStoredReplica(replica);
383
+ // IndexedDB already returned a structured clone owned by this call. The
384
+ // namespace-stripped objects are not retained by the adapter, so another
385
+ // full copy (including every snapshot/journal byte) adds no isolation.
386
+ return replica;
387
+ }
388
+
389
+ async saveReplica(
390
+ scope: EditableArtifactStorageScope,
391
+ replica: EditableArtifactStoredReplica,
392
+ expectedHead: EditableArtifactExpectedStoredHead,
393
+ ): Promise<void> {
394
+ const ownedScope = cloneStorageScope(scope);
395
+ const ownedReplica = cloneStoredReplica(replica);
396
+ const ownedExpectedHead = cloneExpectedStoredHead(expectedHead);
397
+ assertScopeMatchesArtifact(ownedScope, ownedReplica.artifactId);
398
+ assertScopeModality(ownedScope, ownedReplica.modality);
399
+ assertStoredReplica(ownedReplica);
400
+ const database = await this.#database();
401
+ await replaceIndexedDbReplica(database, ownedScope, ownedReplica, ownedExpectedHead);
402
+ }
403
+
404
+ async appendCommitted(
405
+ scope: EditableArtifactStorageScope,
406
+ input: EditableArtifactAppendCommittedInput,
407
+ ): Promise<void> {
408
+ const ownedScope = cloneStorageScope(scope);
409
+ const ownedInput: EditableArtifactAppendCommittedInput = {
410
+ ...input,
411
+ transaction: cloneCommittedTransaction(input.transaction),
412
+ };
413
+ assertScopeMatchesArtifact(ownedScope, ownedInput.artifactId);
414
+ assertScopeModality(ownedScope, ownedInput.transaction.modality);
415
+ assertAppendInput(ownedInput);
416
+ const database = await this.#database();
417
+ await appendIndexedDbCommitted(database, ownedScope, ownedInput);
418
+ }
419
+
420
+ async clearReplica(scope: EditableArtifactStorageScope): Promise<void> {
421
+ const ownedScope = cloneStorageScope(scope);
422
+ const database = await this.#database();
423
+ await clearIndexedDbReplica(database, ownedScope);
424
+ }
425
+
426
+ async listPending(
427
+ scope: EditableArtifactStorageScope,
428
+ ): Promise<EditableArtifactPendingTransaction[]> {
429
+ const ownedScope = cloneStorageScope(scope);
430
+ const database = await this.#database();
431
+ const transaction = database.transaction(PENDING_STORE, "readonly");
432
+ const completed = transactionResult(transaction);
433
+ let values: IndexedDbPendingTransaction[];
434
+ try {
435
+ values = await readBoundedIndexValues<IndexedDbPendingTransaction>(
436
+ transaction,
437
+ transaction.objectStore(PENDING_STORE).index(PENDING_BY_SCOPE_INDEX),
438
+ scopeKey(ownedScope),
439
+ HARD_MAX_PENDING_TRANSACTIONS,
440
+ HARD_MAX_PENDING_BYTES,
441
+ (value) => {
442
+ if (
443
+ !(value.commandBytes instanceof Uint8Array) ||
444
+ !(value.intentBytes instanceof Uint8Array)
445
+ ) {
446
+ throw new TypeError("retained pending bytes must be Uint8Arrays");
447
+ }
448
+ return pendingStorageBytes(value);
449
+ },
450
+ );
451
+ await completed;
452
+ } catch (error) {
453
+ await completed.catch(() => undefined);
454
+ throw error;
455
+ }
456
+ return values
457
+ .map(({ namespace, transactionId, ...value }) => {
458
+ if (namespace !== ownedScope.namespace) {
459
+ throw new TypeError("retained pending namespace does not match its storage key");
460
+ }
461
+ if (transactionId !== value.clientTransactionId) {
462
+ throw new TypeError("retained pending key does not match clientTransactionId");
463
+ }
464
+ const normalized = normalizeStoredPending(value, ownedScope.modality);
465
+ assertPendingTransaction(normalized);
466
+ return normalized;
467
+ })
468
+ .sort(comparePendingTransactions);
469
+ }
470
+
471
+ async putPending(
472
+ scope: EditableArtifactStorageScope,
473
+ transaction: EditableArtifactPendingTransaction,
474
+ ): Promise<void> {
475
+ const ownedScope = cloneStorageScope(scope);
476
+ const ownedTransaction = clonePendingTransaction(transaction);
477
+ assertScopeMatchesArtifact(ownedScope, ownedTransaction.artifactId);
478
+ assertScopeModality(ownedScope, ownedTransaction.modality);
479
+ assertPendingTransaction(ownedTransaction);
480
+ const stored: IndexedDbPendingTransaction = {
481
+ ...ownedTransaction,
482
+ namespace: ownedScope.namespace,
483
+ transactionId: ownedTransaction.clientTransactionId,
484
+ };
485
+ const database = await this.#database();
486
+ await putIndexedDbPending(database, ownedScope, stored);
487
+ }
488
+
489
+ async deletePending(
490
+ scope: EditableArtifactStorageScope,
491
+ clientTransactionId: string,
492
+ ): Promise<void> {
493
+ const ownedScope = cloneStorageScope(scope);
494
+ const database = await this.#database();
495
+ const transaction = database.transaction(PENDING_STORE, "readwrite");
496
+ transaction
497
+ .objectStore(PENDING_STORE)
498
+ .delete([ownedScope.namespace, ownedScope.artifactId, clientTransactionId]);
499
+ await transactionResult(transaction);
500
+ }
501
+
502
+ async close(): Promise<void> {
503
+ const databasePromise = this.#databasePromise;
504
+ this.#databasePromise = null;
505
+ if (databasePromise !== null) (await databasePromise).close();
506
+ }
507
+
508
+ #database(): Promise<IDBDatabase> {
509
+ if (this.#databasePromise === null) {
510
+ const opening = openDatabase(this.#factory, this.#databaseName);
511
+ this.#databasePromise = opening;
512
+ void opening.then(
513
+ (database) => {
514
+ database.onclose = () => {
515
+ if (this.#databasePromise === opening) this.#databasePromise = null;
516
+ };
517
+ database.onversionchange = () => {
518
+ database.close();
519
+ if (this.#databasePromise === opening) this.#databasePromise = null;
520
+ };
521
+ },
522
+ () => {
523
+ if (this.#databasePromise === opening) this.#databasePromise = null;
524
+ },
525
+ );
526
+ }
527
+ return this.#databasePromise;
528
+ }
529
+ }
530
+
531
+ function cloneSnapshot(snapshot: EditableArtifactSnapshot): EditableArtifactSnapshot {
532
+ return snapshot.modality === "spreadsheet"
533
+ ? {
534
+ ...snapshot,
535
+ causalFrontier: cloneFrontier(snapshot.causalFrontier),
536
+ bytes: snapshot.bytes.slice(),
537
+ }
538
+ : { ...snapshot, bytes: snapshot.bytes.slice() };
539
+ }
540
+
541
+ function cloneCommittedTransaction(
542
+ transaction: EditableArtifactCommittedTransaction,
543
+ ): EditableArtifactCommittedTransaction {
544
+ return transaction.modality === "spreadsheet"
545
+ ? {
546
+ ...transaction,
547
+ causalFrontier: cloneFrontier(transaction.causalFrontier),
548
+ committedTransactionBytes: transaction.committedTransactionBytes.slice(),
549
+ }
550
+ : {
551
+ ...transaction,
552
+ committedTransactionBytes: transaction.committedTransactionBytes.slice(),
553
+ };
554
+ }
555
+
556
+ function clonePendingTransaction(
557
+ transaction: EditableArtifactPendingTransaction,
558
+ ): EditableArtifactPendingTransaction {
559
+ return transaction.modality === "spreadsheet"
560
+ ? {
561
+ ...transaction,
562
+ causalBase: cloneFrontier(transaction.causalBase),
563
+ selectiveUndoTargets: [...transaction.selectiveUndoTargets],
564
+ commandBytes: transaction.commandBytes.slice(),
565
+ intentBytes: transaction.intentBytes.slice(),
566
+ }
567
+ : {
568
+ ...transaction,
569
+ commandBytes: transaction.commandBytes.slice(),
570
+ intentBytes: transaction.intentBytes.slice(),
571
+ };
572
+ }
573
+
574
+ function cloneStoredReplica(replica: EditableArtifactStoredReplica): EditableArtifactStoredReplica {
575
+ return {
576
+ ...replica,
577
+ snapshot: cloneSnapshot(replica.snapshot),
578
+ tail: replica.tail.map(cloneCommittedTransaction),
579
+ };
580
+ }
581
+
582
+ function cloneStorageScope(scope: EditableArtifactStorageScope): EditableArtifactStorageScope {
583
+ const owned = {
584
+ namespace: scope.namespace,
585
+ artifactId: scope.artifactId,
586
+ modality: scope.modality,
587
+ };
588
+ assertScope(owned);
589
+ return owned;
590
+ }
591
+
592
+ function cloneExpectedStoredHead(
593
+ head: EditableArtifactExpectedStoredHead,
594
+ ): EditableArtifactExpectedStoredHead {
595
+ if (head === null) return null;
596
+ const owned = { cursor: head.cursor, stateHash: head.stateHash };
597
+ assertSequence(owned.cursor, "expected retained cursor");
598
+ assertNonEmpty(owned.stateHash, "expected retained stateHash");
599
+ return owned;
600
+ }
601
+
602
+ function cloneStoredReplicaHead(replica: StoredReplicaHead): StoredReplicaHead {
603
+ return { ...replica, snapshot: cloneSnapshot(replica.snapshot) };
604
+ }
605
+
606
+ function toStoredReplicaHead(replica: EditableArtifactStoredReplica): StoredReplicaHead {
607
+ const { tail: _tail, ...head } = replica;
608
+ return cloneStoredReplicaHead(head);
609
+ }
610
+
611
+ function toMemoryStoredReplica(replica: EditableArtifactStoredReplica): MemoryStoredReplica {
612
+ return {
613
+ head: toStoredReplicaHead(replica),
614
+ tail: replica.tail.map(cloneCommittedTransaction),
615
+ tailBytes: committedTailBytes(replica.tail),
616
+ };
617
+ }
618
+
619
+ function cloneMemoryStoredReplica(replica: MemoryStoredReplica): EditableArtifactStoredReplica {
620
+ return {
621
+ ...cloneStoredReplicaHead(replica.head),
622
+ tail: replica.tail.map(cloneCommittedTransaction),
623
+ };
624
+ }
625
+
626
+ function assertStoredReplica(replica: EditableArtifactStoredReplica): void {
627
+ assertStableId(replica.artifactId, "artifactId");
628
+ assertModality(replica.modality, "modality");
629
+ if (replica.snapshot.modality !== replica.modality) {
630
+ throw new TypeError("stored snapshot modality does not match its replica");
631
+ }
632
+ if (replica.snapshot.artifactId !== replica.artifactId) {
633
+ throw new TypeError("stored snapshot artifactId does not match its replica");
634
+ }
635
+ assertSequence(replica.snapshot.sequence, "snapshot.sequence");
636
+ assertSequence(replica.cursor, "cursor");
637
+ assertTimestamp(replica.updatedAt, "updatedAt");
638
+ assertBytes(replica.snapshot.bytes, "snapshot.bytes");
639
+ if (replica.snapshot.bytes.byteLength > HARD_MAX_SNAPSHOT_BYTES) {
640
+ throw new RangeError("stored snapshot exceeds its hard byte bound");
641
+ }
642
+ assertSha256(replica.snapshot.stateHash, "snapshot.stateHash");
643
+ assertSha256(replica.snapshot.digest, "snapshot.digest");
644
+ if (replica.snapshot.modality === "spreadsheet") {
645
+ assertFrontier(replica.snapshot.causalFrontier, "snapshot.causalFrontier");
646
+ assertPositiveInteger(replica.snapshot.protocolVersion, "snapshot.protocolVersion");
647
+ } else {
648
+ assertSequence(replica.snapshot.nativeRevision, "snapshot.nativeRevision");
649
+ }
650
+ assertNonEmpty(replica.snapshot.kernelVersion, "snapshot.kernelVersion");
651
+ assertPositiveInteger(replica.snapshot.modelSchemaVersion, "snapshot.modelSchemaVersion");
652
+ assertSha256(replica.stateHash, "stateHash");
653
+
654
+ let cursor = replica.snapshot.sequence;
655
+ let stateHash = replica.snapshot.stateHash;
656
+ let nativeRevision =
657
+ replica.snapshot.modality === "spreadsheet" ? null : replica.snapshot.nativeRevision;
658
+ if (replica.tail.length > HARD_MAX_COMMITTED_TRANSACTIONS) {
659
+ throw new RangeError("stored committed tail exceeds its count bound");
660
+ }
661
+ let tailBytes = 0;
662
+ for (const transaction of replica.tail) {
663
+ assertCommittedTransaction(transaction, replica.artifactId);
664
+ if (transaction.modality !== replica.modality) {
665
+ throw new TypeError("stored committed tail modality does not match its replica");
666
+ }
667
+ tailBytes = checkedAggregateBytes(
668
+ tailBytes,
669
+ committedStorageBytes(transaction),
670
+ HARD_MAX_COMMITTED_BYTES,
671
+ "stored committed tail",
672
+ );
673
+ if (transaction.startSequence !== cursor + 1) {
674
+ throw new TypeError("stored committed tail must be contiguous after its snapshot");
675
+ }
676
+ if (
677
+ transaction.modality === "spreadsheet" &&
678
+ replica.snapshot.modality === "spreadsheet" &&
679
+ transaction.protocolVersion !== replica.snapshot.protocolVersion
680
+ )
681
+ throw new TypeError("stored committed tail protocol does not match its snapshot");
682
+ if (transaction.modality !== "spreadsheet") {
683
+ if (nativeRevision !== transaction.priorNativeRevision) {
684
+ throw new TypeError("stored serialized tail prior native revision does not match");
685
+ }
686
+ nativeRevision = transaction.nativeRevision;
687
+ }
688
+ if (transaction.priorStateHash !== stateHash) {
689
+ throw new TypeError("stored committed tail priorStateHash does not match prior state");
690
+ }
691
+ cursor = transaction.endSequence;
692
+ stateHash = transaction.stateHash;
693
+ }
694
+ if (cursor !== replica.cursor) {
695
+ throw new TypeError("stored cursor is not reconstructible from snapshot plus committed tail");
696
+ }
697
+ if (stateHash !== replica.stateHash) {
698
+ throw new TypeError("stored stateHash does not match snapshot plus committed tail");
699
+ }
700
+ }
701
+
702
+ function committedStorageBytes(
703
+ transaction: Pick<EditableArtifactCommittedTransaction, "committedTransactionBytes">,
704
+ ): number {
705
+ if (!(transaction.committedTransactionBytes instanceof Uint8Array)) {
706
+ throw new TypeError("committedTransactionBytes must be a Uint8Array");
707
+ }
708
+ return transaction.committedTransactionBytes.byteLength + 512;
709
+ }
710
+
711
+ function committedTailBytes(transactions: readonly EditableArtifactCommittedTransaction[]): number {
712
+ let bytes = 0;
713
+ for (const transaction of transactions) {
714
+ bytes = checkedAggregateBytes(
715
+ bytes,
716
+ committedStorageBytes(transaction),
717
+ HARD_MAX_COMMITTED_BYTES,
718
+ "stored committed tail",
719
+ );
720
+ }
721
+ return bytes;
722
+ }
723
+
724
+ function pendingStorageBytes(
725
+ transaction: Pick<EditableArtifactPendingTransaction, "commandBytes" | "intentBytes">,
726
+ ): number {
727
+ if (
728
+ !(transaction.commandBytes instanceof Uint8Array) ||
729
+ !(transaction.intentBytes instanceof Uint8Array)
730
+ ) {
731
+ throw new TypeError("pending transaction bytes must be Uint8Arrays");
732
+ }
733
+ const bytes = transaction.commandBytes.byteLength + transaction.intentBytes.byteLength;
734
+ if (!Number.isSafeInteger(bytes)) {
735
+ throw new RangeError("pending transaction byte size is not a safe integer");
736
+ }
737
+ return bytes;
738
+ }
739
+
740
+ function checkedAggregateBytes(
741
+ retainedBytes: number,
742
+ addedBytes: number,
743
+ maximumBytes: number,
744
+ label: string,
745
+ ): number {
746
+ if (!Number.isSafeInteger(retainedBytes) || retainedBytes < 0) {
747
+ throw new TypeError(`${label} has invalid retained byte accounting`);
748
+ }
749
+ if (!Number.isSafeInteger(addedBytes) || addedBytes < 0) {
750
+ throw new TypeError(`${label} has an invalid added byte size`);
751
+ }
752
+ const aggregateBytes = retainedBytes + addedBytes;
753
+ if (!Number.isSafeInteger(aggregateBytes) || aggregateBytes > maximumBytes) {
754
+ throw new RangeError(`${label} exceeds its byte bound`);
755
+ }
756
+ return aggregateBytes;
757
+ }
758
+
759
+ function assertStoredHeadAccounting(
760
+ head: IndexedDbStoredReplicaHead,
761
+ tailCount: number,
762
+ tailBytes: number,
763
+ ): void {
764
+ assertSequence(head.tailCount, "retained head tailCount");
765
+ assertSequence(head.tailBytes, "retained head tailBytes");
766
+ if (head.tailCount !== tailCount || head.tailBytes !== tailBytes) {
767
+ throw new TypeError("retained head journal accounting does not match its transactions");
768
+ }
769
+ }
770
+
771
+ function assertAppendInput(input: EditableArtifactAppendCommittedInput): void {
772
+ assertNonEmpty(input.artifactId, "artifactId");
773
+ assertSequence(input.expectedCursor, "expectedCursor");
774
+ assertNonEmpty(input.expectedStateHash, "expectedStateHash");
775
+ assertTimestamp(input.updatedAt, "updatedAt");
776
+ assertCommittedTransaction(input.transaction, input.artifactId);
777
+ if (input.transaction.startSequence !== input.expectedCursor + 1) {
778
+ throw new TypeError("committed append must begin immediately after expectedCursor");
779
+ }
780
+ if (input.transaction.priorStateHash !== input.expectedStateHash) {
781
+ throw new TypeError("committed append priorStateHash must equal expectedStateHash");
782
+ }
783
+ }
784
+
785
+ function assertCommittedTransaction(
786
+ transaction: EditableArtifactCommittedTransaction,
787
+ artifactId: EditableArtifactId,
788
+ ): void {
789
+ if (transaction.artifactId !== artifactId) {
790
+ throw new TypeError("committed transaction artifactId does not match its replica");
791
+ }
792
+ assertStableId(transaction.transactionId, "transactionId");
793
+ assertSha256(transaction.requestHash, "requestHash");
794
+ assertSequence(transaction.startSequence, "startSequence");
795
+ assertSequence(transaction.endSequence, "endSequence");
796
+ if (transaction.endSequence < transaction.startSequence) {
797
+ throw new TypeError("committed transaction has an inverted sequence interval");
798
+ }
799
+ assertSha256(transaction.priorStateHash, "priorStateHash");
800
+ assertSha256(transaction.stateHash, "stateHash");
801
+ assertModality(transaction.modality, "transaction.modality");
802
+ assertBytes(transaction.committedTransactionBytes, "committedTransactionBytes");
803
+ if (transaction.committedTransactionBytes.byteLength > HARD_MAX_COMMITTED_TRANSACTION_BYTES) {
804
+ throw new RangeError("committedTransactionBytes exceeds its hard byte bound");
805
+ }
806
+ if (transaction.modality === "spreadsheet") {
807
+ assertFrontier(transaction.causalFrontier, "causalFrontier");
808
+ assertPositiveInteger(transaction.protocolVersion, "protocolVersion");
809
+ const summary = decodeCommittedTransactionSummary(transaction.committedTransactionBytes);
810
+ if (
811
+ summary.transactionId !== transaction.transactionId ||
812
+ summary.operationProtocolVersion !== transaction.protocolVersion ||
813
+ summary.priorStateHash !== transaction.priorStateHash ||
814
+ summary.stateHash !== transaction.stateHash ||
815
+ !frontiersEqual(summary.resultingCausalFrontier, transaction.causalFrontier)
816
+ )
817
+ throw new TypeError("committed fields do not exactly match their OGACO envelope");
818
+ return;
819
+ }
820
+ assertSequence(transaction.priorNativeRevision, "priorNativeRevision");
821
+ assertSequence(transaction.nativeRevision, "nativeRevision");
822
+ assertPositiveInteger(transaction.commitProtocolVersion, "commitProtocolVersion");
823
+ const summary = decodeEditableArtifactSerializedCommit(
824
+ transaction.committedTransactionBytes,
825
+ transaction.modality,
826
+ );
827
+ if (
828
+ summary.transactionId !== transaction.transactionId ||
829
+ summary.requestHash !== transaction.requestHash ||
830
+ summary.parentHeadSequence + 1 !== transaction.startSequence ||
831
+ summary.resultHeadSequence !== transaction.endSequence ||
832
+ summary.priorNativeRevision !== transaction.priorNativeRevision ||
833
+ summary.nativeReceipt.revision !== transaction.nativeRevision ||
834
+ summary.commitProtocolVersion !== transaction.commitProtocolVersion ||
835
+ summary.priorStateHash !== transaction.priorStateHash ||
836
+ summary.stateHash !== transaction.stateHash
837
+ )
838
+ throw new TypeError("committed fields do not exactly match their OGAST envelope");
839
+ }
840
+
841
+ function assertPendingTransaction(transaction: EditableArtifactPendingTransaction): void {
842
+ assertStableId(transaction.artifactId, "artifactId");
843
+ assertClientTransactionId(transaction.clientTransactionId, "clientTransactionId");
844
+ assertSha256(transaction.requestHash, "requestHash");
845
+ assertPositiveU16(transaction.protocolVersion, "protocolVersion");
846
+ assertPositiveU16(transaction.modelSchemaVersion, "modelSchemaVersion");
847
+ assertPositiveU16(transaction.commandVersion, "commandVersion");
848
+ assertReplicaId(transaction.replicaId, "replicaId");
849
+ assertPositiveInteger(transaction.replicaCounter, "replicaCounter");
850
+ if (transaction.previousLocalTransactionId !== null) {
851
+ assertClientTransactionId(transaction.previousLocalTransactionId, "previousLocalTransactionId");
852
+ if (transaction.previousLocalTransactionId === transaction.clientTransactionId) {
853
+ throw new TypeError("pending transaction cannot depend on itself");
854
+ }
855
+ }
856
+ assertModality(transaction.modality, "pending.modality");
857
+ assertTimestamp(transaction.createdAt, "createdAt");
858
+ assertSequence(transaction.observedHeadSequence, "observedHeadSequence");
859
+ assertBytes(transaction.commandBytes, "commandBytes");
860
+ assertBytes(transaction.intentBytes, "intentBytes");
861
+ if (transaction.commandBytes.byteLength > HARD_MAX_COMMAND_BYTES) {
862
+ throw new RangeError("commandBytes exceeds its hard byte bound");
863
+ }
864
+ if (transaction.intentBytes.byteLength > HARD_MAX_INTENT_BYTES) {
865
+ throw new RangeError("intentBytes exceeds its hard byte bound");
866
+ }
867
+ if (transaction.modality === "spreadsheet") {
868
+ assertFrontier(transaction.causalBase, "causalBase");
869
+ assertIdentifiers(transaction.selectiveUndoTargets, "selectiveUndoTargets");
870
+ assertCanonicalSpreadsheetArtifactCommandBytes(transaction.commandBytes);
871
+ } else {
872
+ assertSequence(transaction.observedNativeRevision, "observedNativeRevision");
873
+ editableArtifactCodecFor({
874
+ durableModality: transaction.modality,
875
+ modelSchemaVersion: transaction.modelSchemaVersion,
876
+ commandProtocolVersion: transaction.commandVersion,
877
+ }).command.assertCanonical(transaction.commandBytes);
878
+ }
879
+ const intent = decodeEditableArtifactMutationIntent(transaction.intentBytes);
880
+ if (
881
+ intent.artifactId !== transaction.artifactId ||
882
+ intent.clientTransactionId !== transaction.clientTransactionId ||
883
+ intent.replicaId !== transaction.replicaId ||
884
+ intent.replicaCounter !== transaction.replicaCounter ||
885
+ intent.previousLocalTransactionId !== transaction.previousLocalTransactionId ||
886
+ intent.protocolVersion !== transaction.protocolVersion ||
887
+ intent.modelSchemaVersion !== transaction.modelSchemaVersion ||
888
+ intent.commandProtocolVersion !== transaction.commandVersion ||
889
+ intent.observedHeadSequence !== transaction.observedHeadSequence ||
890
+ !bytesEqual(intent.commandBytes, transaction.commandBytes)
891
+ ) {
892
+ throw new TypeError("pending fields do not exactly match their OGATX envelope");
893
+ }
894
+ if (
895
+ transaction.modality === "spreadsheet"
896
+ ? !frontiersEqual(intent.causalBase, transaction.causalBase) ||
897
+ !identifiersEqual(intent.selectiveUndoOperationIds, transaction.selectiveUndoTargets)
898
+ : intent.causalBase.length !== 0 || intent.selectiveUndoOperationIds.length !== 0
899
+ )
900
+ throw new TypeError("pending causality does not match its durable modality");
901
+ if (
902
+ hashEditableArtifactMutationIntentBytes(transaction.intentBytes) !== transaction.requestHash
903
+ ) {
904
+ throw new TypeError("pending requestHash does not match its OGATX envelope");
905
+ }
906
+ }
907
+
908
+ function assertAppendCanApply(
909
+ head: Pick<StoredReplicaHead, "cursor" | "stateHash"> & {
910
+ modality?: EditableArtifactModality;
911
+ protocolVersion?: number;
912
+ },
913
+ input: EditableArtifactAppendCommittedInput,
914
+ ): void {
915
+ if (head.cursor !== input.expectedCursor || head.stateHash !== input.expectedStateHash) {
916
+ throw new EditableArtifactStorageConflictError(
917
+ `artifact ${input.artifactId} retained head no longer matches append expectation`,
918
+ );
919
+ }
920
+ const headModality = head.modality ?? "spreadsheet";
921
+ if (input.transaction.modality !== headModality) {
922
+ throw new EditableArtifactStorageConflictError(
923
+ `artifact ${input.artifactId} append modality does not match retained snapshot`,
924
+ );
925
+ }
926
+ if (
927
+ input.transaction.modality === "spreadsheet" &&
928
+ input.transaction.protocolVersion !== head.protocolVersion
929
+ ) {
930
+ throw new EditableArtifactStorageConflictError(
931
+ `artifact ${input.artifactId} append protocol does not match retained snapshot`,
932
+ );
933
+ }
934
+ }
935
+
936
+ function assertReplacementCanApply(
937
+ head:
938
+ | (Pick<StoredReplicaHead, "cursor" | "stateHash"> & {
939
+ protocolVersion?: number;
940
+ })
941
+ | undefined,
942
+ expectedHead: EditableArtifactExpectedStoredHead,
943
+ artifactId: EditableArtifactId,
944
+ ): void {
945
+ if (expectedHead === null) {
946
+ if (head === undefined) return;
947
+ } else if (
948
+ head !== undefined &&
949
+ head.cursor === expectedHead.cursor &&
950
+ head.stateHash === expectedHead.stateHash
951
+ ) {
952
+ return;
953
+ }
954
+ throw new EditableArtifactStorageConflictError(
955
+ `artifact ${artifactId} retained head changed before snapshot replacement`,
956
+ );
957
+ }
958
+
959
+ function missingReplicaConflict(
960
+ artifactId: EditableArtifactId,
961
+ ): EditableArtifactStorageConflictError {
962
+ return new EditableArtifactStorageConflictError(
963
+ `artifact ${artifactId} has no retained replica to append to`,
964
+ );
965
+ }
966
+
967
+ function compareCommittedTransactions(
968
+ left: EditableArtifactCommittedTransaction,
969
+ right: EditableArtifactCommittedTransaction,
970
+ ): number {
971
+ if (left.startSequence !== right.startSequence) {
972
+ return left.startSequence < right.startSequence ? -1 : 1;
973
+ }
974
+ if (left.transactionId === right.transactionId) return 0;
975
+ return left.transactionId < right.transactionId ? -1 : 1;
976
+ }
977
+
978
+ function assertPendingCanReplace(
979
+ existing: EditableArtifactPendingTransaction | undefined,
980
+ next: EditableArtifactPendingTransaction,
981
+ ): void {
982
+ if (existing !== undefined && !pendingTransactionsEqual(existing, next)) {
983
+ throw new EditableArtifactStorageConflictError(
984
+ `pending transaction ${next.clientTransactionId} was reused with different immutable content`,
985
+ );
986
+ }
987
+ }
988
+
989
+ function pendingTransactionsEqual(
990
+ left: EditableArtifactPendingTransaction,
991
+ right: EditableArtifactPendingTransaction,
992
+ ): boolean {
993
+ return (
994
+ left.artifactId === right.artifactId &&
995
+ left.modality === right.modality &&
996
+ left.clientTransactionId === right.clientTransactionId &&
997
+ left.requestHash === right.requestHash &&
998
+ left.protocolVersion === right.protocolVersion &&
999
+ left.modelSchemaVersion === right.modelSchemaVersion &&
1000
+ left.commandVersion === right.commandVersion &&
1001
+ left.replicaId === right.replicaId &&
1002
+ left.replicaCounter === right.replicaCounter &&
1003
+ left.previousLocalTransactionId === right.previousLocalTransactionId &&
1004
+ left.observedHeadSequence === right.observedHeadSequence &&
1005
+ left.createdAt === right.createdAt &&
1006
+ (left.modality === "spreadsheet" && right.modality === "spreadsheet"
1007
+ ? frontiersEqual(left.causalBase, right.causalBase) &&
1008
+ identifiersEqual(left.selectiveUndoTargets, right.selectiveUndoTargets)
1009
+ : left.modality !== "spreadsheet" &&
1010
+ right.modality !== "spreadsheet" &&
1011
+ left.observedNativeRevision === right.observedNativeRevision) &&
1012
+ bytesEqual(left.commandBytes, right.commandBytes) &&
1013
+ bytesEqual(left.intentBytes, right.intentBytes)
1014
+ );
1015
+ }
1016
+
1017
+ function committedTransactionsEqual(
1018
+ left: EditableArtifactCommittedTransaction,
1019
+ right: EditableArtifactCommittedTransaction,
1020
+ ): boolean {
1021
+ return (
1022
+ left.artifactId === right.artifactId &&
1023
+ left.modality === right.modality &&
1024
+ left.transactionId === right.transactionId &&
1025
+ left.requestHash === right.requestHash &&
1026
+ left.startSequence === right.startSequence &&
1027
+ left.endSequence === right.endSequence &&
1028
+ left.priorStateHash === right.priorStateHash &&
1029
+ left.stateHash === right.stateHash &&
1030
+ (left.modality === "spreadsheet" && right.modality === "spreadsheet"
1031
+ ? left.protocolVersion === right.protocolVersion &&
1032
+ frontiersEqual(left.causalFrontier, right.causalFrontier)
1033
+ : left.modality !== "spreadsheet" &&
1034
+ right.modality !== "spreadsheet" &&
1035
+ left.priorNativeRevision === right.priorNativeRevision &&
1036
+ left.nativeRevision === right.nativeRevision &&
1037
+ left.commitProtocolVersion === right.commitProtocolVersion) &&
1038
+ bytesEqual(left.committedTransactionBytes, right.committedTransactionBytes)
1039
+ );
1040
+ }
1041
+
1042
+ function frontiersEqual(
1043
+ left: EditableArtifactCausalFrontier,
1044
+ right: EditableArtifactCausalFrontier,
1045
+ ): boolean {
1046
+ return (
1047
+ left.length === right.length &&
1048
+ left.every(
1049
+ (entry, index) =>
1050
+ entry.replicaId === right[index]?.replicaId && entry.counter === right[index]?.counter,
1051
+ )
1052
+ );
1053
+ }
1054
+
1055
+ function bytesEqual(left: Uint8Array, right: Uint8Array): boolean {
1056
+ if (left.byteLength !== right.byteLength) return false;
1057
+ for (let index = 0; index < left.byteLength; index += 1) {
1058
+ if (left[index] !== right[index]) return false;
1059
+ }
1060
+ return true;
1061
+ }
1062
+
1063
+ function comparePendingTransactions(
1064
+ left: EditableArtifactPendingTransaction,
1065
+ right: EditableArtifactPendingTransaction,
1066
+ ): number {
1067
+ if (left.replicaCounter !== right.replicaCounter) {
1068
+ return left.replicaCounter < right.replicaCounter ? -1 : 1;
1069
+ }
1070
+ if (left.createdAt !== right.createdAt) return left.createdAt < right.createdAt ? -1 : 1;
1071
+ if (left.clientTransactionId === right.clientTransactionId) return 0;
1072
+ return left.clientTransactionId < right.clientTransactionId ? -1 : 1;
1073
+ }
1074
+
1075
+ function assertScope(scope: EditableArtifactStorageScope): void {
1076
+ assertNonEmpty(scope.namespace, "storage namespace");
1077
+ assertStableId(scope.artifactId, "storage artifactId");
1078
+ assertModality(scope.modality, "storage modality");
1079
+ }
1080
+
1081
+ function assertModality(value: unknown, path: string): asserts value is EditableArtifactModality {
1082
+ if (value !== "document" && value !== "spreadsheet" && value !== "presentation") {
1083
+ throw new TypeError(`${path} must be document, spreadsheet, or presentation`);
1084
+ }
1085
+ }
1086
+
1087
+ function assertScopeMatchesArtifact(
1088
+ scope: EditableArtifactStorageScope,
1089
+ artifactId: EditableArtifactId,
1090
+ ): void {
1091
+ assertScope(scope);
1092
+ if (scope.artifactId !== artifactId) {
1093
+ throw new TypeError("storage scope artifactId does not match the retained value");
1094
+ }
1095
+ }
1096
+
1097
+ function assertScopeModality(
1098
+ scope: EditableArtifactStorageScope,
1099
+ modality: EditableArtifactModality,
1100
+ ): void {
1101
+ if (scope.modality !== modality) {
1102
+ throw new TypeError("storage scope modality does not match the retained value");
1103
+ }
1104
+ }
1105
+
1106
+ function normalizeStoredSnapshot(
1107
+ snapshot: Omit<IndexedDbStoredSnapshot, "namespace">,
1108
+ modality: EditableArtifactModality,
1109
+ ): EditableArtifactSnapshot {
1110
+ const retainedModality =
1111
+ (snapshot as { modality?: EditableArtifactModality }).modality ?? "spreadsheet";
1112
+ if (retainedModality !== modality) {
1113
+ throw new TypeError("retained snapshot modality does not match its storage scope");
1114
+ }
1115
+ return { ...snapshot, modality } as EditableArtifactSnapshot;
1116
+ }
1117
+
1118
+ function normalizeStoredCommitted(
1119
+ transaction: Omit<IndexedDbCommittedTransaction, "namespace">,
1120
+ modality: EditableArtifactModality,
1121
+ ): EditableArtifactCommittedTransaction {
1122
+ const retainedModality =
1123
+ (transaction as { modality?: EditableArtifactModality }).modality ?? "spreadsheet";
1124
+ if (retainedModality !== modality) {
1125
+ throw new TypeError("retained transaction modality does not match its storage scope");
1126
+ }
1127
+ return { ...transaction, modality } as EditableArtifactCommittedTransaction;
1128
+ }
1129
+
1130
+ function normalizeStoredPending(
1131
+ transaction: Omit<IndexedDbPendingTransaction, "namespace" | "transactionId">,
1132
+ modality: EditableArtifactModality,
1133
+ ): EditableArtifactPendingTransaction {
1134
+ const retainedModality =
1135
+ (transaction as { modality?: EditableArtifactModality }).modality ?? "spreadsheet";
1136
+ if (retainedModality !== modality) {
1137
+ throw new TypeError("retained pending modality does not match its storage scope");
1138
+ }
1139
+ return { ...transaction, modality } as EditableArtifactPendingTransaction;
1140
+ }
1141
+
1142
+ function scopeKey(scope: EditableArtifactStorageScope): [string, EditableArtifactId] {
1143
+ return [scope.namespace, scope.artifactId];
1144
+ }
1145
+
1146
+ function getOrCreate<OuterKey, InnerKey, Value>(
1147
+ outer: Map<OuterKey, Map<InnerKey, Value>>,
1148
+ key: OuterKey,
1149
+ ): Map<InnerKey, Value> {
1150
+ const existing = outer.get(key);
1151
+ if (existing !== undefined) return existing;
1152
+ const created = new Map<InnerKey, Value>();
1153
+ outer.set(key, created);
1154
+ return created;
1155
+ }
1156
+
1157
+ function assertFrontier(frontier: EditableArtifactCausalFrontier, path: string): void {
1158
+ if (!Array.isArray(frontier)) throw new TypeError(`${path} must be an array`);
1159
+ let previous: string | null = null;
1160
+ for (const [index, entry] of frontier.entries()) {
1161
+ if (
1162
+ entry === null ||
1163
+ typeof entry !== "object" ||
1164
+ Array.isArray(entry) ||
1165
+ Object.getPrototypeOf(entry) !== Object.prototype
1166
+ ) {
1167
+ throw new TypeError(`${path}[${index}] must be a plain object`);
1168
+ }
1169
+ assertReplicaId(entry.replicaId, `${path}[${index}].replicaId`);
1170
+ assertPositiveInteger(entry.counter, `${path}[${index}].counter`);
1171
+ if (previous !== null && previous >= entry.replicaId) {
1172
+ throw new TypeError(`${path} must be strictly sorted and duplicate-free`);
1173
+ }
1174
+ previous = entry.replicaId;
1175
+ }
1176
+ }
1177
+
1178
+ function cloneFrontier(frontier: EditableArtifactCausalFrontier): EditableArtifactCausalFrontier {
1179
+ return frontier.map((entry) => ({ ...entry }));
1180
+ }
1181
+
1182
+ function assertIdentifiers(values: readonly string[], path: string): void {
1183
+ if (!Array.isArray(values)) throw new TypeError(`${path} must be an array`);
1184
+ let previous: string | null = null;
1185
+ for (const value of values) {
1186
+ assertStableId(value, path);
1187
+ if (previous !== null && previous >= value) {
1188
+ throw new TypeError(`${path} must be strictly sorted and duplicate-free`);
1189
+ }
1190
+ previous = value;
1191
+ }
1192
+ }
1193
+
1194
+ function identifiersEqual(left: readonly string[], right: readonly string[]): boolean {
1195
+ return left.length === right.length && left.every((value, index) => value === right[index]);
1196
+ }
1197
+
1198
+ function assertBytes(value: Uint8Array, path: string): void {
1199
+ if (!(value instanceof Uint8Array)) throw new TypeError(`${path} must be a Uint8Array`);
1200
+ }
1201
+
1202
+ function assertSequence(value: number, path: string): void {
1203
+ if (!Number.isSafeInteger(value) || value < 0) {
1204
+ throw new TypeError(`${path} must be a non-negative safe integer`);
1205
+ }
1206
+ }
1207
+
1208
+ function assertPositiveInteger(value: number, path: string): void {
1209
+ if (!Number.isSafeInteger(value) || value <= 0) {
1210
+ throw new TypeError(`${path} must be a positive safe integer`);
1211
+ }
1212
+ }
1213
+
1214
+ function assertPositiveU16(value: number, path: string): void {
1215
+ assertPositiveInteger(value, path);
1216
+ if (value > 0xffff) throw new TypeError(`${path} must fit an unsigned 16-bit integer`);
1217
+ }
1218
+
1219
+ function assertTimestamp(value: number, path: string): void {
1220
+ if (!Number.isFinite(value) || value < 0) {
1221
+ throw new TypeError(`${path} must be a non-negative finite number`);
1222
+ }
1223
+ }
1224
+
1225
+ function assertNonEmpty(
1226
+ value: unknown,
1227
+ path: string,
1228
+ maximumCodeUnits = 4_096,
1229
+ ): asserts value is string {
1230
+ if (typeof value !== "string") throw new TypeError(`${path} must be a string`);
1231
+ if (value.length === 0) throw new TypeError(`${path} must not be empty`);
1232
+ if (value.length > maximumCodeUnits) {
1233
+ throw new RangeError(`${path} exceeds its string bound`);
1234
+ }
1235
+ }
1236
+
1237
+ function assertStableId(value: unknown, path: string): asserts value is string {
1238
+ if (typeof value !== "string" || !/^(?!0{32}$)[a-f0-9]{32}$/u.test(value)) {
1239
+ throw new TypeError(`${path} must be 32 lowercase nonzero hex characters`);
1240
+ }
1241
+ }
1242
+
1243
+ function assertReplicaId(value: unknown, path: string): asserts value is string {
1244
+ if (typeof value !== "string" || !/^(?!0{16}$)[a-f0-9]{16}$/u.test(value)) {
1245
+ throw new TypeError(`${path} must be 16 lowercase nonzero hex characters`);
1246
+ }
1247
+ }
1248
+
1249
+ function assertClientTransactionId(value: unknown, path: string): asserts value is string {
1250
+ if (typeof value !== "string" || !/^[A-Za-z0-9._:-]{1,200}$/u.test(value)) {
1251
+ throw new TypeError(`${path} must be a canonical portable transaction id`);
1252
+ }
1253
+ }
1254
+
1255
+ function assertSha256(value: unknown, path: string): asserts value is string {
1256
+ if (typeof value !== "string" || !/^sha256:[a-f0-9]{64}$/u.test(value)) {
1257
+ throw new TypeError(`${path} must be a canonical sha256 digest`);
1258
+ }
1259
+ }
1260
+
1261
+ function openDatabase(factory: IDBFactory, databaseName: string): Promise<IDBDatabase> {
1262
+ return new Promise((resolve, reject) => {
1263
+ const request = factory.open(databaseName, DATABASE_VERSION);
1264
+ let rejectedWhileBlocked = false;
1265
+ request.onerror = () => reject(request.error ?? new Error("failed to open artifact storage"));
1266
+ request.onblocked = () => {
1267
+ rejectedWhileBlocked = true;
1268
+ reject(new Error("artifact storage upgrade is blocked"));
1269
+ };
1270
+ request.onupgradeneeded = (event) => {
1271
+ const database = request.result;
1272
+ if (event.oldVersion < 2) {
1273
+ // V1 records had no principal partition. They cannot be attributed
1274
+ // safely, so discard this rebuildable cache instead of guessing scope.
1275
+ for (const name of [REPLICA_HEAD_STORE, SNAPSHOT_STORE, PENDING_STORE, COMMITTED_STORE]) {
1276
+ if (database.objectStoreNames.contains(name)) database.deleteObjectStore(name);
1277
+ }
1278
+ } else if (event.oldVersion < 3) {
1279
+ // V2 heads did not retain constant-time journal accounting. Confirmed
1280
+ // state is reconstructible, so rebuild it while preserving the pending WAL.
1281
+ for (const name of [REPLICA_HEAD_STORE, SNAPSHOT_STORE, COMMITTED_STORE]) {
1282
+ if (database.objectStoreNames.contains(name)) database.deleteObjectStore(name);
1283
+ }
1284
+ }
1285
+ if (!database.objectStoreNames.contains(REPLICA_HEAD_STORE)) {
1286
+ database.createObjectStore(REPLICA_HEAD_STORE, {
1287
+ keyPath: ["namespace", "artifactId"],
1288
+ });
1289
+ }
1290
+ if (!database.objectStoreNames.contains(SNAPSHOT_STORE)) {
1291
+ database.createObjectStore(SNAPSHOT_STORE, {
1292
+ keyPath: ["namespace", "artifactId"],
1293
+ });
1294
+ }
1295
+ if (!database.objectStoreNames.contains(COMMITTED_STORE)) {
1296
+ const committed = database.createObjectStore(COMMITTED_STORE, {
1297
+ keyPath: ["namespace", "artifactId", "startSequence"],
1298
+ });
1299
+ committed.createIndex(COMMITTED_BY_SCOPE_INDEX, ["namespace", "artifactId"], {
1300
+ unique: false,
1301
+ });
1302
+ }
1303
+ if (!database.objectStoreNames.contains(PENDING_STORE)) {
1304
+ const pending = database.createObjectStore(PENDING_STORE, {
1305
+ keyPath: ["namespace", "artifactId", "transactionId"],
1306
+ });
1307
+ pending.createIndex(PENDING_BY_SCOPE_INDEX, ["namespace", "artifactId"], {
1308
+ unique: false,
1309
+ });
1310
+ }
1311
+ };
1312
+ request.onsuccess = () => {
1313
+ if (rejectedWhileBlocked) request.result.close();
1314
+ else resolve(request.result);
1315
+ };
1316
+ });
1317
+ }
1318
+
1319
+ function replaceIndexedDbReplica(
1320
+ database: IDBDatabase,
1321
+ scope: EditableArtifactStorageScope,
1322
+ replica: EditableArtifactStoredReplica,
1323
+ expectedHead: EditableArtifactExpectedStoredHead,
1324
+ ): Promise<void> {
1325
+ return new Promise((resolve, reject) => {
1326
+ const transaction = database.transaction(
1327
+ [REPLICA_HEAD_STORE, SNAPSHOT_STORE, COMMITTED_STORE],
1328
+ "readwrite",
1329
+ );
1330
+ const headStore = transaction.objectStore(REPLICA_HEAD_STORE);
1331
+ const snapshotStore = transaction.objectStore(SNAPSHOT_STORE);
1332
+ const committedStore = transaction.objectStore(COMMITTED_STORE);
1333
+ let semanticError: unknown;
1334
+ transaction.oncomplete = () => resolve();
1335
+ transaction.onabort = () => rejectTransaction(reject, transaction, semanticError);
1336
+
1337
+ const headRead = headStore.get(scopeKey(scope));
1338
+ headRead.onerror = () => {
1339
+ semanticError = headRead.error ?? new Error("artifact replica head lookup failed");
1340
+ };
1341
+ headRead.onsuccess = () => {
1342
+ try {
1343
+ const retained = headRead.result as IndexedDbStoredReplicaHead | undefined;
1344
+ if (
1345
+ retained !== undefined &&
1346
+ (retained.namespace !== scope.namespace || retained.artifactId !== scope.artifactId)
1347
+ ) {
1348
+ throw new TypeError("retained replica head does not match its storage key");
1349
+ }
1350
+ assertReplacementCanApply(retained, expectedHead, replica.artifactId);
1351
+ } catch (error) {
1352
+ semanticError = error;
1353
+ transaction.abort();
1354
+ return;
1355
+ }
1356
+ deleteIndexedDbScope(
1357
+ committedStore.index(COMMITTED_BY_SCOPE_INDEX),
1358
+ scopeKey(scope),
1359
+ () => {
1360
+ try {
1361
+ for (const committed of replica.tail) {
1362
+ const stored: IndexedDbCommittedTransaction = {
1363
+ ...committed,
1364
+ namespace: scope.namespace,
1365
+ };
1366
+ committedStore.add(stored);
1367
+ }
1368
+ const head: IndexedDbStoredReplicaHead = {
1369
+ namespace: scope.namespace,
1370
+ artifactId: replica.artifactId,
1371
+ cursor: replica.cursor,
1372
+ stateHash: replica.stateHash,
1373
+ updatedAt: replica.updatedAt,
1374
+ modality: replica.modality,
1375
+ ...(replica.snapshot.modality === "spreadsheet"
1376
+ ? { protocolVersion: replica.snapshot.protocolVersion }
1377
+ : {}),
1378
+ tailCount: replica.tail.length,
1379
+ tailBytes: committedTailBytes(replica.tail),
1380
+ };
1381
+ const snapshot: IndexedDbStoredSnapshot = {
1382
+ ...replica.snapshot,
1383
+ namespace: scope.namespace,
1384
+ };
1385
+ snapshotStore.put(snapshot);
1386
+ headStore.put(head);
1387
+ } catch (error) {
1388
+ semanticError = error;
1389
+ transaction.abort();
1390
+ }
1391
+ },
1392
+ (error) => {
1393
+ semanticError = error;
1394
+ transaction.abort();
1395
+ },
1396
+ );
1397
+ };
1398
+ });
1399
+ }
1400
+
1401
+ function appendIndexedDbCommitted(
1402
+ database: IDBDatabase,
1403
+ scope: EditableArtifactStorageScope,
1404
+ input: EditableArtifactAppendCommittedInput,
1405
+ ): Promise<void> {
1406
+ return new Promise((resolve, reject) => {
1407
+ const transaction = database.transaction([REPLICA_HEAD_STORE, COMMITTED_STORE], "readwrite");
1408
+ const headStore = transaction.objectStore(REPLICA_HEAD_STORE);
1409
+ const committedStore = transaction.objectStore(COMMITTED_STORE);
1410
+ let semanticError: unknown;
1411
+ transaction.oncomplete = () => resolve();
1412
+ transaction.onabort = () => rejectTransaction(reject, transaction, semanticError);
1413
+
1414
+ const headRead = headStore.get(scopeKey(scope));
1415
+ headRead.onerror = () => {
1416
+ semanticError = headRead.error ?? new Error("artifact replica head lookup failed");
1417
+ };
1418
+ headRead.onsuccess = () => {
1419
+ try {
1420
+ const head = headRead.result as IndexedDbStoredReplicaHead | undefined;
1421
+ if (head === undefined) throw missingReplicaConflict(scope.artifactId);
1422
+ if (head.namespace !== scope.namespace || head.artifactId !== scope.artifactId) {
1423
+ throw new TypeError("retained replica does not match its storage key");
1424
+ }
1425
+ assertSequence(head.tailCount, "retained head tailCount");
1426
+ assertSequence(head.tailBytes, "retained head tailBytes");
1427
+ if (
1428
+ head.tailCount > HARD_MAX_COMMITTED_TRANSACTIONS ||
1429
+ head.tailBytes > HARD_MAX_COMMITTED_BYTES
1430
+ ) {
1431
+ throw new RangeError("retained head journal accounting exceeds its hard bound");
1432
+ }
1433
+ if (head.cursor !== input.expectedCursor || head.stateHash !== input.expectedStateHash) {
1434
+ const retainedRead = committedStore.get([
1435
+ scope.namespace,
1436
+ scope.artifactId,
1437
+ input.transaction.startSequence,
1438
+ ]);
1439
+ retainedRead.onerror = () => {
1440
+ semanticError =
1441
+ retainedRead.error ?? new Error("artifact committed transaction lookup failed");
1442
+ };
1443
+ retainedRead.onsuccess = () => {
1444
+ try {
1445
+ const retained = retainedRead.result as IndexedDbCommittedTransaction | undefined;
1446
+ if (
1447
+ head.cursor >= input.transaction.endSequence &&
1448
+ retained !== undefined &&
1449
+ retained.namespace === scope.namespace &&
1450
+ committedTransactionsEqual(retained, input.transaction)
1451
+ ) {
1452
+ return;
1453
+ }
1454
+ assertAppendCanApply(head, input);
1455
+ } catch (error) {
1456
+ semanticError = error;
1457
+ transaction.abort();
1458
+ }
1459
+ };
1460
+ return;
1461
+ }
1462
+ assertAppendCanApply(head, input);
1463
+ if (head.tailCount >= HARD_MAX_COMMITTED_TRANSACTIONS) {
1464
+ throw new RangeError("committed transaction store exceeds its count bound");
1465
+ }
1466
+ const nextTailBytes = checkedAggregateBytes(
1467
+ head.tailBytes,
1468
+ committedStorageBytes(input.transaction),
1469
+ HARD_MAX_COMMITTED_BYTES,
1470
+ "committed transaction store",
1471
+ );
1472
+ const committed: IndexedDbCommittedTransaction = {
1473
+ ...input.transaction,
1474
+ namespace: scope.namespace,
1475
+ };
1476
+ committedStore.add(committed);
1477
+ const nextHead: IndexedDbStoredReplicaHead = {
1478
+ ...head,
1479
+ cursor: input.transaction.endSequence,
1480
+ stateHash: input.transaction.stateHash,
1481
+ updatedAt: input.updatedAt,
1482
+ tailCount: head.tailCount + 1,
1483
+ tailBytes: nextTailBytes,
1484
+ };
1485
+ headStore.put(nextHead);
1486
+ } catch (error) {
1487
+ semanticError = error;
1488
+ transaction.abort();
1489
+ }
1490
+ };
1491
+ });
1492
+ }
1493
+
1494
+ function clearIndexedDbReplica(
1495
+ database: IDBDatabase,
1496
+ scope: EditableArtifactStorageScope,
1497
+ ): Promise<void> {
1498
+ return new Promise((resolve, reject) => {
1499
+ const transaction = database.transaction(
1500
+ [REPLICA_HEAD_STORE, SNAPSHOT_STORE, COMMITTED_STORE],
1501
+ "readwrite",
1502
+ );
1503
+ const committedStore = transaction.objectStore(COMMITTED_STORE);
1504
+ let semanticError: unknown;
1505
+ transaction.oncomplete = () => resolve();
1506
+ transaction.onabort = () => rejectTransaction(reject, transaction, semanticError);
1507
+ transaction.objectStore(REPLICA_HEAD_STORE).delete(scopeKey(scope));
1508
+ transaction.objectStore(SNAPSHOT_STORE).delete(scopeKey(scope));
1509
+ deleteIndexedDbScope(
1510
+ committedStore.index(COMMITTED_BY_SCOPE_INDEX),
1511
+ scopeKey(scope),
1512
+ () => undefined,
1513
+ (error) => {
1514
+ semanticError = error;
1515
+ transaction.abort();
1516
+ },
1517
+ );
1518
+ });
1519
+ }
1520
+
1521
+ function deleteIndexedDbScope(
1522
+ index: IDBIndex,
1523
+ query: IDBValidKey,
1524
+ onComplete: () => void,
1525
+ onError: (error: unknown) => void,
1526
+ ): void {
1527
+ const cursorRequest = index.openKeyCursor(query);
1528
+ cursorRequest.onerror = () =>
1529
+ onError(cursorRequest.error ?? new Error("artifact storage delete cursor failed"));
1530
+ cursorRequest.onsuccess = () => {
1531
+ const cursor = cursorRequest.result;
1532
+ if (cursor === null) {
1533
+ onComplete();
1534
+ return;
1535
+ }
1536
+ try {
1537
+ // Queue deletion and cursor advancement from the cursor callback while
1538
+ // the read/write transaction is definitely active. Waiting for a
1539
+ // separate delete callback before `continue()` is not portable across
1540
+ // real browser IndexedDB implementations. A key-only cursor avoids
1541
+ // deserializing the value but must delete through the object store.
1542
+ const deletion = index.objectStore.delete(cursor.primaryKey);
1543
+ deletion.onerror = () =>
1544
+ onError(deletion.error ?? new Error("artifact storage journal delete failed"));
1545
+ cursor.continue();
1546
+ } catch (error) {
1547
+ onError(error);
1548
+ }
1549
+ };
1550
+ }
1551
+
1552
+ function rejectTransaction(
1553
+ reject: (reason?: unknown) => void,
1554
+ transaction: IDBTransaction,
1555
+ semanticError?: unknown,
1556
+ ): void {
1557
+ reject(
1558
+ semanticError ??
1559
+ transaction.error ??
1560
+ new Error("artifact storage transaction failed or was aborted"),
1561
+ );
1562
+ }
1563
+
1564
+ function requestResult<T>(request: IDBRequest<T>): Promise<T> {
1565
+ return new Promise((resolve, reject) => {
1566
+ request.onsuccess = () => resolve(request.result);
1567
+ request.onerror = () => reject(request.error ?? new Error("artifact storage request failed"));
1568
+ });
1569
+ }
1570
+
1571
+ function transactionResult(transaction: IDBTransaction): Promise<void> {
1572
+ return new Promise((resolve, reject) => {
1573
+ transaction.oncomplete = () => resolve();
1574
+ transaction.onabort = () =>
1575
+ reject(transaction.error ?? new Error("artifact storage transaction was aborted"));
1576
+ });
1577
+ }
1578
+
1579
+ function readBoundedIndexValues<T>(
1580
+ transaction: IDBTransaction,
1581
+ index: IDBIndex,
1582
+ query: IDBValidKey,
1583
+ maximumCount: number,
1584
+ maximumBytes: number,
1585
+ measure: (value: T) => number,
1586
+ ): Promise<T[]> {
1587
+ return new Promise((resolve, reject) => {
1588
+ const values: T[] = [];
1589
+ let aggregateBytes = 0;
1590
+ let settled = false;
1591
+ const request = index.openCursor(query);
1592
+ const fail = (error: unknown): void => {
1593
+ if (settled) return;
1594
+ settled = true;
1595
+ try {
1596
+ transaction.abort();
1597
+ } catch {
1598
+ // The transaction may already be aborting because the cursor failed.
1599
+ }
1600
+ reject(error);
1601
+ };
1602
+ request.onerror = () => fail(request.error ?? new Error("artifact storage cursor failed"));
1603
+ request.onsuccess = () => {
1604
+ if (settled) return;
1605
+ const cursor = request.result;
1606
+ if (cursor === null) {
1607
+ settled = true;
1608
+ resolve(values);
1609
+ return;
1610
+ }
1611
+ try {
1612
+ const value = cursor.value as T;
1613
+ if (values.length >= maximumCount) {
1614
+ throw new RangeError("artifact storage cursor exceeds its count bound");
1615
+ }
1616
+ const measured = measure(value);
1617
+ if (!Number.isSafeInteger(measured) || measured < 0) {
1618
+ throw new TypeError("artifact storage cursor returned an invalid byte measurement");
1619
+ }
1620
+ aggregateBytes += measured;
1621
+ if (!Number.isSafeInteger(aggregateBytes) || aggregateBytes > maximumBytes) {
1622
+ throw new RangeError("artifact storage cursor exceeds its byte bound");
1623
+ }
1624
+ values.push(value);
1625
+ cursor.continue();
1626
+ } catch (error) {
1627
+ fail(error);
1628
+ }
1629
+ };
1630
+ });
1631
+ }
1632
+
1633
+ function putIndexedDbPending(
1634
+ database: IDBDatabase,
1635
+ scope: EditableArtifactStorageScope,
1636
+ value: IndexedDbPendingTransaction,
1637
+ ): Promise<void> {
1638
+ return new Promise((resolve, reject) => {
1639
+ const transaction = database.transaction(PENDING_STORE, "readwrite", {
1640
+ durability: "strict",
1641
+ });
1642
+ const store = transaction.objectStore(PENDING_STORE);
1643
+ let semanticError: unknown;
1644
+ transaction.oncomplete = () => resolve();
1645
+ transaction.onabort = () =>
1646
+ reject(
1647
+ semanticError ?? transaction.error ?? new Error("artifact pending transaction was aborted"),
1648
+ );
1649
+
1650
+ const abort = (error: unknown): void => {
1651
+ semanticError ??= error;
1652
+ try {
1653
+ transaction.abort();
1654
+ } catch {
1655
+ // A failed request may already be aborting the transaction.
1656
+ }
1657
+ };
1658
+
1659
+ const read = store.get([scope.namespace, scope.artifactId, value.transactionId]);
1660
+ read.onerror = () => {
1661
+ semanticError = read.error ?? new Error("artifact pending lookup failed");
1662
+ };
1663
+ read.onsuccess = () => {
1664
+ try {
1665
+ const existing = read.result as IndexedDbPendingTransaction | undefined;
1666
+ assertPendingCanReplace(existing, value);
1667
+ if (existing !== undefined) return;
1668
+ } catch (error) {
1669
+ abort(error);
1670
+ return;
1671
+ }
1672
+
1673
+ let retainedCount = 0;
1674
+ let aggregateBytes: number;
1675
+ try {
1676
+ aggregateBytes = pendingStorageBytes(value);
1677
+ if (aggregateBytes > HARD_MAX_PENDING_BYTES) {
1678
+ throw new RangeError("pending transaction store exceeds its byte bound");
1679
+ }
1680
+ } catch (error) {
1681
+ abort(error);
1682
+ return;
1683
+ }
1684
+
1685
+ const cursorRequest = store.index(PENDING_BY_SCOPE_INDEX).openCursor(scopeKey(scope));
1686
+ cursorRequest.onerror = () => {
1687
+ semanticError = cursorRequest.error ?? new Error("artifact pending bound cursor failed");
1688
+ };
1689
+ cursorRequest.onsuccess = () => {
1690
+ const cursor = cursorRequest.result;
1691
+ if (cursor === null) {
1692
+ store.add(value);
1693
+ return;
1694
+ }
1695
+ try {
1696
+ const retained = cursor.value as IndexedDbPendingTransaction;
1697
+ if (retained.namespace !== scope.namespace || retained.artifactId !== scope.artifactId) {
1698
+ throw new TypeError("retained pending transaction does not match its index key");
1699
+ }
1700
+ assertPendingTransaction(retained);
1701
+ retainedCount += 1;
1702
+ if (retainedCount >= HARD_MAX_PENDING_TRANSACTIONS) {
1703
+ throw new RangeError("pending transaction store exceeds its count bound");
1704
+ }
1705
+ aggregateBytes = checkedAggregateBytes(
1706
+ aggregateBytes,
1707
+ pendingStorageBytes(retained),
1708
+ HARD_MAX_PENDING_BYTES,
1709
+ "pending transaction store",
1710
+ );
1711
+ cursor.continue();
1712
+ } catch (error) {
1713
+ abort(error);
1714
+ }
1715
+ };
1716
+ };
1717
+ });
1718
+ }