@opendatalabs/personal-server-ts-lite 1.34.2 → 1.35.0

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 (97) hide show
  1. package/dist/additions-crypto.d.ts +1 -1
  2. package/dist/additions-crypto.d.ts.map +1 -1
  3. package/dist/additions-crypto.js +16 -6
  4. package/dist/additions-crypto.js.map +1 -1
  5. package/dist/additions-protocol.d.ts +9 -1
  6. package/dist/additions-protocol.d.ts.map +1 -1
  7. package/dist/additions-protocol.js.map +1 -1
  8. package/dist/additions-session.d.ts +2 -0
  9. package/dist/additions-session.d.ts.map +1 -1
  10. package/dist/additions-session.js +29 -4
  11. package/dist/additions-session.js.map +1 -1
  12. package/dist/additions-transport.d.ts +10 -2
  13. package/dist/additions-transport.d.ts.map +1 -1
  14. package/dist/additions-transport.js +143 -48
  15. package/dist/additions-transport.js.map +1 -1
  16. package/dist/additions-worker.bundle.js +538 -101
  17. package/dist/additions-worker.js +45 -24
  18. package/dist/additions-worker.js.map +1 -1
  19. package/dist/browser-runtime.d.ts +23 -0
  20. package/dist/browser-runtime.d.ts.map +1 -1
  21. package/dist/browser-runtime.js +38 -1
  22. package/dist/browser-runtime.js.map +1 -1
  23. package/dist/canonical-persistence.d.ts +53 -0
  24. package/dist/canonical-persistence.d.ts.map +1 -0
  25. package/dist/canonical-persistence.js +13 -0
  26. package/dist/canonical-persistence.js.map +1 -0
  27. package/dist/canonical-store.d.ts +395 -0
  28. package/dist/canonical-store.d.ts.map +1 -0
  29. package/dist/canonical-store.js +1807 -0
  30. package/dist/canonical-store.js.map +1 -0
  31. package/dist/client.d.ts.map +1 -1
  32. package/dist/client.js +154 -1
  33. package/dist/client.js.map +1 -1
  34. package/dist/diagnostics.d.ts +3 -0
  35. package/dist/diagnostics.d.ts.map +1 -1
  36. package/dist/diagnostics.js +9 -1
  37. package/dist/diagnostics.js.map +1 -1
  38. package/dist/history-probe-worker.bundle.js +31216 -0
  39. package/dist/history-probe.worker.d.ts +35 -0
  40. package/dist/history-probe.worker.d.ts.map +1 -0
  41. package/dist/history-probe.worker.js +160 -0
  42. package/dist/history-probe.worker.js.map +1 -0
  43. package/dist/index.d.ts +10 -0
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +10 -0
  46. package/dist/index.js.map +1 -1
  47. package/dist/mcp-store.d.ts.map +1 -1
  48. package/dist/mcp-store.js +109 -19
  49. package/dist/mcp-store.js.map +1 -1
  50. package/dist/oura-import.d.ts +2 -0
  51. package/dist/oura-import.d.ts.map +1 -0
  52. package/dist/oura-import.js +3 -0
  53. package/dist/oura-import.js.map +1 -0
  54. package/dist/replication-coordinator-state.d.ts +19 -0
  55. package/dist/replication-coordinator-state.d.ts.map +1 -0
  56. package/dist/replication-coordinator-state.js +20 -0
  57. package/dist/replication-coordinator-state.js.map +1 -0
  58. package/dist/replication-door.d.ts +17 -0
  59. package/dist/replication-door.d.ts.map +1 -0
  60. package/dist/replication-door.js +23 -0
  61. package/dist/replication-door.js.map +1 -0
  62. package/dist/replication-engine.d.ts +49 -0
  63. package/dist/replication-engine.d.ts.map +1 -0
  64. package/dist/replication-engine.js +589 -0
  65. package/dist/replication-engine.js.map +1 -0
  66. package/dist/replication-host.d.ts +92 -0
  67. package/dist/replication-host.d.ts.map +1 -0
  68. package/dist/replication-host.js +135 -0
  69. package/dist/replication-host.js.map +1 -0
  70. package/dist/replication-ports.d.ts +194 -0
  71. package/dist/replication-ports.d.ts.map +1 -0
  72. package/dist/replication-ports.js +58 -0
  73. package/dist/replication-ports.js.map +1 -0
  74. package/dist/replication-runtime.d.ts +64 -0
  75. package/dist/replication-runtime.d.ts.map +1 -0
  76. package/dist/replication-runtime.js +79 -0
  77. package/dist/replication-runtime.js.map +1 -0
  78. package/dist/replication-storage.d.ts +42 -0
  79. package/dist/replication-storage.d.ts.map +1 -0
  80. package/dist/replication-storage.js +173 -0
  81. package/dist/replication-storage.js.map +1 -0
  82. package/dist/runtime.d.ts +20 -2
  83. package/dist/runtime.d.ts.map +1 -1
  84. package/dist/runtime.js +80 -1
  85. package/dist/runtime.js.map +1 -1
  86. package/dist/state.d.ts +1 -1
  87. package/dist/state.d.ts.map +1 -1
  88. package/dist/state.js.map +1 -1
  89. package/dist/storage.d.ts +15 -0
  90. package/dist/storage.d.ts.map +1 -1
  91. package/dist/storage.js +352 -146
  92. package/dist/storage.js.map +1 -1
  93. package/dist/sync.d.ts +8 -0
  94. package/dist/sync.d.ts.map +1 -1
  95. package/dist/sync.js +25 -7
  96. package/dist/sync.js.map +1 -1
  97. package/package.json +8 -4
