@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.
@@ -0,0 +1,1114 @@
1
+ import { DocumentLogEntry, IndexWrite, DocStore, CommitUnit, ShardId, SchemaSetupOptions, ConflictStrategy, CommitGuardUnit, InternalDocumentId, LatestDocument, Interval, Order, TimestampRange, PrevRevQuery, ClientVerdictRecord, ClientVerdictWrite } from '@helipod/docstore';
2
+ import { ObjectStore } from '@helipod/objectstore';
3
+ import { JSONValue } from '@helipod/values';
4
+ import { SqliteDocStore } from '@helipod/docstore-sqlite';
5
+ import { Driver } from '@helipod/component';
6
+ import { SerializedKeyRange } from '@helipod/index-key-codec';
7
+
8
+ /**
9
+ * The segment codec (Tier 3 Slice 2, design record §5) — a segment is one immutable object
10
+ * (`s{shard}/seg/{seqno}`) holding a batch's worth of `DocumentLogEntry`/`IndexWrite` rows, JSON-encoded
11
+ * so it survives any `ObjectStore` byte-string backend unchanged.
12
+ *
13
+ * JSON has no native `bigint`/`Uint8Array`, so the wire form tags them explicitly:
14
+ * - `bigint` (ts/prev_ts) → decimal string
15
+ * - `Uint8Array` (id bytes, index keys) → base64
16
+ * A document's own field VALUES (`ResolvedDocument.value`, a `Record<string, Value>`) reuse
17
+ * `@helipod/values`' existing `convexToJson`/`jsonToConvex` — the same tagged-JSON transport the rest
18
+ * of the engine already round-trips `Value` (including nested `bigint`/`ArrayBuffer`) through.
19
+ */
20
+
21
+ /** One segment's worth of staged rows — the unit `encodeSegment`/`decodeSegment` round-trip. */
22
+ interface SegmentPayload {
23
+ documents: DocumentLogEntry[];
24
+ indexUpdates: IndexWrite[];
25
+ }
26
+ /** Encode a segment's rows to bytes (UTF-8 JSON) for `ObjectStore.putImmutable`. */
27
+ declare function encodeSegment(payload: SegmentPayload): Uint8Array;
28
+ /** Decode segment bytes (as read from `ObjectStore.get`) back to `SegmentPayload`. Inverse of
29
+ * {@link encodeSegment} — round-trips bigints and byte arrays exactly. */
30
+ declare function decodeSegment(bytes: Uint8Array): SegmentPayload;
31
+
32
+ /**
33
+ * Manifest helpers (Tier 3 Slice 2, design record §5/§7) — the per-shard pointer object
34
+ * (`s{shard}/manifest`) that names the segment chain and the commit frontier. It is the object the
35
+ * commit path CAS's on (`casPut`), so it is the fencing/linearization point for `ObjectStoreDocStore`.
36
+ *
37
+ * `tsCounter` (mirrors `frontierTs` today — Slice 2 has no group-commit-ahead-of-frontier gap yet) is
38
+ * the MONOTONE field the seam doc calls for: a real `ObjectStore`'s etag is content-derived, so an
39
+ * A→B→A content sequence could reuse an etag (ABA) — a manifest that never repeats its content (because
40
+ * this field strictly increases every successful CAS) closes that hole.
41
+ *
42
+ * `epoch`/`writerId`/`leaseExpiresAt` (Tier 3 Slice 4, Task 4.2) turn the manifest into the LEASE: an
43
+ * unowned/fresh manifest has `writerId: ""`, `leaseExpiresAt: "0"`, `epoch: 0`. `ObjectStoreDocStore`'s
44
+ * `acquire()` CAS's `epoch` upward and stamps `writerId`/`leaseExpiresAt` to claim ownership (the epoch
45
+ * bump is what FENCES any prior owner's cached etag); `heartbeat()` CAS-renews `leaseExpiresAt` alone.
46
+ * See `object-doc-store.ts`'s class doc for the full acquire/heartbeat/commit-gating protocol.
47
+ */
48
+
49
+ /** The per-shard commit pointer AND lease. `frontierTs`/`tsCounter` are decimal-string bigints (JSON
50
+ * has no native bigint) — the highest committed timestamp and the monotone CAS-content counter,
51
+ * respectively. `segments` is the `seqno` chain of `s{shard}/seg/{seqno}` objects a bootstrap still
52
+ * needs to replay, in order — dense (`[0, 1, 2, …]`) from a fresh manifest, but TRIMMED by
53
+ * `snapshot()` (Tier 3 Slice 3, Task 3.3 fix) to only `seqno > snapshotSegBase` once a snapshot
54
+ * covers the earlier ones, so this array stays BOUNDED (the post-snapshot tail) rather than growing
55
+ * with total commit history. `nextSeqno` (Task 3.3 fix) is the authoritative next-free-segment-seqno
56
+ * cursor — it is NOT derivable from `segments` once `segments` is trimmed (a `Math.max(...segments)`
57
+ * bootstrap computation both breaks on a trimmed/empty array and throws `RangeError` on a large
58
+ * untrimmed one), so it is tracked as its own field and must be read directly by `open()`.
59
+ * `snapshotTs`/`snapshotSegBase` (Tier 3 Slice 3, Task 3.2) are optional and set together: the
60
+ * latest snapshot's key suffix (`s{shard}/snap/{snapshotTs}`) and the highest segment seqno it
61
+ * COVERS — bootstrap restores that snapshot then replays only segments with `seqno >
62
+ * snapshotSegBase`. Unset on a fresh manifest (no snapshot taken yet) — full-replay bootstrap.
63
+ * `writerId`/`leaseExpiresAt` (Tier 3 Slice 4) are the LEASE: `writerId === ""` means unowned;
64
+ * otherwise the lease is live while `now <= Number(leaseExpiresAt)` (both are decimal-string
65
+ * ms-epoch, matching the `frontierTs`/`tsCounter` no-native-bigint-in-JSON convention — `now` itself
66
+ * is a plain number, not a bigint, so `leaseExpiresAt` is a decimal STRING of a NUMBER, not a bigint,
67
+ * purely for the same JSON-round-trip-safety reason). `epoch` (present since Slice 2, now
68
+ * load-bearing) is bumped by every successful `acquire()` — the epoch bump is the mechanical fence
69
+ * that invalidates a prior owner's cached etag. */
70
+ interface Manifest {
71
+ epoch: number;
72
+ frontierTs: string;
73
+ tsCounter: string;
74
+ segments: number[];
75
+ nextSeqno: number;
76
+ writerId: string;
77
+ leaseExpiresAt: string;
78
+ snapshotTs?: string;
79
+ snapshotSegBase?: number;
80
+ }
81
+ /** Read the shard's current manifest + its etag (for a follow-up `casManifest`), or `null` if the
82
+ * shard has never been initialized (no manifest object yet — call `createManifest` first). */
83
+ declare function readManifest(os: ObjectStore, shard: string): Promise<{
84
+ manifest: Manifest;
85
+ etag: string;
86
+ } | null>;
87
+ /** Create-only initialization of a shard's manifest (`casPut` with `ifMatch: null`). Throws
88
+ * `CasConflict` (via `@helipod/objectstore`'s `isCasConflict`) if the manifest already exists —
89
+ * callers racing to initialize the same shard must treat that as "someone else already did it" and
90
+ * `readManifest` instead. */
91
+ declare function createManifest(os: ObjectStore, shard: string): Promise<{
92
+ manifest: Manifest;
93
+ etag: string;
94
+ }>;
95
+ /** Compare-and-swap the shard's manifest to `next`, conditional on `ifMatch` still being the current
96
+ * etag. Throws `CasConflict` (see `isCasConflict`) if another committer's manifest write already moved
97
+ * the etag — the caller (the commit path) must treat this as a fence: nothing else may be applied. */
98
+ declare function casManifest(os: ObjectStore, shard: string, next: Manifest, ifMatch: string): Promise<{
99
+ etag: string;
100
+ }>;
101
+
102
+ /**
103
+ * `ObjectStoreDocStore` (Tier 3 Slice 2, design record §4/§6a/§7) — a `DocStore` DECORATOR over a
104
+ * local `SqliteDocStore` + one shard of an `ObjectStore`. Object storage is the linearization point
105
+ * (the durable log a second process replays); the local SQLite store is a materialized cache that
106
+ * must never get ahead of it.
107
+ *
108
+ * - Every read/non-commit method is a straight FORWARD to the local store (it already has the
109
+ * fully materialized, queryable state — that's the point of local materialization, design §6a).
110
+ * - `commitWrite`/`commitWriteBatch` are INTERCEPTED and made OBJECT-FIRST: allocate ts from the
111
+ * manifest → append an immutable segment → CAS the manifest (the fence) → only on CAS success
112
+ * apply the same rows to the local store via the explicit-ts `write(..., "Overwrite")` path (the
113
+ * same primitive `open`'s bootstrap and a replica tailer use — no second explicit-ts path).
114
+ *
115
+ * Commits serialize under an in-process mutex (`runExclusive`) so the cached `{manifest, etag}` this
116
+ * instance holds is always read-then-CAS'd by exactly one commit at a time — required because the
117
+ * manifest CAS is optimistic (etag-conditional) but the LOCAL apply that follows it is not: without
118
+ * the mutex two concurrent `commitWriteBatch` calls from the same process could race the same cached
119
+ * etag into `casManifest` and (since only one wins) leave the loser's local apply inconsistent with
120
+ * which segment its ts's actually landed in.
121
+ *
122
+ * Any `casManifest` failure (fence OR ambiguous/lost-response) POISONS this instance (whole-branch
123
+ * review C1): the cached cursor is then untrustworthy. `putImmutable` is KEEP-FIRST on every adapter
124
+ * (fs/memory/s3 — s3 via a create-only `IfNoneMatch: "*"` conditional PUT, Tier 3 Slice 4 review), so a
125
+ * reused segment seqno can never overwrite a durable, manifest-referenced segment written by someone
126
+ * else — it silently no-ops and the writer's OWN manifest CAS fails separately on its stale etag. Poison
127
+ * is therefore not a corruption guard against overwriting live data (there is no such hazard left); it's
128
+ * the correct response to an untrustworthy cached cursor after any CAS failure. Recovery = re-open
129
+ * (re-bootstrap).
130
+ *
131
+ * OWNERSHIP (Tier 3 Slice 4, Task 4.2): `open()` only MATERIALIZES — it bootstraps `local` from the
132
+ * bucket (snapshot + tail replay) but claims NO ownership. A writer must additionally `acquire()`
133
+ * before it may commit; a replica (Slice 5) opens without ever acquiring. `acquire({writerId,
134
+ * leaseTtlMs, now})` re-reads the manifest fresh, refuses if a DIFFERENT writer's lease is still live,
135
+ * else catches this instance's local store up to the manifest frontier (`materializeTo` — the same
136
+ * primitive `open()` bootstraps with) and CAS-bumps `epoch` to claim the lease — the epoch bump is
137
+ * what fences any prior owner's cached etag. `heartbeat({now, leaseTtlMs})` CAS-renews
138
+ * `leaseExpiresAt` alone (epoch/writerId unchanged); ANY heartbeat CAS failure means a challenger
139
+ * fenced this instance — it poisons and throws `FencedError`. `commitWriteBatch` now REQUIRES a held
140
+ * lease (throws before the poisoned check if `acquire()` was never called) and additionally asserts
141
+ * its cached epoch still matches the held epoch before every CAS — defense in depth against serving a
142
+ * commit after having been silently fenced.
143
+ *
144
+ * SLICE-2 BOUNDARIES — follow-ups the failover/replica slices (S4/S5) MUST NOT inherit silently:
145
+ * - **Globals + client-verdict receipts are LOCAL-ONLY** (`writeGlobal*`/`recordClientVerdict`/… forward
146
+ * to the local store; they never enter the segment log). So a from-scratch bootstrap over object
147
+ * storage alone does NOT reconstruct them: `createEmbeddedRuntime` would mint a NEW `deploymentId`
148
+ * (flipping every outbox client to `known:false`) and lose dedup receipts (effectively-once →
149
+ * at-least-once). Single-node with a PERSISTENT local SQLite file survives restarts fine; but the
150
+ * failover slice (a fresh node materializing from the bucket) MUST persist `deploymentId` (e.g. in
151
+ * the manifest) and address receipt durability before it can claim "byte-identical from object
152
+ * storage alone" for engine bookkeeping. Effectively-once itself → the manifest idempotency window (deferred).
153
+ * - **Fence/failure paths are exercised at the substrate level only on `objectstore-fs`** (keep-first
154
+ * `putImmutable`, same as every adapter now — `objectstore-s3` was fixed to keep-first too, Tier 3
155
+ * Slice 4 review, closing the overwrite hazard this note used to describe). The `objectstore-s3`
156
+ * package's OWN conformance suite (run against real MinIO under `HELIPOD_OBJECTSTORE_S3=1`) proves
157
+ * the keep-first invariant on S3 directly; a MinIO-gated fence/failure variant AT THIS SUBSTRATE LEVEL
158
+ * (as opposed to a bare adapter conformance check) is still a nice-to-have for the failover slice, not
159
+ * a correctness gap — the adapter-level guarantee is what the fence's safety actually rests on.
160
+ */
161
+
162
+ /** Exported (Tier 3 Slice 5, Task 5.1) so `replica-tailer.ts` can pull the SAME segment objects this
163
+ * class's own commit/`materializeTo` path writes/reads, without duplicating the key format. */
164
+ declare function segmentKey(shard: string, seqno: number): string;
165
+ interface ObjectStoreDocStoreOpts {
166
+ objectStore: ObjectStore;
167
+ shard: string;
168
+ local: SqliteDocStore;
169
+ }
170
+ declare class ObjectStoreDocStore implements DocStore {
171
+ #private;
172
+ private readonly objectStore;
173
+ private readonly shard;
174
+ private readonly local;
175
+ /** The last manifest state this process successfully CAS'd (or bootstrapped from) + its etag —
176
+ * the read-half of the next `casManifest`'s optimistic-concurrency check. Updated ONLY after a
177
+ * successful CAS (never speculatively), under `mutex`. */
178
+ private cached;
179
+ /** The next free segment seqno for THIS process — advanced by one on every successful commit CAS
180
+ * (dense in the common case), AND durably burned forward by one extra on every successful
181
+ * TAKEOVER `acquire()` (Task 4.6, superseding the earlier in-process-only skip-one of Task 4.5 —
182
+ * see `acquire()`'s doc) to fence out the seqno the immediate predecessor may have orphaned. The
183
+ * burn lives in the DURABLE `manifest.nextSeqno` itself (not just this in-process field) so it
184
+ * survives across a CHAIN of stalled takeovers with no successful commit in between — see
185
+ * `acquire()`. Non-dense-by-one-per-takeover is expected and harmless: never read as `[0..n]` —
186
+ * `materializeTo`/GC always iterate the manifest's own explicit `segments`/`nextSeqno` fields. */
187
+ private nextSeqno;
188
+ /** Set when a post-CAS local apply fails: the commit is durable but the local materialization is
189
+ * inconsistent, so further commits are refused until the store is re-opened (re-bootstrapped). */
190
+ private poisoned;
191
+ /** The lease this instance currently holds (Tier 3 Slice 4), or `null` if it has never
192
+ * `acquire()`'d (or was fenced — see `heartbeat`/`commitWriteBatch`). `commitWriteBatch` refuses to
193
+ * run without this set; `epoch` is the CAS-bumped value `acquire()` claimed, checked against
194
+ * `this.cached.manifest.epoch` before every commit as a defense-in-depth fence check. */
195
+ private held;
196
+ /** Serializes `commitWrite`/`commitWriteBatch` — see class doc. */
197
+ private mutex;
198
+ /** Committed segments since the last successful snapshot (or since `open`) — `maybeSnapshot`'s
199
+ * cadence trigger. Reset to 0 only on a successful `snapshot()`. */
200
+ private committedSegmentsSinceSnapshot;
201
+ private constructor();
202
+ /**
203
+ * Open (or initialize) a shard's object-storage-backed store: read-or-create the manifest, then
204
+ * BOOTSTRAP (materialize) the local store — via `materializeTo`, see its doc for the snapshot +
205
+ * tail-replay algorithm. This claims NO ownership (Tier 3 Slice 4): a writer must additionally
206
+ * `acquire()` before it may commit; a replica (Slice 5) opens without ever acquiring. A fresh
207
+ * (empty) bucket bootstraps to an empty local store; a bucket with prior commits reconstructs the
208
+ * IDENTICAL current state either way.
209
+ */
210
+ static open(opts: ObjectStoreDocStoreOpts): Promise<ObjectStoreDocStore>;
211
+ /**
212
+ * Materialize `this.local`/`this.nextSeqno` up to `manifest`'s frontier by applying whatever this
213
+ * instance hasn't already applied: if `manifest` references a snapshot this instance hasn't yet
214
+ * covered (`snapshotSegBase` at or beyond `this.nextSeqno`), restore it first
215
+ * (`write(..., "Overwrite")` of its current-state dump), then replay every remaining referenced
216
+ * segment in order via the same explicit-ts `write(..., "Overwrite")` path — the identical primitive
217
+ * a post-CAS commit applies with, and the one a replica tailer would use (design record §7).
218
+ *
219
+ * Two callers: `open()` on a brand-new instance (`this.nextSeqno === 0`, so this is a full
220
+ * bootstrap — snapshot-if-any + every segment), and `acquire()` on an ALREADY-materialized instance
221
+ * that may be behind the CURRENT manifest (a re-acquiring/challenging writer) — in that case
222
+ * skipping already-applied segments (and skipping a snapshot restore if this instance is already
223
+ * past its `segBase`) matters both for correctness (never double-apply, though `Overwrite` is
224
+ * idempotent) and so a challenger that's only slightly behind doesn't needlessly re-read a snapshot
225
+ * object. Restoring the snapshot when we're behind it is also what makes catch-up correct even if
226
+ * the fencing owner's `gc()` has since deleted the pre-snapshot segments we'd otherwise be missing.
227
+ *
228
+ * Not gated on `runExclusive` itself — both callers already hold it.
229
+ */
230
+ private materializeTo;
231
+ /**
232
+ * Claim ownership of this shard for `opts.writerId` (Tier 3 Slice 4, Task 4.2) — the manifest CAS
233
+ * IS the fence: a successful acquire bumps `epoch`, which invalidates any prior owner's cached
234
+ * etag, so its next `commitWriteBatch`/`heartbeat` fails loudly (`FencedError` + poison).
235
+ *
236
+ * Re-reads the manifest FRESH (not `this.cached` — this instance may be stale or may never have
237
+ * committed before). If the lease is currently LIVE and held by a DIFFERENT writer
238
+ * (`now <= leaseExpiresAt`), refuses: `{acquired: false, heldBy, expiresAt}` — this is what
239
+ * prevents two live writers ping-ponging (a heartbeating owner's lease never expires). Otherwise
240
+ * (unowned, or the current lease is EXPIRED, or `opts.writerId` already owns it and is re-claiming)
241
+ * catches this instance's local store up to the just-read manifest via `materializeTo` — REQUIRED so
242
+ * a challenger that was behind (or a fresh instance that only `open()`'d) is fully materialized to
243
+ * the manifest's frontier BEFORE it starts committing under the claimed lease — then CAS-bumps
244
+ * `epoch` and stamps `writerId`/`leaseExpiresAt`.
245
+ *
246
+ * A `CasConflict` (lost the acquire race to a concurrent challenger) re-reads and returns
247
+ * `{acquired: false, ...}` — the caller may retry. Any OTHER `casManifest` failure is ambiguous
248
+ * (may have landed despite throwing — same lost-response concern as the commit path's C1 fix) and
249
+ * poisons this instance exactly like a commit/heartbeat CAS failure does (global constraint: lease
250
+ * CAS failures poison like commit CAS failures).
251
+ */
252
+ acquire(opts: {
253
+ writerId: string;
254
+ leaseTtlMs: number;
255
+ now: number;
256
+ }): Promise<{
257
+ acquired: true;
258
+ } | {
259
+ acquired: false;
260
+ heldBy: string;
261
+ expiresAt: number;
262
+ }>;
263
+ /**
264
+ * Renew the currently-held lease's `leaseExpiresAt` (Tier 3 Slice 4, Task 4.2) — `epoch`/`writerId`
265
+ * are carried forward UNCHANGED (a heartbeat is not a re-claim). Requires `this.held` (throws a
266
+ * clear error if `acquire()` was never called) and refuses on an already-`poisoned` instance.
267
+ *
268
+ * ANY `casManifest` failure means the manifest moved out from under this instance — a challenger
269
+ * bumped `epoch` (fenced us) or is mid-fence — so this ALWAYS poisons and throws `FencedError`,
270
+ * unlike the commit path's CasConflict-vs-ambiguous-error distinction: a heartbeat's whole job is to
271
+ * detect exactly this condition, so there is no "retry" case to special-case.
272
+ */
273
+ heartbeat(opts: {
274
+ now: number;
275
+ leaseTtlMs: number;
276
+ }): Promise<void>;
277
+ /** Voluntarily give up the held lease WITHOUT touching the bucket — the lease simply expires on its
278
+ * own at `leaseExpiresAt` (Tier 3 Slice 4, Task 4.2). After this, `commitWriteBatch` refuses until
279
+ * a fresh `acquire()`. IN-PROCESS ONLY: a challenger's `acquire()` still has to wait out the full
280
+ * remaining TTL, since nothing in the bucket changed. See `relinquish()` — the graceful-shutdown
281
+ * variant that ALSO clears the lease in the bucket itself, so a challenger can take over
282
+ * immediately instead of waiting for expiry (Tier 3 Slice 6, Task 6.5). */
283
+ release(): void;
284
+ /**
285
+ * Graceful-shutdown variant of `release()` (Tier 3 Slice 6, Task 6.5): best-effort CAS the manifest
286
+ * to clear the lease (`writerId: "", leaseExpiresAt: "0"`) so a challenger's very next `acquire()`
287
+ * — even at `now: 0` — sees an unowned/already-expired lease and takes over IMMEDIATELY, instead of
288
+ * having to wait out this writer's full remaining `leaseTtlMs`. This closes the production gap the
289
+ * Task 6.4 review found: every graceful rolling-deploy stop/start pair otherwise ate a full-TTL
290
+ * write outage.
291
+ *
292
+ * Deliberately best-effort: a CAS failure here means either (a) we were already fenced — someone
293
+ * else owns the shard now, which is exactly the state we wanted anyway, or (b) a transient blip —
294
+ * the lease still expires naturally, falling back to the pre-6.5 behavior. Neither case should
295
+ * block or fail a clean shutdown, so any CAS error is swallowed. Does NOT bump `epoch` — clearing
296
+ * `writerId`/`leaseExpiresAt` is sufficient for a challenger's refusal check to pass trivially; the
297
+ * challenger's own `acquire()` bumps `epoch` and fences any stale in-process state on ITS claim, the
298
+ * same as an expiry-based takeover always has. Does NOT poison on a swallowed CAS failure — this is
299
+ * a clean voluntary exit, not a fence.
300
+ *
301
+ * Runs under `runExclusive` (serializes with commits/heartbeat/acquire, same as every other
302
+ * lease/commit operation on this instance). A no-op if this instance never held the lease
303
+ * (`this.held === null` — already fenced, or `acquire()` was never called): nothing to relinquish.
304
+ * Always demotes `this.held` to `null` at the end, regardless of the CAS outcome.
305
+ */
306
+ relinquish(): Promise<void>;
307
+ /** Chain `fn` onto the mutex so commits from this process serialize (see class doc). */
308
+ private runExclusive;
309
+ commitWriteBatch(units: readonly CommitUnit[], shardId?: ShardId): Promise<bigint[]>;
310
+ commitWrite(documents: readonly DocumentLogEntry[], indexUpdates: readonly IndexWrite[], shardId?: ShardId, opts?: {
311
+ meta?: Record<string, string>;
312
+ }): Promise<bigint>;
313
+ /**
314
+ * Take a snapshot of the local store's current state and CAS the manifest to reference it, so a
315
+ * future `open` can bootstrap in O(state + tail) instead of replaying the whole segment log.
316
+ * Serializes against commits via `runExclusive` — same reasoning as `commitWriteBatch`: it reads
317
+ * `cached`/`nextSeqno` and CAS's the manifest, so it must not race a concurrent commit's CAS
318
+ * against the same cached etag.
319
+ *
320
+ * DEADLOCK HAZARD: `runExclusive` is NOT reentrant. Never call `snapshot()`/`maybeSnapshot()` from
321
+ * INSIDE another `runExclusive` body (e.g. the commit path's own exclusive block) — chain it AFTER
322
+ * that block resolves instead (see `commitWriteBatch`'s `#maybeSnapshotBestEffort` call site).
323
+ *
324
+ * BENIGN DIVERGENCE (whole-branch review, Minor #2): if this snapshot's boundary (`frontierTs`) is
325
+ * a DELETE's ts, the dump it captures (`dumpCurrentState` excludes tombstones) has no row at that
326
+ * ts — see the matching note at `open()`'s snapshot-restore site for the full explanation and why
327
+ * it's safe (the `tsCounter` commit floor, not `local.maxTimestamp()`, is what guards ts reuse).
328
+ */
329
+ snapshot(): Promise<void>;
330
+ /** Take a snapshot if `SNAPSHOT_EVERY` segments have committed since the last one. No-op
331
+ * (including a poisoned instance — the next commit/explicit `snapshot()` call will surface that
332
+ * loudly) otherwise. */
333
+ maybeSnapshot(): Promise<void>;
334
+ /**
335
+ * Reclaim durable objects the CURRENT manifest's snapshot (and every registered consumer's
336
+ * watermark) has superseded: every segment with `seqno <= min(snapshotSegBase, W_min)` (where
337
+ * `W_min` is the SLOWEST published `s{shard}/consumers/{id}` watermark's `appliedSeqno` — `+Infinity` when
338
+ * no consumers are registered, so the floor collapses back to plain `snapshotSegBase`, byte-for-
339
+ * byte the Slice 3 behavior) and every snapshot object except the current one (`snapshotTs`) — the
340
+ * newest snapshot is always kept regardless of `W_min` (a replica re-materializing after falling
341
+ * behind the tail restores the NEWEST snapshot via `materializeTo`'s fallback path, never an older
342
+ * one, so keeping only the newest is correct independent of any watermark).
343
+ *
344
+ * The watermark floor is the Tier 3 Slice 5, Task 5.2 addition (closing the Slice 3/4 GC-under-
345
+ * replicas deferral): a lagging replica's `ObjectStoreReplicaTailer` publishes its own
346
+ * `appliedSeqno` via `publishConsumerWatermark`; as long as it hasn't fallen behind
347
+ * `snapshotSegBase` itself (its own snapshot-fallback backstop covers that case — see
348
+ * `replica-tailer.ts`), `gc()` here will never delete a segment `(W_min, snapshotSegBase]` that
349
+ * replica still needs to tail.
350
+ *
351
+ * Runs under `runExclusive` — it must not race a concurrent commit/snapshot/acquire on THIS
352
+ * instance. Never touches the manifest itself or `this.cached`/`nextSeqno` (beyond adopting a
353
+ * freshly re-read manifest when the epoch check below passes) — GC is purely subtractive over
354
+ * objects the manifest (and consumers) no longer need.
355
+ *
356
+ * PERFORMANCE NOTE (whole-branch review, Minor #4): because it runs under `runExclusive`, `gc()`
357
+ * blocks `commitWriteBatch`/`snapshot()` on this instance for the ENTIRE list+delete sweep (now
358
+ * also a `consumers/` list+get sweep) — fine for a manual, occasional, single-node call, but revisit
359
+ * the locking granularity if GC's cadence ever needs to tighten.
360
+ *
361
+ * GC-FENCING (Tier 3 Slice 7, Task 7.1 — closes the Slice-4/5/6 deferral above): `gc()` used to
362
+ * trust `this.cached.manifest` — a snapshot pointer that can be ARBITRARILY STALE the instant a
363
+ * challenger fences this writer (bumps `epoch`) without this instance having attempted a
364
+ * commit/heartbeat since. A stale writer's cached `snapshotTs` then names an OLD snapshot, and the
365
+ * pre-Slice-7 "delete every `snap/*` except my cached `snapshotTs`" predicate would delete the NEW
366
+ * owner's live snapshot — sinking that owner's next bootstrap. Fixed by two changes, in order:
367
+ * 1. `this.held === null` (never an owner — a replica, or an already-demoted/fenced writer) is a
368
+ * harmless no-op: never GC.
369
+ * 2. RE-READ the manifest fresh (never trust `this.cached`) and compare its `epoch` against
370
+ * `this.held.epoch`. A mismatch means a challenger's `acquire()` landed since we last knew —
371
+ * we've been fenced RIGHT NOW, even though nothing told us yet — so we poison + demote and
372
+ * delete NOTHING. Only once the epoch matches do we adopt the fresh manifest as current truth
373
+ * and compute the delete floor/keepSnap FROM IT (never from a value read before this check).
374
+ * This closes the TOCTOU window at the READ side, but a fence could still land in the gap BETWEEN
375
+ * this re-read and the deletes below (the sweep isn't atomic with the epoch check) — so the delete
376
+ * PREDICATES themselves must independently tolerate a same-gap fence:
377
+ * - Segments: `seqno <= floor` (unchanged) — a new owner's commits only ever land at HIGHER
378
+ * seqnos than the frontier this floor was computed from, so this predicate can never reach one.
379
+ * - Snapshots: **`BigInt(ts) < BigInt(keepSnap)`** (strictly older), not "every snapshot except
380
+ * keepSnap" — a new owner racing a snapshot into the gap produces a `ts` NEWER than our
381
+ * `keepSnap` (ts's are monotone `frontierTs` values), which `<` by construction never matches.
382
+ * `keepSnap` itself is also never deleted (not `<` itself). Compared as `bigint`, not string —
383
+ * ts's are decimal-string bigints, and a naive string compare orders `"9"` after `"10"`.
384
+ * Together: a fenced/stale writer's `gc()` deletes nothing (the epoch check catches the common case,
385
+ * and the TOCTOU-safe predicates catch the rest), while the happy-path owner's behavior is unchanged
386
+ * (its own re-read always matches its own held epoch).
387
+ */
388
+ gc(): Promise<{
389
+ deletedSegments: number;
390
+ deletedSnapshots: number;
391
+ }>;
392
+ setupSchema(options?: SchemaSetupOptions): Promise<void>;
393
+ write(documents: readonly DocumentLogEntry[], indexUpdates: readonly IndexWrite[], conflictStrategy: ConflictStrategy, shardId?: ShardId): Promise<void>;
394
+ /** The materialized current state (Slice 5 — migration export). Delegates to the local SQLite
395
+ * store, which already holds the full materialized image the object log reduces to — the same
396
+ * source `snapshot()` dumps from. */
397
+ dumpCurrentState(): Promise<{
398
+ documents: DocumentLogEntry[];
399
+ indexUpdates: IndexWrite[];
400
+ }>;
401
+ addCommitGuard(guard: (q: any, units: readonly CommitGuardUnit[], shardId: ShardId) => void | Promise<void>): () => void;
402
+ get(id: InternalDocumentId, readTimestamp?: bigint): Promise<LatestDocument | null>;
403
+ index_scan(indexId: string, tableId: string, readTimestamp: bigint, interval: Interval, order: Order, limit?: number): AsyncGenerator<readonly [Uint8Array, LatestDocument]>;
404
+ load_documents(range: TimestampRange, order: Order, limit?: number): AsyncGenerator<DocumentLogEntry>;
405
+ previous_revisions(queries: readonly PrevRevQuery[]): Promise<Map<string, DocumentLogEntry>>;
406
+ scan(tableId: string, readTimestamp?: bigint): Promise<LatestDocument[]>;
407
+ count(tableId: string): Promise<number>;
408
+ maxTimestamp(): Promise<bigint>;
409
+ getGlobal(key: string): Promise<JSONValue | null>;
410
+ writeGlobal(key: string, value: JSONValue): Promise<void>;
411
+ writeGlobalIfAbsent(key: string, value: JSONValue): Promise<boolean>;
412
+ getClientVerdict(identity: string, clientId: string, seq: number): Promise<ClientVerdictRecord | null>;
413
+ getClientFloor(identity: string, clientId: string): Promise<number | null>;
414
+ recordClientVerdict(identity: string, clientId: string, seq: number, record: ClientVerdictWrite): Promise<void>;
415
+ updateClientVerdictValue(identity: string, clientId: string, seq: number, value: JSONValue): Promise<void>;
416
+ pruneClientMutations(identity: string, clientId: string, opts: {
417
+ ackedThrough?: number;
418
+ ttlBeforeMs?: number;
419
+ }): Promise<{
420
+ prunedThroughSeq: number;
421
+ }>;
422
+ sweepExpiredClientMutations(beforeMs: number): Promise<{
423
+ deletedCount: number;
424
+ }>;
425
+ close(): void | Promise<void>;
426
+ }
427
+
428
+ /**
429
+ * `ShardedObjectStoreDocStore` — object-storage multi-shard SINGLE-NODE write scale-out: one node
430
+ * owns ALL N object-storage lanes (each a full `ObjectStoreDocStore`, design record §5's `s{shard}/…`
431
+ * physically independent prefix) and this class composes them behind ONE `DocStore` the engine's
432
+ * `ShardedTransactor` talks to exactly as it talks to a single store today.
433
+ *
434
+ * OWNERSHIP: this class is a pure DocStore-shaped decorator over an already-open, already-`acquire()`d
435
+ * `Map<ShardId, ObjectStoreDocStore>` — it does NOT open/acquire/heartbeat/gc the lanes itself (the
436
+ * boot layer, `packages/cli/src/boot.ts`, drives each lane's lease/heartbeat/gc independently, so a
437
+ * fence on one lane doesn't wait on another lane's cadence). This mirrors `ObjectStoreDocStore`'s own
438
+ * "everything else forwards" shape, just fanned out over N lanes instead of one.
439
+ *
440
+ * ROUTING CONTRACT (confirmed against `packages/transactor/src/shard-writer.ts`): `commitWrite`/
441
+ * `commitWriteBatch`/`write` are ALWAYS called by the transactor with an explicit `shardId` (the
442
+ * `ShardedTransactor`'s own `ShardWriter.shardId`) — that is the ONE input that tells this class which
443
+ * lane owns the commit. `get`/`scan`/`index_scan`/`load_documents`/`previous_revisions` are NEVER
444
+ * called with a shard id (a query spans every shard) — so every read method here MERGES across every
445
+ * lane. This is the asymmetry the whole class is built around: writes route by an explicit key, reads
446
+ * fan out and merge.
447
+ *
448
+ * READ-MERGE CORRECTNESS NOTE (the caveat worth flagging to a reviewer): `get`/`scan`/
449
+ * `previous_revisions` probe EVERY lane (a doc lives in exactly one lane — the one-doc-one-ring
450
+ * invariant B2a established — but the `DocStore` interface gives `get(id)` no shard hint, since the
451
+ * shard key is a document FIELD, not derivable from the internal id alone). This is O(N lanes) local
452
+ * SQLite calls per read — correct, and cheap in practice (every lane is a FULLY MATERIALIZED local
453
+ * store per design §6a, so this is N in-process SQLite lookups, never network I/O), but is a real cost
454
+ * that grows with shard count. A future optimization could route `get`/`previous_revisions` directly
455
+ * when a caller happens to know the shard; out of scope here.
456
+ */
457
+
458
+ interface ShardedObjectStoreDocStoreOpts {
459
+ /** The lane this class routes deployment-level bookkeeping to (globals, client-verdict receipts) —
460
+ * a single source of truth rather than N independently-diverging copies. Unset -> `"default"`
461
+ * (`DEFAULT_SHARD`), matching the un-sharded-table convention every other routing seam in the repo
462
+ * uses (`shard.ts`'s `DEFAULT_SHARD`, `jump-hash.ts`'s slot 0). MUST be a key present in `lanes`. */
463
+ defaultShard?: ShardId;
464
+ }
465
+ declare class ShardedObjectStoreDocStore implements DocStore {
466
+ private readonly lanes;
467
+ private readonly defaultShard;
468
+ constructor(lanes: ReadonlyMap<ShardId, DocStore>, opts?: ShardedObjectStoreDocStoreOpts);
469
+ private lane;
470
+ private laneList;
471
+ setupSchema(options?: SchemaSetupOptions): Promise<void>;
472
+ write(documents: readonly DocumentLogEntry[], indexUpdates: readonly IndexWrite[], conflictStrategy: ConflictStrategy, shardId?: ShardId): Promise<void>;
473
+ commitWrite(documents: readonly DocumentLogEntry[], indexUpdates: readonly IndexWrite[], shardId?: ShardId, opts?: {
474
+ meta?: Record<string, string>;
475
+ }): Promise<bigint>;
476
+ commitWriteBatch(units: readonly CommitUnit[], shardId?: ShardId): Promise<bigint[]>;
477
+ /**
478
+ * Fan the SAME guard out to every lane (one registration per lane) — a guard for the sharded case
479
+ * therefore fences/effects PER LANE, not atomically across the whole sharded store. This matches
480
+ * `ObjectStoreDocStore`'s own single-lane note ("guard atomicity + effectively-once forwarding are a
481
+ * LATER slice") — composing N of them doesn't add a NEW atomicity gap, it just makes the existing
482
+ * per-lane-only scope explicit at the sharded layer too.
483
+ */
484
+ addCommitGuard(guard: (q: any, units: readonly CommitGuardUnit[], shardId: ShardId) => void | Promise<void>): () => void;
485
+ get(id: InternalDocumentId, readTimestamp?: bigint): Promise<LatestDocument | null>;
486
+ index_scan(indexId: string, tableId: string, readTimestamp: bigint, interval: Interval, order: Order, limit?: number): AsyncGenerator<readonly [Uint8Array, LatestDocument]>;
487
+ load_documents(range: TimestampRange, order: Order, limit?: number): AsyncGenerator<DocumentLogEntry>;
488
+ previous_revisions(queries: readonly PrevRevQuery[]): Promise<Map<string, DocumentLogEntry>>;
489
+ scan(tableId: string, readTimestamp?: bigint): Promise<LatestDocument[]>;
490
+ count(tableId: string): Promise<number>;
491
+ maxTimestamp(): Promise<bigint>;
492
+ getGlobal(key: string): Promise<JSONValue | null>;
493
+ writeGlobal(key: string, value: JSONValue): Promise<void>;
494
+ writeGlobalIfAbsent(key: string, value: JSONValue): Promise<boolean>;
495
+ getClientVerdict(identity: string, clientId: string, seq: number): Promise<ClientVerdictRecord | null>;
496
+ getClientFloor(identity: string, clientId: string): Promise<number | null>;
497
+ recordClientVerdict(identity: string, clientId: string, seq: number, record: ClientVerdictWrite): Promise<void>;
498
+ updateClientVerdictValue(identity: string, clientId: string, seq: number, value: JSONValue): Promise<void>;
499
+ pruneClientMutations(identity: string, clientId: string, opts: {
500
+ ackedThrough?: number;
501
+ ttlBeforeMs?: number;
502
+ }): Promise<{
503
+ prunedThroughSeq: number;
504
+ }>;
505
+ sweepExpiredClientMutations(beforeMs: number): Promise<{
506
+ deletedCount: number;
507
+ }>;
508
+ close(): Promise<void>;
509
+ }
510
+
511
+ /**
512
+ * A generic k-way merge over N already-sorted `AsyncGenerator`s, used by
513
+ * `ShardedObjectStoreDocStore` to merge each lane's own `index_scan`/`load_documents` stream into
514
+ * ONE globally-ordered stream without ever buffering a whole lane into memory.
515
+ *
516
+ * Each source generator MUST already yield in the SAME order this merge is told to produce (the
517
+ * caller — `ShardedObjectStoreDocStore` — gets this for free: it calls every lane's `index_scan`/
518
+ * `load_documents` with the identical `order` argument, and each lane's own store already honors
519
+ * that ordering, exactly as `SqliteDocStore.index_scan`'s SQL `ORDER BY i.key ASC|DESC` does). This
520
+ * function does not itself sort anything — it only ever compares the CURRENT head of each source.
521
+ */
522
+ /** Byte-lexicographic comparison — matches SQLite's own BLOB ordering (unsigned byte-wise), which
523
+ * `SqliteDocStore.index_scan`'s `ORDER BY i.key` already relies on, so merging N lanes' index-scan
524
+ * streams by this comparator reproduces the SAME total order a single, unsharded store would. */
525
+ declare function compareBytesLex(a: Uint8Array, b: Uint8Array): number;
526
+ /** bigint comparison — for merging `load_documents`' `ts`-ordered streams. */
527
+ declare function compareBigint(a: bigint, b: bigint): number;
528
+ /**
529
+ * Merge `sources` (each already sorted per `keyCompare`/`order`) into one sorted `AsyncGenerator`,
530
+ * stopping after `limit` yielded items (or exhausting every source when `limit` is undefined).
531
+ *
532
+ * Algorithm: prime every source's first item, then repeatedly pick the source whose current head
533
+ * compares smallest (`order: "asc"`) or largest (`order: "desc"`) per `keyCompare`, yield it, and
534
+ * advance ONLY that source. This is the textbook k-way merge — O(log k) per step with a heap, but a
535
+ * linear scan over k sources here (k = shard count, expected small — tens at most) is simpler and
536
+ * fast enough; a heap can replace the linear scan later if shard counts ever grow large.
537
+ *
538
+ * `limit`, when set, is a HARD cap on the number of items this generator yields — matching every
539
+ * `DocStore.index_scan`/`load_documents` implementation's own `limit` contract. Passing the SAME
540
+ * `limit` down to each individual source (as `ShardedObjectStoreDocStore` does) is always a safe
541
+ * over-fetch: the merged output can never need MORE than `limit` items from any single source, since
542
+ * the whole merged output is capped at `limit`.
543
+ *
544
+ * On early exit (limit reached, or the caller stops iterating this generator before exhaustion — a
545
+ * `for await` `break`), every NOT-YET-EXHAUSTED source generator is `.return()`'d so it can release
546
+ * whatever resources it's holding (mirroring how a single-store `AsyncGenerator` is expected to clean
547
+ * up on an early `.return()` call — the same contract, just fanned out over N sources).
548
+ */
549
+ declare function mergeSortedAsyncGenerators<T>(sources: readonly AsyncGenerator<T>[], keyCompare: (a: T, b: T) => number, order: "asc" | "desc", limit?: number): AsyncGenerator<T>;
550
+
551
+ /**
552
+ * Offline object-storage reshard (`docs/superpowers/plans/2026-02-20-objectstore-reshard.md`).
553
+ *
554
+ * Changes a STOPPED object-storage deployment's shard count N→M. Unlike the fleet reshard (logical lanes
555
+ * over one shared store → moves no rows), object-storage lanes each have their own physical log, so a doc
556
+ * whose lane changes (`shardIdForKeyValue(doc[shardKey], N) ≠ …M`) has its current state PHYSICALLY MOVED
557
+ * between lane logs. The operation:
558
+ * 1. GATE — refuse if any source lane has a live lease (an online reshard is out of scope).
559
+ * 2. MATERIALIZE every source lane's current state into memory (`dumpCurrentState`).
560
+ * 3. RE-PARTITION each doc by `shardIdForKeyValue(doc[table.shardKey], M)` (a doc's table with no
561
+ * shardKey → the "default" lane), routing each live index entry to the same lane as its doc.
562
+ * 4. REWRITE — delete all objects for every lane in source∪target, then write each target lane fresh
563
+ * (open empty → acquire → commit its re-partitioned docs+index → relinquish).
564
+ * 5. Set `globals.numShards = M` LAST — the linearization point.
565
+ *
566
+ * CRASH-SAFETY, honest: object storage has no cross-object transaction, so step 4 is a NON-ATOMIC full
567
+ * rewrite. A crash mid-rewrite leaves the bucket partially rewritten and is NOT resumable — the contract
568
+ * is OFFLINE, against a BACKED-UP bucket, don't interrupt. (The whole current state is read into memory
569
+ * in step 2 first, so the destructive window is as short as possible and never races the read.)
570
+ */
571
+
572
+ /** Thrown when reshard is asked to run against a deployment that still has a live lease on any lane. */
573
+ declare class ReshardObjectStoreLiveError extends Error {
574
+ constructor(message: string);
575
+ }
576
+ interface ReshardObjectStoreOpts {
577
+ objectStore: ObjectStore;
578
+ /** The target shard count (M ≥ 1). */
579
+ toShards: number;
580
+ /** Wall-clock ms (caller-supplied — the substrate holds no ambient clock). Used only for the live-lease
581
+ * gate and the transient lease `acquire` while writing each fresh lane. */
582
+ now: number;
583
+ /** The table's shard key field (`schema.ts` `.shardKey(field)`), or null when the table isn't sharded.
584
+ * The reshard's ONLY schema dependency — injected so the core needs no schema loader (the CLI wires the
585
+ * composed catalog's `getTableByNumber(n)?.shardKey`). */
586
+ shardKeyFor: (tableNumber: number) => string | null;
587
+ /** Mint a throwaway local `SqliteDocStore` (`:memory:`) for materialization + each fresh lane's commit —
588
+ * injected so the substrate stays adapter-agnostic. */
589
+ makeLocal: () => SqliteDocStore;
590
+ }
591
+ interface ReshardObjectStoreResult {
592
+ fromShards: number;
593
+ toShards: number;
594
+ /** Docs whose owning lane changed (physically moved between lane logs). */
595
+ movedDocs: number;
596
+ /** Doc count landed in each target lane. */
597
+ perLaneCounts: Record<string, number>;
598
+ }
599
+ declare function reshardObjectStore(opts: ReshardObjectStoreOpts): Promise<ReshardObjectStoreResult>;
600
+
601
+ /**
602
+ * Fleet globals (Tier 3 Slice 4, Task 4.1, design record §5 layout / carried note I1) — a persist-once,
603
+ * bucket-root `globals` object carrying the deployment's identity (`deploymentId`, `numShards`). A fresh
604
+ * node materializing from the bucket must ADOPT this existing identity rather than mint a new one — a
605
+ * re-minted `deploymentId` would flip every outbox client to `known:false`. Mirrors `manifest.ts`'s
606
+ * create-only-via-`casPut` + JSON-encode/decode shape, but for a single bucket-wide key instead of a
607
+ * per-shard one.
608
+ */
609
+
610
+ /** The deployment-wide identity every node adopts on open. `numShards` is recorded once at deployment
611
+ * creation (Task 4.3 composes `numShards` independent per-shard lanes over the same bucket). */
612
+ interface FleetGlobals {
613
+ deploymentId: string;
614
+ numShards: number;
615
+ }
616
+ /** Read the bucket's fleet globals, or `null` if no node has created them yet. */
617
+ declare function readGlobals(os: ObjectStore): Promise<FleetGlobals | null>;
618
+ /** Create-only initialization of the bucket's fleet globals (`casPut` with `ifMatch: null`). Throws
619
+ * `CasConflict` (see `isCasConflict`) if another node already wrote them — callers racing to initialize
620
+ * the same bucket must treat that as "someone else already did it" and `readGlobals` instead (this is
621
+ * exactly what `ensureGlobals` does below). */
622
+ declare function createGlobals(os: ObjectStore, globals: FleetGlobals): Promise<FleetGlobals>;
623
+ /** Adopt-on-open: read the bucket's existing fleet globals and return them if present — NEVER overwrite
624
+ * an already-established `deploymentId`/`numShards`. Only when the bucket has no globals yet does this
625
+ * create them (create-only). Two nodes racing to initialize a fresh bucket both call this concurrently:
626
+ * the `casPut` one-winner property means exactly one `createGlobals` lands; the loser's `CasConflict` is
627
+ * caught here and resolved by re-reading — so both callers converge on the SAME winning globals. */
628
+ /**
629
+ * SINGLE-DEPLOYMENT-PER-BUCKET (whole-branch review, Finding 3, Task 4.5): Slice 4's object keyspace
630
+ * is bare (`s{shard}/...`, `globals`) — NOT namespaced per deployment (design record §5's
631
+ * `deployment/{id}/...` layout is deferred to Slice 5/6). Consequence: this function has no way to
632
+ * tell "a fresh deployment pointed at an already-occupied bucket" apart from "a node of the SAME
633
+ * deployment reconnecting" — a misconfigured second deployment aimed at an occupied bucket silently
634
+ * ADOPTS the first's `deploymentId` (and, if it differs, its `numShards` too) rather than erroring.
635
+ * This is a documented boundary, not a bug: fix it by giving each deployment its own bucket/prefix
636
+ * until key-namespacing lands.
637
+ */
638
+ declare function ensureGlobals(os: ObjectStore, globals: FleetGlobals): Promise<FleetGlobals>;
639
+ /**
640
+ * OVERWRITE the bucket's globals (create if absent). Unlike `ensureGlobals` (adopt-if-present), this
641
+ * REPLACES the value — used by the offline reshard tool to set the new `numShards` as the linearization
642
+ * point of a completed reshard. Read the current etag (or null if absent) then `casPut` against it; a
643
+ * `CasConflict` (a concurrent writer moved the globals) is re-tried a few times. Reshard runs against a
644
+ * STOPPED deployment, so contention here is not expected — the retry is belt-and-braces.
645
+ */
646
+ declare function writeGlobals(os: ObjectStore, globals: FleetGlobals): Promise<void>;
647
+
648
+ /**
649
+ * Thrown when `ObjectStoreDocStore`'s manifest CAS loses the race — this writer's cached etag no
650
+ * longer matches the shard's `s{shard}/manifest` object, meaning some other committer's write
651
+ * already advanced it. The manifest CAS IS the fence for this substrate (design record §4/§7):
652
+ * unlike `@helipod/fleet`'s Postgres-epoch fence (`FencedError` on a zero-row epoch-predicated
653
+ * UPDATE, see `ee/packages/fleet/src/fenced-error.ts`), here the fence is the object store's own
654
+ * conditional-write primitive (`CasConflict` on `casPut`) rather than a database row.
655
+ *
656
+ * This package deliberately does NOT import `@helipod/fleet`'s `FencedError` — the object-storage
657
+ * substrate is an ALTERNATIVE single-shard store to fleet's Postgres store, not a consumer of it, and
658
+ * the whole-arc plan keeps this package leaf-dependency-free of `ee/packages/fleet`. A later
659
+ * multi-shard/failover slice over this substrate is expected to unify the two error types (or at
660
+ * least their self-demote handling) once fleet-style writer promotion/relinquish lands here too.
661
+ *
662
+ * Same retry contract as fleet's: the transactor only OCC-retries `OccConflictError`. `FencedError`
663
+ * is any other error as far as the transactor is concerned, so it propagates uncaught and is NEVER
664
+ * retried — a fenced writer must stop (its cached manifest state is stale), not blindly resend the
665
+ * same commit against a manifest that has already moved on.
666
+ */
667
+ declare class FencedError extends Error {
668
+ constructor(message: string);
669
+ }
670
+
671
+ /**
672
+ * The object-storage writer's lease-heartbeat driver (Tier 3 Slice 6, Task 6.2) — a recurring
673
+ * `Driver` (the same seam `@helipod/scheduler`/`@helipod/triggers`/`storageReaper`/
674
+ * `receiptsReaper` run on) that keeps this node's `ObjectStoreDocStore` lease alive by calling
675
+ * `store.heartbeat({now, leaseTtlMs})` on a fixed cadence, so a long-running writer never lets its
676
+ * lease lapse just because nobody happened to commit recently.
677
+ *
678
+ * Mirrors `receiptsReaper`'s (`packages/receipts/src/reaper.ts`) single-timer shape — `start`
679
+ * captures `ctx` and calls `wake()`, `wake()` fires the tick fire-and-forget and (on the SUCCESS/
680
+ * transient-failure paths only, see below) re-arms via `.finally`-equivalent branching, `stop()`
681
+ * sets a `stopped` guard before clearing the timer.
682
+ *
683
+ * THE CRITICAL DIFFERENCE FROM EVERY OTHER DRIVER IN THIS CODEBASE: a heartbeat's `FencedError`
684
+ * does not mean "retry later" — it means this node has DEFINITIVELY LOST the lease (some other
685
+ * writer's `acquire()` already bumped the manifest epoch past this instance's, per
686
+ * `ObjectStoreDocStore.heartbeat`'s doc comment) and `store` is now `poisoned`: every further
687
+ * `commitWriteBatch` on it will throw immediately anyway. Silently re-arming and retrying would
688
+ * just keep failing forever while the caller believes the node is healthy. So on `FencedError` this
689
+ * driver does NOT re-arm — it logs a loud, unambiguous fatal and calls `opts.onFenced?.(e)`, which
690
+ * the CLI wires to trigger graceful node shutdown (a fenced writer MUST stop serving writes, never
691
+ * keep accepting them against a store that will reject every one). Every OTHER error (a transient
692
+ * object-store blip — a timeout, a 5xx, a network blip) is NOT a fence: the lease may still be alive
693
+ * until `leaseExpiresAt` elapses, so this driver logs and re-arms, keeping up the renewal attempts —
694
+ * exactly the resilience `receiptsReaper`'s "one bad pass doesn't kill the reaper" policy embodies,
695
+ * just with the fence case carved out as the one terminal exception.
696
+ */
697
+
698
+ /** The minimal surface this driver needs from `ObjectStoreDocStore` — kept narrow (rather than
699
+ * importing the whole class as a type) so a test fake doesn't need to construct a real store. */
700
+ interface HeartbeatableStore {
701
+ heartbeat(opts: {
702
+ now: number;
703
+ leaseTtlMs: number;
704
+ }): Promise<void>;
705
+ }
706
+ interface LeaseHeartbeatDriverOpts {
707
+ /** The lease TTL to renew to on every successful heartbeat — must match the TTL `acquire()` was
708
+ * called with (the driver does not itself acquire; it only renews an already-held lease). */
709
+ leaseTtlMs: number;
710
+ /** How often to attempt a renewal. Should be comfortably shorter than `leaseTtlMs` (the same
711
+ * "renew well before expiry" margin every lease-holding system needs) — this driver does not
712
+ * enforce a ratio; that's the caller's judgment call. */
713
+ heartbeatMs: number;
714
+ /** Called exactly once, synchronously from within `wake()`, the moment a heartbeat surfaces a
715
+ * `FencedError` — i.e. this node has lost the lease. The CLI wires this to trigger graceful
716
+ * shutdown (stop serving writes). Optional so a test can omit it. */
717
+ onFenced?: (e: FencedError) => void;
718
+ }
719
+ /** Test/introspection seam mirroring `ReceiptsReaperDriver`'s `__tick`: runs one heartbeat pass and
720
+ * awaits its real completion (propagating any error) rather than the timer path's swallow+log. */
721
+ interface LeaseHeartbeatDriver extends Driver {
722
+ __tick: () => Promise<void>;
723
+ }
724
+ /**
725
+ * Build the lease-heartbeat driver for `store` (Tier 3 Slice 6, Task 6.2). See the module doc above
726
+ * for the full fence-vs-transient-error policy.
727
+ */
728
+ declare function leaseHeartbeatDriver(store: HeartbeatableStore, opts: LeaseHeartbeatDriverOpts): LeaseHeartbeatDriver;
729
+
730
+ /**
731
+ * The object-storage writer's periodic reclamation driver (Tier 3 Slice 7, Task 7.2) — a recurring
732
+ * `Driver` (the same seam `@helipod/scheduler`/`@helipod/triggers`/`storageReaper`/
733
+ * `receiptsReaper`/`leaseHeartbeatDriver` all run on) that calls `store.gc()` on a fixed cadence, so a
734
+ * long-running writer's superseded segments/snapshots get reclaimed automatically instead of only on a
735
+ * manual call.
736
+ *
737
+ * Mirrors `receiptsReaper`'s (`packages/receipts/src/reaper.ts`) single-timer shape — `start` arms the
738
+ * first timer, `wake()` fires the tick fire-and-forget and unconditionally re-arms once it settles
739
+ * (success or failure), `stop()` sets a `stopped` guard before clearing the timer.
740
+ *
741
+ * UNLIKE `leaseHeartbeatDriver` (`./heartbeat-driver.ts`), this driver has NO terminal/fence carve-out:
742
+ * `gc()` is SELF-FENCING by construction (Task 7.1 — it re-reads the manifest and aborts as a harmless
743
+ * no-op if this instance no longer owns the current epoch, or was never an owner at all), so a fenced
744
+ * gc() call simply returns zero counts rather than throwing. Any error `gc()` DOES throw (a transient
745
+ * object-store blip, or the `poisoned` guard's own throw) is therefore never a "must stop serving
746
+ * writes now" signal the way a heartbeat's `FencedError` is — it's just "this sweep didn't complete,
747
+ * try again next cadence." So this driver swallows EVERY error (log + re-arm) and never signals
748
+ * shutdown, exactly `receiptsReaper`'s "one bad pass doesn't kill the reaper" policy with no exception
749
+ * carved out.
750
+ */
751
+
752
+ /** The minimal surface this driver needs from `ObjectStoreDocStore` — kept narrow (rather than
753
+ * importing the whole class as a type) so a test fake doesn't need to construct a real store, same
754
+ * spirit as `heartbeat-driver.ts`'s `HeartbeatableStore`. */
755
+ interface GcableStore {
756
+ gc(): Promise<{
757
+ deletedSegments: number;
758
+ deletedSnapshots: number;
759
+ }>;
760
+ }
761
+ interface GcDriverOpts {
762
+ /** How often to run a gc() sweep. gc() is best-effort/idempotent and self-fencing, so there's no
763
+ * correctness ratio to enforce here (unlike the heartbeat driver's heartbeatMs < leaseTtlMs) — pick
764
+ * a cadence that trades reclamation latency against sweep cost (default ~60s, mirroring
765
+ * `storageReaper`'s cadence — see the caller's default). */
766
+ sweepMs: number;
767
+ }
768
+ /** Test/introspection seam mirroring `ReceiptsReaperDriver`'s `__tick`: runs one gc() pass and awaits
769
+ * its real completion (propagating any error) rather than the timer path's swallow+log. */
770
+ interface GcDriver extends Driver {
771
+ __tick: () => Promise<{
772
+ deletedSegments: number;
773
+ deletedSnapshots: number;
774
+ }>;
775
+ }
776
+ /**
777
+ * Build the periodic gc-driver for `store` (Tier 3 Slice 7, Task 7.2). See the module doc above for
778
+ * the full swallow-everything/no-fence-carve-out policy.
779
+ */
780
+ declare function gcDriver(store: GcableStore, opts: GcDriverOpts): GcDriver;
781
+
782
+ /**
783
+ * The snapshot codec + object helpers (Tier 3 Slice 3, design record §6b, Task 3.1) — a snapshot
784
+ * `s{shard}/snap/{ts}` is a materialized image of a shard's CURRENT state at `frontierTs = ts`: the
785
+ * current (non-tombstone) revision of every live document at its REAL `ts`/`prev_ts`, plus the
786
+ * current row of every index entry. Produced by `SqliteDocStore.dumpCurrentState()` and restored via
787
+ * the SAME `write(..., "Overwrite")` primitive segments use — see that method's doc comment.
788
+ *
789
+ * The wire codec deliberately REUSES `segment.ts`'s per-row `DocumentLogEntry`/`IndexWrite`
790
+ * bigint/base64/`Value` tagging (`encodeDocumentLogEntries`/`decodeDocumentLogEntries`/
791
+ * `encodeIndexWrites`/`decodeIndexWrites`) rather than duplicating it — a snapshot's `documents`/
792
+ * `indexUpdates` are the exact same row shapes a segment carries; only the envelope (`frontierTs`/
793
+ * `segBase` instead of none) differs.
794
+ */
795
+
796
+ /** A snapshot's payload — the CURRENT state of a shard's local store at `frontierTs`, covering
797
+ * every committed segment up to and including seqno `segBase` (bootstrap replays only segments
798
+ * with seqno > `segBase` after restoring this). `documents`/`indexUpdates` are the same
799
+ * `DocumentLogEntry`/`IndexWrite` rows `SqliteDocStore.dumpCurrentState()` returns — real `ts`/
800
+ * `prev_ts`, not renumbered. */
801
+ interface SnapshotPayload {
802
+ frontierTs: string;
803
+ segBase: number;
804
+ documents: DocumentLogEntry[];
805
+ indexUpdates: IndexWrite[];
806
+ }
807
+ /** Encode a snapshot's payload to bytes (UTF-8 JSON) for `ObjectStore.putImmutable`. */
808
+ declare function encodeSnapshot(payload: SnapshotPayload): Uint8Array;
809
+ /** Decode snapshot bytes (as read from `ObjectStore.get`) back to `SnapshotPayload`. Inverse of
810
+ * {@link encodeSnapshot} — round-trips bigints and byte arrays exactly, same as `decodeSegment`. */
811
+ declare function decodeSnapshot(bytes: Uint8Array): SnapshotPayload;
812
+ /** The object key a shard's snapshot at `ts` (a decimal-string `frontierTs`, matching the manifest's
813
+ * own string-bigint convention) lives at — `s{shard}/snap/{ts}`, parallel to `segmentKey`'s
814
+ * `s{shard}/seg/{seqno}` in `object-doc-store.ts`. */
815
+ declare function snapshotKey(shard: string, ts: string): string;
816
+ /** Write a snapshot object (immutable — one snapshot per `frontierTs`, never overwritten). Callers
817
+ * write the snapshot object FIRST, then CAS the manifest to reference it (Task 3.2) — never the
818
+ * reverse, so the manifest can never point at an absent snapshot (the same torn-forward discipline
819
+ * segments follow). */
820
+ declare function writeSnapshot(os: ObjectStore, shard: string, payload: SnapshotPayload): Promise<void>;
821
+ /** Read + decode a shard's snapshot at `ts`, or `null` if no such snapshot object exists. */
822
+ declare function readSnapshot(os: ObjectStore, shard: string, ts: string): Promise<SnapshotPayload | null>;
823
+
824
+ /**
825
+ * Cross-shard frontier (Tier 3 Slice 5, Task 5.1, design record §8) — the object-storage analog of
826
+ * the shipped fleet `ReplicaTailer.readFrontier`'s `min(shard_leases.frontier_ts)` — but read
827
+ * straight from each shard's own manifest object instead of a shared Postgres table, since object
828
+ * storage has no cross-shard row to `min()` over server-side.
829
+ *
830
+ * `F = min(manifest.frontierTs)` over every shard in `shards` is the dense prefix the WHOLE fleet has
831
+ * durably committed: a caller may treat state up to `F` as fully replicated everywhere. Mirrors the
832
+ * fleet's partial-lease-set guard (`count < numShards` → not-ready): a shard whose manifest doesn't
833
+ * exist yet (never `open()`'d/initialized) makes the WHOLE frontier `0n`, not merely excluded from the
834
+ * min — a half-initialized fleet must not let the present shards' min fake readiness.
835
+ *
836
+ * Pure read helper: it does not track/assert monotonicity itself (unlike the fleet tailer's own
837
+ * internal `readFrontier`, which owns a `lastF` cache) — a caller that needs the monotone-assertion
838
+ * belt-and-braces should track its own last-observed value and compare across calls, mirroring
839
+ * `ReplicaTailer`'s `lastF` if it wants that defense-in-depth.
840
+ */
841
+
842
+ /** `min(frontierTs)` over every shard's manifest — `0n` if any shard's manifest is absent (a
843
+ * partial/not-yet-initialized shard set, the same F1×N hole guard the fleet tailer applies). A
844
+ * single-shard replica passes `[shard]`; an empty `shards` array also returns `0n` (vacuously
845
+ * "nothing is ready" rather than a vacuous `+Infinity`-derived value). */
846
+ declare function readGlobalFrontier(os: ObjectStore, shards: readonly string[]): Promise<bigint>;
847
+
848
+ /**
849
+ * `ObjectStoreReplicaTailer` (Tier 3 Slice 5, Task 5.1, design record §7/§8) — the object-storage
850
+ * analog of the shipped fleet `ReplicaTailer` (`ee/packages/fleet/src/replica-tailer.ts`): polls a
851
+ * shard's manifest, pulls whatever segments/snapshot it references that this replica hasn't already
852
+ * applied, materializes them onto a local `SqliteDocStore` via the SAME `write(..., "Overwrite")`
853
+ * primitive `ObjectStoreDocStore.open()`'s `materializeTo` uses, derives an `AppliedInvalidation` from
854
+ * the SAME batch it just applied (not a separate query), and advances its watermark only after the
855
+ * caller's `onInvalidation` sink resolves — mirroring the fleet tailer's tick()/`AppliedInvalidation`/
856
+ * watermark-after-sink shape (see that file's module doc for the fuller rationale this class carries
857
+ * over unmodified: verbatim apply, advance-after-sink so a throwing/slow handler can't skip a range).
858
+ *
859
+ * DIVERGES from the fleet tailer in exactly the ways the substrate itself diverges from Postgres:
860
+ * - No LISTEN/NOTIFY wake — object storage has no such primitive, so this tailer is PURELY poll-
861
+ * driven (`start()` arms a `setInterval`; a caller can also drive `tick()` directly, e.g. in tests).
862
+ * - No `batchSize` cap — a round always pulls everything between the last applied point and the
863
+ * manifest's CURRENT frontier in one pass (a segment is already a bounded unit; there is no
864
+ * unbounded single-transaction pull to guard against the way Postgres's per-row `load_documents`
865
+ * needed capping).
866
+ * - No density assertions — object storage's manifest CAS is itself the fence (Slice 2/4); a
867
+ * replica can only ever observe a manifest state that was durably, atomically committed, so
868
+ * there is no "torn" row shape to defend against the way Postgres's row-at-a-time replication
869
+ * could theoretically skip a write.
870
+ * - A SNAPSHOT FALLBACK the fleet tailer has no analog for: object storage's `gc()` can delete a
871
+ * segment a lagging replica hasn't pulled yet (there is no `pg_advisory` retention the way a
872
+ * logical-replication slot would give Postgres) — see `#materializeRound`'s doc for how a missing
873
+ * segment falls back to a snapshot restore instead of failing outright. Task 5.2's watermark-aware
874
+ * `gc()` is the production mitigation (never GC below a lagging consumer's watermark); this
875
+ * fallback is the correctness backstop for the eventually-consistent object-store window regardless.
876
+ *
877
+ * BOOTSTRAP: unlike the fleet tailer (which bootstraps itself via a bounded catch-up loop inside
878
+ * `start()`), this tailer does NOT bootstrap `local` — the caller is expected to have already
879
+ * materialized it (typically via `ObjectStoreDocStore.open({objectStore, shard, local})`, or by
880
+ * simply handing over a bare, empty `SqliteDocStore` and letting this tailer's OWN first `tick()`
881
+ * perform the full catch-up, since `#materializeRound`'s snapshot-fallback + tail-pull IS the same
882
+ * algorithm `materializeTo` runs). The tailer discovers where `local` actually stands lazily, on its
883
+ * first `tick()`, by reading `local.maxTimestamp()` — see `#ensureInitialized`'s doc for why this must
884
+ * happen lazily (on first tick, not in the constructor) and how it seeds `appliedSeqno` safely. Either
885
+ * way, the caller MUST have already run `local.setupSchema()` before handing it over (`open()`'s own
886
+ * first step) — this tailer never creates the schema itself, only applies rows into it.
887
+ */
888
+
889
+ /** Mirrors the fleet tailer's `AppliedInvalidation` shape byte-for-byte (see that file's doc for why
890
+ * this is a deliberate parallel type, not a shared import — the substrate must not depend on
891
+ * `@helipod/fleet`). `newMaxTs` is the ts THROUGH which this round applied — the manifest's
892
+ * `frontierTs` at the moment this round finished (see `#tickOnce`'s doc for why that, not a
893
+ * row-derived max, is the authoritative value here). */
894
+ interface AppliedInvalidation {
895
+ newMaxTs: bigint;
896
+ /** DISTINCT storage-encoded table ids touched, derived from BOTH the applied documents' own ids
897
+ * AND any applied index write whose value is a live (`NonClustered`) entry — a `Deleted` index
898
+ * entry carries no docId to derive a table from, so it contributes nothing here (its owning
899
+ * document's own entry in the SAME round already does). */
900
+ writtenTables: string[];
901
+ /** Raw written index keys — point invalidation input, NOT yet point ranges. */
902
+ writtenKeys: Array<{
903
+ indexId: string;
904
+ key: Uint8Array;
905
+ }>;
906
+ /** DISTINCT `(tableId, internalId)` pairs written this round, deduped from the applied
907
+ * `DocumentLogEntry` rows (one entry per doc regardless of how many revisions accompanied it).
908
+ * Point invalidation input for the DOCUMENT keyspace, NOT yet point ranges — same split as
909
+ * `writtenKeys` above. */
910
+ writtenDocs: Array<{
911
+ tableId: string;
912
+ internalId: Uint8Array;
913
+ }>;
914
+ }
915
+ interface ObjectStoreReplicaTailerOptions {
916
+ objectStore: ObjectStore;
917
+ shard: string;
918
+ /** The replica's materialize target. The CALLER is responsible for it existing (typically via
919
+ * `ObjectStoreDocStore.open()`'s bootstrap, or a bare fresh `SqliteDocStore` — see the class doc). */
920
+ local: SqliteDocStore;
921
+ /** Invoked once per non-empty applied round, AFTER the round has already been written to `local`.
922
+ * The watermark only advances after this resolves — a throwing/slow handler must not cause a
923
+ * round to be silently skipped on the next tick. */
924
+ onInvalidation: (inv: AppliedInvalidation) => Promise<void>;
925
+ /** Wall-clock poll interval for `start()`, in ms. Default 1000. */
926
+ pollMs?: number;
927
+ }
928
+ /** Poll-driven object-storage replica tailer — see the module doc for the full contract. */
929
+ declare class ObjectStoreReplicaTailer {
930
+ #private;
931
+ private readonly objectStore;
932
+ private readonly shard;
933
+ private readonly local;
934
+ private readonly onInvalidation;
935
+ private readonly pollMs;
936
+ /** The highest segment seqno this tailer has itself applied (or correlated `local` to — see
937
+ * `#ensureInitialized`/the "nothing new" opportunistic-seed note in `#tickOnce`). `-1` means
938
+ * "not yet correlated to any manifest state" — the sentinel that makes `#materializeRound`'s
939
+ * snapshot-fallback check and tail-pull loop naturally perform a FULL catch-up (every segment,
940
+ * or the newest snapshot + its tail) the first time this tailer actually has new work to do,
941
+ * which is the correct, safe behavior for a `local` this tailer hasn't yet correlated to a
942
+ * known-caught-up point (re-applying already-present rows via `write(..., "Overwrite")` is an
943
+ * idempotent no-op on `local`'s actual state; the only cost is a possibly-oversized first
944
+ * invalidation batch, never a missed one). */
945
+ private appliedSeqnoValue;
946
+ /** The ts through which `local` is known to be caught up. Lazily seeded from
947
+ * `local.maxTimestamp()` on the first `tick()` — see `#ensureInitialized`. */
948
+ private appliedMaxTsValue;
949
+ private initialized;
950
+ private timer;
951
+ /** Reentrancy guard — a manual `tick()` call racing the `start()`-armed poll timer must not run
952
+ * two overlapping apply rounds against the same `local`/cursor state. */
953
+ private draining;
954
+ private readonly waiters;
955
+ constructor(opts: ObjectStoreReplicaTailerOptions);
956
+ get appliedSeqno(): number;
957
+ get appliedMaxTs(): bigint;
958
+ /** Arms a `setInterval(tick, pollMs)`. Tick errors are swallowed (logged) — mirrors the fleet
959
+ * tailer's fire-and-forget poll posture: a transient failure must not crash the caller's process,
960
+ * and the NEXT tick retries from the same (unadvanced) watermark regardless. No-op if already
961
+ * started. */
962
+ start(): void;
963
+ stop(): void;
964
+ /** Resolves when `appliedMaxTs >= ts` (immediately if already true). Poll-driven: nothing here
965
+ * itself advances the watermark — either `start()` must be armed, or the caller must drive
966
+ * `tick()` itself (e.g. in a test loop). Rejects on `timeoutMs` elapsing first. */
967
+ waitFor(ts: bigint, timeoutMs: number): Promise<void>;
968
+ /** One poll round: returns `true` if anything was applied (or the watermark otherwise advanced),
969
+ * `false` if there was nothing new. Reentrancy-guarded (see `draining`'s doc) — a call landing
970
+ * while another is already in flight is a no-op `false`, not queued; the next timer tick (or the
971
+ * caller's own retry) picks up whatever was missed. */
972
+ tick(): Promise<boolean>;
973
+ }
974
+
975
+ /**
976
+ * Consumer watermarks (Tier 3 Slice 5, Task 5.2, design record §6c) — a per-shard
977
+ * `s{shard}/consumers/{id}` object per registered consumer (a replica's `ObjectStoreReplicaTailer`,
978
+ * keyed by whatever `consumerId` the caller chooses — e.g. a per-`(replica, shard)` id) carrying the
979
+ * seqno it has durably applied. `ObjectStoreDocStore.gc()` floors its deletion at the SLOWEST
980
+ * published watermark ON ITS OWN SHARD so it never reclaims a segment a lagging replica still needs
981
+ * to tail.
982
+ *
983
+ * SHARD-SCOPED (Finding 2, whole-branch review): keys were originally bucket-global (`consumers/{id}`),
984
+ * but `gc()` is per-shard with per-shard seqno spaces (a seqno on shard "0" and the SAME numeric
985
+ * seqno on shard "1" are unrelated cursors) — a bucket-global watermark set meant a stuck consumer on
986
+ * ANY shard floored GC on EVERY shard, over-retaining bucket-wide instead of just on its own shard.
987
+ * Scoping the key prefix to `s{shard}/consumers/{consumerId}` confines that (already-safe,
988
+ * never-under-retains) over-retention to the shard the lagging consumer is actually on.
989
+ *
990
+ * Unlike a segment (`putImmutable`, keep-first-immutable) or the manifest (`casManifest`, the fence
991
+ * a WRITER contends on), a watermark is a single value that must be OVERWRITABLE as its owning
992
+ * consumer advances — `putImmutable`'s keep-first semantics (Tier 3 Slice 4 fix) make it unusable
993
+ * here. This module instead does a plain read-etag-then-`casPut` upsert: `get()` the current etag (or
994
+ * `null` if the object doesn't exist yet, which makes the `casPut` create-only), then CAS the new
995
+ * value over it. A watermark is single-writer-per-`(shard, consumerId)` in the intended usage (one
996
+ * tailer instance publishes its own watermark), so a lost CAS race is expected only from a genuine
997
+ * concurrent writer under the SAME key (a misconfiguration, or a brief overlap during a consumer's
998
+ * own restart) — handled with a small bounded retry (re-read, re-CAS) rather than treated as a hard
999
+ * error on the first conflict.
1000
+ */
1001
+
1002
+ /** Upsert `consumerId`'s watermark to `appliedSeqno` on `shard` — overwritable (see module doc), NOT
1003
+ * `putImmutable`. Retries a handful of times on a lost CAS race (re-reading the current etag each
1004
+ * time) before giving up loudly; a genuine single-writer-per-`(shard, consumerId)` caller should
1005
+ * never exhaust this. */
1006
+ declare function publishConsumerWatermark(os: ObjectStore, shard: string, consumerId: string, watermark: {
1007
+ appliedSeqno: number;
1008
+ }): Promise<void>;
1009
+ /** List every consumer registered on `shard` and its published watermark. Order is whatever
1010
+ * `os.list()` returns (unspecified) — callers that need `min(appliedSeqno)` (GC) reduce over the
1011
+ * whole set anyway. */
1012
+ declare function readConsumerWatermarks(os: ObjectStore, shard: string): Promise<{
1013
+ consumerId: string;
1014
+ appliedSeqno: number;
1015
+ }[]>;
1016
+ /** Deregister a departing consumer (e.g. a decommissioned replica) on `shard` so it no longer floors
1017
+ * that shard's GC. */
1018
+ declare function removeConsumer(os: ObjectStore, shard: string, consumerId: string): Promise<void>;
1019
+
1020
+ /**
1021
+ * `startReplicaReactiveTailer` (Tier 3 Slice 8, Task 8.1, design record §7/§8) — extracts the
1022
+ * reactive-tailer wiring the Slice-5 cross-node E2E (`test/cross-node-reactivity.e2e.test.ts`) built
1023
+ * inline into a reusable production helper: a caller (typically `helipod serve --replica`'s boot
1024
+ * path, Task 8.2) hands over a REPLICA-side runtime + its materialized `local` store + the bucket,
1025
+ * and this helper drives the runtime's reactive fan-out from the writer's committed segments, and
1026
+ * publishes the replica's consumer watermark (so the writer's `gcDriver`, Slice 7, never reclaims a
1027
+ * segment this replica hasn't tailed yet).
1028
+ *
1029
+ * The sink mirrors the Slice-5 E2E's `invalidationSink` — itself mirroring the shipped fleet
1030
+ * `invalidationSink` (`ee/packages/fleet/src/node.ts` ~:1358) — byte-for-byte:
1031
+ * 1. `runtime.observeTimestamp(inv.newMaxTs)` — BEFORE fanning ranges into the sync handler, so the
1032
+ * query oracle's re-run actually reads the newly-applied rows.
1033
+ * 2. Convert the round's raw `writtenKeys`/`writtenDocs` into point ranges via the canonical
1034
+ * `keyToPointRange`/`docKeyToPointRange` (`@helipod/id-codec`, Task 8.1's other half of this
1035
+ * extraction).
1036
+ * 3. `await runtime.handler.notifyWrites(...)` — the live-subscription re-run/re-push path.
1037
+ * 4. `runtime.notifyExternalCommit(...)` — wakes any driver `onCommit` listener (e.g. a composed
1038
+ * component's own reactive hook) on this replica process.
1039
+ * The sink is REACTIVITY ONLY — it does NOT publish the consumer watermark. `ObjectStoreReplicaTailer`
1040
+ * advances `appliedSeqno` only AFTER `onInvalidation` resolves (the Slice-5 redelivery-safety
1041
+ * discipline: if the sink throws, the next tick must redeliver the identical round), so reading
1042
+ * `tailer.appliedSeqno` FROM INSIDE the sink observes the PRE-advance value (`-1` on the very first
1043
+ * round) — one round stale, and permanently stuck-stale for a replica that goes idle after its last
1044
+ * commit (no further tick ever republishes the true position). Under-reporting is GC-safe (a stale-low
1045
+ * watermark only makes `gc()` over-retain), but stuck-stale defeats the watermark's whole purpose for a
1046
+ * mostly-idle replica. Instead, THIS HELPER owns its own poll loop (`#pump`, below): each pass calls
1047
+ * `tailer.tick()` (which drives the sink above AND THEN advances `appliedSeqno`), and only afterward —
1048
+ * if `appliedSeqno` actually advanced — publishes the accurate POST-advance value. A `__pump()` test
1049
+ * seam (mirroring `gc-driver.ts`'s `__tick`) drives one round deterministically, awaiting completion
1050
+ * and propagating errors, for tests that don't want to wait on a real timer.
1051
+ *
1052
+ * `ReplicaReactiveRuntime` is a deliberately NARROW structural type — only the three members this
1053
+ * sink actually calls — rather than an import of `@helipod/runtime-embedded`'s `EmbeddedRuntime`.
1054
+ * `objectstore-substrate` must not take a dependency on `runtime-embedded` just for this helper's
1055
+ * type signature; the real `EmbeddedRuntime` already satisfies this shape structurally, so the CLI's
1056
+ * boot path (Task 8.2) can pass one straight through with no adapter.
1057
+ */
1058
+
1059
+ /** The shape a runtime's reactive tier must expose for this sink to drive it — satisfied
1060
+ * structurally by `@helipod/runtime-embedded`'s `EmbeddedRuntime` (see module doc). */
1061
+ interface ReplicaReactiveRuntime {
1062
+ /** Advances the runtime's own observed timestamp so a re-run oracle sees rows through `ts`. */
1063
+ observeTimestamp(ts: bigint): void;
1064
+ handler: {
1065
+ /** Re-runs/re-pushes every live subscription whose recorded read set intersects `ranges`. */
1066
+ notifyWrites(inv: {
1067
+ tables: string[];
1068
+ ranges: SerializedKeyRange[];
1069
+ commitTs: number;
1070
+ }): Promise<void>;
1071
+ };
1072
+ /** Wakes any driver `onCommit` listener composed into this runtime. */
1073
+ notifyExternalCommit(inv: {
1074
+ tables: string[];
1075
+ ranges: SerializedKeyRange[];
1076
+ commitTs: number;
1077
+ }): void;
1078
+ }
1079
+ interface StartReplicaReactiveTailerOptions {
1080
+ /** The replica-side runtime whose reactive tier this helper drives. */
1081
+ runtime: ReplicaReactiveRuntime;
1082
+ objectStore: ObjectStore;
1083
+ shard: string;
1084
+ /** The SAME `local` the replica's runtime/store reads from — the tailer applies the writer's
1085
+ * segments directly onto it. */
1086
+ local: SqliteDocStore;
1087
+ /** This replica's consumer-watermark identity (shard-scoped key `s{shard}/consumers/{id}`, Slice
1088
+ * 5 Task 5.2) — a per-process id the caller mints. */
1089
+ consumerId: string;
1090
+ /** Wall-clock poll interval, ms. Default (`ObjectStoreReplicaTailer`'s own default): 1000. */
1091
+ pollMs?: number;
1092
+ }
1093
+ /** Returned by `startReplicaReactiveTailer` — see that function's doc. */
1094
+ interface ReplicaReactiveTailerHandle {
1095
+ /** Halts this helper's own poll loop (idempotent, no re-arm after). Does NOT deregister the
1096
+ * consumer watermark (`removeConsumer` is a boot-path/shutdown concern for the caller, not this
1097
+ * helper — see Task 8.2). Defensively also calls `tailer.stop()`, though this helper never calls
1098
+ * `tailer.start()` itself (no self-timer of the tailer's own to clear). */
1099
+ stop(): Promise<void>;
1100
+ /** Test/introspection seam mirroring `gc-driver.ts`'s `__tick`: runs exactly one
1101
+ * `tailer.tick()` + the conditional watermark publish, awaiting real completion and propagating
1102
+ * any error (unlike the timer path, which swallows + logs + re-arms). Lets a test drive
1103
+ * deterministic rounds without waiting on a real timer. */
1104
+ __pump(): Promise<void>;
1105
+ }
1106
+ /**
1107
+ * Builds an `ObjectStoreReplicaTailer` over `opts.local`/`opts.objectStore`/`opts.shard` whose
1108
+ * `onInvalidation` sink drives `opts.runtime`'s reactive fan-out, then arms this helper's OWN poll
1109
+ * loop (see the module doc for why the watermark publish must live HERE, post-`tick()`, rather than
1110
+ * inside the sink). `stop()` halts the loop.
1111
+ */
1112
+ declare function startReplicaReactiveTailer(opts: StartReplicaReactiveTailerOptions): ReplicaReactiveTailerHandle;
1113
+
1114
+ export { type AppliedInvalidation, FencedError, type FleetGlobals, type GcDriver, type GcDriverOpts, type GcableStore, type HeartbeatableStore, type LeaseHeartbeatDriver, type LeaseHeartbeatDriverOpts, type Manifest, ObjectStoreDocStore, type ObjectStoreDocStoreOpts, ObjectStoreReplicaTailer, type ObjectStoreReplicaTailerOptions, type ReplicaReactiveRuntime, type ReplicaReactiveTailerHandle, ReshardObjectStoreLiveError, type ReshardObjectStoreOpts, type ReshardObjectStoreResult, type SegmentPayload, ShardedObjectStoreDocStore, type ShardedObjectStoreDocStoreOpts, type SnapshotPayload, type StartReplicaReactiveTailerOptions, casManifest, compareBigint, compareBytesLex, createGlobals, createManifest, decodeSegment, decodeSnapshot, encodeSegment, encodeSnapshot, ensureGlobals, gcDriver, leaseHeartbeatDriver, mergeSortedAsyncGenerators, publishConsumerWatermark, readConsumerWatermarks, readGlobalFrontier, readGlobals, readManifest, readSnapshot, removeConsumer, reshardObjectStore, segmentKey, snapshotKey, startReplicaReactiveTailer, writeGlobals, writeSnapshot };