@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/LICENSE +11 -0
- package/dist/index.d.ts +1114 -0
- package/dist/index.js +1584 -0
- package/dist/index.js.map +1 -0
- package/package.json +55 -0
package/dist/index.d.ts
ADDED
|
@@ -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 };
|