@helipod/objectstore-substrate 0.1.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.
package/dist/index.js ADDED
@@ -0,0 +1,1584 @@
1
+ // src/segment.ts
2
+ import { convexToJson, jsonToConvex } from "@helipod/values";
3
+ function bytesToBase64(bytes) {
4
+ let binary = "";
5
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
6
+ return btoa(binary);
7
+ }
8
+ function base64ToBytes(b64) {
9
+ const binary = atob(b64);
10
+ const out = new Uint8Array(binary.length);
11
+ for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
12
+ return out;
13
+ }
14
+ function encodeId(id) {
15
+ return { tableNumber: id.tableNumber, internalId: bytesToBase64(id.internalId) };
16
+ }
17
+ function decodeId(id) {
18
+ return { tableNumber: id.tableNumber, internalId: base64ToBytes(id.internalId) };
19
+ }
20
+ function encodeResolvedDocument(doc) {
21
+ return { id: encodeId(doc.id), value: convexToJson(doc.value) };
22
+ }
23
+ function decodeResolvedDocument(doc) {
24
+ return { id: decodeId(doc.id), value: jsonToConvex(doc.value) };
25
+ }
26
+ function encodeDocumentLogEntry(entry) {
27
+ return {
28
+ ts: entry.ts.toString(),
29
+ id: encodeId(entry.id),
30
+ value: entry.value === null ? null : encodeResolvedDocument(entry.value),
31
+ prev_ts: entry.prev_ts === null ? null : entry.prev_ts.toString()
32
+ };
33
+ }
34
+ function decodeDocumentLogEntry(entry) {
35
+ return {
36
+ ts: BigInt(entry.ts),
37
+ id: decodeId(entry.id),
38
+ value: entry.value === null ? null : decodeResolvedDocument(entry.value),
39
+ prev_ts: entry.prev_ts === null ? null : BigInt(entry.prev_ts)
40
+ };
41
+ }
42
+ function encodeIndexValue(value) {
43
+ return value.type === "Deleted" ? { type: "Deleted" } : { type: "NonClustered", docId: encodeId(value.docId) };
44
+ }
45
+ function decodeIndexValue(value) {
46
+ return value.type === "Deleted" ? { type: "Deleted" } : { type: "NonClustered", docId: decodeId(value.docId) };
47
+ }
48
+ function encodeIndexUpdate(update) {
49
+ return { indexId: update.indexId, key: bytesToBase64(update.key), value: encodeIndexValue(update.value) };
50
+ }
51
+ function decodeIndexUpdate(update) {
52
+ return { indexId: update.indexId, key: base64ToBytes(update.key), value: decodeIndexValue(update.value) };
53
+ }
54
+ function encodeIndexWrite(write) {
55
+ return { ts: write.ts.toString(), update: encodeIndexUpdate(write.update) };
56
+ }
57
+ function decodeIndexWrite(write) {
58
+ return { ts: BigInt(write.ts), update: decodeIndexUpdate(write.update) };
59
+ }
60
+ function encodeDocumentLogEntries(documents) {
61
+ return documents.map(encodeDocumentLogEntry);
62
+ }
63
+ function decodeDocumentLogEntries(wire) {
64
+ return wire.map(decodeDocumentLogEntry);
65
+ }
66
+ function encodeIndexWrites(writes) {
67
+ return writes.map(encodeIndexWrite);
68
+ }
69
+ function decodeIndexWrites(wire) {
70
+ return wire.map(decodeIndexWrite);
71
+ }
72
+ function encodeSegment(payload) {
73
+ const wire = {
74
+ documents: encodeDocumentLogEntries(payload.documents),
75
+ indexUpdates: encodeIndexWrites(payload.indexUpdates)
76
+ };
77
+ return new TextEncoder().encode(JSON.stringify(wire));
78
+ }
79
+ function decodeSegment(bytes) {
80
+ const wire = JSON.parse(new TextDecoder().decode(bytes));
81
+ return {
82
+ documents: decodeDocumentLogEntries(wire.documents),
83
+ indexUpdates: decodeIndexWrites(wire.indexUpdates)
84
+ };
85
+ }
86
+
87
+ // src/manifest.ts
88
+ function manifestKey(shard) {
89
+ return `s${shard}/manifest`;
90
+ }
91
+ function emptyManifest() {
92
+ return { epoch: 0, frontierTs: "0", tsCounter: "0", segments: [], nextSeqno: 0, writerId: "", leaseExpiresAt: "0" };
93
+ }
94
+ async function readManifest(os, shard) {
95
+ const entry = await os.get(manifestKey(shard));
96
+ if (entry === null) return null;
97
+ const manifest = JSON.parse(new TextDecoder().decode(entry.body));
98
+ return { manifest, etag: entry.etag };
99
+ }
100
+ async function createManifest(os, shard) {
101
+ const manifest = emptyManifest();
102
+ const { etag } = await os.casPut(manifestKey(shard), new TextEncoder().encode(JSON.stringify(manifest)), null);
103
+ return { manifest, etag };
104
+ }
105
+ async function casManifest(os, shard, next, ifMatch) {
106
+ return os.casPut(manifestKey(shard), new TextEncoder().encode(JSON.stringify(next)), ifMatch);
107
+ }
108
+
109
+ // src/object-doc-store.ts
110
+ import { isCasConflict as isCasConflict2 } from "@helipod/objectstore";
111
+
112
+ // src/snapshot.ts
113
+ function encodeSnapshot(payload) {
114
+ const wire = {
115
+ frontierTs: payload.frontierTs,
116
+ segBase: payload.segBase,
117
+ documents: encodeDocumentLogEntries(payload.documents),
118
+ indexUpdates: encodeIndexWrites(payload.indexUpdates)
119
+ };
120
+ return new TextEncoder().encode(JSON.stringify(wire));
121
+ }
122
+ function decodeSnapshot(bytes) {
123
+ const wire = JSON.parse(new TextDecoder().decode(bytes));
124
+ return {
125
+ frontierTs: wire.frontierTs,
126
+ segBase: wire.segBase,
127
+ documents: decodeDocumentLogEntries(wire.documents),
128
+ indexUpdates: decodeIndexWrites(wire.indexUpdates)
129
+ };
130
+ }
131
+ function snapshotKey(shard, ts) {
132
+ return `s${shard}/snap/${ts}`;
133
+ }
134
+ async function writeSnapshot(os, shard, payload) {
135
+ await os.putImmutable(snapshotKey(shard, payload.frontierTs), encodeSnapshot(payload));
136
+ }
137
+ async function readSnapshot(os, shard, ts) {
138
+ const entry = await os.get(snapshotKey(shard, ts));
139
+ if (entry === null) return null;
140
+ return decodeSnapshot(entry.body);
141
+ }
142
+
143
+ // src/fenced-error.ts
144
+ var FencedError = class extends Error {
145
+ constructor(message) {
146
+ super(message);
147
+ this.name = "FencedError";
148
+ }
149
+ };
150
+
151
+ // src/consumers.ts
152
+ import { isCasConflict } from "@helipod/objectstore";
153
+ function consumersPrefix(shard) {
154
+ return `s${shard}/consumers/`;
155
+ }
156
+ function consumerKey(shard, consumerId) {
157
+ return `${consumersPrefix(shard)}${consumerId}`;
158
+ }
159
+ async function publishConsumerWatermark(os, shard, consumerId, watermark) {
160
+ const key = consumerKey(shard, consumerId);
161
+ const body = new TextEncoder().encode(JSON.stringify({ appliedSeqno: watermark.appliedSeqno }));
162
+ const MAX_ATTEMPTS = 5;
163
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
164
+ const existing = await os.get(key);
165
+ try {
166
+ await os.casPut(key, body, existing === null ? null : existing.etag);
167
+ return;
168
+ } catch (e) {
169
+ if (!isCasConflict(e)) throw e;
170
+ }
171
+ }
172
+ throw new Error(
173
+ `objectstore-substrate: publishConsumerWatermark exhausted ${MAX_ATTEMPTS} retries for '${key}' \u2014 unexpected sustained contention (watermarks are meant to be single-writer-per-(shard, consumerId))`
174
+ );
175
+ }
176
+ async function readConsumerWatermarks(os, shard) {
177
+ const prefix = consumersPrefix(shard);
178
+ const keys = await os.list(prefix);
179
+ const out = [];
180
+ for (const key of keys) {
181
+ const entry = await os.get(key);
182
+ if (entry === null) continue;
183
+ const parsed = JSON.parse(new TextDecoder().decode(entry.body));
184
+ out.push({ consumerId: key.slice(prefix.length), appliedSeqno: parsed.appliedSeqno });
185
+ }
186
+ return out;
187
+ }
188
+ async function removeConsumer(os, shard, consumerId) {
189
+ await os.delete(consumerKey(shard, consumerId));
190
+ }
191
+
192
+ // src/apply-snapshot.ts
193
+ import { documentIdKey } from "@helipod/id-codec";
194
+ async function applySnapshotState(local, snap, frontierTs) {
195
+ const snapshotIds = new Set(snap.documents.map((d) => documentIdKey(d.id)));
196
+ const currentState = await local.dumpCurrentState();
197
+ const dropped = currentState.documents.filter((d) => !snapshotIds.has(documentIdKey(d.id)));
198
+ let deletedDocs = [];
199
+ if (dropped.length > 0) {
200
+ deletedDocs = dropped.map((d) => ({ ts: frontierTs, id: d.id, value: null, prev_ts: d.ts }));
201
+ await local.write(deletedDocs, [], "Overwrite");
202
+ }
203
+ await local.write(snap.documents, snap.indexUpdates, "Overwrite");
204
+ return { deletedDocs };
205
+ }
206
+
207
+ // src/object-doc-store.ts
208
+ function segmentKey(shard, seqno) {
209
+ return `s${shard}/seg/${seqno}`;
210
+ }
211
+ var SNAPSHOT_EVERY = 8;
212
+ var ObjectStoreDocStore = class _ObjectStoreDocStore {
213
+ objectStore;
214
+ shard;
215
+ local;
216
+ /** The last manifest state this process successfully CAS'd (or bootstrapped from) + its etag —
217
+ * the read-half of the next `casManifest`'s optimistic-concurrency check. Updated ONLY after a
218
+ * successful CAS (never speculatively), under `mutex`. */
219
+ cached;
220
+ /** The next free segment seqno for THIS process — advanced by one on every successful commit CAS
221
+ * (dense in the common case), AND durably burned forward by one extra on every successful
222
+ * TAKEOVER `acquire()` (Task 4.6, superseding the earlier in-process-only skip-one of Task 4.5 —
223
+ * see `acquire()`'s doc) to fence out the seqno the immediate predecessor may have orphaned. The
224
+ * burn lives in the DURABLE `manifest.nextSeqno` itself (not just this in-process field) so it
225
+ * survives across a CHAIN of stalled takeovers with no successful commit in between — see
226
+ * `acquire()`. Non-dense-by-one-per-takeover is expected and harmless: never read as `[0..n]` —
227
+ * `materializeTo`/GC always iterate the manifest's own explicit `segments`/`nextSeqno` fields. */
228
+ nextSeqno;
229
+ /** Set when a post-CAS local apply fails: the commit is durable but the local materialization is
230
+ * inconsistent, so further commits are refused until the store is re-opened (re-bootstrapped). */
231
+ poisoned = false;
232
+ /** The lease this instance currently holds (Tier 3 Slice 4), or `null` if it has never
233
+ * `acquire()`'d (or was fenced — see `heartbeat`/`commitWriteBatch`). `commitWriteBatch` refuses to
234
+ * run without this set; `epoch` is the CAS-bumped value `acquire()` claimed, checked against
235
+ * `this.cached.manifest.epoch` before every commit as a defense-in-depth fence check. */
236
+ held = null;
237
+ /** Serializes `commitWrite`/`commitWriteBatch` — see class doc. */
238
+ mutex = Promise.resolve();
239
+ /** Committed segments since the last successful snapshot (or since `open`) — `maybeSnapshot`'s
240
+ * cadence trigger. Reset to 0 only on a successful `snapshot()`. */
241
+ committedSegmentsSinceSnapshot = 0;
242
+ constructor(objectStore, shard, local, cached, nextSeqno) {
243
+ this.objectStore = objectStore;
244
+ this.shard = shard;
245
+ this.local = local;
246
+ this.cached = cached;
247
+ this.nextSeqno = nextSeqno;
248
+ }
249
+ /**
250
+ * Open (or initialize) a shard's object-storage-backed store: read-or-create the manifest, then
251
+ * BOOTSTRAP (materialize) the local store — via `materializeTo`, see its doc for the snapshot +
252
+ * tail-replay algorithm. This claims NO ownership (Tier 3 Slice 4): a writer must additionally
253
+ * `acquire()` before it may commit; a replica (Slice 5) opens without ever acquiring. A fresh
254
+ * (empty) bucket bootstraps to an empty local store; a bucket with prior commits reconstructs the
255
+ * IDENTICAL current state either way.
256
+ */
257
+ static async open(opts) {
258
+ const { objectStore, shard, local } = opts;
259
+ await local.setupSchema();
260
+ let cached = await readManifest(objectStore, shard);
261
+ if (cached === null) {
262
+ try {
263
+ cached = await createManifest(objectStore, shard);
264
+ } catch (e) {
265
+ if (!isCasConflict2(e)) throw e;
266
+ cached = await readManifest(objectStore, shard);
267
+ if (cached === null) throw e;
268
+ }
269
+ }
270
+ const store = new _ObjectStoreDocStore(objectStore, shard, local, cached, 0);
271
+ await store.materializeTo(cached.manifest);
272
+ return store;
273
+ }
274
+ /**
275
+ * Materialize `this.local`/`this.nextSeqno` up to `manifest`'s frontier by applying whatever this
276
+ * instance hasn't already applied: if `manifest` references a snapshot this instance hasn't yet
277
+ * covered (`snapshotSegBase` at or beyond `this.nextSeqno`), restore it first
278
+ * (`write(..., "Overwrite")` of its current-state dump), then replay every remaining referenced
279
+ * segment in order via the same explicit-ts `write(..., "Overwrite")` path — the identical primitive
280
+ * a post-CAS commit applies with, and the one a replica tailer would use (design record §7).
281
+ *
282
+ * Two callers: `open()` on a brand-new instance (`this.nextSeqno === 0`, so this is a full
283
+ * bootstrap — snapshot-if-any + every segment), and `acquire()` on an ALREADY-materialized instance
284
+ * that may be behind the CURRENT manifest (a re-acquiring/challenging writer) — in that case
285
+ * skipping already-applied segments (and skipping a snapshot restore if this instance is already
286
+ * past its `segBase`) matters both for correctness (never double-apply, though `Overwrite` is
287
+ * idempotent) and so a challenger that's only slightly behind doesn't needlessly re-read a snapshot
288
+ * object. Restoring the snapshot when we're behind it is also what makes catch-up correct even if
289
+ * the fencing owner's `gc()` has since deleted the pre-snapshot segments we'd otherwise be missing.
290
+ *
291
+ * Not gated on `runExclusive` itself — both callers already hold it.
292
+ */
293
+ async materializeTo(manifest) {
294
+ let appliedThrough = this.nextSeqno - 1;
295
+ if (manifest.snapshotTs !== void 0 && manifest.snapshotSegBase !== void 0 && manifest.snapshotSegBase > appliedThrough) {
296
+ const snap = await readSnapshot(this.objectStore, this.shard, manifest.snapshotTs);
297
+ if (snap === null) {
298
+ throw new Error(
299
+ `objectstore-substrate: missing snapshot '${manifest.snapshotTs}' referenced by manifest for shard '${this.shard}' (torn state)`
300
+ );
301
+ }
302
+ await applySnapshotState(this.local, snap, BigInt(snap.frontierTs));
303
+ appliedThrough = manifest.snapshotSegBase;
304
+ }
305
+ for (const seqno of manifest.segments) {
306
+ if (seqno <= appliedThrough) continue;
307
+ const entry = await this.objectStore.get(segmentKey(this.shard, seqno));
308
+ if (entry === null) {
309
+ throw new Error(`objectstore-substrate: missing segment '${segmentKey(this.shard, seqno)}' referenced by manifest for shard '${this.shard}'`);
310
+ }
311
+ const payload = decodeSegment(entry.body);
312
+ await this.local.write(payload.documents, payload.indexUpdates, "Overwrite");
313
+ appliedThrough = seqno;
314
+ }
315
+ this.nextSeqno = manifest.nextSeqno;
316
+ }
317
+ /**
318
+ * Claim ownership of this shard for `opts.writerId` (Tier 3 Slice 4, Task 4.2) — the manifest CAS
319
+ * IS the fence: a successful acquire bumps `epoch`, which invalidates any prior owner's cached
320
+ * etag, so its next `commitWriteBatch`/`heartbeat` fails loudly (`FencedError` + poison).
321
+ *
322
+ * Re-reads the manifest FRESH (not `this.cached` — this instance may be stale or may never have
323
+ * committed before). If the lease is currently LIVE and held by a DIFFERENT writer
324
+ * (`now <= leaseExpiresAt`), refuses: `{acquired: false, heldBy, expiresAt}` — this is what
325
+ * prevents two live writers ping-ponging (a heartbeating owner's lease never expires). Otherwise
326
+ * (unowned, or the current lease is EXPIRED, or `opts.writerId` already owns it and is re-claiming)
327
+ * catches this instance's local store up to the just-read manifest via `materializeTo` — REQUIRED so
328
+ * a challenger that was behind (or a fresh instance that only `open()`'d) is fully materialized to
329
+ * the manifest's frontier BEFORE it starts committing under the claimed lease — then CAS-bumps
330
+ * `epoch` and stamps `writerId`/`leaseExpiresAt`.
331
+ *
332
+ * A `CasConflict` (lost the acquire race to a concurrent challenger) re-reads and returns
333
+ * `{acquired: false, ...}` — the caller may retry. Any OTHER `casManifest` failure is ambiguous
334
+ * (may have landed despite throwing — same lost-response concern as the commit path's C1 fix) and
335
+ * poisons this instance exactly like a commit/heartbeat CAS failure does (global constraint: lease
336
+ * CAS failures poison like commit CAS failures).
337
+ */
338
+ async acquire(opts) {
339
+ return this.runExclusive(async () => {
340
+ const fresh = await readManifest(this.objectStore, this.shard);
341
+ if (fresh === null) {
342
+ throw new Error(`objectstore-substrate: acquire() found no manifest for shard '${this.shard}' \u2014 open() must initialize it first`);
343
+ }
344
+ const { manifest, etag } = fresh;
345
+ if (manifest.writerId !== "" && opts.now <= Number(manifest.leaseExpiresAt) && manifest.writerId !== opts.writerId) {
346
+ return { acquired: false, heldBy: manifest.writerId, expiresAt: Number(manifest.leaseExpiresAt) };
347
+ }
348
+ await this.materializeTo(manifest);
349
+ const burn = manifest.epoch > 0;
350
+ const next = {
351
+ ...manifest,
352
+ epoch: manifest.epoch + 1,
353
+ writerId: opts.writerId,
354
+ leaseExpiresAt: String(opts.now + opts.leaseTtlMs),
355
+ nextSeqno: burn ? manifest.nextSeqno + 1 : manifest.nextSeqno
356
+ };
357
+ try {
358
+ const { etag: newEtag } = await casManifest(this.objectStore, this.shard, next, etag);
359
+ this.cached = { manifest: next, etag: newEtag };
360
+ this.held = { epoch: next.epoch, writerId: opts.writerId };
361
+ this.poisoned = false;
362
+ this.nextSeqno = next.nextSeqno;
363
+ return { acquired: true };
364
+ } catch (e) {
365
+ if (!isCasConflict2(e)) {
366
+ this.poisoned = true;
367
+ throw e;
368
+ }
369
+ const reread = await readManifest(this.objectStore, this.shard);
370
+ if (reread === null) throw e;
371
+ return { acquired: false, heldBy: reread.manifest.writerId, expiresAt: Number(reread.manifest.leaseExpiresAt) };
372
+ }
373
+ });
374
+ }
375
+ /**
376
+ * Renew the currently-held lease's `leaseExpiresAt` (Tier 3 Slice 4, Task 4.2) — `epoch`/`writerId`
377
+ * are carried forward UNCHANGED (a heartbeat is not a re-claim). Requires `this.held` (throws a
378
+ * clear error if `acquire()` was never called) and refuses on an already-`poisoned` instance.
379
+ *
380
+ * ANY `casManifest` failure means the manifest moved out from under this instance — a challenger
381
+ * bumped `epoch` (fenced us) or is mid-fence — so this ALWAYS poisons and throws `FencedError`,
382
+ * unlike the commit path's CasConflict-vs-ambiguous-error distinction: a heartbeat's whole job is to
383
+ * detect exactly this condition, so there is no "retry" case to special-case.
384
+ */
385
+ async heartbeat(opts) {
386
+ return this.runExclusive(async () => {
387
+ if (this.held === null) {
388
+ throw new FencedError(
389
+ `heartbeat on a store with no held lease for shard '${this.shard}' \u2014 ownership is lost (never acquired or already fenced)`
390
+ );
391
+ }
392
+ if (this.poisoned) {
393
+ throw new FencedError(
394
+ `heartbeat on a poisoned store for shard '${this.shard}' (fenced or a prior apply failed) \u2014 ownership is lost`
395
+ );
396
+ }
397
+ const next = { ...this.cached.manifest, leaseExpiresAt: String(opts.now + opts.leaseTtlMs) };
398
+ try {
399
+ const { etag } = await casManifest(this.objectStore, this.shard, next, this.cached.etag);
400
+ this.cached = { manifest: next, etag };
401
+ } catch (e) {
402
+ this.poisoned = true;
403
+ this.held = null;
404
+ throw new FencedError(
405
+ `heartbeat fenced: manifest for shard '${this.shard}' moved (stale etag) \u2014 this writer is no longer current and is now poisoned (re-acquire to continue). Cause: ${e?.message ?? String(e)}`
406
+ );
407
+ }
408
+ });
409
+ }
410
+ /** Voluntarily give up the held lease WITHOUT touching the bucket — the lease simply expires on its
411
+ * own at `leaseExpiresAt` (Tier 3 Slice 4, Task 4.2). After this, `commitWriteBatch` refuses until
412
+ * a fresh `acquire()`. IN-PROCESS ONLY: a challenger's `acquire()` still has to wait out the full
413
+ * remaining TTL, since nothing in the bucket changed. See `relinquish()` — the graceful-shutdown
414
+ * variant that ALSO clears the lease in the bucket itself, so a challenger can take over
415
+ * immediately instead of waiting for expiry (Tier 3 Slice 6, Task 6.5). */
416
+ release() {
417
+ this.held = null;
418
+ }
419
+ /**
420
+ * Graceful-shutdown variant of `release()` (Tier 3 Slice 6, Task 6.5): best-effort CAS the manifest
421
+ * to clear the lease (`writerId: "", leaseExpiresAt: "0"`) so a challenger's very next `acquire()`
422
+ * — even at `now: 0` — sees an unowned/already-expired lease and takes over IMMEDIATELY, instead of
423
+ * having to wait out this writer's full remaining `leaseTtlMs`. This closes the production gap the
424
+ * Task 6.4 review found: every graceful rolling-deploy stop/start pair otherwise ate a full-TTL
425
+ * write outage.
426
+ *
427
+ * Deliberately best-effort: a CAS failure here means either (a) we were already fenced — someone
428
+ * else owns the shard now, which is exactly the state we wanted anyway, or (b) a transient blip —
429
+ * the lease still expires naturally, falling back to the pre-6.5 behavior. Neither case should
430
+ * block or fail a clean shutdown, so any CAS error is swallowed. Does NOT bump `epoch` — clearing
431
+ * `writerId`/`leaseExpiresAt` is sufficient for a challenger's refusal check to pass trivially; the
432
+ * challenger's own `acquire()` bumps `epoch` and fences any stale in-process state on ITS claim, the
433
+ * same as an expiry-based takeover always has. Does NOT poison on a swallowed CAS failure — this is
434
+ * a clean voluntary exit, not a fence.
435
+ *
436
+ * Runs under `runExclusive` (serializes with commits/heartbeat/acquire, same as every other
437
+ * lease/commit operation on this instance). A no-op if this instance never held the lease
438
+ * (`this.held === null` — already fenced, or `acquire()` was never called): nothing to relinquish.
439
+ * Always demotes `this.held` to `null` at the end, regardless of the CAS outcome.
440
+ */
441
+ async relinquish() {
442
+ return this.runExclusive(async () => {
443
+ if (this.held === null) return;
444
+ const next = { ...this.cached.manifest, writerId: "", leaseExpiresAt: "0" };
445
+ try {
446
+ const { etag } = await casManifest(this.objectStore, this.shard, next, this.cached.etag);
447
+ this.cached = { manifest: next, etag };
448
+ } catch {
449
+ }
450
+ this.held = null;
451
+ });
452
+ }
453
+ /** Chain `fn` onto the mutex so commits from this process serialize (see class doc). */
454
+ runExclusive(fn) {
455
+ const result = this.mutex.then(fn, fn);
456
+ this.mutex = result.then(
457
+ () => void 0,
458
+ () => void 0
459
+ );
460
+ return result;
461
+ }
462
+ // ── Commit path (intercepted, object-first) ─────────────────────────────────────────────────
463
+ async commitWriteBatch(units, shardId) {
464
+ const tsList = await this.runExclusive(async () => {
465
+ if (this.held === null) {
466
+ throw new Error(`ObjectStoreDocStore for shard '${this.shard}': not the lease owner \u2014 call acquire() before committing`);
467
+ }
468
+ if (units.length === 0) return [];
469
+ if (this.poisoned) {
470
+ throw new Error(
471
+ `ObjectStoreDocStore for shard '${this.shard}' is poisoned (a prior post-commit local apply failed); it must be re-opened before further use`
472
+ );
473
+ }
474
+ if (this.cached.manifest.epoch !== this.held.epoch) {
475
+ this.poisoned = true;
476
+ throw new FencedError(
477
+ `commit fenced: shard '${this.shard}' cached epoch (${this.cached.manifest.epoch}) diverges from the held lease epoch (${this.held.epoch}) \u2014 this writer was fenced by another acquirer and is now poisoned (re-acquire to continue)`
478
+ );
479
+ }
480
+ const localMax = await this.local.maxTimestamp();
481
+ const cachedTsCounter = BigInt(this.cached.manifest.tsCounter);
482
+ const floor = cachedTsCounter > localMax ? cachedTsCounter : localMax;
483
+ const stampedUnits = [];
484
+ const allDocuments = [];
485
+ const allIndexUpdates = [];
486
+ const tsList2 = [];
487
+ for (let i = 0; i < units.length; i++) {
488
+ const unit = units[i];
489
+ const ts = floor + BigInt(i) + 1n;
490
+ tsList2.push(ts);
491
+ const stampedDocs = unit.documents.map((d) => ({ ...d, ts }));
492
+ const stampedIdx = unit.indexUpdates.map((w) => ({ ...w, ts }));
493
+ stampedUnits.push({ documents: stampedDocs, indexUpdates: stampedIdx });
494
+ allDocuments.push(...stampedDocs);
495
+ allIndexUpdates.push(...stampedIdx);
496
+ }
497
+ const maxTs = tsList2[tsList2.length - 1];
498
+ const seqno = this.nextSeqno;
499
+ const payload = { documents: allDocuments, indexUpdates: allIndexUpdates };
500
+ await this.objectStore.putImmutable(segmentKey(this.shard, seqno), encodeSegment(payload));
501
+ const next = {
502
+ ...this.cached.manifest,
503
+ epoch: this.held.epoch,
504
+ frontierTs: maxTs.toString(),
505
+ tsCounter: maxTs.toString(),
506
+ segments: [...this.cached.manifest.segments, seqno],
507
+ nextSeqno: seqno + 1
508
+ };
509
+ let etag;
510
+ try {
511
+ ({ etag } = await casManifest(this.objectStore, this.shard, next, this.cached.etag));
512
+ } catch (e) {
513
+ this.poisoned = true;
514
+ if (isCasConflict2(e)) {
515
+ this.held = null;
516
+ throw new FencedError(
517
+ `commit fenced: manifest for shard '${this.shard}' moved (stale etag) \u2014 this writer is no longer current and is now poisoned (re-open to continue)`
518
+ );
519
+ }
520
+ throw e;
521
+ }
522
+ try {
523
+ for (const u of stampedUnits) {
524
+ await this.local.write(u.documents, u.indexUpdates, "Overwrite", shardId);
525
+ }
526
+ } catch (e) {
527
+ this.poisoned = true;
528
+ throw new Error(
529
+ `post-commit local apply failed after a DURABLE commit to shard '${this.shard}' (seqno ${seqno}); the commit is durable but this ObjectStoreDocStore's local materialization is inconsistent and must be re-opened. Cause: ${e?.message ?? String(e)}`
530
+ );
531
+ }
532
+ this.cached = { manifest: next, etag };
533
+ this.nextSeqno = seqno + 1;
534
+ this.committedSegmentsSinceSnapshot++;
535
+ return tsList2;
536
+ });
537
+ await this.#maybeSnapshotBestEffort();
538
+ return tsList;
539
+ }
540
+ async commitWrite(documents, indexUpdates, shardId, opts) {
541
+ const out = await this.commitWriteBatch([{ documents, indexUpdates, meta: opts?.meta }], shardId);
542
+ return out[0];
543
+ }
544
+ // ── Snapshots (Tier 3 Slice 3, Task 3.2) — object-first, under the commit mutex ─────────────
545
+ /**
546
+ * Take a snapshot of the local store's current state and CAS the manifest to reference it, so a
547
+ * future `open` can bootstrap in O(state + tail) instead of replaying the whole segment log.
548
+ * Serializes against commits via `runExclusive` — same reasoning as `commitWriteBatch`: it reads
549
+ * `cached`/`nextSeqno` and CAS's the manifest, so it must not race a concurrent commit's CAS
550
+ * against the same cached etag.
551
+ *
552
+ * DEADLOCK HAZARD: `runExclusive` is NOT reentrant. Never call `snapshot()`/`maybeSnapshot()` from
553
+ * INSIDE another `runExclusive` body (e.g. the commit path's own exclusive block) — chain it AFTER
554
+ * that block resolves instead (see `commitWriteBatch`'s `#maybeSnapshotBestEffort` call site).
555
+ *
556
+ * BENIGN DIVERGENCE (whole-branch review, Minor #2): if this snapshot's boundary (`frontierTs`) is
557
+ * a DELETE's ts, the dump it captures (`dumpCurrentState` excludes tombstones) has no row at that
558
+ * ts — see the matching note at `open()`'s snapshot-restore site for the full explanation and why
559
+ * it's safe (the `tsCounter` commit floor, not `local.maxTimestamp()`, is what guards ts reuse).
560
+ */
561
+ async snapshot() {
562
+ return this.runExclusive(async () => {
563
+ if (this.poisoned) {
564
+ throw new Error(
565
+ `ObjectStoreDocStore for shard '${this.shard}' is poisoned (a prior post-commit local apply failed); it must be re-opened before further use`
566
+ );
567
+ }
568
+ if (this.nextSeqno === 0) return;
569
+ const dump = await this.local.dumpCurrentState();
570
+ const frontierTs = this.cached.manifest.frontierTs;
571
+ const segBase = this.nextSeqno - 1;
572
+ const payload = { frontierTs, segBase, documents: dump.documents, indexUpdates: dump.indexUpdates };
573
+ await writeSnapshot(this.objectStore, this.shard, payload);
574
+ const next = {
575
+ ...this.cached.manifest,
576
+ snapshotTs: frontierTs,
577
+ snapshotSegBase: segBase,
578
+ segments: this.cached.manifest.segments.filter((s) => s > segBase)
579
+ };
580
+ try {
581
+ const { etag } = await casManifest(this.objectStore, this.shard, next, this.cached.etag);
582
+ this.cached = { manifest: next, etag };
583
+ } catch (e) {
584
+ this.poisoned = true;
585
+ if (isCasConflict2(e)) {
586
+ throw new FencedError(
587
+ `snapshot fenced: manifest for shard '${this.shard}' moved (stale etag) \u2014 this writer is no longer current and is now poisoned (re-open to continue)`
588
+ );
589
+ }
590
+ throw e;
591
+ }
592
+ this.committedSegmentsSinceSnapshot = 0;
593
+ });
594
+ }
595
+ /** Take a snapshot if `SNAPSHOT_EVERY` segments have committed since the last one. No-op
596
+ * (including a poisoned instance — the next commit/explicit `snapshot()` call will surface that
597
+ * loudly) otherwise. */
598
+ async maybeSnapshot() {
599
+ if (this.poisoned) return;
600
+ if (this.committedSegmentsSinceSnapshot < SNAPSHOT_EVERY) return;
601
+ await this.snapshot();
602
+ }
603
+ /** `maybeSnapshot()`, swallowing any error — called from OUTSIDE the commit's exclusive block
604
+ * (see `commitWriteBatch`). A snapshot failure must never fail an already-durable commit; if it
605
+ * poisoned this instance, the next commit's own poisoned-check surfaces that loudly instead. */
606
+ async #maybeSnapshotBestEffort() {
607
+ try {
608
+ await this.maybeSnapshot();
609
+ } catch {
610
+ }
611
+ }
612
+ // ── GC (Tier 3 Slice 3, Task 3.3) — reclaim what the latest snapshot has superseded ─────────
613
+ /**
614
+ * Reclaim durable objects the CURRENT manifest's snapshot (and every registered consumer's
615
+ * watermark) has superseded: every segment with `seqno <= min(snapshotSegBase, W_min)` (where
616
+ * `W_min` is the SLOWEST published `s{shard}/consumers/{id}` watermark's `appliedSeqno` — `+Infinity` when
617
+ * no consumers are registered, so the floor collapses back to plain `snapshotSegBase`, byte-for-
618
+ * byte the Slice 3 behavior) and every snapshot object except the current one (`snapshotTs`) — the
619
+ * newest snapshot is always kept regardless of `W_min` (a replica re-materializing after falling
620
+ * behind the tail restores the NEWEST snapshot via `materializeTo`'s fallback path, never an older
621
+ * one, so keeping only the newest is correct independent of any watermark).
622
+ *
623
+ * The watermark floor is the Tier 3 Slice 5, Task 5.2 addition (closing the Slice 3/4 GC-under-
624
+ * replicas deferral): a lagging replica's `ObjectStoreReplicaTailer` publishes its own
625
+ * `appliedSeqno` via `publishConsumerWatermark`; as long as it hasn't fallen behind
626
+ * `snapshotSegBase` itself (its own snapshot-fallback backstop covers that case — see
627
+ * `replica-tailer.ts`), `gc()` here will never delete a segment `(W_min, snapshotSegBase]` that
628
+ * replica still needs to tail.
629
+ *
630
+ * Runs under `runExclusive` — it must not race a concurrent commit/snapshot/acquire on THIS
631
+ * instance. Never touches the manifest itself or `this.cached`/`nextSeqno` (beyond adopting a
632
+ * freshly re-read manifest when the epoch check below passes) — GC is purely subtractive over
633
+ * objects the manifest (and consumers) no longer need.
634
+ *
635
+ * PERFORMANCE NOTE (whole-branch review, Minor #4): because it runs under `runExclusive`, `gc()`
636
+ * blocks `commitWriteBatch`/`snapshot()` on this instance for the ENTIRE list+delete sweep (now
637
+ * also a `consumers/` list+get sweep) — fine for a manual, occasional, single-node call, but revisit
638
+ * the locking granularity if GC's cadence ever needs to tighten.
639
+ *
640
+ * GC-FENCING (Tier 3 Slice 7, Task 7.1 — closes the Slice-4/5/6 deferral above): `gc()` used to
641
+ * trust `this.cached.manifest` — a snapshot pointer that can be ARBITRARILY STALE the instant a
642
+ * challenger fences this writer (bumps `epoch`) without this instance having attempted a
643
+ * commit/heartbeat since. A stale writer's cached `snapshotTs` then names an OLD snapshot, and the
644
+ * pre-Slice-7 "delete every `snap/*` except my cached `snapshotTs`" predicate would delete the NEW
645
+ * owner's live snapshot — sinking that owner's next bootstrap. Fixed by two changes, in order:
646
+ * 1. `this.held === null` (never an owner — a replica, or an already-demoted/fenced writer) is a
647
+ * harmless no-op: never GC.
648
+ * 2. RE-READ the manifest fresh (never trust `this.cached`) and compare its `epoch` against
649
+ * `this.held.epoch`. A mismatch means a challenger's `acquire()` landed since we last knew —
650
+ * we've been fenced RIGHT NOW, even though nothing told us yet — so we poison + demote and
651
+ * delete NOTHING. Only once the epoch matches do we adopt the fresh manifest as current truth
652
+ * and compute the delete floor/keepSnap FROM IT (never from a value read before this check).
653
+ * This closes the TOCTOU window at the READ side, but a fence could still land in the gap BETWEEN
654
+ * this re-read and the deletes below (the sweep isn't atomic with the epoch check) — so the delete
655
+ * PREDICATES themselves must independently tolerate a same-gap fence:
656
+ * - Segments: `seqno <= floor` (unchanged) — a new owner's commits only ever land at HIGHER
657
+ * seqnos than the frontier this floor was computed from, so this predicate can never reach one.
658
+ * - Snapshots: **`BigInt(ts) < BigInt(keepSnap)`** (strictly older), not "every snapshot except
659
+ * keepSnap" — a new owner racing a snapshot into the gap produces a `ts` NEWER than our
660
+ * `keepSnap` (ts's are monotone `frontierTs` values), which `<` by construction never matches.
661
+ * `keepSnap` itself is also never deleted (not `<` itself). Compared as `bigint`, not string —
662
+ * ts's are decimal-string bigints, and a naive string compare orders `"9"` after `"10"`.
663
+ * Together: a fenced/stale writer's `gc()` deletes nothing (the epoch check catches the common case,
664
+ * and the TOCTOU-safe predicates catch the rest), while the happy-path owner's behavior is unchanged
665
+ * (its own re-read always matches its own held epoch).
666
+ */
667
+ async gc() {
668
+ return this.runExclusive(async () => {
669
+ if (this.poisoned) {
670
+ throw new Error(
671
+ `ObjectStoreDocStore for shard '${this.shard}' is poisoned (a prior post-commit local apply failed); it must be re-opened before further use`
672
+ );
673
+ }
674
+ if (this.held === null) {
675
+ return { deletedSegments: 0, deletedSnapshots: 0 };
676
+ }
677
+ const fresh = await readManifest(this.objectStore, this.shard);
678
+ if (fresh === null) {
679
+ throw new Error(`objectstore-substrate: gc() found no manifest for shard '${this.shard}' \u2014 open() must initialize it first`);
680
+ }
681
+ if (fresh.manifest.epoch !== this.held.epoch) {
682
+ this.poisoned = true;
683
+ this.held = null;
684
+ return { deletedSegments: 0, deletedSnapshots: 0 };
685
+ }
686
+ this.cached = fresh;
687
+ if (fresh.manifest.snapshotTs === void 0) {
688
+ return { deletedSegments: 0, deletedSnapshots: 0 };
689
+ }
690
+ const segBase = fresh.manifest.snapshotSegBase;
691
+ const keepSnap = fresh.manifest.snapshotTs;
692
+ const keepSnapBig = BigInt(keepSnap);
693
+ const watermarks = await readConsumerWatermarks(this.objectStore, this.shard);
694
+ const wMin = watermarks.length > 0 ? Math.min(...watermarks.map((w) => w.appliedSeqno)) : Number.POSITIVE_INFINITY;
695
+ const floor = Math.min(segBase, wMin);
696
+ const segPrefix = `s${this.shard}/seg/`;
697
+ const segKeys = await this.objectStore.list(segPrefix);
698
+ let deletedSegments = 0;
699
+ for (const key of segKeys) {
700
+ const suffix = key.slice(key.lastIndexOf("/") + 1);
701
+ const seqno = Number(suffix);
702
+ if (!Number.isInteger(seqno)) continue;
703
+ if (seqno <= floor) {
704
+ await this.objectStore.delete(key);
705
+ deletedSegments++;
706
+ }
707
+ }
708
+ const snapPrefix = `s${this.shard}/snap/`;
709
+ const snapKeys = await this.objectStore.list(snapPrefix);
710
+ let deletedSnapshots = 0;
711
+ for (const key of snapKeys) {
712
+ const ts = key.slice(key.lastIndexOf("/") + 1);
713
+ if (!/^\d+$/.test(ts)) continue;
714
+ if (BigInt(ts) < keepSnapBig) {
715
+ await this.objectStore.delete(key);
716
+ deletedSnapshots++;
717
+ }
718
+ }
719
+ return { deletedSegments, deletedSnapshots };
720
+ });
721
+ }
722
+ // ── Everything else: forward to the local materialized store ───────────────────────────────
723
+ setupSchema(options) {
724
+ return this.local.setupSchema(options);
725
+ }
726
+ write(documents, indexUpdates, conflictStrategy, shardId) {
727
+ return this.local.write(documents, indexUpdates, conflictStrategy, shardId);
728
+ }
729
+ /** The materialized current state (Slice 5 — migration export). Delegates to the local SQLite
730
+ * store, which already holds the full materialized image the object log reduces to — the same
731
+ * source `snapshot()` dumps from. */
732
+ dumpCurrentState() {
733
+ return this.local.dumpCurrentState();
734
+ }
735
+ addCommitGuard(guard) {
736
+ return this.local.addCommitGuard(guard);
737
+ }
738
+ get(id, readTimestamp) {
739
+ return this.local.get(id, readTimestamp);
740
+ }
741
+ index_scan(indexId, tableId, readTimestamp, interval, order, limit) {
742
+ return this.local.index_scan(indexId, tableId, readTimestamp, interval, order, limit);
743
+ }
744
+ load_documents(range, order, limit) {
745
+ return this.local.load_documents(range, order, limit);
746
+ }
747
+ previous_revisions(queries) {
748
+ return this.local.previous_revisions(queries);
749
+ }
750
+ scan(tableId, readTimestamp) {
751
+ return this.local.scan(tableId, readTimestamp);
752
+ }
753
+ count(tableId) {
754
+ return this.local.count(tableId);
755
+ }
756
+ maxTimestamp() {
757
+ return this.local.maxTimestamp();
758
+ }
759
+ getGlobal(key) {
760
+ return this.local.getGlobal(key);
761
+ }
762
+ writeGlobal(key, value) {
763
+ return this.local.writeGlobal(key, value);
764
+ }
765
+ writeGlobalIfAbsent(key, value) {
766
+ return this.local.writeGlobalIfAbsent(key, value);
767
+ }
768
+ getClientVerdict(identity, clientId, seq) {
769
+ return this.local.getClientVerdict(identity, clientId, seq);
770
+ }
771
+ getClientFloor(identity, clientId) {
772
+ return this.local.getClientFloor(identity, clientId);
773
+ }
774
+ recordClientVerdict(identity, clientId, seq, record) {
775
+ return this.local.recordClientVerdict(identity, clientId, seq, record);
776
+ }
777
+ updateClientVerdictValue(identity, clientId, seq, value) {
778
+ return this.local.updateClientVerdictValue(identity, clientId, seq, value);
779
+ }
780
+ pruneClientMutations(identity, clientId, opts) {
781
+ return this.local.pruneClientMutations(identity, clientId, opts);
782
+ }
783
+ sweepExpiredClientMutations(beforeMs) {
784
+ return this.local.sweepExpiredClientMutations(beforeMs);
785
+ }
786
+ close() {
787
+ return this.local.close();
788
+ }
789
+ };
790
+
791
+ // src/sharded-object-doc-store.ts
792
+ import { DEFAULT_SHARD } from "@helipod/id-codec";
793
+
794
+ // src/merge-sorted.ts
795
+ function compareBytesLex(a, b) {
796
+ const len = Math.min(a.length, b.length);
797
+ for (let i = 0; i < len; i++) {
798
+ const diff = a[i] - b[i];
799
+ if (diff !== 0) return diff;
800
+ }
801
+ return a.length - b.length;
802
+ }
803
+ function compareBigint(a, b) {
804
+ return a < b ? -1 : a > b ? 1 : 0;
805
+ }
806
+ async function* mergeSortedAsyncGenerators(sources, keyCompare, order, limit) {
807
+ const cursors = sources.map((gen) => ({ gen, current: void 0, done: false }));
808
+ async function advance(c) {
809
+ const r = await c.gen.next();
810
+ if (r.done) {
811
+ c.done = true;
812
+ c.current = void 0;
813
+ } else {
814
+ c.current = r.value;
815
+ }
816
+ }
817
+ try {
818
+ await Promise.all(cursors.map((c) => advance(c)));
819
+ let yielded = 0;
820
+ for (; ; ) {
821
+ if (limit !== void 0 && yielded >= limit) return;
822
+ let bestIdx = -1;
823
+ for (let i = 0; i < cursors.length; i++) {
824
+ const c = cursors[i];
825
+ if (c.done) continue;
826
+ if (bestIdx === -1) {
827
+ bestIdx = i;
828
+ continue;
829
+ }
830
+ const cmp = keyCompare(c.current, cursors[bestIdx].current);
831
+ if (order === "asc" ? cmp < 0 : cmp > 0) bestIdx = i;
832
+ }
833
+ if (bestIdx === -1) return;
834
+ const winner = cursors[bestIdx];
835
+ yield winner.current;
836
+ yielded++;
837
+ await advance(winner);
838
+ }
839
+ } finally {
840
+ await Promise.all(
841
+ cursors.filter((c) => !c.done).map(async (c) => {
842
+ try {
843
+ await c.gen.return(void 0);
844
+ } catch {
845
+ }
846
+ })
847
+ );
848
+ }
849
+ }
850
+
851
+ // src/sharded-object-doc-store.ts
852
+ var ShardedObjectStoreDocStore = class {
853
+ lanes;
854
+ defaultShard;
855
+ constructor(lanes, opts) {
856
+ if (lanes.size === 0) {
857
+ throw new Error("ShardedObjectStoreDocStore: at least one lane is required");
858
+ }
859
+ const defaultShard = opts?.defaultShard ?? DEFAULT_SHARD;
860
+ if (!lanes.has(defaultShard)) {
861
+ throw new Error(
862
+ `ShardedObjectStoreDocStore: defaultShard '${defaultShard}' is not one of the composed lanes (${[...lanes.keys()].join(", ")})`
863
+ );
864
+ }
865
+ this.lanes = lanes;
866
+ this.defaultShard = defaultShard;
867
+ }
868
+ lane(shardId) {
869
+ const l = this.lanes.get(shardId);
870
+ if (!l) {
871
+ throw new Error(
872
+ `ShardedObjectStoreDocStore: unknown shard '${shardId}' \u2014 composed lanes are (${[...this.lanes.keys()].join(", ")})`
873
+ );
874
+ }
875
+ return l;
876
+ }
877
+ laneList() {
878
+ return [...this.lanes.values()];
879
+ }
880
+ // ── Writes — route by the caller-supplied shardId (the ShardedTransactor always passes one for
881
+ // commits; `write`'s replica/bootstrap callers do too — see `ObjectStoreDocStore.materializeTo`) ─
882
+ setupSchema(options) {
883
+ return Promise.all(this.laneList().map((l) => l.setupSchema(options))).then(() => void 0);
884
+ }
885
+ // NOTE: these three are deliberately `async` (not a bare `return this.lane(...)...`) so an
886
+ // unknown-shardId lookup failure — thrown SYNCHRONOUSLY by `this.lane()` — surfaces as a REJECTED
887
+ // promise, matching every other `DocStore` method's async contract, rather than throwing
888
+ // synchronously out of the call before the caller ever gets a promise to await/catch.
889
+ async write(documents, indexUpdates, conflictStrategy, shardId) {
890
+ return this.lane(shardId ?? this.defaultShard).write(documents, indexUpdates, conflictStrategy, shardId);
891
+ }
892
+ async commitWrite(documents, indexUpdates, shardId, opts) {
893
+ return this.lane(shardId ?? this.defaultShard).commitWrite(documents, indexUpdates, shardId, opts);
894
+ }
895
+ async commitWriteBatch(units, shardId) {
896
+ return this.lane(shardId ?? this.defaultShard).commitWriteBatch(units, shardId);
897
+ }
898
+ /**
899
+ * Fan the SAME guard out to every lane (one registration per lane) — a guard for the sharded case
900
+ * therefore fences/effects PER LANE, not atomically across the whole sharded store. This matches
901
+ * `ObjectStoreDocStore`'s own single-lane note ("guard atomicity + effectively-once forwarding are a
902
+ * LATER slice") — composing N of them doesn't add a NEW atomicity gap, it just makes the existing
903
+ * per-lane-only scope explicit at the sharded layer too.
904
+ */
905
+ addCommitGuard(guard) {
906
+ const unregs = this.laneList().map((l) => l.addCommitGuard(guard));
907
+ return () => {
908
+ for (const unreg of unregs) unreg();
909
+ };
910
+ }
911
+ // ── Reads — MERGE across every lane (the transactor never tells us which shard a read is for) ────
912
+ async get(id, readTimestamp) {
913
+ const hits = await Promise.all(this.laneList().map((l) => l.get(id, readTimestamp)));
914
+ for (const hit of hits) if (hit !== null) return hit;
915
+ return null;
916
+ }
917
+ async *index_scan(indexId, tableId, readTimestamp, interval, order, limit) {
918
+ const generators = this.laneList().map((l) => l.index_scan(indexId, tableId, readTimestamp, interval, order, limit));
919
+ yield* mergeSortedAsyncGenerators(generators, (a, b) => compareBytesLex(a[0], b[0]), order, limit);
920
+ }
921
+ async *load_documents(range, order, limit) {
922
+ const generators = this.laneList().map((l) => l.load_documents(range, order, limit));
923
+ yield* mergeSortedAsyncGenerators(generators, (a, b) => compareBigint(a.ts, b.ts), order, limit);
924
+ }
925
+ async previous_revisions(queries) {
926
+ const perLane = await Promise.all(this.laneList().map((l) => l.previous_revisions(queries)));
927
+ const merged = /* @__PURE__ */ new Map();
928
+ for (const laneResult of perLane) {
929
+ for (const [key, entry] of laneResult) merged.set(key, entry);
930
+ }
931
+ return merged;
932
+ }
933
+ async scan(tableId, readTimestamp) {
934
+ const perLane = await Promise.all(this.laneList().map((l) => l.scan(tableId, readTimestamp)));
935
+ return perLane.flat();
936
+ }
937
+ async count(tableId) {
938
+ const perLane = await Promise.all(this.laneList().map((l) => l.count(tableId)));
939
+ return perLane.reduce((sum, n) => sum + n, 0);
940
+ }
941
+ async maxTimestamp() {
942
+ const perLane = await Promise.all(this.laneList().map((l) => l.maxTimestamp()));
943
+ let max = 0n;
944
+ for (const ts of perLane) if (ts > max) max = ts;
945
+ return max;
946
+ }
947
+ // ── Deployment-level bookkeeping — route to ONE lane (the default shard) consistently, never
948
+ // fan out: these are single-source-of-truth concerns (fleet identity, per-client receipts), not
949
+ // per-shard data. Routing every deployment ever to the SAME lane means a client's receipts/floor
950
+ // are never split across lanes regardless of which table its mutations touched. ────────────────
951
+ getGlobal(key) {
952
+ return this.lane(this.defaultShard).getGlobal(key);
953
+ }
954
+ writeGlobal(key, value) {
955
+ return this.lane(this.defaultShard).writeGlobal(key, value);
956
+ }
957
+ writeGlobalIfAbsent(key, value) {
958
+ return this.lane(this.defaultShard).writeGlobalIfAbsent(key, value);
959
+ }
960
+ getClientVerdict(identity, clientId, seq) {
961
+ return this.lane(this.defaultShard).getClientVerdict(identity, clientId, seq);
962
+ }
963
+ getClientFloor(identity, clientId) {
964
+ return this.lane(this.defaultShard).getClientFloor(identity, clientId);
965
+ }
966
+ recordClientVerdict(identity, clientId, seq, record) {
967
+ return this.lane(this.defaultShard).recordClientVerdict(identity, clientId, seq, record);
968
+ }
969
+ updateClientVerdictValue(identity, clientId, seq, value) {
970
+ return this.lane(this.defaultShard).updateClientVerdictValue(identity, clientId, seq, value);
971
+ }
972
+ pruneClientMutations(identity, clientId, opts) {
973
+ return this.lane(this.defaultShard).pruneClientMutations(identity, clientId, opts);
974
+ }
975
+ sweepExpiredClientMutations(beforeMs) {
976
+ return this.lane(this.defaultShard).sweepExpiredClientMutations(beforeMs);
977
+ }
978
+ async close() {
979
+ await Promise.all(this.laneList().map((l) => l.close()));
980
+ }
981
+ };
982
+
983
+ // src/reshard.ts
984
+ import { shardIdList, shardIdForKeyValue, DEFAULT_SHARD as DEFAULT_SHARD2, documentIdKey as documentIdKey2 } from "@helipod/id-codec";
985
+
986
+ // src/globals.ts
987
+ import { isCasConflict as isCasConflict3 } from "@helipod/objectstore";
988
+ var GLOBALS_KEY = "globals";
989
+ async function readGlobals(os) {
990
+ const entry = await os.get(GLOBALS_KEY);
991
+ if (entry === null) return null;
992
+ return JSON.parse(new TextDecoder().decode(entry.body));
993
+ }
994
+ async function createGlobals(os, globals) {
995
+ await os.casPut(GLOBALS_KEY, new TextEncoder().encode(JSON.stringify(globals)), null);
996
+ return globals;
997
+ }
998
+ async function ensureGlobals(os, globals) {
999
+ const existing = await readGlobals(os);
1000
+ if (existing !== null) return existing;
1001
+ try {
1002
+ return await createGlobals(os, globals);
1003
+ } catch (e) {
1004
+ if (!isCasConflict3(e)) throw e;
1005
+ const winner = await readGlobals(os);
1006
+ if (winner === null) {
1007
+ throw new Error("objectstore-substrate: globals CasConflict but re-read found no globals object");
1008
+ }
1009
+ return winner;
1010
+ }
1011
+ }
1012
+ async function writeGlobals(os, globals) {
1013
+ const bytes = new TextEncoder().encode(JSON.stringify(globals));
1014
+ for (let attempt = 0; attempt < 5; attempt++) {
1015
+ const existing = await os.get(GLOBALS_KEY);
1016
+ try {
1017
+ await os.casPut(GLOBALS_KEY, bytes, existing === null ? null : existing.etag);
1018
+ return;
1019
+ } catch (e) {
1020
+ if (!isCasConflict3(e)) throw e;
1021
+ }
1022
+ }
1023
+ throw new Error("objectstore-substrate: writeGlobals exhausted retries on a moving globals object");
1024
+ }
1025
+
1026
+ // src/reshard.ts
1027
+ var ReshardObjectStoreLiveError = class extends Error {
1028
+ constructor(message) {
1029
+ super(message);
1030
+ this.name = "ReshardObjectStoreLiveError";
1031
+ }
1032
+ };
1033
+ function laneIdsFor(numShards) {
1034
+ return numShards === 1 ? ["0"] : [...shardIdList(numShards)];
1035
+ }
1036
+ function pushTo(map, key, value) {
1037
+ const arr = map.get(key);
1038
+ if (arr) arr.push(value);
1039
+ else map.set(key, [value]);
1040
+ }
1041
+ async function reshardObjectStore(opts) {
1042
+ const { objectStore: os, toShards, now, shardKeyFor, makeLocal } = opts;
1043
+ if (!Number.isInteger(toShards) || toShards < 1) {
1044
+ throw new RangeError(`reshardObjectStore: toShards must be a positive integer, got ${toShards}`);
1045
+ }
1046
+ const globals = await readGlobals(os);
1047
+ if (globals === null) {
1048
+ throw new Error("reshardObjectStore: no `globals` object \u2014 this bucket is not an object-storage deployment");
1049
+ }
1050
+ const fromShards = globals.numShards;
1051
+ if (fromShards === toShards) {
1052
+ return { fromShards, toShards, movedDocs: 0, perLaneCounts: {} };
1053
+ }
1054
+ const sourceLaneIds = laneIdsFor(fromShards);
1055
+ const targetLaneIds = laneIdsFor(toShards);
1056
+ const toBucketLane = (engineShard) => toShards === 1 ? "0" : engineShard;
1057
+ for (const shard of sourceLaneIds) {
1058
+ const m = await readManifest(os, shard);
1059
+ if (m !== null && m.manifest.writerId !== "" && now <= Number(m.manifest.leaseExpiresAt)) {
1060
+ throw new ReshardObjectStoreLiveError(
1061
+ `reshardObjectStore: refusing \u2014 lane '${shard}' has a live lease held by '${m.manifest.writerId}' (expires at ${m.manifest.leaseExpiresAt}, now ${now}). Stop the deployment (scale to zero / let leases expire) first.`
1062
+ );
1063
+ }
1064
+ }
1065
+ const allDocs = [];
1066
+ const allIndex = [];
1067
+ const oldLaneOfDoc = /* @__PURE__ */ new Map();
1068
+ for (const shard of sourceLaneIds) {
1069
+ const local = makeLocal();
1070
+ await ObjectStoreDocStore.open({ objectStore: os, shard, local });
1071
+ const state = await local.dumpCurrentState();
1072
+ for (const d of state.documents) {
1073
+ allDocs.push(d);
1074
+ oldLaneOfDoc.set(documentIdKey2(d.id), shard);
1075
+ }
1076
+ for (const iw of state.indexUpdates) allIndex.push(iw);
1077
+ await local.close();
1078
+ }
1079
+ const laneToDocs = /* @__PURE__ */ new Map();
1080
+ const newLaneOfDoc = /* @__PURE__ */ new Map();
1081
+ let movedDocs = 0;
1082
+ for (const d of allDocs) {
1083
+ const shardKey = shardKeyFor(d.id.tableNumber);
1084
+ const fields = d.value?.value;
1085
+ const engineShard = shardKey !== null && fields !== void 0 ? shardIdForKeyValue(fields[shardKey], toShards) : DEFAULT_SHARD2;
1086
+ const newLane = toBucketLane(engineShard);
1087
+ const key = documentIdKey2(d.id);
1088
+ newLaneOfDoc.set(key, newLane);
1089
+ if (oldLaneOfDoc.get(key) !== newLane) movedDocs++;
1090
+ pushTo(laneToDocs, newLane, d);
1091
+ }
1092
+ const laneToIndex = /* @__PURE__ */ new Map();
1093
+ for (const iw of allIndex) {
1094
+ if (iw.update.value.type !== "NonClustered") continue;
1095
+ const lane = newLaneOfDoc.get(documentIdKey2(iw.update.value.docId));
1096
+ if (lane === void 0) continue;
1097
+ pushTo(laneToIndex, lane, iw);
1098
+ }
1099
+ const lanesToClear = /* @__PURE__ */ new Set([...sourceLaneIds, ...targetLaneIds]);
1100
+ for (const shard of lanesToClear) {
1101
+ for (const k of await os.list(`s${shard}/`)) await os.delete(k);
1102
+ }
1103
+ const perLaneCounts = {};
1104
+ for (const shard of targetLaneIds) {
1105
+ const docs = laneToDocs.get(shard) ?? [];
1106
+ const index = laneToIndex.get(shard) ?? [];
1107
+ perLaneCounts[shard] = docs.length;
1108
+ const local = makeLocal();
1109
+ const store = await ObjectStoreDocStore.open({ objectStore: os, shard, local });
1110
+ await store.acquire({ writerId: `reshard-${shard}`, leaseTtlMs: 6e4, now });
1111
+ if (docs.length > 0 || index.length > 0) {
1112
+ await store.commitWriteBatch([{ documents: docs, indexUpdates: index }], shard);
1113
+ }
1114
+ await store.relinquish();
1115
+ await local.close();
1116
+ }
1117
+ await writeGlobals(os, { deploymentId: globals.deploymentId, numShards: toShards });
1118
+ return { fromShards, toShards, movedDocs, perLaneCounts };
1119
+ }
1120
+
1121
+ // src/heartbeat-driver.ts
1122
+ function leaseHeartbeatDriver(store, opts) {
1123
+ const { leaseTtlMs, heartbeatMs, onFenced } = opts;
1124
+ if (heartbeatMs >= leaseTtlMs) {
1125
+ throw new Error(
1126
+ `objectstore-substrate: leaseHeartbeatDriver requires heartbeatMs (${heartbeatMs}) < leaseTtlMs (${leaseTtlMs}) \u2014 a heartbeat cadence at or slower than the lease TTL can let the lease lapse before a renewal ever lands`
1127
+ );
1128
+ }
1129
+ let ctx;
1130
+ let timer = null;
1131
+ let stopped = false;
1132
+ async function tick() {
1133
+ if (stopped) return;
1134
+ await store.heartbeat({ now: ctx.now(), leaseTtlMs });
1135
+ }
1136
+ function armTimer() {
1137
+ if (stopped) return;
1138
+ if (timer !== null) {
1139
+ ctx.clearTimer(timer);
1140
+ timer = null;
1141
+ }
1142
+ timer = ctx.setTimer(ctx.now() + heartbeatMs, wake);
1143
+ }
1144
+ function wake() {
1145
+ if (stopped) return;
1146
+ tick().then(
1147
+ () => {
1148
+ armTimer();
1149
+ },
1150
+ (e) => {
1151
+ if (stopped) return;
1152
+ if (e instanceof FencedError) {
1153
+ stopped = true;
1154
+ console.error(
1155
+ `[objectstore-substrate] FATAL: lease heartbeat fenced \u2014 this node no longer owns its shard's write lease and must stop serving writes. Cause: ${e.message}`
1156
+ );
1157
+ try {
1158
+ onFenced?.(e);
1159
+ } catch (callbackError) {
1160
+ console.error("[objectstore-substrate] lease heartbeat: onFenced callback threw:", callbackError);
1161
+ }
1162
+ return;
1163
+ }
1164
+ console.error("[objectstore-substrate] lease heartbeat: renewal attempt failed (will retry):", e);
1165
+ armTimer();
1166
+ }
1167
+ );
1168
+ }
1169
+ return {
1170
+ name: "leaseHeartbeat",
1171
+ start(c) {
1172
+ ctx = c;
1173
+ armTimer();
1174
+ },
1175
+ stop() {
1176
+ stopped = true;
1177
+ if (timer !== null) {
1178
+ ctx.clearTimer(timer);
1179
+ timer = null;
1180
+ }
1181
+ },
1182
+ // Test seam: runs one heartbeat pass and awaits its real completion, letting a `FencedError` (or
1183
+ // any other error) propagate to the caller — unlike `wake()`, used by the timer path, which
1184
+ // catches and branches instead. Does NOT itself set `stopped`/call `onFenced` on a fence; a test
1185
+ // exercising that behavior should drive it through the timer callback captured by a fake
1186
+ // `DriverContext.setTimer`, same as `receiptsReaper`'s own tests do for its policy.
1187
+ __tick: () => tick()
1188
+ };
1189
+ }
1190
+
1191
+ // src/gc-driver.ts
1192
+ function gcDriver(store, opts) {
1193
+ const { sweepMs } = opts;
1194
+ let ctx;
1195
+ let timer = null;
1196
+ let stopped = false;
1197
+ async function tick() {
1198
+ if (stopped) return { deletedSegments: 0, deletedSnapshots: 0 };
1199
+ return store.gc();
1200
+ }
1201
+ function armTimer() {
1202
+ if (stopped) return;
1203
+ if (timer !== null) {
1204
+ ctx.clearTimer(timer);
1205
+ timer = null;
1206
+ }
1207
+ timer = ctx.setTimer(ctx.now() + sweepMs, wake);
1208
+ }
1209
+ function wake() {
1210
+ if (stopped) return;
1211
+ tick().catch((e) => {
1212
+ console.error("[objectstore-substrate] gc-driver: sweep pass failed (will retry):", e);
1213
+ }).finally(() => {
1214
+ armTimer();
1215
+ });
1216
+ }
1217
+ return {
1218
+ name: "objectStoreGc",
1219
+ start(c) {
1220
+ ctx = c;
1221
+ armTimer();
1222
+ },
1223
+ stop() {
1224
+ stopped = true;
1225
+ if (timer !== null) {
1226
+ ctx.clearTimer(timer);
1227
+ timer = null;
1228
+ }
1229
+ },
1230
+ // Test seam: runs one gc() pass and awaits its real completion, letting any error propagate
1231
+ // (unlike `wake()`, used by the timer path, which swallows+logs) — see the interface doc above.
1232
+ __tick: () => tick()
1233
+ };
1234
+ }
1235
+
1236
+ // src/frontier.ts
1237
+ async function readGlobalFrontier(os, shards) {
1238
+ if (shards.length === 0) return 0n;
1239
+ let min = null;
1240
+ for (const shard of shards) {
1241
+ const entry = await readManifest(os, shard);
1242
+ if (entry === null) return 0n;
1243
+ const ts = BigInt(entry.manifest.frontierTs);
1244
+ if (min === null || ts < min) min = ts;
1245
+ }
1246
+ return min;
1247
+ }
1248
+
1249
+ // src/replica-tailer.ts
1250
+ import { encodeStorageTableId, internalIdToHex } from "@helipod/id-codec";
1251
+ var MAX_MISSING_SEGMENT_RETRIES = 8;
1252
+ var ObjectStoreReplicaTailer = class {
1253
+ objectStore;
1254
+ shard;
1255
+ local;
1256
+ onInvalidation;
1257
+ pollMs;
1258
+ /** The highest segment seqno this tailer has itself applied (or correlated `local` to — see
1259
+ * `#ensureInitialized`/the "nothing new" opportunistic-seed note in `#tickOnce`). `-1` means
1260
+ * "not yet correlated to any manifest state" — the sentinel that makes `#materializeRound`'s
1261
+ * snapshot-fallback check and tail-pull loop naturally perform a FULL catch-up (every segment,
1262
+ * or the newest snapshot + its tail) the first time this tailer actually has new work to do,
1263
+ * which is the correct, safe behavior for a `local` this tailer hasn't yet correlated to a
1264
+ * known-caught-up point (re-applying already-present rows via `write(..., "Overwrite")` is an
1265
+ * idempotent no-op on `local`'s actual state; the only cost is a possibly-oversized first
1266
+ * invalidation batch, never a missed one). */
1267
+ appliedSeqnoValue = -1;
1268
+ /** The ts through which `local` is known to be caught up. Lazily seeded from
1269
+ * `local.maxTimestamp()` on the first `tick()` — see `#ensureInitialized`. */
1270
+ appliedMaxTsValue = 0n;
1271
+ initialized = false;
1272
+ timer;
1273
+ /** Reentrancy guard — a manual `tick()` call racing the `start()`-armed poll timer must not run
1274
+ * two overlapping apply rounds against the same `local`/cursor state. */
1275
+ draining = false;
1276
+ waiters = /* @__PURE__ */ new Set();
1277
+ constructor(opts) {
1278
+ this.objectStore = opts.objectStore;
1279
+ this.shard = opts.shard;
1280
+ this.local = opts.local;
1281
+ this.onInvalidation = opts.onInvalidation;
1282
+ this.pollMs = opts.pollMs ?? 1e3;
1283
+ }
1284
+ get appliedSeqno() {
1285
+ return this.appliedSeqnoValue;
1286
+ }
1287
+ get appliedMaxTs() {
1288
+ return this.appliedMaxTsValue;
1289
+ }
1290
+ /** Arms a `setInterval(tick, pollMs)`. Tick errors are swallowed (logged) — mirrors the fleet
1291
+ * tailer's fire-and-forget poll posture: a transient failure must not crash the caller's process,
1292
+ * and the NEXT tick retries from the same (unadvanced) watermark regardless. No-op if already
1293
+ * started. */
1294
+ start() {
1295
+ if (this.timer !== void 0) return;
1296
+ this.timer = setInterval(() => {
1297
+ void this.tick().catch((e) => {
1298
+ console.error(`objectstore-substrate: replica tailer tick failed for shard '${this.shard}'`, e);
1299
+ });
1300
+ }, this.pollMs);
1301
+ }
1302
+ stop() {
1303
+ if (this.timer !== void 0) {
1304
+ clearInterval(this.timer);
1305
+ this.timer = void 0;
1306
+ }
1307
+ }
1308
+ /** Resolves when `appliedMaxTs >= ts` (immediately if already true). Poll-driven: nothing here
1309
+ * itself advances the watermark — either `start()` must be armed, or the caller must drive
1310
+ * `tick()` itself (e.g. in a test loop). Rejects on `timeoutMs` elapsing first. */
1311
+ waitFor(ts, timeoutMs) {
1312
+ if (this.appliedMaxTsValue >= ts) return Promise.resolve();
1313
+ return new Promise((resolve, reject) => {
1314
+ const waiter = {
1315
+ ts,
1316
+ resolve: () => {
1317
+ clearTimeout(waiter.timer);
1318
+ this.waiters.delete(waiter);
1319
+ resolve();
1320
+ },
1321
+ timer: setTimeout(() => {
1322
+ this.waiters.delete(waiter);
1323
+ reject(
1324
+ new Error(
1325
+ `ObjectStoreReplicaTailer.waitFor timed out after ${timeoutMs}ms waiting for ts >= ${ts} (currently at ${this.appliedMaxTsValue})`
1326
+ )
1327
+ );
1328
+ }, timeoutMs)
1329
+ };
1330
+ this.waiters.add(waiter);
1331
+ });
1332
+ }
1333
+ #wakeSatisfiedWaiters() {
1334
+ for (const w of [...this.waiters]) {
1335
+ if (this.appliedMaxTsValue >= w.ts) w.resolve();
1336
+ }
1337
+ }
1338
+ /** One poll round: returns `true` if anything was applied (or the watermark otherwise advanced),
1339
+ * `false` if there was nothing new. Reentrancy-guarded (see `draining`'s doc) — a call landing
1340
+ * while another is already in flight is a no-op `false`, not queued; the next timer tick (or the
1341
+ * caller's own retry) picks up whatever was missed. */
1342
+ async tick() {
1343
+ if (this.draining) return false;
1344
+ this.draining = true;
1345
+ try {
1346
+ return await this.#tickOnce();
1347
+ } finally {
1348
+ this.draining = false;
1349
+ }
1350
+ }
1351
+ /** Seeds `appliedMaxTsValue` from `local.maxTimestamp()` on the FIRST tick only — this must be
1352
+ * lazy (not done in the constructor) because `DocStore.maxTimestamp()` is async and constructors
1353
+ * can't await. Deliberately does NOT try to also seed `appliedSeqnoValue` here: at this point we
1354
+ * have no manifest read yet to correlate a ts to a seqno cursor against, so `appliedSeqnoValue`
1355
+ * stays at its conservative `-1` sentinel — `#tickOnce`'s "nothing new" branch opportunistically
1356
+ * seeds it instead, the first time it reads a manifest whose `frontierTs` this exact
1357
+ * `appliedMaxTsValue` already covers (see that branch's doc for why that correlation is safe). */
1358
+ async #ensureInitialized() {
1359
+ if (this.initialized) return;
1360
+ this.appliedMaxTsValue = await this.local.maxTimestamp();
1361
+ this.initialized = true;
1362
+ }
1363
+ async #tickOnce() {
1364
+ await this.#ensureInitialized();
1365
+ const fresh = await readManifest(this.objectStore, this.shard);
1366
+ if (fresh === null) return false;
1367
+ const manifest = fresh.manifest;
1368
+ const frontierTs = BigInt(manifest.frontierTs);
1369
+ if (frontierTs <= this.appliedMaxTsValue) {
1370
+ if (this.appliedSeqnoValue < manifest.nextSeqno - 1) this.appliedSeqnoValue = manifest.nextSeqno - 1;
1371
+ return false;
1372
+ }
1373
+ const round = await this.#materializeRound(manifest);
1374
+ const newMaxTs = BigInt(round.manifest.frontierTs);
1375
+ if (round.documents.length === 0 && round.indexUpdates.length === 0) {
1376
+ this.appliedSeqnoValue = round.appliedSeqno;
1377
+ this.appliedMaxTsValue = newMaxTs;
1378
+ this.#wakeSatisfiedWaiters();
1379
+ return true;
1380
+ }
1381
+ const inv = this.#buildInvalidation(round.documents, round.indexUpdates, newMaxTs);
1382
+ await this.onInvalidation(inv);
1383
+ this.appliedSeqnoValue = round.appliedSeqno;
1384
+ this.appliedMaxTsValue = newMaxTs;
1385
+ this.#wakeSatisfiedWaiters();
1386
+ return true;
1387
+ }
1388
+ /**
1389
+ * Pulls + applies everything between `this.appliedSeqnoValue` and `manifest`'s frontier, mutating
1390
+ * `local` as it goes (idempotent — a segment/snapshot already reflected in `local` is a safe
1391
+ * no-op re-`Overwrite`), and returns every row applied THIS round (for `#buildInvalidation`), the
1392
+ * final manifest revision actually reached, and the seqno cursor the round advanced to.
1393
+ *
1394
+ * Deliberately does NOT mutate `this.appliedSeqnoValue` — only `#tickOnce` may do that, and only
1395
+ * AFTER its `onInvalidation` sink resolves (see that method's doc). This method runs entirely
1396
+ * against a LOCAL `appliedSeqno` variable seeded from the instance field, so a caller that discards
1397
+ * this round's result (sink threw) leaves the instance cursor exactly where it started, and a retry
1398
+ * re-runs this same method from the same starting point — re-applying idempotently and rebuilding
1399
+ * the identical returned batch.
1400
+ *
1401
+ * Mirrors `object-doc-store.ts`'s `materializeTo` — same snapshot-restore-then-replay-tail shape —
1402
+ * with one addition `materializeTo` doesn't need: a MISSING segment (`objectStore.get` returns
1403
+ * `null`) means a lagging replica lost a race against `gc()`, which only ever deletes a segment
1404
+ * once a NEWER snapshot supersedes it. The safe recovery is therefore always available: re-read the
1405
+ * manifest (it must now reference a snapshot covering the missing seqno, or GC could not have
1406
+ * deleted it) and restart the round against that fresher manifest — looping rather than recursing,
1407
+ * so partial progress already applied (and already reflected in the local `appliedSeqno`) is never
1408
+ * discarded, and the accumulated `documents`/`indexUpdates` carry across the restart intact.
1409
+ */
1410
+ async #materializeRound(initialManifest) {
1411
+ const documents = [];
1412
+ const indexUpdates = [];
1413
+ let manifest = initialManifest;
1414
+ let appliedSeqno = this.appliedSeqnoValue;
1415
+ let missingSegmentRetries = 0;
1416
+ for (; ; ) {
1417
+ if (manifest.snapshotTs !== void 0 && manifest.snapshotSegBase !== void 0 && manifest.snapshotSegBase > appliedSeqno) {
1418
+ const snap = await readSnapshot(this.objectStore, this.shard, manifest.snapshotTs);
1419
+ if (snap === null) {
1420
+ throw new Error(
1421
+ `objectstore-substrate: replica tailer for shard '${this.shard}' \u2014 missing snapshot '${manifest.snapshotTs}' referenced by the manifest (torn state)`
1422
+ );
1423
+ }
1424
+ const snapFrontierTs = BigInt(snap.frontierTs);
1425
+ const { deletedDocs } = await applySnapshotState(this.local, snap, snapFrontierTs);
1426
+ if (deletedDocs.length > 0) {
1427
+ documents.push(...deletedDocs);
1428
+ }
1429
+ documents.push(...snap.documents);
1430
+ indexUpdates.push(...snap.indexUpdates);
1431
+ appliedSeqno = manifest.snapshotSegBase;
1432
+ }
1433
+ let missedSegment = false;
1434
+ for (const seqno of manifest.segments) {
1435
+ if (seqno <= appliedSeqno) continue;
1436
+ const entry = await this.objectStore.get(segmentKey(this.shard, seqno));
1437
+ if (entry === null) {
1438
+ if (++missingSegmentRetries > MAX_MISSING_SEGMENT_RETRIES) {
1439
+ throw new Error(
1440
+ `objectstore-substrate: replica tailer for shard '${this.shard}' \u2014 missing segment '${segmentKey(this.shard, seqno)}' did not resolve via snapshot fallback after ${MAX_MISSING_SEGMENT_RETRIES} retries`
1441
+ );
1442
+ }
1443
+ const refreshed = await readManifest(this.objectStore, this.shard);
1444
+ if (refreshed === null) {
1445
+ throw new Error(
1446
+ `objectstore-substrate: replica tailer for shard '${this.shard}' \u2014 manifest disappeared mid-round`
1447
+ );
1448
+ }
1449
+ manifest = refreshed.manifest;
1450
+ missedSegment = true;
1451
+ break;
1452
+ }
1453
+ const payload = decodeSegment(entry.body);
1454
+ await this.local.write(payload.documents, payload.indexUpdates, "Overwrite");
1455
+ documents.push(...payload.documents);
1456
+ indexUpdates.push(...payload.indexUpdates);
1457
+ appliedSeqno = seqno;
1458
+ }
1459
+ if (!missedSegment) return { documents, indexUpdates, manifest, appliedSeqno };
1460
+ }
1461
+ }
1462
+ /** `${tableId}|${internalIdHex}` — the doc-identity key `#buildInvalidation`'s dedupe uses to
1463
+ * distinguish applied documents by `(tableId, internalId)`. (The snapshot-restore diff this
1464
+ * method used to also serve, Finding 1, now lives in the shared `applySnapshotState` helper,
1465
+ * keyed by `@helipod/id-codec`'s own `documentIdKey` — see `apply-snapshot.ts`.) */
1466
+ #docKey(id) {
1467
+ return `${encodeStorageTableId(id.tableNumber)}|${internalIdToHex(id.internalId)}`;
1468
+ }
1469
+ /** Builds the round's `AppliedInvalidation` — mirrors the fleet tailer's derivation
1470
+ * (`replica-tailer.ts` ~:476-488): `writtenDocs` DISTINCT-deduped by `(tableId, internalId)` from
1471
+ * the applied documents, `writtenKeys` straight from the applied index writes, `writtenTables`
1472
+ * DISTINCT across both sources. */
1473
+ #buildInvalidation(documents, indexUpdates, newMaxTs) {
1474
+ const tables = /* @__PURE__ */ new Set();
1475
+ const seenDocs = /* @__PURE__ */ new Set();
1476
+ const writtenDocs = [];
1477
+ for (const d of documents) {
1478
+ const tableId = encodeStorageTableId(d.id.tableNumber);
1479
+ tables.add(tableId);
1480
+ const dedupeKey = this.#docKey(d.id);
1481
+ if (seenDocs.has(dedupeKey)) continue;
1482
+ seenDocs.add(dedupeKey);
1483
+ writtenDocs.push({ tableId, internalId: d.id.internalId });
1484
+ }
1485
+ const writtenKeys = indexUpdates.map((w) => ({ indexId: w.update.indexId, key: w.update.key }));
1486
+ for (const w of indexUpdates) {
1487
+ if (w.update.value.type === "NonClustered") tables.add(encodeStorageTableId(w.update.value.docId.tableNumber));
1488
+ }
1489
+ return { newMaxTs, writtenTables: [...tables], writtenKeys, writtenDocs };
1490
+ }
1491
+ };
1492
+
1493
+ // src/replica-wiring.ts
1494
+ import { keyToPointRange, docKeyToPointRange } from "@helipod/id-codec";
1495
+ function startReplicaReactiveTailer(opts) {
1496
+ const { runtime, objectStore, shard, local, consumerId, pollMs } = opts;
1497
+ const interval = pollMs ?? 1e3;
1498
+ const onInvalidation = async (inv) => {
1499
+ runtime.observeTimestamp(inv.newMaxTs);
1500
+ const ranges = [
1501
+ ...inv.writtenKeys.map((k) => keyToPointRange(k.indexId, k.key)),
1502
+ ...inv.writtenDocs.map((d) => docKeyToPointRange(d.tableId, d.internalId))
1503
+ ];
1504
+ const commitTs = Number(inv.newMaxTs);
1505
+ await runtime.handler.notifyWrites({ tables: inv.writtenTables, ranges, commitTs });
1506
+ runtime.notifyExternalCommit({ tables: inv.writtenTables, ranges, commitTs });
1507
+ };
1508
+ const tailer = new ObjectStoreReplicaTailer({ objectStore, shard, local, onInvalidation, pollMs });
1509
+ let lastPublishedSeqno = -1;
1510
+ let timer;
1511
+ let stopped = false;
1512
+ let inflight;
1513
+ async function pump() {
1514
+ if (stopped) return;
1515
+ await tailer.tick();
1516
+ if (stopped) return;
1517
+ if (tailer.appliedSeqno !== lastPublishedSeqno) {
1518
+ await publishConsumerWatermark(objectStore, shard, consumerId, { appliedSeqno: tailer.appliedSeqno });
1519
+ lastPublishedSeqno = tailer.appliedSeqno;
1520
+ }
1521
+ }
1522
+ function armTimer() {
1523
+ if (stopped) return;
1524
+ timer = setTimeout(wake, interval);
1525
+ }
1526
+ function wake() {
1527
+ if (stopped) return;
1528
+ const round = pump().catch((e) => {
1529
+ console.error(`objectstore-substrate: replica reactive tailer wiring pump failed for shard '${shard}'`, e);
1530
+ });
1531
+ inflight = round;
1532
+ void round.finally(() => {
1533
+ if (inflight === round) inflight = void 0;
1534
+ armTimer();
1535
+ });
1536
+ }
1537
+ armTimer();
1538
+ return {
1539
+ async stop() {
1540
+ stopped = true;
1541
+ if (timer !== void 0) {
1542
+ clearTimeout(timer);
1543
+ timer = void 0;
1544
+ }
1545
+ await inflight;
1546
+ tailer.stop();
1547
+ },
1548
+ __pump: pump
1549
+ };
1550
+ }
1551
+ export {
1552
+ FencedError,
1553
+ ObjectStoreDocStore,
1554
+ ObjectStoreReplicaTailer,
1555
+ ReshardObjectStoreLiveError,
1556
+ ShardedObjectStoreDocStore,
1557
+ casManifest,
1558
+ compareBigint,
1559
+ compareBytesLex,
1560
+ createGlobals,
1561
+ createManifest,
1562
+ decodeSegment,
1563
+ decodeSnapshot,
1564
+ encodeSegment,
1565
+ encodeSnapshot,
1566
+ ensureGlobals,
1567
+ gcDriver,
1568
+ leaseHeartbeatDriver,
1569
+ mergeSortedAsyncGenerators,
1570
+ publishConsumerWatermark,
1571
+ readConsumerWatermarks,
1572
+ readGlobalFrontier,
1573
+ readGlobals,
1574
+ readManifest,
1575
+ readSnapshot,
1576
+ removeConsumer,
1577
+ reshardObjectStore,
1578
+ segmentKey,
1579
+ snapshotKey,
1580
+ startReplicaReactiveTailer,
1581
+ writeGlobals,
1582
+ writeSnapshot
1583
+ };
1584
+ //# sourceMappingURL=index.js.map