@@ -0,0 +1,1807 @@
1
+ import { canonicalJson, replicaEnvelopeSchema, replicaOperationSchema, replicaTextParts, } from "@opendatalabs/personal-server-ts-core/replication/envelope";
2
+ import { declarationWait, planReplicaMerge, } from "@opendatalabs/personal-server-ts-core/replication/merge-plan";
3
+ import { planIngest, } from "@opendatalabs/personal-server-ts-core/storage/pdpp-records/portable";
4
+ import { sha256, toBytes } from "viem";
5
+ import { REPLICA_MAX_OPERATIONS, REPLICA_MAX_PAYLOAD_ENVELOPE_BYTES, } from "@opendatalabs/personal-server-ts-core/replication/limits";
6
+ import { createReplicaRecordReducer } from "@opendatalabs/personal-server-ts-core/replication/record-reducer";
7
+ import { reissuedFrom, replicaOperationEffect, replicaUpsertEffect, } from "@opendatalabs/personal-server-ts-core/replication/rebaseline";
8
+ import { operationDigest, } from "@opendatalabs/personal-server-ts-core/replication/completion";
9
+ import { blobReady as engineBlobReady, completionAvailability, connectionFromFacts, } from "@opendatalabs/personal-server-ts-core/replication/visibility-engine";
10
+ import { replicaStateVisible, replicaPendingMetadata, } from "@opendatalabs/personal-server-ts-core/replication/visibility-policy";
11
+ import { LITE_CANONICAL_BATCH_BYTES, LITE_CANONICAL_MAX_OPERATION_BYTES, } from "./canonical-persistence.js";
12
+ /**
13
+ * A refusal that the batch's content or the store's immutable state decides:
14
+ * the same input fails the same way on every retry. A replica import that
15
+ * throws it stops retrying (`ReplicaCopyRefused` on the engine host).
16
+ */
17
+ export class LiteCanonicalRefusal extends Error {
18
+ reason;
19
+ code = "lite_canonical_refused";
20
+ constructor(reason, message) {
21
+ super(message);
22
+ this.reason = reason;
23
+ this.name = "LiteCanonicalRefusal";
24
+ }
25
+ }
26
+ const pendingKey = (id) => `pending-batches/${keyDigest(id)}`;
27
+ /**
28
+ * Stored key layout. Layout 1 kept `journal/`, `outbox/` and `mutations/`;
29
+ * layout 2 keeps one `log/` pointer per local operation. A store opened with
30
+ * any other layout fails closed instead of reading it.
31
+ */
32
+ const LAYOUT_VERSION = 2;
33
+ const LAYOUT_KEY = "canonical-layout-version";
34
+ const logKey = (sequence) => `log/${String(sequence).padStart(16, "0")}`;
35
+ /** A store with a clock was created by some layout; it must be this one. */
36
+ async function checkLayout(tx) {
37
+ if ((await tx.get("clock")) === undefined)
38
+ return;
39
+ const layout = (await get(tx, LAYOUT_KEY)) ?? 1;
40
+ if (layout !== LAYOUT_VERSION)
41
+ throw new Error(layout > LAYOUT_VERSION
42
+ ? `canonical layout ${layout} is newer than this build (${LAYOUT_VERSION})`
43
+ : `canonical layout ${layout} is no longer supported (this build reads ${LAYOUT_VERSION}); clear the canonical store and import again`);
44
+ }
45
+ /** A Node batch targets 32 MiB, so a pending envelope is kept in parts. */
46
+ const pendingPartKey = (id, part) => `pending-envelopes/${keyDigest(id)}/${String(part).padStart(8, "0")}`;
47
+ const PENDING_PART_BYTES = 128 * 1024;
48
+ /**
49
+ * One blob descriptor of a pending batch per value, so a batch of many small
50
+ * blob claims never builds one value above the native bound.
51
+ */
52
+ const pendingBlobKey = (id, blobId) => `pending-batch-blobs/${keyDigest(id)}/${keyDigest(blobId)}`;
53
+ /** A pending batch's blob descriptors, from either storage. */
54
+ async function* pendingBlobsOf(tx, batch) {
55
+ yield* batch.blobs ?? [];
56
+ for await (const entry of scan(tx, `pending-batch-blobs/${keyDigest(batch.id)}/`))
57
+ yield JSON.parse(entry.value);
58
+ }
59
+ /** Bump when a derived pending index changes; older databases need `retry`. */
60
+ const PENDING_INDEX_VERSION = 3;
61
+ const PREVIEW_BLOB_REFS = "preview-blob-refs/";
62
+ /**
63
+ * Writes that decide what a reader sees, by connection digest. Row and
64
+ * overlay values and connection control facts count; retained evidence and
65
+ * pending indexes do not, because their visible effects land in these rows.
66
+ */
67
+ function viewKey(key) {
68
+ if (key.startsWith("preview/rows/"))
69
+ return { digest: key.split("/")[2], preview: true };
70
+ if (key.startsWith("rows/") ||
71
+ key.startsWith("connection-generation/") ||
72
+ key.startsWith("binding-conflicts/") ||
73
+ key.startsWith("connections/") ||
74
+ key.startsWith(CONNECTION_QUARANTINE))
75
+ return { digest: key.split("/")[1], preview: false };
76
+ return undefined;
77
+ }
78
+ /** Original value of each view key a transaction first wrote, by transaction. */
79
+ const viewWrites = new WeakMap();
80
+ function trackViews(base) {
81
+ const first = new Map();
82
+ const note = async (key) => {
83
+ if (viewKey(key) && !first.has(key))
84
+ first.set(key, await base.get(key));
85
+ };
86
+ const tx = {
87
+ get: (key) => base.get(key),
88
+ put: async (key, value) => {
89
+ await note(key);
90
+ await base.put(key, value);
91
+ },
92
+ delete: async (key) => {
93
+ await note(key);
94
+ await base.delete(key);
95
+ },
96
+ scan: (prefix, after) => base.scan(prefix, after),
97
+ attachBlob: (stagingId, expected) => base.attachBlob(stagingId, expected),
98
+ blob: (blobId) => base.blob(blobId),
99
+ };
100
+ viewWrites.set(tx, { base, first });
101
+ return tx;
102
+ }
103
+ /** Net changes so far: a key counts only when it differs from its first value. */
104
+ async function changedViews(tx) {
105
+ const writes = viewWrites.get(tx);
106
+ if (!writes)
107
+ throw new Error("view changes read from an untracked transaction");
108
+ const canonical = new Set();
109
+ const preview = new Set();
110
+ for (const [key, before] of writes.first) {
111
+ if ((await writes.base.get(key)) === before)
112
+ continue;
113
+ const view = viewKey(key);
114
+ (view.preview ? preview : canonical).add(view.digest);
115
+ }
116
+ return { canonical, preview };
117
+ }
118
+ const digest = (value) => `sha256:${sha256(toBytes(canonicalJson(value))).slice(2)}`;
119
+ /** The fingerprint `stagePending` and `pendingBatch` report for an envelope. */
120
+ export const litePendingFingerprint = (envelope) => digest(replicaEnvelopeSchema.parse(envelope));
121
+ // Fixed-size internal keys fit the existing native 512-character key bound.
122
+ const keyDigest = (value) => digest(value).slice(7);
123
+ const operationId = (op) => keyDigest([op.revision.writerId, op.revision.operationId]);
124
+ const connectionPrefix = (id) => `entries/${keyDigest(id)}/`;
125
+ const recordId = (stream, key) => keyDigest([stream, key]);
126
+ const rowKey = (connection, stream, key) => `rows/${keyDigest(connection)}/${recordId(stream, key)}`;
127
+ const operationPath = (op) => {
128
+ const suffix = op.kind === "upsert" || op.kind === "delete"
129
+ ? `records/${recordId(op.declaration.stream, op.recordKey)}/`
130
+ : op.kind === "blob-claim"
131
+ ? `claims/${keyDigest(op.blob.blobId)}/`
132
+ : op.kind === "stream-complete" || op.kind === "stream-invalidate"
133
+ ? "completions/"
134
+ : "lifecycle/";
135
+ return `${connectionPrefix(op.connectionId)}${suffix}${digest(op)}`;
136
+ };
137
+ const maxWindowBytes = 4 * 1024 * 1024;
138
+ const encoder = new TextEncoder();
139
+ /** Room for the revision (and its origin) a rebaseline adds to a draft. */
140
+ const REVISION_MARGIN_BYTES = 1024;
141
+ async function get(tx, key) {
142
+ const value = await tx.get(key);
143
+ return value === undefined ? undefined : JSON.parse(value);
144
+ }
145
+ const put = (tx, key, value) => tx.put(key, canonicalJson(value));
146
+ async function* scan(tx, prefix) {
147
+ let after;
148
+ do {
149
+ const page = await tx.scan(prefix, after);
150
+ for (const entry of page.entries)
151
+ yield entry;
152
+ if (page.after !== undefined &&
153
+ (page.after === after || !page.entries.length))
154
+ throw new Error("invalid native scan cursor");
155
+ after = page.after;
156
+ } while (after !== undefined);
157
+ }
158
+ /** Whether a prefix holds any entry, read through the validating page walker. */
159
+ async function any(tx, prefix) {
160
+ for await (const _ of scan(tx, prefix))
161
+ return true;
162
+ return false;
163
+ }
164
+ async function operations(tx, prefix) {
165
+ const result = [];
166
+ let bytes = 0;
167
+ for await (const entry of scan(tx, prefix)) {
168
+ bytes += encoder.encode(entry.value).length;
169
+ if (bytes > maxWindowBytes)
170
+ throw new LiteCanonicalRefusal("partition_too_large", "replica merge partition exceeds 4 MiB; native repair required");
171
+ result.push(replicaOperationSchema.parse(JSON.parse(entry.value)));
172
+ }
173
+ return result;
174
+ }
175
+ /**
176
+ * Log entries after `after` with their retained operations: one native scan
177
+ * page, at most 512 KiB of operation bytes. An empty page reports `reported`.
178
+ */
179
+ async function logPage(tx, after, reported = after) {
180
+ const page = await tx.scan("log/", logKey(after));
181
+ const entries = [];
182
+ let bytes = 0;
183
+ for (const { value } of page.entries) {
184
+ const entry = JSON.parse(value);
185
+ const operation = await tx.get(entry.operationKey);
186
+ if (!operation)
187
+ throw new Error("missing immutable outbox operation");
188
+ bytes += encoder.encode(operation).length;
189
+ if (bytes > 512 * 1024)
190
+ break;
191
+ entries.push({
192
+ ...entry,
193
+ operation: replicaOperationSchema.parse(JSON.parse(operation)),
194
+ });
195
+ }
196
+ return {
197
+ entries,
198
+ nextAfterSequence: entries.at(-1)?.sequence ?? reported,
199
+ hasMore: page.after !== undefined || entries.length < page.entries.length,
200
+ };
201
+ }
202
+ /**
203
+ * Bounds a received envelope part by part before schema parsing builds it:
204
+ * its header, its operation count and each operation. The whole envelope has
205
+ * no bound, because the store keeps it in parts.
206
+ */
207
+ function refuseOversizedEnvelope(raw) {
208
+ const { operations, ...header } = (raw ?? {});
209
+ if (encoder.encode(canonicalJson(header)).length >
210
+ REPLICA_MAX_PAYLOAD_ENVELOPE_BYTES ||
211
+ (Array.isArray(operations) && operations.length > REPLICA_MAX_OPERATIONS))
212
+ throw new LiteCanonicalRefusal("batch_too_large", "replica envelope exceeds its header or operation count bound");
213
+ if (Array.isArray(operations))
214
+ for (const op of operations)
215
+ if (encoder.encode(canonicalJson(op)).length >
216
+ LITE_CANONICAL_MAX_OPERATION_BYTES)
217
+ throw new LiteCanonicalRefusal("operation_too_large", "replica operation exceeds the native value bound");
218
+ }
219
+ /** A pending batch's envelope, from its parts or as staged before them. */
220
+ async function pendingEnvelope(tx, batch) {
221
+ if (batch.envelope)
222
+ return batch.envelope;
223
+ let json = "";
224
+ for (let part = 0; part < (batch.parts ?? 0); part++) {
225
+ const text = await tx.get(pendingPartKey(batch.id, part));
226
+ if (text === undefined)
227
+ throw new Error("pending envelope part missing");
228
+ json += text;
229
+ }
230
+ return replicaEnvelopeSchema.parse(JSON.parse(json));
231
+ }
232
+ /**
233
+ * Experimental canonical persistence port. No runtime/route/publisher wiring.
234
+ * Lifecycle plus one record's retained variants enter the EXISTING merge planner;
235
+ * a scope's record bodies are never loaded together. Native transactions hide all
236
+ * intermediate rows, blob references, log entries and import receipts.
237
+ */
238
+ /** Absorbing terminal-batch fact per connection (contract v3.1 A, H). */
239
+ const CONNECTION_QUARANTINE = "connection-quarantine/";
240
+ /** Operations a writer change re-exported, which their log entries point at. */
241
+ const REEXPORTED = "reexported/";
242
+ export function createLiteCanonicalStore(deps) {
243
+ // Every transaction checks the stored layout first, so no reader or
244
+ // writer can reach entries of another layout.
245
+ const persistence = {
246
+ transaction: (run) => deps.persistence.transaction(async (tx) => {
247
+ await checkLayout(tx);
248
+ return run(tx);
249
+ }),
250
+ stageBlob: (descriptor, chunks) => deps.persistence.stageBlob(descriptor, chunks),
251
+ readBlob: (blobId) => deps.persistence.readBlob(blobId),
252
+ };
253
+ const newId = deps.newId ?? (() => globalThis.crypto.randomUUID());
254
+ function enabled() {
255
+ if (deps.enabled !== true)
256
+ throw new Error("Lite canonical replication disabled");
257
+ }
258
+ async function clock(tx, context = deps.context()) {
259
+ const owner = await get(tx, "owner");
260
+ if (!context.owner || (owner !== undefined && owner !== context.owner))
261
+ throw new Error("immutable canonical owner binding");
262
+ await put(tx, "owner", context.owner);
263
+ const stored = await get(tx, "clock");
264
+ if (stored)
265
+ return stored;
266
+ const initial = { writerId: newId(), counter: 0, sequence: 0 };
267
+ await put(tx, "clock", initial);
268
+ await put(tx, LAYOUT_KEY, LAYOUT_VERSION);
269
+ await put(tx, "pending-index-version", PENDING_INDEX_VERSION);
270
+ return initial;
271
+ }
272
+ async function mapping(tx, op) {
273
+ const local = await get(tx, `connections/${keyDigest(op.connectionId)}`);
274
+ if (local &&
275
+ (local.sourceId !== op.sourceId || local.methodId !== op.methodId))
276
+ throw new LiteCanonicalRefusal("connection_binding_mismatch", "replica connection source or method mismatch");
277
+ return local;
278
+ }
279
+ async function retain(tx, op, dirty) {
280
+ await put(tx, `retained-connections/${keyDigest(op.connectionId)}`, op.connectionId);
281
+ const bindingPath = `binding-evidence/${keyDigest(op.connectionId)}`;
282
+ const binding = { sourceId: op.sourceId, methodId: op.methodId };
283
+ const priorBinding = await get(tx, bindingPath);
284
+ const conflictPath = `binding-conflicts/${keyDigest(op.connectionId)}`;
285
+ // The first binding that differs: with the first binding it shows the
286
+ // conflict to `connectionFromFacts`. Written once, so no view moves again.
287
+ if (priorBinding &&
288
+ canonicalJson(priorBinding) !== canonicalJson(binding)) {
289
+ if (!(await tx.get(conflictPath)))
290
+ await put(tx, conflictPath, binding);
291
+ }
292
+ else
293
+ await put(tx, bindingPath, binding);
294
+ const opKey = operationPath(op);
295
+ await put(tx, opKey, op);
296
+ if (op.kind === "upsert" || op.kind === "delete") {
297
+ await put(tx, `keys/${keyDigest(op.connectionId)}/${recordId(op.declaration.stream, op.recordKey)}`, { stream: op.declaration.stream, key: op.recordKey });
298
+ }
299
+ const index = `revisions/${operationId(op)}/`;
300
+ await put(tx, `${index}${digest(op)}`, {
301
+ connectionId: op.connectionId,
302
+ opKey,
303
+ });
304
+ const variants = [];
305
+ for await (const entry of scan(tx, index))
306
+ variants.push(JSON.parse(entry.value));
307
+ if (variants.length > 1) {
308
+ for (const variant of variants) {
309
+ await put(tx, `equivocations/${keyDigest(variant.connectionId)}/${operationId(op)}`, variants);
310
+ dirty.add(variant.connectionId);
311
+ }
312
+ }
313
+ dirty.add(op.connectionId);
314
+ return { collision: variants.length > 1, opKey };
315
+ }
316
+ /**
317
+ * The engine's `blobReady` over this connection's retained claims and the
318
+ * bytes Lite holds. Canonical state needs confirmed bytes; the owner
319
+ * preview also accepts bytes that only a pending batch carries.
320
+ */
321
+ async function blobReady(tx, op, pendingBytes = false) {
322
+ const claims = [];
323
+ const verified = new Set();
324
+ for (const blob of op.blobs) {
325
+ for await (const entry of scan(tx, `${connectionPrefix(op.connectionId)}claims/${keyDigest(blob.blobId)}/`))
326
+ claims.push(replicaOperationSchema.parse(JSON.parse(entry.value)));
327
+ if ((await tx.blob(blob.blobId)) &&
328
+ ((await get(tx, `confirmed-blobs/${keyDigest(blob.blobId)}`)) ||
329
+ (pendingBytes &&
330
+ (await any(tx, `pending-blob-active/${keyDigest(blob.blobId)}/`)))))
331
+ verified.add(blob.blobId);
332
+ }
333
+ if (!engineBlobReady(op, claims, verified))
334
+ return false;
335
+ for (const blob of op.blobs)
336
+ if (canonicalJson(await tx.blob(blob.blobId)) !== canonicalJson(blob))
337
+ throw new LiteCanonicalRefusal("blob_metadata_conflict", "replica blob metadata conflict");
338
+ return true;
339
+ }
340
+ /**
341
+ * The engine's eligibility for one connection, from Lite's indexes: the
342
+ * terminal-batch fact, equivocations, retained bindings and the
343
+ * connection's globally unique lifecycle. A withdrawn connection is hidden
344
+ * from every reader. An unprovisioned connection has no projection; it is
345
+ * judged against its first retained binding, so that its quarantine answer
346
+ * and the blob gate still see its terminal fact, equivocations and
347
+ * conflicting bindings.
348
+ */
349
+ async function connectionState(tx, id, context) {
350
+ const d = keyDigest(id);
351
+ const lifecycle = [];
352
+ for (const op of await operations(tx, `${connectionPrefix(id)}lifecycle/`))
353
+ if (!(await any(tx, `equivocations/${d}/${operationId(op)}`)))
354
+ lifecycle.push(op);
355
+ const mapped = await get(tx, `connections/${d}`);
356
+ const bindings = [
357
+ await get(tx, `binding-evidence/${d}`),
358
+ await get(tx, `binding-conflicts/${d}`),
359
+ ].filter((binding) => binding !== undefined);
360
+ const connection = connectionFromFacts(context, {
361
+ mapping: mapped ?? bindings[0] ?? { sourceId: "", methodId: "" },
362
+ terminal: !!(await get(tx, `${CONNECTION_QUARANTINE}${d}`)),
363
+ equivocated: await any(tx, `equivocations/${d}/`),
364
+ bindings,
365
+ lifecycle,
366
+ appliedGeneration: (await get(tx, `connection-generation/${d}`)) ?? 1,
367
+ });
368
+ return {
369
+ lifecycle,
370
+ mapped,
371
+ connection,
372
+ blocked: connection.status === "withdrawn",
373
+ };
374
+ }
375
+ async function reconcile(tx, id, context, only, canonical) {
376
+ const { lifecycle, mapped, connection, blocked } = await connectionState(tx, id, context);
377
+ const pendingLifecycle = connection.status === "held";
378
+ const deleted = connection.status === "deleted";
379
+ const generation = connection.generation;
380
+ const activeGeneration = (await get(tx, `connection-generation/${keyDigest(id)}`)) ?? 1;
381
+ const reset = connection.status === "active" && generation > activeGeneration;
382
+ // Completion inputs are kept for canonical rows of provisioned, active
383
+ // connections only; the preview certifies nothing.
384
+ const certify = canonical === undefined && !!mapped && connection.status === "active";
385
+ // Keep canonical lifecycle suppression in the source view. Only provisional
386
+ // removals are omitted when deriving the value they would provisionally remove.
387
+ const sourceLifecycle = [];
388
+ const provisionalResets = [];
389
+ let provisionalRemoval = false;
390
+ if (canonical && !blocked && !pendingLifecycle && (deleted || reset))
391
+ for (const op of lifecycle) {
392
+ if ((op.kind === "connection-reset" || op.kind === "connection-delete") &&
393
+ !(await canonical.get(operationPath(op)))) {
394
+ provisionalRemoval = true;
395
+ if (op.kind === "connection-reset")
396
+ provisionalResets.push(op);
397
+ }
398
+ else
399
+ sourceLifecycle.push(op);
400
+ }
401
+ const keys = only
402
+ ? only.map((key) => ({ value: canonicalJson(key) }))
403
+ : scan(tx, `keys/${keyDigest(id)}/`);
404
+ for await (const entry of keys) {
405
+ const { stream, key } = JSON.parse(entry.value);
406
+ const reducer = createReplicaRecordReducer(context, lifecycle);
407
+ let sourceReducer;
408
+ let sourceGeneration = 0;
409
+ // An unresolved pending or rejected operation of the key, as the
410
+ // planner judges it alone with the lifecycle; conflicts quarantine.
411
+ let unresolved = false;
412
+ if (!blocked && !pendingLifecycle && (!deleted || provisionalRemoval)) {
413
+ for await (const variant of scan(tx, `${connectionPrefix(id)}records/${recordId(stream, key)}/`)) {
414
+ const op = replicaOperationSchema.parse(JSON.parse(variant.value));
415
+ if (op.kind !== "upsert" && op.kind !== "delete")
416
+ throw new Error("invalid record partition");
417
+ if (!deleted)
418
+ reducer.push(op);
419
+ if (certify) {
420
+ const issues = planReplicaMerge(context, [...lifecycle, op]);
421
+ unresolved ||= [...issues.pending, ...issues.rejected].some((issue) => !declarationWait(issue.reason));
422
+ }
423
+ if (provisionalRemoval && op.generation >= sourceGeneration) {
424
+ // As in the planner, a newer eligible generation supersedes older
425
+ // evidence. Retain just that generation's bounded reducer state.
426
+ const candidate = op.generation === sourceGeneration
427
+ ? sourceReducer
428
+ : createReplicaRecordReducer(context, [
429
+ ...sourceLifecycle,
430
+ ...provisionalResets.filter((reset) => reset.generation <= op.generation),
431
+ ]);
432
+ candidate.push(op);
433
+ if (candidate.record !== undefined) {
434
+ sourceGeneration = op.generation;
435
+ sourceReducer = candidate;
436
+ }
437
+ }
438
+ }
439
+ }
440
+ const record = reducer.record;
441
+ const winner = mapped ? record?.winner : undefined;
442
+ const materialized = winner?.kind === "upsert" &&
443
+ (await blobReady(tx, winner, canonical !== undefined))
444
+ ? winner
445
+ : winner?.kind === "delete"
446
+ ? winner
447
+ : null;
448
+ const path = rowKey(id, stream, key);
449
+ if (certify) {
450
+ const blocker = {
451
+ stream,
452
+ quarantined: record?.quarantined === true,
453
+ // An unmaterializable winner is unresolved even without a row.
454
+ unresolved: unresolved || (winner?.kind === "upsert" && !materialized),
455
+ };
456
+ const blockerPath = `completion-blockers/${keyDigest(id)}/${recordId(stream, key)}`;
457
+ if (blocker.quarantined || blocker.unresolved)
458
+ await put(tx, blockerPath, blocker);
459
+ else
460
+ await tx.delete(blockerPath);
461
+ }
462
+ if (canonical)
463
+ await tx.delete(path);
464
+ let prior = await get(tx, path);
465
+ const source = mapped ? sourceReducer?.record?.winner : undefined;
466
+ if (!prior &&
467
+ !materialized &&
468
+ source?.kind === "upsert" &&
469
+ (await blobReady(tx, source, canonical !== undefined)))
470
+ prior = {
471
+ instance: id,
472
+ stream,
473
+ recordKey: key,
474
+ version: 1,
475
+ emittedAt: source.emittedAt,
476
+ deleted: false,
477
+ data: source.data,
478
+ };
479
+ const priorGeneration = await get(tx, `row-generation/${path}`);
480
+ // A validated reset withdraws live rows before applying its generation,
481
+ // even when the new winner has identical content and emitted time.
482
+ if (reset && prior && !prior.deleted) {
483
+ prior = {
484
+ instance: prior.instance,
485
+ stream: prior.stream,
486
+ recordKey: prior.recordKey,
487
+ version: prior.version + 1,
488
+ emittedAt: prior.emittedAt,
489
+ deleted: true,
490
+ deletedAt: prior.emittedAt,
491
+ };
492
+ await put(tx, path, prior);
493
+ await put(tx, `row-generation/${path}`, generation);
494
+ }
495
+ // The Node receiver's lifecycle cleanup touches live rows only. Preserve
496
+ // an existing tombstone's original deletion time/version under a reset,
497
+ // and under a withdrawn winner (oracle D2 on the Lite port).
498
+ if (!materialized && prior?.deleted)
499
+ continue;
500
+ // Match the receiver: missing lifecycle/declaration evidence does not
501
+ // retract a previously materialized row. A validated reset does.
502
+ if (!blocked &&
503
+ !deleted &&
504
+ (pendingLifecycle ||
505
+ (!record && (priorGeneration ?? generation) === generation)))
506
+ continue;
507
+ // A derived withdrawal keeps a tombstone if this key was once visible.
508
+ if (!materialized && !prior)
509
+ continue;
510
+ const data = materialized?.kind === "upsert" ? materialized.data : null;
511
+ const emittedAt = materialized?.emittedAt ?? prior.emittedAt;
512
+ const deletedAt = materialized?.kind === "delete" ? materialized.deletedAt : emittedAt;
513
+ if (prior &&
514
+ prior.emittedAt === emittedAt &&
515
+ (prior.deleted
516
+ ? data === null && prior.deletedAt === deletedAt
517
+ : data !== null && canonicalJson(prior.data) === canonicalJson(data)))
518
+ continue;
519
+ const base = {
520
+ instance: id,
521
+ stream,
522
+ recordKey: key,
523
+ version: (prior?.version ?? 0) + 1,
524
+ emittedAt,
525
+ };
526
+ const row = data === null
527
+ ? { ...base, deleted: true, deletedAt }
528
+ : { ...base, deleted: false, data };
529
+ // A confirmed winner that shows the canonical content needs no preview
530
+ // row; a pending winner always gets one, so the owner sees its marker.
531
+ if (canonical &&
532
+ winner &&
533
+ (await canonical.get(operationPath(winner))) !== undefined &&
534
+ sameContent(canonicalJson(row), await canonical.get(path))) {
535
+ await tx.delete(path);
536
+ continue;
537
+ }
538
+ await put(tx, path, row);
539
+ await put(tx, `row-generation/${path}`, generation);
540
+ }
541
+ if (!blocked && !pendingLifecycle)
542
+ await put(tx, `connection-generation/${keyDigest(id)}`, generation);
543
+ if (canonical === undefined)
544
+ await certifyCompletions(tx, id, context, mapped ? connection : undefined);
545
+ }
546
+ /**
547
+ * Persist the engine's completion availability for each stream of a
548
+ * connection, from Lite's indexes: completion evidence at the
549
+ * connection's generation, the per-key blockers `reconcile` keeps, live
550
+ * canonical rows, and dependency variants of provisioned connections.
551
+ * Reads every canonical row of a stream that has completion evidence.
552
+ */
553
+ async function certifyCompletions(tx, id, context, connection) {
554
+ const d = keyDigest(id);
555
+ const available = new Map();
556
+ const events = new Map();
557
+ if (connection?.status === "active")
558
+ for await (const entry of scan(tx, `${connectionPrefix(id)}completions/`)) {
559
+ const op = replicaOperationSchema.parse(JSON.parse(entry.value));
560
+ if ((op.kind !== "stream-complete" && op.kind !== "stream-invalidate") ||
561
+ op.generation !== connection.generation)
562
+ continue;
563
+ const stream = op.declaration.stream;
564
+ events.set(stream, [...(events.get(stream) ?? []), op]);
565
+ }
566
+ if (connection && events.size) {
567
+ const blockers = new Map();
568
+ for await (const entry of scan(tx, `completion-blockers/${d}/`)) {
569
+ const blocker = JSON.parse(entry.value);
570
+ const prior = blockers.get(blocker.stream);
571
+ blockers.set(blocker.stream, {
572
+ stream: blocker.stream,
573
+ quarantined: blocker.quarantined || !!prior?.quarantined,
574
+ unresolved: blocker.unresolved || !!prior?.unresolved,
575
+ });
576
+ }
577
+ const live = new Map();
578
+ for await (const entry of scan(tx, `rows/${d}/`)) {
579
+ const row = JSON.parse(entry.value);
580
+ if (row.deleted || !events.has(row.stream))
581
+ continue;
582
+ live.set(row.stream, [
583
+ ...(live.get(row.stream) ?? []),
584
+ { recordKey: row.recordKey, dataJson: canonicalJson(row.data) },
585
+ ]);
586
+ }
587
+ const variants = new Map();
588
+ for (const list of events.values())
589
+ for (const op of list)
590
+ if (op.kind === "stream-complete")
591
+ for (const dependency of op.dependencies) {
592
+ const revision = keyDigest([
593
+ dependency.writerId,
594
+ dependency.operationId,
595
+ ]);
596
+ if (variants.has(revision))
597
+ continue;
598
+ const digests = new Set();
599
+ for await (const entry of scan(tx, `revisions/${revision}/`)) {
600
+ const variant = JSON.parse(entry.value);
601
+ // Unmapped evidence never satisfies a dependency.
602
+ if (!(await tx.get(`connections/${keyDigest(variant.connectionId)}`)))
603
+ continue;
604
+ const value = await tx.get(variant.opKey);
605
+ if (value === undefined)
606
+ throw new Error("missing retained replica operation");
607
+ digests.add(operationDigest(JSON.parse(value)));
608
+ }
609
+ variants.set(revision, [...digests]);
610
+ }
611
+ for (const [stream, list] of events) {
612
+ const result = completionAvailability({
613
+ connection,
614
+ connectionId: id,
615
+ events: list,
616
+ declarations: context.declarations,
617
+ quarantinedKey: !!blockers.get(stream)?.quarantined,
618
+ unresolved: !!blockers.get(stream)?.unresolved,
619
+ variantDigests: (writerId, operationId) => variants.get(keyDigest([writerId, operationId])) ?? [],
620
+ liveRows: live.get(stream) ?? [],
621
+ });
622
+ if (result)
623
+ available.set(stream, result.operation);
624
+ }
625
+ }
626
+ const prefix = `stream-completions/${d}/`;
627
+ for await (const entry of scan(tx, prefix)) {
628
+ const stream = JSON.parse(entry.value)
629
+ .declaration.stream;
630
+ if (!available.has(stream))
631
+ await tx.delete(entry.key);
632
+ }
633
+ for (const [stream, operation] of available)
634
+ await put(tx, `${prefix}${keyDigest(stream)}`, operation);
635
+ }
636
+ /**
637
+ * The current canonical state as new local operations of `writerId`, in
638
+ * the outbox, after skipping every unpublished one: lifecycle, blob
639
+ * claims, records and tombstones, then certified completions, per
640
+ * provisioned connection. Each record and completion copies the
641
+ * operation the merge chose for it (core `rebaseline.ts`): the copy keeps
642
+ * its counter, origin and declaration, so it orders against every other
643
+ * writer exactly as the original. Lifecycle and claims take new counters.
644
+ * Each copy is kept under `reexported/`, like any logged operation;
645
+ * canonical state does not change. A row the merge does not explain, such
646
+ * as one whose stream this store no longer declares, is not re-exported
647
+ * (`skipped`): Lite keeps no binding history to write it under.
648
+ */
649
+ async function reexport(tx, current, writerId, context) {
650
+ // Skip every unpublished entry: the new writer's chain carries its
651
+ // effect instead. The log keeps them.
652
+ await put(tx, "outbox-ack", current.sequence);
653
+ current.writerId = writerId;
654
+ let reissued = 0;
655
+ let skipped = 0;
656
+ const issue = async (draft, copy) => {
657
+ const kept = copy && reissuedFrom(copy);
658
+ if (!kept && !Number.isSafeInteger(current.counter + 1))
659
+ throw new Error("replication counter exhausted");
660
+ const counter = kept ? kept.revision.counter : current.counter + 1;
661
+ const op = replicaOperationSchema.parse({
662
+ ...draft,
663
+ ...(kept && { declaration: kept.declaration }),
664
+ revision: {
665
+ counter,
666
+ writerId,
667
+ operationId: newId(),
668
+ ...(kept && { origin: kept.revision.origin }),
669
+ },
670
+ });
671
+ current.counter = Math.max(current.counter, counter);
672
+ current.sequence += 1;
673
+ const sequence = String(current.sequence).padStart(16, "0");
674
+ await put(tx, `${REEXPORTED}${sequence}`, op);
675
+ const entry = {
676
+ batchId: `rebaseline/${writerId}`,
677
+ operationKey: `${REEXPORTED}${sequence}`,
678
+ sequence: current.sequence,
679
+ };
680
+ if (op.kind === "upsert" || op.kind === "delete") {
681
+ const row = await get(tx, rowKey(op.connectionId, op.declaration.stream, op.recordKey));
682
+ if (row)
683
+ entry.rowVersion = row.version;
684
+ }
685
+ await put(tx, logKey(current.sequence), entry);
686
+ reissued += 1;
687
+ return op;
688
+ };
689
+ for await (const entry of scan(tx, "retained-connections/")) {
690
+ const id = JSON.parse(entry.value);
691
+ const d = keyDigest(id);
692
+ const mapped = await get(tx, `connections/${d}`);
693
+ if (!mapped)
694
+ continue;
695
+ const { connection, lifecycle } = await connectionState(tx, id, context);
696
+ if (connection.status !== "active" && connection.status !== "deleted")
697
+ continue;
698
+ const generation = connection.generation;
699
+ const base = {
700
+ owner: context.owner,
701
+ sourceId: mapped.sourceId,
702
+ connectionId: id,
703
+ methodId: mapped.methodId,
704
+ };
705
+ // What a completion of each stream depends on: the new writer's
706
+ // lifecycle and claims, and its operations of that stream.
707
+ const shared = [];
708
+ const byStream = new Map();
709
+ shared.push(await issue({
710
+ ...base,
711
+ generation: 1,
712
+ kind: "connection-register",
713
+ }));
714
+ for (let next = 2; next <= generation; next++)
715
+ shared.push(await issue({
716
+ ...base,
717
+ generation: next,
718
+ kind: "connection-reset",
719
+ previousGeneration: next - 1,
720
+ }));
721
+ if (connection.status === "deleted")
722
+ shared.push(await issue({
723
+ ...base,
724
+ generation,
725
+ kind: "connection-delete",
726
+ }));
727
+ const claimed = new Set();
728
+ const claim = async (blob) => {
729
+ if (claimed.has(blob.blobId))
730
+ return;
731
+ claimed.add(blob.blobId);
732
+ shared.push(await issue({
733
+ ...base,
734
+ generation,
735
+ kind: "blob-claim",
736
+ blob,
737
+ }));
738
+ };
739
+ for await (const entry of scan(tx, `${connectionPrefix(id)}claims/`)) {
740
+ const op = replicaOperationSchema.parse(JSON.parse(entry.value));
741
+ if (op.kind === "blob-claim" &&
742
+ op.generation === generation &&
743
+ (await tx.blob(op.blob.blobId)) &&
744
+ (await get(tx, `confirmed-blobs/${keyDigest(op.blob.blobId)}`)))
745
+ await claim(op.blob);
746
+ }
747
+ for await (const entry of scan(tx, `rows/${d}/`)) {
748
+ const row = JSON.parse(entry.value);
749
+ const reducer = createReplicaRecordReducer(context, lifecycle);
750
+ for await (const variant of scan(tx, `${connectionPrefix(id)}records/${recordId(row.stream, row.recordKey)}/`)) {
751
+ const op = replicaOperationSchema.parse(JSON.parse(variant.value));
752
+ if (op.kind !== "upsert" && op.kind !== "delete")
753
+ throw new Error("invalid record partition");
754
+ reducer.push(op);
755
+ }
756
+ // The operation the merge chose explains the row.
757
+ const source = reducer.record?.winner;
758
+ if (!source ||
759
+ replicaOperationEffect(source) !==
760
+ (row.deleted ? "delete" : replicaUpsertEffect(row.data))) {
761
+ skipped += 1;
762
+ continue;
763
+ }
764
+ if (source.kind === "upsert")
765
+ for (const blob of source.blobs)
766
+ await claim(blob);
767
+ const { revision: _, ...draft } = source;
768
+ const op = await issue({ ...draft, ...base, generation }, source);
769
+ byStream.set(row.stream, [...(byStream.get(row.stream) ?? []), op]);
770
+ }
771
+ for await (const entry of scan(tx, `stream-completions/${d}/`)) {
772
+ const certified = replicaOperationSchema.parse(JSON.parse(entry.value));
773
+ if (certified.kind !== "stream-complete")
774
+ continue;
775
+ // The certified completion is the one the merge chose.
776
+ const { revision: _, ...draft } = certified;
777
+ const rewritten = {
778
+ ...draft,
779
+ ...base,
780
+ generation,
781
+ dependencies: [
782
+ ...shared,
783
+ ...(byStream.get(certified.declaration.stream) ?? []),
784
+ ].map((op) => ({
785
+ writerId: op.revision.writerId,
786
+ operationId: op.revision.operationId,
787
+ digest: operationDigest(op),
788
+ })),
789
+ };
790
+ // One dependency per live record: a large stream's completion can
791
+ // outgrow one operation. Its records still re-export; the stream is
792
+ // then not certified complete from this writer.
793
+ if (encoder.encode(canonicalJson(rewritten)).length +
794
+ REVISION_MARGIN_BYTES >
795
+ LITE_CANONICAL_MAX_OPERATION_BYTES) {
796
+ skipped += 1;
797
+ continue;
798
+ }
799
+ await issue(rewritten, certified);
800
+ }
801
+ }
802
+ return { reissued, skipped };
803
+ }
804
+ async function commit(input) {
805
+ enabled();
806
+ if (!input.id ||
807
+ input.id.length > 256 ||
808
+ (input.operations === undefined) === (input.envelope === undefined))
809
+ throw new Error("exactly one local batch or replica envelope required");
810
+ const context = deps.context();
811
+ const local = input.operations !== undefined;
812
+ if (!context.owner ||
813
+ (!local && deps.authenticateReplica(input.credential) !== context.owner))
814
+ throw new Error("replica authority required for owner");
815
+ if (!local)
816
+ refuseOversizedEnvelope(input.envelope);
817
+ const envelope = local
818
+ ? undefined
819
+ : replicaEnvelopeSchema.parse(input.envelope);
820
+ if (envelope &&
821
+ (envelope.owner !== context.owner ||
822
+ envelope.operations.some((op) => op.owner !== context.owner)))
823
+ throw new LiteCanonicalRefusal("wrong_owner", "wrong replica owner");
824
+ // A local request id also fences the original, unrevised inputs after lost ack.
825
+ const encoded = canonicalJson(local ? input.operations : envelope);
826
+ if (local && encoder.encode(encoded).length > LITE_CANONICAL_BATCH_BYTES)
827
+ throw new LiteCanonicalRefusal("batch_too_large", "canonical batch exceeds 192 KiB; split before commit");
828
+ const fingerprint = `sha256:${sha256(toBytes(encoded)).slice(2)}`;
829
+ return persistence.transaction(async (base) => {
830
+ const tx = trackViews(base);
831
+ if (local) {
832
+ // One write boundary for every local operation kind. Quarantine is
833
+ // checked before clocks, staged attachments, receipts, or the log.
834
+ for (const connectionId of new Set(input.operations.map((op) => op.connectionId))) {
835
+ if ((await connectionState(tx, connectionId, context)).blocked)
836
+ throw new Error("local connection is quarantined");
837
+ }
838
+ }
839
+ if (input.promotion) {
840
+ const batch = await get(tx, pendingKey(input.promotion.id));
841
+ if (!batch ||
842
+ batch.state === "quarantined" ||
843
+ batch.fingerprint !== input.promotion.fingerprint)
844
+ throw new Error("pending promotion conflict");
845
+ }
846
+ const current = await clock(tx, context);
847
+ await ensurePendingIndex(tx, context);
848
+ const receiptKey = `${local ? "local" : "import"}/${keyDigest(input.id)}`;
849
+ const prior = await get(tx, receiptKey);
850
+ if (prior) {
851
+ if (prior.fingerprint !== fingerprint)
852
+ throw new LiteCanonicalRefusal("commit_id_reused", "commit id reused with different content");
853
+ if (local)
854
+ return prior.receipt;
855
+ // Retry imports can supply formerly absent bytes/declarations. Retaining
856
+ // exact operations again is idempotent; canonical writes compare content.
857
+ }
858
+ const newlyConfirmed = [];
859
+ for (const blob of input.blobs ?? []) {
860
+ await tx.attachBlob(blob.stagingId, blob.descriptor);
861
+ if (await confirmBlob(tx, blob.descriptor.blobId))
862
+ newlyConfirmed.push(blob.descriptor.blobId);
863
+ }
864
+ const dirty = new Set();
865
+ const changed = new Map();
866
+ const full = new Set();
867
+ if (input.promotion)
868
+ for await (const blob of pendingBlobsOf(tx, (await get(tx, pendingKey(input.promotion.id)))))
869
+ if (await confirmBlob(tx, blob.blobId))
870
+ newlyConfirmed.push(blob.blobId);
871
+ for (const blobId of newlyConfirmed)
872
+ for (const id of await claimants(tx, blobId)) {
873
+ dirty.add(id);
874
+ full.add(id);
875
+ }
876
+ const accepted = [];
877
+ for (const raw of local ? input.operations : envelope.operations) {
878
+ let op;
879
+ if (local) {
880
+ if (!Number.isSafeInteger(current.counter + 1))
881
+ throw new Error("replication counter exhausted");
882
+ op = replicaOperationSchema.parse({
883
+ ...raw,
884
+ revision: {
885
+ counter: current.counter + 1,
886
+ writerId: current.writerId,
887
+ operationId: newId(),
888
+ },
889
+ });
890
+ }
891
+ else
892
+ op = replicaOperationSchema.parse(raw);
893
+ if (op.owner !== context.owner)
894
+ throw new LiteCanonicalRefusal("wrong_owner", "wrong operation owner");
895
+ const mapped = await mapping(tx, op);
896
+ if (local && !mapped)
897
+ throw new Error("local connection must be provisioned by host");
898
+ if (local) {
899
+ const lifecycle = await operations(tx, `${connectionPrefix(op.connectionId)}lifecycle/`);
900
+ const plan = planReplicaMerge(context, [...lifecycle, op]);
901
+ const collisions = await tx.scan(`equivocations/${keyDigest(op.connectionId)}/`);
902
+ if (collisions.entries.length ||
903
+ (await get(tx, `binding-conflicts/${keyDigest(op.connectionId)}`)) ||
904
+ plan.pending.length ||
905
+ plan.rejected.length ||
906
+ plan.conflicts.length ||
907
+ (op.kind !== "connection-delete" &&
908
+ plan.suppressed.some((issue) => issue.reason === "connection_deleted" ||
909
+ issue.reason === "old_generation")))
910
+ throw new Error("local batch violates replica contract");
911
+ }
912
+ if (local && (op.kind === "upsert" || op.kind === "delete")) {
913
+ const declaration = context.declarations.find((d) => d.sourceId === op.sourceId &&
914
+ canonicalJson(d.binding) === canonicalJson(op.declaration));
915
+ if (!declaration)
916
+ throw new Error("missing retained declaration");
917
+ const row = await get(tx, rowKey(op.connectionId, op.declaration.stream, op.recordKey));
918
+ const decision = planIngest(0, {
919
+ instance: op.connectionId,
920
+ stream: op.declaration.stream,
921
+ key: op.declaration.primaryKey.length === 1
922
+ ? op.kind === "upsert"
923
+ ? op.data[op.declaration.primaryKey[0]]
924
+ : op.recordKey
925
+ : JSON.parse(op.recordKey),
926
+ data: op.kind === "upsert" ? op.data : null,
927
+ emitted_at: op.emittedAt,
928
+ op: op.kind,
929
+ }, declaration.binding.semantics, declaration.binding.primaryKey, () => row
930
+ ? { deleted: row.deleted, data: row.deleted ? null : row.data }
931
+ : undefined, (_s, _i, data) => declaration.validateData(data) ? null : "invalid_record_data");
932
+ if (decision.outcome.outcome === "rejected")
933
+ throw new Error(decision.outcome.reason);
934
+ if (decision.outcome.outcome === "unchanged")
935
+ continue;
936
+ if (op.kind === "upsert" && !(await blobReady(tx, op)))
937
+ throw new Error("missing_or_invalid_blob");
938
+ }
939
+ const confirmedBlobs = op.kind === "blob-claim"
940
+ ? [op.blob]
941
+ : op.kind === "upsert"
942
+ ? op.blobs
943
+ : [];
944
+ // Bytes that only a pending batch carries stay pending: a confirmed
945
+ // claim must not adopt them.
946
+ for (const blob of confirmedBlobs)
947
+ if ((await tx.blob(blob.blobId)) &&
948
+ !(await get(tx, `pending-blobs/${keyDigest(blob.blobId)}`)))
949
+ await put(tx, `confirmed-blobs/${keyDigest(blob.blobId)}`, true);
950
+ current.counter = Math.max(current.counter, op.revision.counter);
951
+ const { collision, opKey } = await retain(tx, op, dirty);
952
+ const address = op.kind === "upsert" || op.kind === "delete"
953
+ ? { stream: op.declaration.stream, key: op.recordKey }
954
+ : undefined;
955
+ if (address) {
956
+ const keys = changed.get(op.connectionId) ?? new Map();
957
+ keys.set(recordId(address.stream, address.key), address);
958
+ changed.set(op.connectionId, keys);
959
+ }
960
+ else
961
+ full.add(op.connectionId);
962
+ if (collision)
963
+ for (const id of dirty)
964
+ full.add(id);
965
+ accepted.push(op);
966
+ // Keep subsequent same-batch local content decisions on staged canonical rows.
967
+ if (local) {
968
+ await reconcile(tx, op.connectionId, context, address && !collision ? [address] : undefined);
969
+ current.sequence += 1;
970
+ const entry = {
971
+ batchId: input.id,
972
+ operationKey: opKey,
973
+ sequence: current.sequence,
974
+ };
975
+ if (op.kind === "upsert" || op.kind === "delete") {
976
+ const row = await get(tx, rowKey(op.connectionId, op.declaration.stream, op.recordKey));
977
+ if (!row)
978
+ throw new Error("local record was not materialized");
979
+ entry.rowVersion = row.version;
980
+ }
981
+ await put(tx, logKey(current.sequence), entry);
982
+ }
983
+ }
984
+ for (const id of dirty)
985
+ await reconcile(tx, id, context, full.has(id)
986
+ ? undefined
987
+ : changed.get(id)
988
+ ? [...changed.get(id).values()]
989
+ : undefined);
990
+ if (local && accepted.length) {
991
+ // Reject invalid lifecycle/generation batches before any acknowledgment.
992
+ for (const id of dirty) {
993
+ const lifecycle = await operations(tx, `${connectionPrefix(id)}lifecycle/`);
994
+ const own = accepted.filter((op) => op.connectionId === id);
995
+ const plan = planReplicaMerge(context, [...lifecycle, ...own]);
996
+ if (plan.pending.length ||
997
+ plan.rejected.length ||
998
+ plan.conflicts.length ||
999
+ plan.suppressed.some((issue) => issue.reason === "old_generation"))
1000
+ throw new Error("local batch violates replica contract");
1001
+ }
1002
+ }
1003
+ await put(tx, "clock", current);
1004
+ const receipt = prior?.receipt ?? {
1005
+ sequence: current.sequence,
1006
+ operationCount: accepted.length,
1007
+ };
1008
+ await put(tx, receiptKey, { fingerprint, receipt });
1009
+ if (input.promotion) {
1010
+ const path = pendingKey(input.promotion.id);
1011
+ const batch = (await get(tx, path));
1012
+ await put(tx, path, { ...batch, state: "confirmed" });
1013
+ await indexPending(tx, batch, false, context);
1014
+ }
1015
+ const previewDirty = new Set(dirty);
1016
+ for (const op of accepted) {
1017
+ if (await refreshPendingRevision(tx, op, previewDirty))
1018
+ for (const id of previewDirty)
1019
+ full.add(id);
1020
+ }
1021
+ for (const id of previewDirty)
1022
+ await refreshPreview(tx, id, context, full.has(id) ? undefined : [...(changed.get(id)?.values() ?? [])]);
1023
+ await advanceChanged(tx, previewDirty);
1024
+ return receipt;
1025
+ });
1026
+ }
1027
+ function authenticate(credential) {
1028
+ const context = deps.context();
1029
+ if (!context.owner ||
1030
+ deps.authenticateReplica(credential) !== context.owner)
1031
+ throw new Error("replica authority required for owner");
1032
+ return context;
1033
+ }
1034
+ // Merge two native keyset pages. The earlier continuation boundary limits
1035
+ // both inputs, so no unconsumed entry can be skipped on the next page.
1036
+ async function mergedPage(tx, namespace, prefix, after, formatExtra) {
1037
+ const [base, extra] = await Promise.all([
1038
+ tx.scan(prefix, after),
1039
+ tx.scan(`${namespace}${prefix}`, after === undefined ? undefined : `${namespace}${after}`),
1040
+ ]);
1041
+ const boundaries = [base.after, extra.after?.slice(namespace.length)]
1042
+ .filter((key) => key !== undefined)
1043
+ .sort();
1044
+ const boundary = boundaries[0];
1045
+ const entries = new Map(base.entries.map((entry) => [entry.key, entry.value]));
1046
+ for (const entry of extra.entries)
1047
+ entries.set(entry.key.slice(namespace.length), formatExtra ? formatExtra(entry.value) : entry.value);
1048
+ const sorted = [...entries]
1049
+ .filter(([key]) => boundary === undefined || key <= boundary)
1050
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
1051
+ const page = [];
1052
+ let bytes = 0;
1053
+ for (const [key, value] of sorted) {
1054
+ const size = encoder.encode(value).length;
1055
+ if (page.length && (page.length === 100 || bytes + size > 512 * 1024))
1056
+ break;
1057
+ page.push({ key, value });
1058
+ bytes += size;
1059
+ }
1060
+ return {
1061
+ entries: page,
1062
+ ...(boundary !== undefined || page.length < sorted.length
1063
+ ? { after: page.at(-1)?.key ?? boundary }
1064
+ : {}),
1065
+ };
1066
+ }
1067
+ function previewRows(tx) {
1068
+ return {
1069
+ ...tx,
1070
+ get: async (key) => (await tx.get(`preview/${key}`)) ?? tx.get(key),
1071
+ scan: (prefix, after) => mergedPage(tx, "preview/", prefix, after, (value) => canonicalJson({ ...JSON.parse(value), ...replicaPendingMetadata() })),
1072
+ };
1073
+ }
1074
+ // Only writers use this view. Retained canonical and active pending partitions
1075
+ // feed the same reducer; derived rows are durable but never enter canonical data.
1076
+ /**
1077
+ * A preview row equals its canonical row when it shows the same content. Its
1078
+ * version and emitted time may differ, for example after a pending reset
1079
+ * re-adds a row. The owner sees such a row without a pending marker, as on
1080
+ * Node.
1081
+ */
1082
+ function sameContent(preview, canonical) {
1083
+ if (preview === canonical)
1084
+ return true;
1085
+ if (canonical === undefined)
1086
+ return false;
1087
+ const a = JSON.parse(preview);
1088
+ const b = JSON.parse(canonical);
1089
+ return (!a.deleted &&
1090
+ !b.deleted &&
1091
+ canonicalJson(a.data) === canonicalJson(b.data));
1092
+ }
1093
+ function pendingMaterializer(tx) {
1094
+ return {
1095
+ ...tx,
1096
+ get: async (key) => {
1097
+ if (key.startsWith("binding-evidence/") ||
1098
+ key.startsWith("binding-conflicts/")) {
1099
+ const id = key.slice(key.indexOf("/") + 1);
1100
+ const canonical = await get(tx, `binding-evidence/${id}`);
1101
+ const bindings = new Set(canonical ? [canonicalJson(canonical)] : []);
1102
+ for await (const entry of scan(tx, `pending-bindings/${id}/`))
1103
+ bindings.add(canonicalJson(JSON.parse(entry.value).binding));
1104
+ const [first, second] = bindings;
1105
+ if (key.startsWith("binding-evidence/"))
1106
+ return first;
1107
+ return (await tx.get(key)) ?? second;
1108
+ }
1109
+ return tx.get(key);
1110
+ },
1111
+ put: async (key, value) => {
1112
+ if (!key.startsWith("rows/"))
1113
+ return;
1114
+ const before = await tx.get(`preview/${key}`);
1115
+ await tx.put(`preview/${key}`, value);
1116
+ await indexPreviewBlobRef(tx, key, before, value);
1117
+ },
1118
+ delete: async (key) => {
1119
+ const before = await tx.get(`preview/${key}`);
1120
+ await tx.delete(`preview/${key}`);
1121
+ await indexPreviewBlobRef(tx, key, before, undefined);
1122
+ },
1123
+ scan: (prefix, after) => mergedPage(tx, "pending-index/", prefix, after),
1124
+ };
1125
+ }
1126
+ /**
1127
+ * Blob id -> preview rows that reference it, kept in the same write as the
1128
+ * preview row. Only preview rows can reference pending-only bytes: a
1129
+ * canonical upsert commits its bytes and marks the blob confirmed. So this
1130
+ * index is the visible-reference set of every include-pending reader;
1131
+ * confirmed-only readers never see pending bytes.
1132
+ */
1133
+ async function indexPreviewBlobRef(tx, path, before, after) {
1134
+ const blobOf = (value) => {
1135
+ if (value === undefined)
1136
+ return undefined;
1137
+ const row = JSON.parse(value);
1138
+ if (row.deleted)
1139
+ return undefined;
1140
+ const id = row.data.blob_ref
1141
+ ?.blob_id;
1142
+ return typeof id === "string" ? id : undefined;
1143
+ };
1144
+ const old = blobOf(before);
1145
+ const next = blobOf(after);
1146
+ if (old === next)
1147
+ return;
1148
+ if (old !== undefined)
1149
+ await tx.delete(`${PREVIEW_BLOB_REFS}${keyDigest(old)}/${keyDigest(path)}`);
1150
+ if (next !== undefined)
1151
+ await put(tx, `${PREVIEW_BLOB_REFS}${keyDigest(next)}/${keyDigest(path)}`, path);
1152
+ }
1153
+ async function refreshPreview(tx, id, context, only) {
1154
+ // Each key starts from canonical rows, never yesterday's preview. Reconcile
1155
+ // streams native key pages on lifecycle fanout instead of collecting a scope.
1156
+ await reconcile(pendingMaterializer(tx), id, context, only, tx);
1157
+ }
1158
+ async function advancePreview(tx, id) {
1159
+ const path = `preview-revision/${keyDigest(id)}`;
1160
+ await put(tx, path, ((await get(tx, path)) ?? 0) + 1);
1161
+ }
1162
+ async function advanceCanonicalView(tx, id) {
1163
+ const path = `canonical-view-revision/${keyDigest(id)}`;
1164
+ await put(tx, path, ((await get(tx, path)) ?? 0) + 1);
1165
+ await advancePreview(tx, id);
1166
+ }
1167
+ /**
1168
+ * Advance only the connections whose visible state this transaction changed.
1169
+ * An unchanged retry, a repeated import or a repeated promotion keeps every
1170
+ * cursor (oracle D4). Canonical changes also move the owner view.
1171
+ */
1172
+ async function advanceChanged(tx, ids) {
1173
+ const { canonical, preview } = await changedViews(tx);
1174
+ let changed = false;
1175
+ for (const id of new Set(ids)) {
1176
+ const digest = keyDigest(id);
1177
+ if (canonical.has(digest)) {
1178
+ await advanceCanonicalView(tx, id);
1179
+ changed = true;
1180
+ }
1181
+ else if (preview.has(digest)) {
1182
+ await advancePreview(tx, id);
1183
+ changed = true;
1184
+ }
1185
+ }
1186
+ if (changed)
1187
+ await put(tx, "visibility-epoch", ((await get(tx, "visibility-epoch")) ?? 0) + 1);
1188
+ }
1189
+ async function refreshPendingRevision(tx, op, dirty) {
1190
+ const prefix = `revisions/${operationId(op)}/`;
1191
+ const variants = [];
1192
+ for await (const entry of scan(pendingMaterializer(tx), prefix))
1193
+ variants.push(JSON.parse(entry.value));
1194
+ for (const id of new Set([
1195
+ op.connectionId,
1196
+ ...variants.map((v) => v.connectionId),
1197
+ ])) {
1198
+ const key = `pending-index/equivocations/${keyDigest(id)}/${operationId(op)}`;
1199
+ if (variants.length > 1 &&
1200
+ variants.some((variant) => variant.connectionId === id))
1201
+ await put(tx, key, variants);
1202
+ else
1203
+ await tx.delete(key);
1204
+ dirty.add(id);
1205
+ }
1206
+ return variants.length > 1;
1207
+ }
1208
+ async function indexPending(tx, batch, active, context) {
1209
+ const dirty = new Set();
1210
+ const full = new Set();
1211
+ const changed = new Map();
1212
+ for (const op of (await pendingEnvelope(tx, batch)).operations) {
1213
+ await put(tx, `pending-index/retained-connections/${keyDigest(op.connectionId)}`, op.connectionId);
1214
+ const path = operationPath(op);
1215
+ const refs = `pending-refs/${digest(op)}/`;
1216
+ const ref = `${refs}${keyDigest(batch.id)}`;
1217
+ const existed = await tx.get(`pending-index/${path}`);
1218
+ if (active)
1219
+ await put(tx, ref, true);
1220
+ else
1221
+ await tx.delete(ref);
1222
+ const remains = (await tx.scan(refs)).entries.length > 0;
1223
+ if (remains)
1224
+ await put(tx, `pending-index/${path}`, op);
1225
+ else
1226
+ await tx.delete(`pending-index/${path}`);
1227
+ const binding = { sourceId: op.sourceId, methodId: op.methodId };
1228
+ const bindingKey = `pending-bindings/${keyDigest(op.connectionId)}/${keyDigest(binding)}`;
1229
+ const priorBinding = await get(tx, bindingKey);
1230
+ const count = (priorBinding?.count ?? 0) + (remains ? 1 : 0) - (existed ? 1 : 0);
1231
+ if (count)
1232
+ await put(tx, bindingKey, { count, binding });
1233
+ else
1234
+ await tx.delete(bindingKey);
1235
+ const revisionKey = `pending-index/revisions/${operationId(op)}/${digest(op)}`;
1236
+ if (remains)
1237
+ await put(tx, revisionKey, {
1238
+ connectionId: op.connectionId,
1239
+ opKey: path,
1240
+ });
1241
+ else
1242
+ await tx.delete(revisionKey);
1243
+ const wasCollision = !!(await tx.get(`pending-index/equivocations/${keyDigest(op.connectionId)}/${operationId(op)}`));
1244
+ const collision = await refreshPendingRevision(tx, op, dirty);
1245
+ if (collision || wasCollision)
1246
+ for (const id of dirty)
1247
+ full.add(id);
1248
+ if (op.kind === "upsert" || op.kind === "delete") {
1249
+ const address = { stream: op.declaration.stream, key: op.recordKey };
1250
+ await put(tx, `pending-index/keys/${keyDigest(op.connectionId)}/${recordId(address.stream, address.key)}`, address);
1251
+ const keys = changed.get(op.connectionId) ?? new Map();
1252
+ keys.set(recordId(address.stream, address.key), address);
1253
+ changed.set(op.connectionId, keys);
1254
+ }
1255
+ else
1256
+ full.add(op.connectionId);
1257
+ }
1258
+ // A carrier can provide bytes for a record retained on another connection.
1259
+ // Update availability before materializing every connection that claims it.
1260
+ for await (const blob of pendingBlobsOf(tx, batch)) {
1261
+ const path = `pending-blob-active/${keyDigest(blob.blobId)}/${keyDigest(batch.id)}`;
1262
+ if (active)
1263
+ await put(tx, path, true);
1264
+ else
1265
+ await tx.delete(path);
1266
+ for (const id of await claimants(pendingMaterializer(tx), blob.blobId)) {
1267
+ dirty.add(id);
1268
+ full.add(id);
1269
+ }
1270
+ }
1271
+ for (const id of dirty)
1272
+ await refreshPreview(tx, id, context, full.has(id) ? undefined : [...(changed.get(id)?.values() ?? [])]);
1273
+ if (!active) {
1274
+ for (const [id, keys] of changed)
1275
+ for (const { stream, key } of keys.values())
1276
+ if (!(await tx.scan(`pending-index/${connectionPrefix(id)}records/${recordId(stream, key)}/`)).entries.length)
1277
+ await tx.delete(`pending-index/keys/${keyDigest(id)}/${recordId(stream, key)}`);
1278
+ for (const id of dirty)
1279
+ if (!(await tx.scan(`pending-index/${connectionPrefix(id)}`)).entries
1280
+ .length)
1281
+ await tx.delete(`pending-index/retained-connections/${keyDigest(id)}`);
1282
+ }
1283
+ await advanceChanged(tx, dirty);
1284
+ }
1285
+ async function ensurePendingIndex(tx, context) {
1286
+ const version = await get(tx, "pending-index-version");
1287
+ if (version === PENDING_INDEX_VERSION)
1288
+ return;
1289
+ // One-time upgrade belongs to an authenticated writer, never a record read.
1290
+ if (version === 2) {
1291
+ // Version 3 adds the visible blob-reference index over existing rows.
1292
+ for await (const entry of scan(tx, "preview/rows/"))
1293
+ await indexPreviewBlobRef(tx, entry.key.slice("preview/".length), undefined, entry.value);
1294
+ await put(tx, "pending-index-version", PENDING_INDEX_VERSION);
1295
+ return;
1296
+ }
1297
+ for await (const entry of scan(tx, "pending-batches/")) {
1298
+ const batch = JSON.parse(entry.value);
1299
+ if (batch.state === "pending")
1300
+ await indexPending(tx, batch, true, context);
1301
+ }
1302
+ // Version 2 receipts bind each connection's canonical and pending snapshots.
1303
+ // Initialize them during this one-time writer upgrade, never during a read.
1304
+ for await (const entry of scan(pendingMaterializer(tx), "retained-connections/"))
1305
+ await advanceCanonicalView(tx, JSON.parse(entry.value));
1306
+ await put(tx, "pending-index-version", PENDING_INDEX_VERSION);
1307
+ }
1308
+ async function readView(tx, options) {
1309
+ if (!replicaStateVisible("pending", options.reader, deps.visibilityPolicy))
1310
+ return tx;
1311
+ if ((await get(tx, "pending-index-version")) !== PENDING_INDEX_VERSION)
1312
+ throw new Error("pending preview requires reconciliation; call retry before owner reads");
1313
+ return previewRows(tx);
1314
+ }
1315
+ /** A confirmed claim is usable only while a current live row references it. */
1316
+ async function hasCurrentConfirmedReference(tx, blobId, context) {
1317
+ for await (const entry of scan(tx, "retained-connections/")) {
1318
+ const id = JSON.parse(entry.value);
1319
+ const digest = keyDigest(id);
1320
+ if (!(await any(tx, `${connectionPrefix(id)}claims/${keyDigest(blobId)}/`)))
1321
+ continue;
1322
+ const { connection } = await connectionState(tx, id, context);
1323
+ // Missing lifecycle evidence keeps the applied generation's rows live.
1324
+ // Their confirmed bytes must remain readable with those rows.
1325
+ if (connection.status !== "active" && connection.status !== "held")
1326
+ continue;
1327
+ for await (const rowEntry of scan(tx, `rows/${digest}/`)) {
1328
+ const row = JSON.parse(rowEntry.value);
1329
+ if (row.deleted)
1330
+ continue;
1331
+ const generation = await get(tx, `row-generation/${rowEntry.key}`);
1332
+ if (generation !== connection.generation)
1333
+ continue;
1334
+ const reference = row.data.blob_ref?.blob_id;
1335
+ if (reference === blobId)
1336
+ return true;
1337
+ }
1338
+ }
1339
+ return false;
1340
+ }
1341
+ async function blobReadState(tx, blobId, options) {
1342
+ const previewReader = replicaStateVisible("pending", options.reader, deps.visibilityPolicy);
1343
+ const pendingBytes = await get(tx, `pending-blobs/${keyDigest(blobId)}`);
1344
+ const confirmed = await get(tx, `confirmed-blobs/${keyDigest(blobId)}`);
1345
+ let visible = false;
1346
+ if (confirmed) {
1347
+ visible = await hasCurrentConfirmedReference(tx, blobId, deps.context());
1348
+ if (previewReader) {
1349
+ await readView(tx, options);
1350
+ visible ||= await any(tx, `${PREVIEW_BLOB_REFS}${keyDigest(blobId)}/`);
1351
+ }
1352
+ }
1353
+ else if (pendingBytes && previewReader) {
1354
+ // Pending bytes need a live reference this reader sees, and an active
1355
+ // batch that carries them. One batch can stage the record and another
1356
+ // the bytes; either can later be withdrawn independently.
1357
+ await readView(tx, options);
1358
+ visible =
1359
+ (await any(tx, `${PREVIEW_BLOB_REFS}${keyDigest(blobId)}/`)) &&
1360
+ (await any(tx, `pending-blob-active/${keyDigest(blobId)}/`));
1361
+ }
1362
+ return {
1363
+ visible,
1364
+ epoch: (await get(tx, "visibility-epoch")) ?? 0,
1365
+ };
1366
+ }
1367
+ /** Mark a blob's bytes confirmed. True when they were not confirmed before. */
1368
+ async function confirmBlob(tx, blobId) {
1369
+ const path = `confirmed-blobs/${keyDigest(blobId)}`;
1370
+ if (await get(tx, path))
1371
+ return false;
1372
+ await put(tx, path, true);
1373
+ return true;
1374
+ }
1375
+ /**
1376
+ * Connections with a retained claim on the blob. A row on any of them can
1377
+ * turn visible when the blob's bytes become confirmed.
1378
+ */
1379
+ async function claimants(tx, blobId) {
1380
+ const ids = [];
1381
+ for await (const entry of scan(tx, "retained-connections/")) {
1382
+ const id = JSON.parse(entry.value);
1383
+ if (await any(tx, `${connectionPrefix(id)}claims/${keyDigest(blobId)}/`))
1384
+ ids.push(id);
1385
+ }
1386
+ return ids;
1387
+ }
1388
+ async function visibleRow(tx, view, path) {
1389
+ const row = await get(view, path);
1390
+ if (!row || view === tx || (await tx.get(path)) === (await view.get(path)))
1391
+ return row;
1392
+ return { ...row, ...replicaPendingMetadata() };
1393
+ }
1394
+ return {
1395
+ /** Host hook: caller authenticates/decrypts transport and verifies its hash before staging. */
1396
+ async stagePending(input) {
1397
+ enabled();
1398
+ const context = authenticate(input.credential);
1399
+ if (!input.id || input.id.length > 256)
1400
+ throw new Error("invalid pending batch id");
1401
+ refuseOversizedEnvelope(input.envelope);
1402
+ const envelope = replicaEnvelopeSchema.parse(input.envelope);
1403
+ if (envelope.owner !== context.owner ||
1404
+ envelope.operations.some((op) => op.owner !== context.owner))
1405
+ throw new LiteCanonicalRefusal("wrong_owner", "wrong replica owner");
1406
+ if (planReplicaMerge(context, envelope.operations).rejected.length)
1407
+ throw new Error("invalid pending replica operations");
1408
+ const fingerprint = digest(envelope);
1409
+ return persistence.transaction(async (base) => {
1410
+ const tx = trackViews(base);
1411
+ await clock(tx, context);
1412
+ await ensurePendingIndex(tx, context);
1413
+ const path = pendingKey(input.id);
1414
+ const prior = await get(tx, path);
1415
+ if (prior && prior.fingerprint !== fingerprint)
1416
+ throw new LiteCanonicalRefusal("commit_id_reused", "pending id reused with different content");
1417
+ if (prior && prior.state !== "pending")
1418
+ return { fingerprint, state: prior.state };
1419
+ for (const op of envelope.operations)
1420
+ await mapping(tx, op);
1421
+ const claims = new Set(envelope.operations.flatMap((op) => op.kind === "blob-claim" ? [canonicalJson(op.blob)] : []));
1422
+ for (const blob of input.blobs ?? []) {
1423
+ if (!claims.has(canonicalJson(blob.descriptor)))
1424
+ throw new Error("pending blob requires matching claim");
1425
+ if ((await tx.blob(blob.descriptor.blobId)) &&
1426
+ !(await get(tx, `pending-blobs/${keyDigest(blob.descriptor.blobId)}`)))
1427
+ await put(tx, `confirmed-blobs/${keyDigest(blob.descriptor.blobId)}`, true);
1428
+ await tx.attachBlob(blob.stagingId, blob.descriptor);
1429
+ await put(tx, `pending-blobs/${keyDigest(blob.descriptor.blobId)}`, true);
1430
+ await put(tx, pendingBlobKey(input.id, blob.descriptor.blobId), blob.descriptor);
1431
+ }
1432
+ // A restaged batch keeps the envelope it stored first.
1433
+ const parts = prior
1434
+ ? undefined
1435
+ : replicaTextParts(canonicalJson(envelope), PENDING_PART_BYTES);
1436
+ for (const [part, text] of (parts ?? []).entries())
1437
+ await tx.put(pendingPartKey(input.id, part), text);
1438
+ const batch = {
1439
+ ...prior,
1440
+ id: input.id,
1441
+ fingerprint,
1442
+ state: "pending",
1443
+ ...(parts && { parts: parts.length }),
1444
+ };
1445
+ await put(tx, path, batch);
1446
+ await indexPending(tx, { ...batch, envelope }, true, context);
1447
+ return { fingerprint, state: "pending" };
1448
+ });
1449
+ },
1450
+ /** Trusted finality hook; fingerprint must come from independently verified finalized evidence. */
1451
+ async promotePending(input) {
1452
+ enabled();
1453
+ const context = authenticate(input.credential);
1454
+ const batch = await persistence.transaction(async (base) => {
1455
+ const tx = trackViews(base);
1456
+ await clock(tx, context);
1457
+ await ensurePendingIndex(tx, context);
1458
+ const path = pendingKey(input.id);
1459
+ const value = await get(tx, path);
1460
+ if (!value)
1461
+ throw new Error("unknown pending batch");
1462
+ if (value.state === "confirmed" &&
1463
+ value.fingerprint !== input.fingerprint)
1464
+ throw new Error("cannot quarantine confirmed batch through pending hook");
1465
+ if (value.fingerprint !== input.fingerprint) {
1466
+ await put(tx, path, {
1467
+ ...value,
1468
+ state: "quarantined",
1469
+ reason: "divergent finalized commitment",
1470
+ });
1471
+ await indexPending(tx, value, false, context);
1472
+ return undefined;
1473
+ }
1474
+ if (value.state === "quarantined")
1475
+ throw new Error("pending batch quarantined");
1476
+ return pendingEnvelope(tx, value);
1477
+ });
1478
+ if (!batch)
1479
+ throw new Error("divergent finalized commitment");
1480
+ return commit({
1481
+ id: input.id,
1482
+ envelope: batch,
1483
+ credential: input.credential,
1484
+ promotion: input,
1485
+ });
1486
+ },
1487
+ async quarantinePending(input) {
1488
+ enabled();
1489
+ const context = authenticate(input.credential);
1490
+ await persistence.transaction(async (base) => {
1491
+ const tx = trackViews(base);
1492
+ await clock(tx, context);
1493
+ await ensurePendingIndex(tx, context);
1494
+ const path = pendingKey(input.id);
1495
+ const value = await get(tx, path);
1496
+ if (!value)
1497
+ throw new Error("unknown pending batch");
1498
+ if (value.state === "confirmed")
1499
+ throw new Error("cannot quarantine confirmed batch through pending hook");
1500
+ await put(tx, path, {
1501
+ ...value,
1502
+ state: "quarantined",
1503
+ reason: input.reason,
1504
+ });
1505
+ await indexPending(tx, value, false, context);
1506
+ });
1507
+ },
1508
+ /** Trusted host provisioning only; never called by replica or collector input. */
1509
+ async provisionConnection(id, connection) {
1510
+ enabled();
1511
+ if (!id || !connection.sourceId || !connection.methodId)
1512
+ throw new Error("invalid connection mapping");
1513
+ await persistence.transaction(async (base) => {
1514
+ const tx = trackViews(base);
1515
+ const context = deps.context();
1516
+ const current = await clock(tx, context);
1517
+ await ensurePendingIndex(tx, context);
1518
+ const prior = await get(tx, `connections/${keyDigest(id)}`);
1519
+ if (prior && canonicalJson(prior) !== canonicalJson(connection))
1520
+ throw new Error("immutable connection binding");
1521
+ const retained = await get(tx, `binding-evidence/${keyDigest(id)}`);
1522
+ if ((retained && canonicalJson(retained) !== canonicalJson(connection)) ||
1523
+ (await get(tx, `binding-conflicts/${keyDigest(id)}`)))
1524
+ throw new Error("retained connection source or method mismatch");
1525
+ await put(tx, `connections/${keyDigest(id)}`, connection);
1526
+ await reconcile(tx, id, context);
1527
+ await refreshPreview(tx, id, context);
1528
+ await advanceChanged(tx, [id]);
1529
+ await put(tx, "clock", current);
1530
+ });
1531
+ },
1532
+ /**
1533
+ * Replication's writer identity (host contract §3.2): local operations
1534
+ * from now on name `writerId`. The first time a writer binds, every
1535
+ * unpublished local operation is dropped from the outbox and the current
1536
+ * canonical state is re-exported under it (`reexport`), so a phone with
1537
+ * local writes before its first enable, or after a writer rotation,
1538
+ * still converges. Binding the writer already exported is a no-op.
1539
+ */
1540
+ async rebaseline(writerId) {
1541
+ enabled();
1542
+ if (!writerId)
1543
+ throw new Error("invalid replica writer");
1544
+ return persistence.transaction(async (base) => {
1545
+ const tx = trackViews(base);
1546
+ const context = deps.context();
1547
+ const current = await clock(tx, context);
1548
+ if ((await get(tx, "export-writer")) === writerId)
1549
+ return undefined;
1550
+ const result = await reexport(tx, current, writerId, context);
1551
+ await put(tx, "export-writer", writerId);
1552
+ await put(tx, "clock", current);
1553
+ return result;
1554
+ });
1555
+ },
1556
+ /** A staged pending batch's state and fingerprint, if any. */
1557
+ async pendingBatch(id) {
1558
+ enabled();
1559
+ return persistence.transaction(async (tx) => {
1560
+ const batch = await get(tx, pendingKey(id));
1561
+ return batch && { state: batch.state, fingerprint: batch.fingerprint };
1562
+ });
1563
+ },
1564
+ commitLocal: (id, operations, blobs) => commit({ id, operations, blobs }).then((receipt) => {
1565
+ deps.localCommitted?.();
1566
+ return receipt;
1567
+ }),
1568
+ import: (input) => commit(input),
1569
+ /** True when reconcile withdraws this connection from every reader. */
1570
+ async isConnectionQuarantined(connectionId) {
1571
+ enabled();
1572
+ return persistence.transaction(async (tx) => (await connectionState(tx, connectionId, deps.context())).blocked);
1573
+ },
1574
+ /**
1575
+ * Trusted host hook for a terminal batch (contract v3.1 A, H): record
1576
+ * the absorbing connection-quarantine fact and withdraw the connection
1577
+ * from every reader in the same transaction. The view revisions advance,
1578
+ * so open page cursors expire. Rows stay retained; nothing is deleted.
1579
+ */
1580
+ async quarantineConnection(input) {
1581
+ enabled();
1582
+ const context = deps.context();
1583
+ if (!context.owner ||
1584
+ deps.authenticateReplica(input.credential) !== context.owner)
1585
+ throw new Error("replica authority required for owner");
1586
+ const id = input.connectionId;
1587
+ if (!id || !input.reason)
1588
+ throw new Error("invalid connection quarantine");
1589
+ await persistence.transaction(async (base) => {
1590
+ const tx = trackViews(base);
1591
+ await clock(tx, context);
1592
+ await ensurePendingIndex(tx, context);
1593
+ const path = `${CONNECTION_QUARANTINE}${keyDigest(id)}`;
1594
+ if (await get(tx, path))
1595
+ return;
1596
+ await put(tx, path, { reason: input.reason });
1597
+ await reconcile(tx, id, context);
1598
+ await refreshPreview(tx, id, context);
1599
+ await advanceChanged(tx, [id]);
1600
+ });
1601
+ },
1602
+ /** Invoke after trusted declaration/validator changes. Continue until after is absent. */
1603
+ async retry(input) {
1604
+ enabled();
1605
+ const context = deps.context();
1606
+ if (!context.owner ||
1607
+ deps.authenticateReplica(input.credential) !== context.owner)
1608
+ throw new Error("replica authority required for owner");
1609
+ return persistence.transaction(async (base) => {
1610
+ const tx = trackViews(base);
1611
+ await clock(tx, context);
1612
+ await ensurePendingIndex(tx, context);
1613
+ const page = await mergedPage(tx, "pending-index/", "retained-connections/", input.after);
1614
+ for (const entry of page.entries) {
1615
+ const id = JSON.parse(entry.value);
1616
+ await reconcile(tx, id, context);
1617
+ await refreshPreview(tx, id, context);
1618
+ await advanceChanged(tx, [id]);
1619
+ }
1620
+ return { after: page.after };
1621
+ });
1622
+ },
1623
+ async readRecord(connection, stream, key, options = {}) {
1624
+ enabled();
1625
+ return persistence.transaction(async (base) => {
1626
+ const tx = trackViews(base);
1627
+ await clock(tx);
1628
+ const view = await readView(tx, options);
1629
+ return visibleRow(tx, view, rowKey(connection, stream, key));
1630
+ });
1631
+ },
1632
+ /**
1633
+ * The certified completions of a connection's canonical streams, by
1634
+ * stream. A completion describes canonical rows as stored, so a reader
1635
+ * that sees the pending preview gets none, as on Node.
1636
+ */
1637
+ async readStreamCompletions(connection, options = {}) {
1638
+ enabled();
1639
+ if (replicaStateVisible("pending", options.reader, deps.visibilityPolicy))
1640
+ return [];
1641
+ return persistence.transaction(async (tx) => {
1642
+ const completions = [];
1643
+ for await (const entry of scan(tx, `stream-completions/${keyDigest(connection)}/`))
1644
+ completions.push(JSON.parse(entry.value));
1645
+ return completions.sort((a, b) => a.declaration.stream < b.declaration.stream ? -1 : 1);
1646
+ });
1647
+ },
1648
+ /** Host-only current-row page. Includes tombstones; not a public query cursor. */
1649
+ async readConnectionPage(connection, after, options = {}) {
1650
+ enabled();
1651
+ if (!connection)
1652
+ throw new Error("connection required");
1653
+ const prefix = `rows/${keyDigest(connection)}/`;
1654
+ if (after !== undefined && !after.startsWith(prefix))
1655
+ throw new Error("invalid canonical connection cursor");
1656
+ return persistence.transaction(async (base) => {
1657
+ const tx = trackViews(base);
1658
+ await clock(tx);
1659
+ const view = await readView(tx, options);
1660
+ const mode = view === tx ? "canonical" : "pending";
1661
+ const revision = (await get(tx, `${mode === "canonical" ? "canonical-view-revision" : "preview-revision"}/${keyDigest(connection)}`)) ?? 0;
1662
+ const cursorPrefix = `${prefix}cursor/v2/${revision}/${mode}/`;
1663
+ if (after !== undefined && !after.startsWith(cursorPrefix))
1664
+ throw new Error("canonical connection cursor expired");
1665
+ const page = await view.scan(prefix, after === undefined
1666
+ ? undefined
1667
+ : `${prefix}${after.slice(cursorPrefix.length)}`);
1668
+ const visible = page.entries.map((entry) => JSON.parse(entry.value));
1669
+ const rows = [];
1670
+ let bytes = 2; // JSON array delimiters; pending metadata counts too.
1671
+ for (const row of visible) {
1672
+ const size = encoder.encode(canonicalJson(row)).length + (rows.length ? 1 : 0);
1673
+ if (rows.length && bytes + size > 512 * 1024)
1674
+ break;
1675
+ rows.push(row);
1676
+ bytes += size;
1677
+ }
1678
+ const next = rows.length < page.entries.length
1679
+ ? page.entries[rows.length - 1].key
1680
+ : page.after;
1681
+ return {
1682
+ rows,
1683
+ after: next === undefined
1684
+ ? undefined
1685
+ : `${cursorPrefix}${next.slice(prefix.length)}`,
1686
+ };
1687
+ });
1688
+ },
1689
+ /**
1690
+ * Internal bounded page of every local commit after a sequence, including
1691
+ * acknowledged ones. Never a public PDPP cursor or grant-relative read.
1692
+ */
1693
+ async readLogPage(afterSequence = 0) {
1694
+ enabled();
1695
+ if (!Number.isSafeInteger(afterSequence) || afterSequence < 0)
1696
+ throw new Error("invalid log offset");
1697
+ return persistence.transaction(async (base) => {
1698
+ const tx = trackViews(base);
1699
+ await clock(tx);
1700
+ return logPage(tx, afterSequence);
1701
+ });
1702
+ },
1703
+ /** Local commits after `afterSequence` that are not yet acknowledged. */
1704
+ async readOutboxPage(afterSequence = 0) {
1705
+ enabled();
1706
+ if (!Number.isSafeInteger(afterSequence) || afterSequence < 0)
1707
+ throw new Error("invalid outbox offset");
1708
+ return persistence.transaction(async (base) => {
1709
+ const tx = trackViews(base);
1710
+ await clock(tx);
1711
+ const acknowledged = (await get(tx, "outbox-ack")) ?? 0;
1712
+ const page = await logPage(tx, Math.max(afterSequence, acknowledged), afterSequence);
1713
+ return {
1714
+ entries: page.entries.map(({ sequence, batchId, operation }) => ({
1715
+ sequence,
1716
+ batchId,
1717
+ operation,
1718
+ })),
1719
+ nextAfterSequence: page.nextAfterSequence,
1720
+ hasMore: page.hasMore,
1721
+ };
1722
+ });
1723
+ },
1724
+ /** Moves the outbox past `next`. The log keeps every entry. */
1725
+ async acknowledgeOutbox(expected, next) {
1726
+ enabled();
1727
+ if (!Number.isSafeInteger(expected) ||
1728
+ !Number.isSafeInteger(next) ||
1729
+ expected < 0 ||
1730
+ next < expected)
1731
+ throw new Error("invalid outbox acknowledgment");
1732
+ await persistence.transaction(async (base) => {
1733
+ const tx = trackViews(base);
1734
+ const current = await clock(tx);
1735
+ const actual = (await get(tx, "outbox-ack")) ?? 0;
1736
+ if (actual === next)
1737
+ return;
1738
+ if (actual !== expected || next > current.sequence)
1739
+ throw new Error("outbox acknowledgment conflict");
1740
+ await put(tx, "outbox-ack", next);
1741
+ });
1742
+ },
1743
+ /**
1744
+ * The engine published every outbox operation up to `next` and the
1745
+ * gateway confirmed its registration: move the outbox past them. The log
1746
+ * keeps every entry. Monotonic; a lower or repeated acknowledgment is a
1747
+ * no-op.
1748
+ */
1749
+ async acknowledgePublished(next) {
1750
+ enabled();
1751
+ if (!Number.isSafeInteger(next) || next < 0)
1752
+ throw new Error("invalid outbox acknowledgment");
1753
+ await persistence.transaction(async (base) => {
1754
+ const tx = trackViews(base);
1755
+ const current = await clock(tx);
1756
+ if (next > current.sequence)
1757
+ throw new Error("outbox acknowledgment conflict");
1758
+ if (next <= ((await get(tx, "outbox-ack")) ?? 0))
1759
+ return;
1760
+ await put(tx, "outbox-ack", next);
1761
+ });
1762
+ },
1763
+ async stageBlob(descriptor, chunks, signal) {
1764
+ enabled();
1765
+ await persistence.transaction(async (base) => {
1766
+ const tx = trackViews(base);
1767
+ await clock(tx);
1768
+ });
1769
+ let totalBytes = 0;
1770
+ const capped = async function* () {
1771
+ for await (const chunk of chunks) {
1772
+ signal?.throwIfAborted();
1773
+ const next = totalBytes + chunk.length;
1774
+ if (!Number.isSafeInteger(next) || next > descriptor.sizeBytes)
1775
+ throw new Error("staged blob exceeds declared size");
1776
+ totalBytes = next;
1777
+ yield chunk;
1778
+ }
1779
+ signal?.throwIfAborted();
1780
+ if (totalBytes !== descriptor.sizeBytes)
1781
+ throw new Error("staged blob length does not match descriptor");
1782
+ };
1783
+ return persistence.stageBlob(descriptor, capped());
1784
+ },
1785
+ async *readBlob(blobId, options = {}) {
1786
+ enabled();
1787
+ const state = await persistence.transaction(async (base) => {
1788
+ const tx = trackViews(base);
1789
+ await clock(tx);
1790
+ return blobReadState(tx, blobId, options);
1791
+ });
1792
+ if (!state.visible)
1793
+ throw new Error("missing committed blob");
1794
+ for await (const chunk of persistence.readBlob(blobId)) {
1795
+ const current = await persistence.transaction(async (base) => {
1796
+ const tx = trackViews(base);
1797
+ await clock(tx);
1798
+ return blobReadState(tx, blobId, options);
1799
+ });
1800
+ if (!current.visible || current.epoch !== state.epoch)
1801
+ throw new Error("visibility changed during blob read");
1802
+ yield chunk;
1803
+ }
1804
+ },
1805
+ };
1806
+ }
1807
+ //# sourceMappingURL=canonical-store.js.map