@helipod/fleet 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,2514 @@
1
+ // src/lease.ts
2
+ import { DEFAULT_SHARD } from "@helipod/id-codec";
3
+ var SHARD_ID = DEFAULT_SHARD;
4
+ var DEFAULT_LEASE_TTL_MS = 15e3;
5
+ var DEFAULT_RETRY_MS = 2e3;
6
+ var IDEMPOTENCY_VALUE_CAP_BYTES = 64 * 1024;
7
+ var IDEMPOTENCY_TTL_INTERVAL = "1 hour";
8
+ function toBigIntOrZero(v) {
9
+ if (v === null || v === void 0) return 0n;
10
+ return typeof v === "bigint" ? v : BigInt(v);
11
+ }
12
+ function isLockTimeoutError(e) {
13
+ if (e && typeof e === "object") {
14
+ if (e.code === "55P03") return true;
15
+ const msg = e.message;
16
+ if (typeof msg === "string" && /lock[_ ]?timeout|lock_not_available/i.test(msg)) return true;
17
+ }
18
+ return false;
19
+ }
20
+ function rowToLeaseRow(row) {
21
+ return {
22
+ epoch: row.epoch,
23
+ writerUrl: row.writer_url,
24
+ writerAppName: row.writer_app_name ?? null,
25
+ expiresAt: row.expires_at,
26
+ frontierTs: toBigIntOrZero(row.frontier_ts),
27
+ prevTs: toBigIntOrZero(row.prev_ts)
28
+ };
29
+ }
30
+ var LeaseManager = class {
31
+ client;
32
+ /** This node's advertised URL, recorded onto `shard_leases`/`fleet_nodes`. Public so the balancer
33
+ * can use it as this node's rendezvous identity (`ShardLeaseBalancer.myUrl`) without threading it
34
+ * separately through `startFleetNode`. */
35
+ advertiseUrl;
36
+ applicationName;
37
+ retryMs;
38
+ /** The lease TTL in ms this manager stamps onto `expires_at`. Public so the LeaseMonitor can
39
+ * derive its probe cadence from the SAME knob (see `startFleetNode`) — a live writer must renew
40
+ * well within the TTL. */
41
+ ttlMs;
42
+ /** `ttlMs` as a whole-ms integer, ready to interpolate into the `interval '<n> milliseconds'` SQL
43
+ * below. Integer-coerced (never a fraction/NaN) so the interpolation can't produce invalid SQL. */
44
+ ttlMsSql;
45
+ timer = null;
46
+ stopped = false;
47
+ /** Per-shard epoch map: `shardId → the epoch this node most recently acquired for it`. B1 tracked
48
+ * ONE `lastEpoch` (default shard only); B2a's writer holds N shards, each fenced against its own
49
+ * epoch, so the commit guard/heartbeat/idle-closer look up `currentEpoch(shardId)`. Updated on
50
+ * every successful per-shard `tryAcquire(shardId)` (including re-promotion's epoch bumps) so the
51
+ * guard always fences against the CURRENT epoch, not a boot snapshot. A shard absent from the
52
+ * map is one this node has never acquired. */
53
+ lastEpochByShard = /* @__PURE__ */ new Map();
54
+ constructor(client, opts) {
55
+ this.client = client;
56
+ this.advertiseUrl = opts.advertiseUrl;
57
+ this.applicationName = opts.applicationName ?? null;
58
+ this.retryMs = opts.retryMs ?? DEFAULT_RETRY_MS;
59
+ this.ttlMs = opts.ttlMs ?? DEFAULT_LEASE_TTL_MS;
60
+ const rounded = Math.round(this.ttlMs);
61
+ this.ttlMsSql = Number.isFinite(rounded) && rounded > 0 ? rounded : DEFAULT_LEASE_TTL_MS;
62
+ }
63
+ /**
64
+ * Idempotent DDL: creates shard_leases if it doesn't already exist, and — when `shardIds` is
65
+ * non-empty — pre-seeds a row for every shard in the list (Fleet B3, D4: the concurrent-boot
66
+ * count-gate fix, reversing B2a's "no pre-seeding" call).
67
+ *
68
+ * B2a originally left this deliberately EMPTY (no pre-seeded rows): a bare `frontier_ts = 0` row
69
+ * created at DDL time, on a database that already held documents (a pre-`--fleet` upgrade), would
70
+ * be momentarily visible at `frontier_ts = 0` to a concurrently-booting sync node — `count(*) = N`
71
+ * AND `min(frontier_ts) = 0` would fake-report ready with an EMPTY replica (the F1×N hole,
72
+ * recurring ×N). That is why row existence used to be tied to a REAL, frontier-seeded acquisition
73
+ * (`tryAcquire`) rather than to `setup()`.
74
+ *
75
+ * The reversal here is safe ONLY because the seed now travels WITH the row: `documentsExist` (the
76
+ * caller's `documentsTableExists` probe, run BEFORE calling this) selects the exact same
77
+ * `frontierSeedExpr` fragment `tryAcquire` uses — `0` on a fresh database (no `documents` table
78
+ * yet: correct, there is no history to protect against), or `(SELECT COALESCE(MAX(ts), 0) FROM
79
+ * documents)` on an upgrade (`documents` already exists: correct, the row is born at the true
80
+ * high-water mark, never momentarily at 0). A pre-seeded row is created UNACQUIRED — `epoch = 0`,
81
+ * `writer_url = NULL`, already-expired `expires_at` — so the FIRST real `tryAcquire` still lands on
82
+ * its `ON CONFLICT` branch and bumps `epoch` from 0 to 1, byte-identical to a fresh INSERT's
83
+ * `epoch = 1`; every existing epoch/ownership assertion in the test suite is unaffected. Idempotent
84
+ * (`ON CONFLICT (shard_id) DO NOTHING`): a row already created by a peer's concurrent `setup()` or
85
+ * by a real acquisition is left untouched — this can only ever RAISE a row into existence, never
86
+ * clobber one. With every shard row present immediately, the tailer's `count(*) < NUM_SHARDS →
87
+ * not-ready` gate is satisfied the instant `setup()` returns — a concurrent multi-writer boot no
88
+ * longer stalls waiting for a writer to finish its acquire-all pass. See `node.ts`'s
89
+ * `prepareFleetNode` for the `documentsTableExists` probe + call site, and `replica-tailer.ts`'s
90
+ * `readFrontier` for the count gate.
91
+ */
92
+ /** Run one DDL statement, swallowing the concurrent-boot duplicate-object race family
93
+ * (23505 unique_violation / 42P07 duplicate_table / 42710 duplicate_object — the third face
94
+ * is "type <table> already exists", the table's composite rowtype, which is what two nodes
95
+ * racing CREATE TABLE IF NOT EXISTS actually surface). The statement's objective — the
96
+ * object exists — is achieved when any of these fires; anything else propagates. Mirrors
97
+ * docstore-postgres setupSchema's discipline; added when the SIMULTANEOUS multi-writer boot
98
+ * E2E caught the unguarded race here (Plan A's extra boot DDL widened the window). */
99
+ async ddl(stmt) {
100
+ try {
101
+ await this.client.query(stmt);
102
+ } catch (e) {
103
+ const code = e?.code;
104
+ if (code !== "23505" && code !== "42P07" && code !== "42710") throw e;
105
+ }
106
+ }
107
+ async setup(shardIds = [], documentsExist = false) {
108
+ await this.ddl(`
109
+ CREATE TABLE IF NOT EXISTS shard_leases (
110
+ shard_id TEXT PRIMARY KEY,
111
+ epoch BIGINT NOT NULL,
112
+ writer_url TEXT,
113
+ writer_app_name TEXT,
114
+ expires_at TIMESTAMPTZ NOT NULL,
115
+ frontier_ts BIGINT NOT NULL DEFAULT 0,
116
+ prev_ts BIGINT NOT NULL DEFAULT 0
117
+ )
118
+ `);
119
+ await this.ddl(`
120
+ CREATE TABLE IF NOT EXISTS fleet_nodes (
121
+ advertise_url TEXT PRIMARY KEY,
122
+ epoch BIGINT NOT NULL,
123
+ expires_at TIMESTAMPTZ NOT NULL
124
+ )
125
+ `);
126
+ await this.ddl(`
127
+ CREATE TABLE IF NOT EXISTS fleet_idempotency (
128
+ key TEXT PRIMARY KEY,
129
+ commit_ts BIGINT NOT NULL,
130
+ value_json TEXT,
131
+ oversized BOOLEAN NOT NULL DEFAULT false,
132
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
133
+ )
134
+ `);
135
+ if (shardIds.length === 0) return;
136
+ const frontierSeed = this.frontierSeedExpr(documentsExist);
137
+ const params = [];
138
+ const values = shardIds.map((shardId, i) => {
139
+ params.push(shardId);
140
+ return `($${i + 1}, 0, NULL, NULL, now(), ${frontierSeed}, 0)`;
141
+ });
142
+ await this.client.query(
143
+ `INSERT INTO shard_leases (shard_id, epoch, writer_url, writer_app_name, expires_at, frontier_ts, prev_ts)
144
+ VALUES ${values.join(", ")}
145
+ ON CONFLICT (shard_id) DO NOTHING`,
146
+ params
147
+ );
148
+ }
149
+ /**
150
+ * The `frontier_ts` seed SQL fragment shared by `setup()`'s row pre-seed and `tryAcquire()`'s
151
+ * first-creation INSERT (Fleet B3, D4 — the one place this reasoning is written down): `0` when the
152
+ * caller has verified `documents` does not exist yet (a fresh database — no history to protect
153
+ * against), else `(SELECT COALESCE(MAX(ts), 0) FROM documents)` (an upgrade — the row is born at the
154
+ * store's true high-water mark, never momentarily visible below it). Both call sites gate this on
155
+ * the SAME `documentsTableExists` probe (`node.ts`), so a row is never seeded incorrectly regardless
156
+ * of which of the two code paths creates it first.
157
+ */
158
+ frontierSeedExpr(documentsExist) {
159
+ return documentsExist ? `(SELECT COALESCE(MAX(ts), 0) FROM documents)` : `0`;
160
+ }
161
+ /**
162
+ * Upsert this node's `fleet_nodes` presence row (B2b, D3), extending `expires_at` by the lease TTL.
163
+ * Called at boot (BEFORE the writer-election `tryAcquire`, so a losing node is already visible when
164
+ * it boots sync) and on every balancer beat / writer probe — the node's liveness signal now. A node
165
+ * whose process dies stops heartbeating; its row expires after the TTL and it drops out of every
166
+ * survivor's `liveNodes()`, re-converging the rendezvous assignment. `epoch` increments per beat —
167
+ * carried only for observability/debugging (which incarnation of a node the row belongs to); the
168
+ * live-set membership check keys off `expires_at`, not `epoch`.
169
+ */
170
+ async heartbeatPresence() {
171
+ await this.client.query(
172
+ `INSERT INTO fleet_nodes (advertise_url, epoch, expires_at)
173
+ VALUES ($1, 1, now() + interval '${this.ttlMsSql} milliseconds')
174
+ ON CONFLICT (advertise_url) DO UPDATE SET
175
+ epoch = fleet_nodes.epoch + 1,
176
+ expires_at = now() + interval '${this.ttlMsSql} milliseconds'`,
177
+ [this.advertiseUrl]
178
+ );
179
+ }
180
+ /**
181
+ * The live fleet node set (B2b, D3): distinct unexpired `fleet_nodes` advertise URLs, UNION the live
182
+ * `shard_leases` writer URLs (belt-and-braces — a writer whose presence heartbeat momentarily lapsed
183
+ * but whose shard lease is still live is unambiguously alive). Every node computes rendezvous over
184
+ * this set, so they all derive the same shard→owner assignment. Expiry is `expires_at >= now()` per
185
+ * the DB's OWN clock, authoritative against host clock skew.
186
+ */
187
+ async liveNodes() {
188
+ const rows = await this.client.query(
189
+ `SELECT advertise_url AS url FROM fleet_nodes WHERE expires_at >= now()
190
+ UNION
191
+ SELECT writer_url AS url FROM shard_leases WHERE writer_url IS NOT NULL AND expires_at >= now()`
192
+ );
193
+ return rows.map((r) => r.url).filter((u) => typeof u === "string" && u.length > 0);
194
+ }
195
+ /**
196
+ * Per-shard ownership snapshot for the balancer (B2b, D3): for each EXISTING `shard_leases` row, its
197
+ * `writer_url` (null = orphaned) and whether it has expired per the DB clock. A shard with no row is
198
+ * simply absent from the map — the balancer treats an absent shard as acquirable (never created, or
199
+ * fully reaped), the same as an orphaned one. Read-only; the balancer decides acquire/release from it.
200
+ */
201
+ async readShardOwnership() {
202
+ const rows = await this.client.query(
203
+ `SELECT shard_id, writer_url, (expires_at < now()) AS expired FROM shard_leases`
204
+ );
205
+ const map = /* @__PURE__ */ new Map();
206
+ for (const r of rows) {
207
+ map.set(r.shard_id, {
208
+ writerUrl: r.writer_url ?? null,
209
+ expired: r.expired === true
210
+ });
211
+ }
212
+ return map;
213
+ }
214
+ /**
215
+ * Self-fence a shard this node currently holds, for a GRACEFUL balancer release (B2b, D3): bump the
216
+ * epoch (so this node's own subsequent commit/heartbeat on the now-stale epoch fences cleanly),
217
+ * clear `writer_url` (so the shard reads as orphaned and its rightful rendezvous owner acquires it),
218
+ * and GREATEST-bump `frontier_ts` from the shared sequence (so F never regresses across the handoff).
219
+ * Predicated on this node's currently-held epoch — a silent no-op if the shard is already fenced/
220
+ * relinquished (`currentEpoch === null`). The caller runs this only when the shard is fully IDLE —
221
+ * `runtime.tryRunExclusiveOnShard` grants the mutex only if no commit holds it AND no group-commit
222
+ * batch is staged/flushing (Fleet B4), so no in-flight commit's OR batch's frontier write races the
223
+ * GREATEST-bump here; the release is deferred a beat if the shard is busy rather than fencing
224
+ * mid-flush (see `releaseShard` in `node.ts`). A mutation that begins mid-execute after the fence
225
+ * simply hits `FencedError` at its own commit (OCC-retryable, the forwarder re-routes the retry to
226
+ * the new owner). Mirrors `evictExpired`'s frontier-bump SQL, but unconditional on expiry (this is a
227
+ * voluntary handoff, not an eviction of a wedged peer).
228
+ */
229
+ async selfFence(shardId) {
230
+ const epoch = this.currentEpoch(shardId);
231
+ if (epoch === null) return;
232
+ await this.client.query(
233
+ `UPDATE shard_leases SET
234
+ epoch = epoch + 1,
235
+ writer_url = NULL,
236
+ writer_app_name = NULL,
237
+ frontier_ts = GREATEST(frontier_ts, (SELECT nextval('helipod_ts')))
238
+ WHERE shard_id = $1 AND epoch = $2`,
239
+ [shardId, epoch]
240
+ );
241
+ }
242
+ /**
243
+ * The epoch this node most recently acquired for `shardId` (via `tryAcquire`), or `null` if it
244
+ * never has. Read live — not a boot-time snapshot — by the commit guard and the heartbeat probe,
245
+ * so a re-promotion's epoch bump (another `tryAcquire` call) is picked up automatically with no
246
+ * extra threading between `prepareFleetNode`/`startFleetNode`/`promoteFleetNode`. Defaults to the
247
+ * default shard so B1 call sites (`currentEpoch()`) are unchanged.
248
+ */
249
+ currentEpoch(shardId = SHARD_ID) {
250
+ return this.lastEpochByShard.get(shardId) ?? null;
251
+ }
252
+ /** The (shard, epoch) pairs this node currently holds — the input to the batched heartbeat,
253
+ * all-rows seed, and idle-shard closer (all fence per-row against the epoch on file). A snapshot
254
+ * of the live per-shard epoch map, so it reflects any re-promotion's epoch bumps. */
255
+ heldPairs() {
256
+ return [...this.lastEpochByShard].map(([shardId, epoch]) => ({ shardId, epoch }));
257
+ }
258
+ /**
259
+ * Drop `shardId` from this node's held-epoch map (Fenced Frontier B2b, D2 — per-shard relinquish).
260
+ * After this call `currentEpoch(shardId)` returns `null`, so:
261
+ * - the commit guard's "no acquired epoch" branch fences any straggler commit on `shardId`
262
+ * cleanly (rather than a stale epoch happening to still match a row some other node has since
263
+ * re-fenced and moved on from);
264
+ * - `heldPairs()` (and therefore `heartbeatAll()`/`closeIdleFrontiers()`/`seedFrontierAll()`) stop
265
+ * including this shard.
266
+ * Idempotent: forgetting a shard this node doesn't currently hold is a silent no-op — the
267
+ * relinquish dispatcher (`node.ts`) relies on exactly this for ITS OWN idempotency, via
268
+ * `currentEpoch(shardId) === null` as its "already relinquished" check. Deliberately does NOT touch
269
+ * the `shard_leases` ROW — only the caller's advisory-lock release (`PgClient.releaseShardLock`) and
270
+ * this in-memory forget are relinquish's job; the row itself was already epoch-bumped by whoever
271
+ * fenced this node (or will lapse via TTL), and a future re-acquire (`tryAcquire`) is a fresh INSERT/
272
+ * ON CONFLICT UPDATE regardless of what this map remembers.
273
+ */
274
+ forgetShard(shardId) {
275
+ this.lastEpochByShard.delete(shardId);
276
+ }
277
+ /**
278
+ * One non-blocking attempt: takes the advisory lock (fast path); on success, runs the fencing
279
+ * upsert against `shard_leases` (bumping `epoch`, recording this node's URL/app-name, extending
280
+ * `expires_at`) and returns the new state. On failure to take the lock, returns null.
281
+ * `prev_ts` is seeded to 0 on first creation only, and `frontier_ts` to either 0 or the store's
282
+ * current `MAX(ts)` (see `seedFrontierFromDocuments`) — an `ON CONFLICT` re-acquisition (including
283
+ * promotion) leaves BOTH untouched, so the durable-commit chain survives across epochs (D3 depends
284
+ * on this: frontier must never reset just because the writer changed).
285
+ *
286
+ * `seedFrontierFromDocuments` (the F1×N residual-window fix): when a row is FIRST created, seed its
287
+ * `frontier_ts` to `MAX(ts)` from the `documents` log inside the same INSERT — atomically, so the
288
+ * row is NEVER momentarily visible at `frontier_ts = 0` on a pre-loaded store (which would let a
289
+ * concurrently-booting sync node pass its `count == N ∧ min-F` ready gate with an empty replica).
290
+ * Pass `true` only when the `documents` table is known to exist (post-`setupSchema`, or a pre-loaded
291
+ * store) — the caller guards this; `false` (the default, and the only safe value pre-DDL on a fresh
292
+ * database) creates the row at `frontier_ts = 0`, which is correct precisely because a fresh store
293
+ * holds no data. `seedFrontierAll` remains the idempotent belt-and-braces second pass.
294
+ */
295
+ async tryAcquire(shardId = SHARD_ID, slot = 0, seedFrontierFromDocuments = false) {
296
+ const acquired = this.client.tryAcquireShardLock ? await this.client.tryAcquireShardLock(slot) : await this.client.tryAcquireWriterLock();
297
+ if (!acquired) return null;
298
+ const frontierSeed = this.frontierSeedExpr(seedFrontierFromDocuments);
299
+ const rows = await this.client.query(
300
+ `INSERT INTO shard_leases (shard_id, epoch, writer_url, writer_app_name, expires_at, frontier_ts, prev_ts)
301
+ VALUES ($1, 1, $2, $3, now() + interval '${this.ttlMsSql} milliseconds', ${frontierSeed}, 0)
302
+ ON CONFLICT (shard_id) DO UPDATE SET
303
+ epoch = shard_leases.epoch + 1,
304
+ writer_url = $2,
305
+ writer_app_name = $3,
306
+ expires_at = now() + interval '${this.ttlMsSql} milliseconds'
307
+ RETURNING epoch, writer_url, frontier_ts`,
308
+ [shardId, this.advertiseUrl, this.applicationName]
309
+ );
310
+ const row = rows[0];
311
+ if (!row) throw new Error("shard_leases upsert returned no row");
312
+ const state = {
313
+ epoch: row.epoch,
314
+ writerUrl: row.writer_url,
315
+ // On a re-acquire this is the frontier an interim owner left; on a fresh INSERT it's the seed
316
+ // (`frontierSeed` — store max or 0). Feeds `runtime.observeWriteTimestamp` at every acquisition.
317
+ frontierTs: toBigIntOrZero(row.frontier_ts)
318
+ };
319
+ this.lastEpochByShard.set(shardId, state.epoch);
320
+ return state;
321
+ }
322
+ /**
323
+ * Extend `expires_at` for this node's `epoch` — the LeaseMonitor's periodic probe (D2: one
324
+ * round-trip serves liveness-probe + TTL maintenance + fence verification). Returns the number
325
+ * of rows updated: 1 = still this node's epoch (TTL extended), 0 = fenced — some other node has
326
+ * bumped the epoch (a D4 eviction) and this node no longer holds the lease, even though its
327
+ * connection never dropped. Callers (see `node.ts`) treat 0 as definitive lease loss.
328
+ */
329
+ async heartbeat(epoch, shardId = SHARD_ID) {
330
+ const rows = await this.client.query(
331
+ `UPDATE shard_leases SET expires_at = now() + interval '${this.ttlMsSql} milliseconds'
332
+ WHERE shard_id = $1 AND epoch = $2
333
+ RETURNING epoch`,
334
+ [shardId, epoch]
335
+ );
336
+ return rows.length;
337
+ }
338
+ /**
339
+ * Batched heartbeat over EVERY (shard, epoch) pair this node holds — one UPDATE per beat (B2a):
340
+ * the writer holds N leases and must renew all of them in a single round-trip, not N. Returns
341
+ * `{ updated, expected, fencedShardIds }` where `expected` is how many pairs this node believes it
342
+ * holds, `updated` is how many rows actually matched, and `fencedShardIds` (B2b, D2) is PRECISELY
343
+ * which held shards did NOT match — diffing the `RETURNING shard_id` rows against the held set —
344
+ * so the caller can relinquish exactly those shards rather than treat `updated < expected` as an
345
+ * undifferentiated whole-node signal. `updated < expected` (equivalently `fencedShardIds.length >
346
+ * 0`) means at least one shard's epoch was superseded; `fencedShardIds` is always `[]` when this
347
+ * node holds nothing (never a writer) — see the early return. The `(shard_id, epoch) IN ((..),(..))`
348
+ * tuple form fences per row.
349
+ */
350
+ async heartbeatAll() {
351
+ const pairs = this.heldPairs();
352
+ if (pairs.length === 0) return { updated: 0, expected: 0, fencedShardIds: [] };
353
+ const { clause, params } = this.tupleInClause(pairs, 0);
354
+ const rows = await this.client.query(
355
+ `UPDATE shard_leases SET expires_at = now() + interval '${this.ttlMsSql} milliseconds'
356
+ WHERE (shard_id, epoch) IN (${clause})
357
+ RETURNING shard_id`,
358
+ params
359
+ );
360
+ const renewedIds = new Set(rows.map((r) => r.shard_id));
361
+ const fencedShardIds = pairs.filter((p) => !renewedIds.has(p.shardId)).map((p) => p.shardId);
362
+ return { updated: rows.length, expected: pairs.length, fencedShardIds };
363
+ }
364
+ /**
365
+ * Read-only expiry check: does the lease row exist AND is `expires_at` in the past, per the DB's
366
+ * OWN clock? Used as the acquire loop's per-tick pre-gate before the heavier `evictExpired`
367
+ * transaction — a healthy (live) lease must NOT make every follower take the row lock every tick and
368
+ * contend with the writer's commit guard, so the common case stays a single cheap SELECT. The
369
+ * comparison is `now()` in SQL (not `read().expiresAt` in JS) so it's authoritative against clock
370
+ * skew between a follower's host and Postgres.
371
+ */
372
+ async isExpired(shardId = SHARD_ID) {
373
+ const rows = await this.client.query(
374
+ `SELECT 1 FROM shard_leases WHERE shard_id = $1 AND expires_at < now()`,
375
+ [shardId]
376
+ );
377
+ return rows.length > 0;
378
+ }
379
+ /**
380
+ * Fencing-first eviction of an EXPIRED lease (Fenced Frontier B1, D4). Bumps `epoch` (fencing the
381
+ * wedged holder — its next commit guard / heartbeat now matches 0 rows), clears `writer_url`/
382
+ * `writer_app_name`, and advances `frontier_ts`, all predicated on the lease actually being
383
+ * expired. Returns `{ fenced: true, oldAppName }` capturing the evicted holder's `writer_app_name`
384
+ * (so the acquire loop can `pg_terminate_backend` its lingering connection); `{ fenced: false }`
385
+ * when the row is live (no-op) OR the row lock couldn't be taken within `lock_timeout` (retry next
386
+ * tick — never throws that to the loop).
387
+ *
388
+ * Why a `SELECT ... FOR UPDATE` then a separate `UPDATE`, NOT a single `UPDATE ... RETURNING (SELECT
389
+ * writer_app_name FROM cte)`: on Postgres, RETURNING — and the inlined CTE subquery it evaluates —
390
+ * sees the NEW (already-nulled) row, so a single statement reads back NULL for the old app name
391
+ * (verified on PGlite; a `MATERIALIZED` CTE with its own `FOR UPDATE` instead collides with the
392
+ * same-statement UPDATE's row lock and yields zero rows). The two-statement form captures the old
393
+ * value cleanly, and the `FOR UPDATE` takes the very row lock a concurrent commit-guard UPDATE
394
+ * contends on — serializing eviction against an in-flight commit. That contention is single-
395
+ * connection-untestable and covered E2E only (see the test header).
396
+ */
397
+ async evictExpired(shardId = SHARD_ID) {
398
+ try {
399
+ return await this.client.transaction(async (tx) => {
400
+ await tx.query(`SET LOCAL lock_timeout = '2s'`);
401
+ const sel = await tx.query(
402
+ `SELECT writer_app_name FROM shard_leases WHERE shard_id = $1 AND expires_at < now() FOR UPDATE`,
403
+ [shardId]
404
+ );
405
+ if (sel.length === 0) return { fenced: false, oldAppName: null };
406
+ const oldAppName = sel[0].writer_app_name ?? null;
407
+ await tx.query(
408
+ `UPDATE shard_leases SET
409
+ epoch = epoch + 1,
410
+ writer_url = NULL,
411
+ writer_app_name = NULL,
412
+ frontier_ts = GREATEST(frontier_ts, (SELECT nextval('helipod_ts')))
413
+ WHERE shard_id = $1`,
414
+ [shardId]
415
+ );
416
+ return { fenced: true, oldAppName };
417
+ });
418
+ } catch (e) {
419
+ if (isLockTimeoutError(e)) return { fenced: false, oldAppName: null };
420
+ throw e;
421
+ }
422
+ }
423
+ /**
424
+ * Kill the evicted holder's lingering Postgres backend(s) by `application_name` so its session-level
425
+ * advisory writer lock is released and the NEXT acquire tick can win. Matches the fleet E2E's query
426
+ * shape exactly, including the `pid <> pg_backend_pid()` self-exclusion (the fencer never terminates
427
+ * its own connection — its app name differs anyway, so this is belt-and-braces). Best-effort: if the
428
+ * backend is already gone, the query simply affects zero rows.
429
+ */
430
+ async terminateBackend(appName) {
431
+ await this.client.query(
432
+ `SELECT pg_terminate_backend(pid) FROM pg_stat_activity
433
+ WHERE (application_name = $1 OR application_name LIKE $1 || '-commit-%') AND pid <> pg_backend_pid()`,
434
+ [appName]
435
+ );
436
+ }
437
+ /**
438
+ * A tick observed the advisory-lock try FAIL — another backend still holds the writer lock. If the
439
+ * lease has ALSO expired, that holder is wedged (its heartbeat stopped) while its connection lingers
440
+ * (a hung/paused process still owns the advisory lock, so no other node can acquire). Fence it
441
+ * (`evictExpired` bumps the epoch) then terminate its backend to release the lock; the next tick's
442
+ * advisory try then succeeds → normal acquisition → promotion. No-op when the lease is still live.
443
+ */
444
+ async maybeEvictWedged() {
445
+ if (!await this.isExpired()) return;
446
+ const { fenced, oldAppName } = await this.evictExpired();
447
+ if (fenced && oldAppName !== null) await this.terminateBackend(oldAppName);
448
+ }
449
+ /** Loop tryAcquire() every retryMs until it succeeds, then invoke onAcquired once. stop() cancels.
450
+ * On a tick where the advisory try fails AND the lease has expired, the wedged holder is fenced +
451
+ * its backend terminated (`maybeEvictWedged`) so a later tick can take over. */
452
+ acquireLoop(onAcquired) {
453
+ this.stopped = false;
454
+ const schedule = () => {
455
+ if (this.stopped) return;
456
+ this.timer = setTimeout(attempt, this.retryMs);
457
+ };
458
+ const attempt = () => {
459
+ if (this.stopped) return;
460
+ void (async () => {
461
+ try {
462
+ const state = await this.tryAcquire();
463
+ if (this.stopped) return;
464
+ if (state) {
465
+ onAcquired(state);
466
+ return;
467
+ }
468
+ await this.maybeEvictWedged();
469
+ } catch {
470
+ }
471
+ schedule();
472
+ })();
473
+ };
474
+ this.timer = setTimeout(attempt, this.retryMs);
475
+ }
476
+ /** Cancels any pending acquireLoop() retry. */
477
+ stop() {
478
+ this.stopped = true;
479
+ if (this.timer !== null) {
480
+ clearTimeout(this.timer);
481
+ this.timer = null;
482
+ }
483
+ }
484
+ /**
485
+ * F1 fix (Fenced Frontier B1 whole-branch review, BLOCKER): seed `frontier_ts` up to at least
486
+ * `maxTs` — the writer-BOOT step that closes the "pre-loaded database" hole. `tryAcquire()` only
487
+ * seeds `frontier_ts` to 0 on the row's FIRST creation (see above); a single-node Postgres `serve`
488
+ * that accumulates real data BEFORE `--fleet` is ever turned on has no `shard_leases` row at all,
489
+ * so the first `tryAcquire()` after enabling `--fleet` creates one at `frontier_ts=0` even though
490
+ * the store already holds history. Because the tailer targets `F=frontier_ts` (not
491
+ * `primary.maxTimestamp()`, see `replica-tailer.ts`'s D5), a fresh sync node's ready gate
492
+ * (`wm=0 < target=0`) is then a silent no-op — it reports ready with an EMPTY replica while the
493
+ * primary holds everything. Callers must invoke this at WRITER BOOT, after this node's own
494
+ * `setupSchema()` has definitely run (the `documents` table this reads may not exist before
495
+ * that — see `prepareFleetNode`/`startFleetNode`'s boot ordering) and BEFORE the node is reported
496
+ * ready; a later promotion does NOT need to call this — by then `frontier_ts` is already live,
497
+ * tracking every commit via the guard installed by `installCommitGuard`.
498
+ *
499
+ * `GREATEST` makes this a no-op on an already-live fleet (frontier already tracks every commit)
500
+ * and on re-acquisition — never regresses `frontier_ts`. Epoch-fenced like every other
501
+ * `shard_leases` write: a stale `epoch` here (this node lost the writer race to someone else
502
+ * between its `tryAcquire()` and this call) affects 0 rows rather than clobbering the new
503
+ * writer's state — that writer's own commit guard is the source of truth for `frontier_ts` going
504
+ * forward regardless. Deliberately does NOT touch `prev_ts`: this is a seed of the high-water
505
+ * mark, not a commit, so there is no new "previous commit" to record — the next REAL commit's
506
+ * guard sets `prev_ts := frontier_ts` (now the seeded value) exactly as it always does.
507
+ */
508
+ async seedFrontier(epoch, maxTs, shardId = SHARD_ID) {
509
+ await this.client.query(
510
+ `UPDATE shard_leases SET frontier_ts = GREATEST(frontier_ts, $1) WHERE shard_id = $2 AND epoch = $3`,
511
+ [maxTs, shardId, epoch]
512
+ );
513
+ }
514
+ /**
515
+ * All-rows frontier seed (B2a — the F1×N fix): seed EVERY shard this node holds up to `maxTs` in
516
+ * ONE batched, per-row-epoch-fenced UPDATE, run at writer boot BEFORE the node reports ready. The
517
+ * same reasoning as single-shard `seedFrontier` applies to every row at once: any future commit on
518
+ * any shard takes a later `nextval`, so seeding all held frontiers to the store's current max can
519
+ * never over-shoot a real commit, and `GREATEST` keeps it a no-op on an already-live fleet.
520
+ * Epoch-fenced per pair (`(shard_id, epoch) IN (...)`), so a shard this node lost between acquiring
521
+ * it and this call is simply skipped rather than clobbered. See `node.ts`'s writer boot.
522
+ */
523
+ async seedFrontierAll(maxTs) {
524
+ const pairs = this.heldPairs();
525
+ if (pairs.length === 0) return;
526
+ const { clause, params } = this.tupleInClause(pairs, 1);
527
+ await this.client.query(
528
+ `UPDATE shard_leases SET frontier_ts = GREATEST(frontier_ts, $1) WHERE (shard_id, epoch) IN (${clause})`,
529
+ [maxTs, ...params]
530
+ );
531
+ }
532
+ /**
533
+ * Per-shard idle-shard frontier closing (B2a, D5 — needed the moment N>1: an idle shard pins the
534
+ * fleet's `F = min(frontier_ts)`). Allocate a single fresh `nextval` `N` from the shared
535
+ * `helipod_ts` sequence per beat, then for EACH held shard advance its frontier up to `N` —
536
+ * `UPDATE ... SET frontier_ts = GREATEST(frontier_ts, $N) WHERE shard_id=$s AND epoch=$e AND
537
+ * frontier_ts < $N` — but run each shard's UPDATE UNDER THAT SHARD'S COMMIT MUTEX
538
+ * (`runExclusiveOnShard`), skipping any shard currently mid-commit (mutex busy → left for the next
539
+ * beat). Returns the `nextval` used (the ceiling this beat closed idle shards up to).
540
+ *
541
+ * Why skip-if-busy, not the old bare batched UPDATE (the frontier-inversion fix): the commit
542
+ * guard writes `frontier_ts := commitTs` for a commit that drew its ts `T` from the SAME sequence,
543
+ * *inside* the commit transaction — which runs under the shard's commit mutex (single-commit) or at
544
+ * FLUSH time for a group-commit batch (Fleet B4). If the closer drew `N > T` and bare-wrote
545
+ * `frontier_ts = N` while that commit's/batch's rows had not yet landed, a tailer could read `F ≥
546
+ * N`, pull `(watermark, N]`, and MISS `T`'s rows when they land afterward (silent replica miss), or
547
+ * the guard's later write of `T` would trip a frontier-regression assert. `runExclusiveOnShard`
548
+ * treats a shard as available only when it is fully IDLE — mutex free AND no staged/flushing
549
+ * group-commit batch (`ShardWriter.hasInFlightWork()`): group commit runs the flush OFF the mutex,
550
+ * so a mid-flush batch (ts's already drawn, rows not yet landed) reads BUSY even though the mutex is
551
+ * free, and is skipped for the next beat. Given that, the closer and that shard's commits are
552
+ * mutually exclusive on this (single, writer-owned) node: any commit/batch STARTED before the draw
553
+ * either holds the mutex or is staged/flushing → skipped this beat; any commit/batch that STARTS
554
+ * after the closer's `nextval` draws its `T` at commit/flush time, AFTER `N`, so `T > N` and
555
+ * `GREATEST` keeps `frontier_ts ≥ N` with no regression and nothing in-flight ever sits below `N`.
556
+ * Cross-node closing of a held shard is impossible by design (only
557
+ * the lease holder closes its own held shards; orphan bumping via `evictExpired` targets
558
+ * writer-less rows, where no commit can be in flight). Epoch-fenced per pair. No-op (returns the
559
+ * allocated ts anyway) when this node holds nothing.
560
+ */
561
+ async closeIdleFrontiers(runExclusiveOnShard) {
562
+ const pairs = this.heldPairs();
563
+ const tsRows = await this.client.query(`SELECT nextval('helipod_ts') AS ts`);
564
+ const newTs = toBigIntOrZero(tsRows[0]?.ts);
565
+ for (const { shardId, epoch } of pairs) {
566
+ await runExclusiveOnShard(shardId, async () => {
567
+ await this.client.query(
568
+ `UPDATE shard_leases SET frontier_ts = GREATEST(frontier_ts, $1)
569
+ WHERE shard_id = $2 AND epoch = $3 AND frontier_ts < $1`,
570
+ [newTs, shardId, epoch]
571
+ );
572
+ });
573
+ }
574
+ return newTs;
575
+ }
576
+ /**
577
+ * Orphan frontier bumping (B2b, D4): advance the frontier of every WRITER-LESS shard row
578
+ * (`writer_url IS NULL`) that lags `ceiling`, so an unassigned/relinquished/expired shard never
579
+ * pins the fleet's `F = min(frontier_ts)` below the live commit position. Runs on the WRITER beat
580
+ * alongside `closeIdleFrontiers` (which only ever touches THIS node's OWN held rows via
581
+ * `heldPairs()` and so structurally cannot un-pin a shard nobody holds).
582
+ *
583
+ * Safe with NO commit mutex, unlike `closeIdleFrontiers`: a writer-less shard has no in-flight
584
+ * commit by construction — its last writer was fenced (epoch-bumped, `writer_url` nulled) BEFORE
585
+ * the row became orphaned, so nothing can be mid-commit writing `frontier_ts` on it. The `UPDATE`'s
586
+ * own row lock serializes any two writers racing this bump, and `GREATEST` + `frontier_ts < ceiling`
587
+ * keep it monotone regardless. Reuses the SAME `ceiling` (one `nextval` per beat) the idle closer
588
+ * drew, so a beat costs one sequence draw, not two.
589
+ */
590
+ async bumpOrphanFrontiers(ceiling) {
591
+ await this.client.query(
592
+ `UPDATE shard_leases SET frontier_ts = GREATEST(frontier_ts, $1)
593
+ WHERE writer_url IS NULL AND frontier_ts < $1`,
594
+ [ceiling]
595
+ );
596
+ }
597
+ /** Reads the current lease row for `shardId` (discovery for forwarding, plus the full fencing/
598
+ * frontier state); null if none exists yet. Defaults to the default shard (B1 call sites). */
599
+ async read(shardId = SHARD_ID) {
600
+ const rows = await this.client.query(
601
+ `SELECT epoch, writer_url, writer_app_name, expires_at, frontier_ts, prev_ts
602
+ FROM shard_leases WHERE shard_id = $1`,
603
+ [shardId]
604
+ );
605
+ const row = rows[0];
606
+ if (!row) return null;
607
+ return rowToLeaseRow(row);
608
+ }
609
+ /** All shard rows' `(shard_id, frontier_ts)` — the fleet-wide frontier picture the writer's
610
+ * frontier-lag monitor reads to compute `min(frontier_ts)` + which shard is pinning it (D5's
611
+ * health observability). Ordered by frontier ascending so `rows[0]` is the pinning shard. */
612
+ async readAllFrontiers() {
613
+ const rows = await this.client.query(
614
+ `SELECT shard_id, frontier_ts FROM shard_leases ORDER BY frontier_ts ASC, shard_id ASC`
615
+ );
616
+ return rows.map((r) => ({ shardId: r.shard_id, frontierTs: toBigIntOrZero(r.frontier_ts) }));
617
+ }
618
+ /**
619
+ * Read back `key`'s `fleet_idempotency` row for a replay decision (Fleet B3, D3), or `null` if no
620
+ * such key has ever committed (a genuine miss — proceed to run). Called by `packages/cli`'s
621
+ * `/_fleet/run` handler BOTH before running (the SELECT-first check) and after catching a
622
+ * unique_violation on this table (the concurrent-duplicate race's loser re-selecting the
623
+ * winner's row). See `IdempotencyReplay` for what `hasValue`/`oversized` distinguish.
624
+ */
625
+ async lookupIdempotency(key) {
626
+ const rows = await this.client.query(
627
+ `SELECT commit_ts, value_json, oversized FROM fleet_idempotency WHERE key = $1`,
628
+ [key]
629
+ );
630
+ const row = rows[0];
631
+ if (!row) return null;
632
+ const raw = row.value_json;
633
+ return {
634
+ commitTs: toBigIntOrZero(row.commit_ts),
635
+ hasValue: raw !== null && raw !== void 0,
636
+ value: raw !== null && raw !== void 0 ? JSON.parse(raw) : null,
637
+ oversized: row.oversized === true
638
+ };
639
+ }
640
+ /**
641
+ * Best-effort post-run recording of a forwarded mutation's return VALUE onto its already-committed
642
+ * `fleet_idempotency` row (Fleet B3, D3) — the value isn't known inside the commit transaction (the
643
+ * guard only sees `commitTs`), so this runs AFTER `runtime.run`/`runAction`/`runSystem` returns,
644
+ * from `packages/cli`'s `/_fleet/run` handler. Over `IDEMPOTENCY_VALUE_CAP_BYTES` → `value_json`
645
+ * stays/goes NULL and `oversized = true` instead of storing it (a replay then reports
646
+ * `valueMissing: true`, same as the crash-window case where this UPDATE never ran at all). A
647
+ * missing row (no matching `key` — e.g. a no-op mutation that staged nothing, so the guard never
648
+ * ran) silently affects 0 rows; the caller doesn't need to check for that itself.
649
+ */
650
+ async recordIdempotencyValue(key, value) {
651
+ const json = JSON.stringify(value ?? null);
652
+ if (Buffer.byteLength(json, "utf8") > IDEMPOTENCY_VALUE_CAP_BYTES) {
653
+ await this.client.query(
654
+ `UPDATE fleet_idempotency SET value_json = NULL, oversized = true WHERE key = $1`,
655
+ [key]
656
+ );
657
+ return;
658
+ }
659
+ await this.client.query(
660
+ `UPDATE fleet_idempotency SET value_json = $1, oversized = false WHERE key = $2`,
661
+ [json, key]
662
+ );
663
+ }
664
+ /**
665
+ * Reclaim `fleet_idempotency` rows older than the TTL (Fleet B3, D3) — a cheap indexed (PK-only
666
+ * table, no index needed at this scale) delete run on the balancer beat of every WRITER-ish node
667
+ * (see `ShardLeaseBalancerDeps.sweepIdempotency` / `node.ts`'s wiring). A retry arriving after a
668
+ * row has been swept simply re-executes (documented boundary — retries are seconds-scale, the
669
+ * sweep window is 1h).
670
+ */
671
+ async sweepIdempotency() {
672
+ await this.client.query(`DELETE FROM fleet_idempotency WHERE created_at < now() - interval '${IDEMPOTENCY_TTL_INTERVAL}'`);
673
+ }
674
+ /**
675
+ * Build a `(shard_id, epoch) IN ((...),(...))` VALUES-list clause + its flat params array for a
676
+ * batched per-row-fenced statement, with placeholder numbers starting AFTER `offset` positional
677
+ * params the caller already consumed (e.g. a leading `$1 = maxTs`). Returns `{ clause, params }`
678
+ * where `params` is `[shardA, epochA, shardB, epochB, …]` in placeholder order.
679
+ */
680
+ tupleInClause(pairs, offset) {
681
+ const tuples = [];
682
+ const params = [];
683
+ for (const { shardId, epoch } of pairs) {
684
+ const a = offset + params.length + 1;
685
+ const b = offset + params.length + 2;
686
+ tuples.push(`($${a}, $${b})`);
687
+ params.push(shardId, epoch);
688
+ }
689
+ return { clause: tuples.join(", "), params };
690
+ }
691
+ };
692
+
693
+ // src/lease-monitor.ts
694
+ var DEFAULT_PROBE_MS = 5e3;
695
+ var DEFAULT_MAX_MISSES = 3;
696
+ var LeaseMonitor = class {
697
+ probe;
698
+ onExit;
699
+ probeMs;
700
+ maxMisses;
701
+ probeTimeoutMs;
702
+ timer = null;
703
+ misses = 0;
704
+ inFlight = false;
705
+ exited = false;
706
+ stopped = false;
707
+ constructor(deps) {
708
+ this.probe = deps.probe;
709
+ this.onExit = deps.onExit;
710
+ this.probeMs = deps.probeMs ?? DEFAULT_PROBE_MS;
711
+ this.maxMisses = deps.maxMisses ?? DEFAULT_MAX_MISSES;
712
+ this.probeTimeoutMs = deps.probeTimeoutMs ?? this.probeMs;
713
+ }
714
+ /** Begin probing every `probeMs`. Idempotent; a no-op once stopped or after exit. */
715
+ start() {
716
+ if (this.timer !== null || this.stopped || this.exited) return;
717
+ this.timer = setInterval(() => void this.tick(), this.probeMs);
718
+ }
719
+ async tick() {
720
+ if (this.stopped || this.exited || this.inFlight) return;
721
+ this.inFlight = true;
722
+ let timeoutHandle = null;
723
+ try {
724
+ const probeSettled = Promise.resolve().then(() => this.probe()).then(() => "success");
725
+ const timedOutMarker = new Promise((resolve) => {
726
+ timeoutHandle = setTimeout(() => resolve("timeout"), this.probeTimeoutMs);
727
+ });
728
+ const result = await Promise.race([probeSettled, timedOutMarker]);
729
+ if (this.stopped || this.exited) return;
730
+ if (result === "timeout") {
731
+ this.recordMiss("timed out");
732
+ } else {
733
+ this.misses = 0;
734
+ }
735
+ } catch {
736
+ if (this.stopped || this.exited) return;
737
+ this.recordMiss("failed");
738
+ } finally {
739
+ if (timeoutHandle !== null) clearTimeout(timeoutHandle);
740
+ this.inFlight = false;
741
+ }
742
+ }
743
+ recordMiss(mode) {
744
+ this.misses += 1;
745
+ if (this.misses > this.maxMisses) {
746
+ this.fireExit(`writer lease probe ${mode} (${this.misses} consecutive times)`);
747
+ }
748
+ }
749
+ /** DEFINITIVE lease loss (pinned connection closed/errored) → exit immediately. */
750
+ connectionLost() {
751
+ if (this.stopped || this.exited) return;
752
+ this.fireExit("writer lease lost: database connection closed");
753
+ }
754
+ /** DEFINITIVE lease loss (epoch superseded — heartbeat or commit guard found 0 rows for this
755
+ * node's epoch) → exit immediately, mirroring `connectionLost()`. Idempotent/at-most-once via
756
+ * the same `fireExit` guard — safe to call from both the heartbeat probe and a commit guard. */
757
+ fenced(reason) {
758
+ if (this.stopped || this.exited) return;
759
+ this.fireExit(`writer lease fenced: ${reason ?? "epoch superseded by another node"}`);
760
+ }
761
+ fireExit(reason) {
762
+ if (this.exited) return;
763
+ this.exited = true;
764
+ this.clearTimer();
765
+ this.onExit(reason);
766
+ }
767
+ /** Halt all probing and disarm exit — graceful shutdown or promotion hand-off. */
768
+ stop() {
769
+ this.stopped = true;
770
+ this.clearTimer();
771
+ }
772
+ clearTimer() {
773
+ if (this.timer !== null) {
774
+ clearInterval(this.timer);
775
+ this.timer = null;
776
+ }
777
+ }
778
+ };
779
+
780
+ // src/fenced-error.ts
781
+ var FencedError = class extends Error {
782
+ constructor(message) {
783
+ super(message);
784
+ this.name = "FencedError";
785
+ }
786
+ };
787
+
788
+ // src/commit-notifier.ts
789
+ var COMMIT_CHANNEL = "helipod_commits";
790
+ var NotifyingFanoutAdapter = class {
791
+ constructor(inner, client) {
792
+ this.inner = inner;
793
+ this.client = client;
794
+ }
795
+ inner;
796
+ client;
797
+ publish(payload) {
798
+ this.inner.publish(payload);
799
+ void this.client.notify(COMMIT_CHANNEL, String(payload.commitTs)).catch(() => {
800
+ });
801
+ }
802
+ subscribe(listener) {
803
+ return this.inner.subscribe(listener);
804
+ }
805
+ };
806
+
807
+ // src/forwarder.ts
808
+ import { DEFAULT_SHARD as DEFAULT_SHARD2 } from "@helipod/id-codec";
809
+ import { isHelipodError, helipodErrorFromJSON, NOT_SHARD_OWNER_CODE } from "@helipod/errors";
810
+ function trimTrailingSlash(url) {
811
+ return url.endsWith("/") ? url.slice(0, -1) : url;
812
+ }
813
+ var RYOW_WAIT_MS = 5e3;
814
+ var WriteForwarder = class {
815
+ constructor(lease, opts) {
816
+ this.lease = lease;
817
+ this.opts = opts;
818
+ }
819
+ lease;
820
+ opts;
821
+ tailer;
822
+ /** Guard each distinct malformed-response warning to once per process (independently — an absent
823
+ * commitTs and an unparseable one are different failure modes and each deserves its own log
824
+ * line) so a bad/old writer response doesn't spam the log on every subsequent forwarded write. */
825
+ warnedMissingCommitTs = false;
826
+ warnedUnparseableCommitTs = false;
827
+ /** Per-shard writer-URL cache (B2b, T2): populated on first forward for a shard, refreshed on a
828
+ * POST failure (transport error OR a not-the-owner rejection) and retried once. A node normally
829
+ * forwards the same shard repeatedly, so caching avoids a `shard_leases` read on every call. */
830
+ writerUrlCache = /* @__PURE__ */ new Map();
831
+ /** Attach the local `ReplicaTailer` this node reads off, enabling the read-your-own-writes wait
832
+ * in `forward()`. Fleet WRITER nodes never call this — they have no replica to wait on. */
833
+ attachTailer(t) {
834
+ this.tailer = t;
835
+ }
836
+ /** Called on promotion (sync → writer-ish). Releases any read-your-own-writes wait in flight —
837
+ * this node no longer serves reads off a replica for its own forwarded writes, so waiting on
838
+ * catch-up would only add latency. `isLocalWriter` itself needs no flip here: it already reads
839
+ * the live `LeaseManager` state directly (see below), which promotion's `tryAcquire` calls
840
+ * update as a side effect.
841
+ *
842
+ * NOTE (Fleet B3): a HYBRID promotion (multi-writer) does NOT call this — a hybrid keeps its
843
+ * replica + tailer running past promotion and STILL serves reads (and forwarded writes to shards
844
+ * it doesn't own) off the replica, so releasing the RYOW waits would be wrong. Only the
845
+ * single-writer failover promotion (`promoteFleetNode`, no replica read path afterward) calls it. */
846
+ promote() {
847
+ this.tailer?.release();
848
+ }
849
+ /**
850
+ * Fleet B3 (D2) — the HYBRID own-commit RYOW gate. Wired as the runtime's `beforeNotify` drain
851
+ * hook: awaited before a LOCALLY-committed mutation's subscription re-runs fire, so those re-runs
852
+ * (which read this node's replica, via the hybrid query path) don't observe the commit's absence on
853
+ * a replica that hasn't applied it yet. Delegates to the SAME attached `ReplicaTailer.waitFor` the
854
+ * forwarded-write RYOW uses (`attachTailer`), so local and forwarded writes share one catch-up
855
+ * primitive. No-op when no tailer is attached (a single-writer node with no replica, or before the
856
+ * tailer exists) or when nothing committed (`0n`). A timeout is swallowed — reactivity is
857
+ * best-effort; the read path stays correct regardless (the re-run just fires against a replica that
858
+ * is still catching up, no worse than the pre-gate behavior).
859
+ */
860
+ async waitForReplica(commitTs) {
861
+ if (!this.tailer || commitTs === 0n) return;
862
+ await this.tailer.waitFor(commitTs, RYOW_WAIT_MS);
863
+ }
864
+ /**
865
+ * True iff this node currently holds `shardId`'s write lease (B2b, D1: per-shard membership, not
866
+ * a whole-node binary role). `LeaseManager.currentEpoch(shardId) !== null` is the SAME live
867
+ * held-set accessor `relinquish()` uses as its own idempotency check (`node.ts`) — so this can
868
+ * never disagree with what the commit guard / relinquish dispatcher consider "held". Consulted
869
+ * fresh on every call (never cached): a shard acquired or relinquished mid-flight (balancer
870
+ * rebalance, failover, promotion) takes effect on the very next mutation.
871
+ */
872
+ isLocalWriter(shardId = DEFAULT_SHARD2) {
873
+ return this.lease.currentEpoch(shardId) !== null;
874
+ }
875
+ async forward(kind, path, args, identity, shardId = DEFAULT_SHARD2, dedup) {
876
+ const body = {
877
+ path,
878
+ args,
879
+ identity,
880
+ kind,
881
+ shardId,
882
+ forwarded: true,
883
+ idempotencyKey: crypto.randomUUID(),
884
+ ...dedup ? { clientId: dedup.clientId, seq: dedup.seq } : {}
885
+ };
886
+ const first = await this.writerUrlFor(shardId);
887
+ let result;
888
+ try {
889
+ result = await this.post(first, body);
890
+ } catch (firstErr) {
891
+ const shouldRetry = !isHelipodError(firstErr) || firstErr.code === NOT_SHARD_OWNER_CODE;
892
+ if (!shouldRetry) throw firstErr;
893
+ let second;
894
+ try {
895
+ second = await this.refreshWriterUrlFor(shardId);
896
+ } catch {
897
+ throw firstErr;
898
+ }
899
+ result = await this.post(second, body);
900
+ }
901
+ if (result.clientReplay) {
902
+ return { value: result.clientReplay.value ?? null, replay: result.clientReplay };
903
+ }
904
+ await this.waitForReplicaCatchUp(path, result.commitTs);
905
+ return {
906
+ value: result.value,
907
+ // `commitTs` is stringified over the wire (bigints don't survive JSON) — coerced to `number`
908
+ // here to match the `WriteRouter` seam's contract (consistent with how every other commitTs
909
+ // consumer at this precision layer already converts, e.g. `Number(r.oplog?.commitTs ?? 0)` in
910
+ // `runtime-embedded`'s sync path). The RYOW wait above already parsed the STRING via `BigInt`
911
+ // directly, so full precision was preserved for the one place that actually needs it.
912
+ commitTs: result.commitTs !== void 0 ? Number(result.commitTs) : void 0,
913
+ shardId: result.shardId
914
+ };
915
+ }
916
+ /** Discover shard `shardId`'s current writer URL — cached after the first read. */
917
+ async writerUrlFor(shardId) {
918
+ const cached = this.writerUrlCache.get(shardId);
919
+ if (cached !== void 0) return cached;
920
+ return this.refreshWriterUrlFor(shardId);
921
+ }
922
+ /** Force a fresh `shard_leases` read for `shardId`, overwriting any cached URL — the refresh half
923
+ * of `writerUrlFor`'s cache, also used directly by `forward()`'s retry-once path. */
924
+ async refreshWriterUrlFor(shardId) {
925
+ const state = await this.lease.read(shardId);
926
+ if (!state) throw new Error(`fleet: no writer lease found for shard '${shardId}' \u2014 cannot forward write`);
927
+ this.writerUrlCache.set(shardId, state.writerUrl);
928
+ return state.writerUrl;
929
+ }
930
+ async post(writerUrl, body) {
931
+ const res = await fetch(`${trimTrailingSlash(writerUrl)}/_fleet/run`, {
932
+ method: "POST",
933
+ headers: { "content-type": "application/json", authorization: `Bearer ${this.opts.adminKey}` },
934
+ body: JSON.stringify(body)
935
+ });
936
+ const text = await res.text();
937
+ let parsed;
938
+ try {
939
+ parsed = text ? JSON.parse(text) : {};
940
+ } catch {
941
+ parsed = {};
942
+ }
943
+ if (!res.ok || parsed.error !== void 0) {
944
+ if (parsed.errorJson) throw helipodErrorFromJSON(parsed.errorJson);
945
+ throw new Error(parsed.error ?? `fleet: writer /_fleet/run returned HTTP ${res.status}`);
946
+ }
947
+ return { value: parsed.value ?? null, commitTs: parsed.commitTs, shardId: parsed.shardId, clientReplay: parsed.clientReplay };
948
+ }
949
+ /** Waits for the local replica to observe `commitTsStr`, when a tailer is attached. No-op on a
950
+ * fleet WRITER node (no tailer attached) or when the write committed nothing (`0`/absent). */
951
+ async waitForReplicaCatchUp(path, commitTsStr) {
952
+ if (!this.tailer) return;
953
+ if (commitTsStr === void 0) {
954
+ if (!this.warnedMissingCommitTs) {
955
+ this.warnedMissingCommitTs = true;
956
+ console.warn(
957
+ `fleet: writer's /_fleet/run response for ${path} had no commitTs \u2014 skipping read-your-own-writes wait`
958
+ );
959
+ }
960
+ return;
961
+ }
962
+ let commitTs;
963
+ try {
964
+ commitTs = BigInt(commitTsStr);
965
+ } catch {
966
+ if (!this.warnedUnparseableCommitTs) {
967
+ this.warnedUnparseableCommitTs = true;
968
+ console.warn(
969
+ `fleet: writer's /_fleet/run response for ${path} had an unparseable commitTs ${JSON.stringify(commitTsStr)} \u2014 skipping read-your-own-writes wait`
970
+ );
971
+ }
972
+ return;
973
+ }
974
+ if (commitTs === 0n) return;
975
+ const outcome = await this.tailer.waitFor(commitTs, RYOW_WAIT_MS);
976
+ if (outcome === "timeout") {
977
+ console.warn(
978
+ `fleet: read-your-own-writes wait timed out after ${RYOW_WAIT_MS}ms for ${path} at commitTs ${commitTs}`
979
+ );
980
+ }
981
+ }
982
+ };
983
+
984
+ // src/balancer.ts
985
+ import { DEFAULT_SHARD as DEFAULT_SHARD3, shardIdList } from "@helipod/id-codec";
986
+
987
+ // src/rendezvous.ts
988
+ var U64_MASK = 0xffffffffffffffffn;
989
+ var FNV_OFFSET_BASIS = 0xcbf29ce484222325n;
990
+ var FNV_PRIME = 0x100000001b3n;
991
+ var utf8 = new TextEncoder();
992
+ function fnv1a64(bytes) {
993
+ let h = FNV_OFFSET_BASIS;
994
+ for (let i = 0; i < bytes.length; i++) {
995
+ h ^= BigInt(bytes[i]);
996
+ h = h * FNV_PRIME & U64_MASK;
997
+ }
998
+ return h;
999
+ }
1000
+ function rendezvousWeight(shardId, advertiseUrl) {
1001
+ return fnv1a64(utf8.encode(`${shardId}\0${advertiseUrl}`));
1002
+ }
1003
+ function rendezvousOwner(shardId, liveUrls) {
1004
+ let bestUrl = "";
1005
+ let bestWeight = -1n;
1006
+ for (const url of liveUrls) {
1007
+ const w = rendezvousWeight(shardId, url);
1008
+ if (w > bestWeight || w === bestWeight && url < bestUrl) {
1009
+ bestWeight = w;
1010
+ bestUrl = url;
1011
+ }
1012
+ }
1013
+ return bestUrl;
1014
+ }
1015
+
1016
+ // src/balancer.ts
1017
+ var DEFAULT_BEAT_MS = 2e3;
1018
+ var ShardLeaseBalancer = class {
1019
+ constructor(deps) {
1020
+ this.deps = deps;
1021
+ this.shards = shardIdList(deps.numShards);
1022
+ this.multiWriter = deps.multiWriter ?? false;
1023
+ this.beatMs = deps.beatMs ?? DEFAULT_BEAT_MS;
1024
+ this.log = deps.log ?? ((m) => console.error(m));
1025
+ }
1026
+ deps;
1027
+ shards;
1028
+ multiWriter;
1029
+ beatMs;
1030
+ log;
1031
+ timer = null;
1032
+ stopped = false;
1033
+ running = false;
1034
+ /** Canonical (sorted, comma-joined) live set from the PREVIOUS beat — damping compares against it:
1035
+ * a RELEASE only fires when the current live set equals this (identical for ≥2 consecutive beats). */
1036
+ previousLiveSet = null;
1037
+ /** Begin the periodic beat. The FIRST beat fires after `beatMs` (never synchronously) — prompt boot
1038
+ * acquisition is done explicitly via `acquireTargetsNow()`, not the periodic loop. Idempotent. */
1039
+ start() {
1040
+ if (this.timer !== null || this.stopped) return;
1041
+ this.timer = setInterval(() => void this.tick(), this.beatMs);
1042
+ }
1043
+ stop() {
1044
+ this.stopped = true;
1045
+ if (this.timer !== null) {
1046
+ clearInterval(this.timer);
1047
+ this.timer = null;
1048
+ }
1049
+ }
1050
+ /** Resolve the live candidate set for a beat: the DB's live nodes ∪ this node itself (a node is
1051
+ * always live to itself — belt-and-braces so its own boot never depends on its presence row having
1052
+ * landed/propagated yet). Returns both the URL array (rendezvous input) and its canonical form. */
1053
+ async liveCandidates() {
1054
+ const live = await this.deps.lease.liveNodes();
1055
+ const set = new Set(live);
1056
+ set.add(this.deps.myUrl);
1057
+ const urls = [...set];
1058
+ return { urls, canonical: [...set].sort().join(",") };
1059
+ }
1060
+ /** This node's target shards. In single-writer mode (the default) the sole writer's targets are ALL
1061
+ * shards — byte-identical to B2a's acquire-all — so a joining node never steals a share. In
1062
+ * multi-writer mode it is this node's rendezvous (HRW) share over `liveUrls`. */
1063
+ targetsFor(liveUrls) {
1064
+ if (!this.multiWriter) return new Set(this.shards);
1065
+ const targets = /* @__PURE__ */ new Set();
1066
+ for (const s of this.shards) {
1067
+ if (rendezvousOwner(s, liveUrls) === this.deps.myUrl) targets.add(s);
1068
+ }
1069
+ return targets;
1070
+ }
1071
+ /** A shard is acquirable iff it has no row (absent from the map), is orphaned (`writer_url NULL`), or
1072
+ * has expired — i.e. NOT held live by a peer. Acquisition only ever targets these; a live non-target
1073
+ * holder is never fenced by the balancer (that's failover's job). */
1074
+ acquirable(shardId, ownership) {
1075
+ const o = ownership.get(shardId);
1076
+ return o === void 0 || o.writerUrl === null || o.expired;
1077
+ }
1078
+ /**
1079
+ * Un-damped acquire pass over this node's CURRENT rendezvous targets — called at writer boot and on
1080
+ * promotion so the node holds its share PROMPTLY (before the ready line / before the first periodic
1081
+ * beat), rather than waiting out a 2s tick. In a single-node fleet the target set is EVERY shard, so
1082
+ * this acquires all N — byte-identical steady state to B2a's acquire-all. Seeds `previousLiveSet` so
1083
+ * the first periodic beat treats the boot membership as the damping baseline.
1084
+ */
1085
+ async acquireTargetsNow() {
1086
+ const { urls, canonical } = await this.liveCandidates();
1087
+ const ownership = await this.deps.lease.readShardOwnership();
1088
+ const targets = this.targetsFor(urls);
1089
+ for (const s of this.shards) {
1090
+ if (targets.has(s) && !this.deps.isHeld(s) && this.acquirable(s, ownership)) {
1091
+ await this.deps.tryAcquireShard(s);
1092
+ }
1093
+ }
1094
+ this.previousLiveSet = canonical;
1095
+ }
1096
+ /** One balancer beat (see the class doc for the five steps). Never throws — a tick error is logged
1097
+ * and the loop continues; the next beat retries against fresh state. */
1098
+ async tick() {
1099
+ if (this.stopped || this.running) return;
1100
+ this.running = true;
1101
+ try {
1102
+ await this.deps.lease.heartbeatPresence();
1103
+ const { urls, canonical } = await this.liveCandidates();
1104
+ const stable = canonical === this.previousLiveSet;
1105
+ this.previousLiveSet = canonical;
1106
+ const ownership = await this.deps.lease.readShardOwnership();
1107
+ const targets = this.targetsFor(urls);
1108
+ if (!this.deps.isWriterish()) {
1109
+ if (this.multiWriter) {
1110
+ const wantsPromotion = [...targets].some(
1111
+ (s) => s !== DEFAULT_SHARD3 && this.acquirable(s, ownership)
1112
+ );
1113
+ if (wantsPromotion) await this.deps.requestPromotion();
1114
+ }
1115
+ return;
1116
+ }
1117
+ for (const s of this.shards) {
1118
+ if (targets.has(s) && !this.deps.isHeld(s) && this.acquirable(s, ownership)) {
1119
+ await this.deps.tryAcquireShard(s);
1120
+ }
1121
+ }
1122
+ if (this.multiWriter && stable) {
1123
+ for (const s of this.shards) {
1124
+ if (this.deps.isHeld(s) && !targets.has(s)) {
1125
+ await this.deps.releaseShard(s);
1126
+ }
1127
+ }
1128
+ }
1129
+ if (this.deps.sweepIdempotency) {
1130
+ try {
1131
+ await this.deps.sweepIdempotency();
1132
+ } catch (e) {
1133
+ this.log(`fleet: idempotency sweep failed: ${e instanceof Error ? e.message : String(e)}`);
1134
+ }
1135
+ }
1136
+ } catch (e) {
1137
+ this.log(`fleet: balancer tick failed: ${e instanceof Error ? e.message : String(e)}`);
1138
+ } finally {
1139
+ this.running = false;
1140
+ }
1141
+ }
1142
+ };
1143
+
1144
+ // src/node.ts
1145
+ import { rmSync } from "fs";
1146
+ import { join } from "path";
1147
+ import { NodePgClient, PostgresDocStore } from "@helipod/docstore-postgres";
1148
+ import { SqliteDocStore, NodeSqliteAdapter, BunSqliteAdapter } from "@helipod/docstore-sqlite";
1149
+ import { CommitGuardRejection } from "@helipod/errors";
1150
+ import { DEFAULT_SHARD as DEFAULT_SHARD4, shardIdList as shardIdList2 } from "@helipod/id-codec";
1151
+ import {
1152
+ InMemoryWriteFanoutAdapter,
1153
+ clientReceiptsGuard
1154
+ } from "@helipod/runtime-embedded";
1155
+
1156
+ // src/switchable-store.ts
1157
+ var SwitchableDocStore = class {
1158
+ delegate;
1159
+ constructor(initial) {
1160
+ this.delegate = initial;
1161
+ }
1162
+ /** Atomically repoint all future calls at `next`. Does not touch the outgoing delegate. */
1163
+ swapTo(next) {
1164
+ this.delegate = next;
1165
+ }
1166
+ /** The delegate current calls are being forwarded to. */
1167
+ current() {
1168
+ return this.delegate;
1169
+ }
1170
+ async setupSchema(options) {
1171
+ const d = this.delegate;
1172
+ return d.setupSchema(options);
1173
+ }
1174
+ async write(documents, indexUpdates, conflictStrategy, shardId) {
1175
+ const d = this.delegate;
1176
+ return d.write(documents, indexUpdates, conflictStrategy, shardId);
1177
+ }
1178
+ async commitWrite(documents, indexUpdates, shardId, opts) {
1179
+ const d = this.delegate;
1180
+ return d.commitWrite(documents, indexUpdates, shardId, opts);
1181
+ }
1182
+ async commitWriteBatch(units, shardId) {
1183
+ const d = this.delegate;
1184
+ return d.commitWriteBatch(units, shardId);
1185
+ }
1186
+ /** Forwards to the CURRENT delegate at call entry, same atomicity contract as every other
1187
+ * method here — but note the resulting registration is NOT itself re-forwarded on a later
1188
+ * `swapTo()`: the guard lands on whichever concrete store was `this.delegate` at the moment
1189
+ * `addCommitGuard` was called, and stays registered there even after the switchable repoints
1190
+ * elsewhere. In practice fleet code (`node.ts`) always calls `addCommitGuard` on the concrete
1191
+ * `PostgresDocStore` directly (never through this wrapper), so this pass-through exists purely
1192
+ * for `DocStore` interface conformance. */
1193
+ addCommitGuard(guard) {
1194
+ const d = this.delegate;
1195
+ return d.addCommitGuard(guard);
1196
+ }
1197
+ async get(id, readTimestamp) {
1198
+ const d = this.delegate;
1199
+ return d.get(id, readTimestamp);
1200
+ }
1201
+ async *index_scan(indexId, tableId, readTimestamp, interval, order, limit) {
1202
+ const d = this.delegate;
1203
+ yield* d.index_scan(indexId, tableId, readTimestamp, interval, order, limit);
1204
+ }
1205
+ async *load_documents(range, order, limit) {
1206
+ const d = this.delegate;
1207
+ yield* d.load_documents(range, order, limit);
1208
+ }
1209
+ async previous_revisions(queries) {
1210
+ const d = this.delegate;
1211
+ return d.previous_revisions(queries);
1212
+ }
1213
+ async scan(tableId, readTimestamp) {
1214
+ const d = this.delegate;
1215
+ return d.scan(tableId, readTimestamp);
1216
+ }
1217
+ async count(tableId) {
1218
+ const d = this.delegate;
1219
+ return d.count(tableId);
1220
+ }
1221
+ async maxTimestamp() {
1222
+ const d = this.delegate;
1223
+ return d.maxTimestamp();
1224
+ }
1225
+ async getGlobal(key) {
1226
+ const d = this.delegate;
1227
+ return d.getGlobal(key);
1228
+ }
1229
+ async writeGlobal(key, value) {
1230
+ const d = this.delegate;
1231
+ return d.writeGlobal(key, value);
1232
+ }
1233
+ async writeGlobalIfAbsent(key, value) {
1234
+ const d = this.delegate;
1235
+ return d.writeGlobalIfAbsent(key, value);
1236
+ }
1237
+ // ── Client mutation receipts (the Receipted Outbox, verdict §(c)) — pass-through ──────────────
1238
+ async getClientVerdict(identity, clientId, seq) {
1239
+ const d = this.delegate;
1240
+ return d.getClientVerdict(identity, clientId, seq);
1241
+ }
1242
+ async getClientFloor(identity, clientId) {
1243
+ const d = this.delegate;
1244
+ return d.getClientFloor(identity, clientId);
1245
+ }
1246
+ async recordClientVerdict(identity, clientId, seq, record) {
1247
+ const d = this.delegate;
1248
+ return d.recordClientVerdict(identity, clientId, seq, record);
1249
+ }
1250
+ async updateClientVerdictValue(identity, clientId, seq, value) {
1251
+ const d = this.delegate;
1252
+ return d.updateClientVerdictValue(identity, clientId, seq, value);
1253
+ }
1254
+ async pruneClientMutations(identity, clientId, opts) {
1255
+ const d = this.delegate;
1256
+ return d.pruneClientMutations(identity, clientId, opts);
1257
+ }
1258
+ async sweepExpiredClientMutations(beforeMs) {
1259
+ const d = this.delegate;
1260
+ return d.sweepExpiredClientMutations(beforeMs);
1261
+ }
1262
+ close() {
1263
+ const d = this.delegate;
1264
+ return d.close();
1265
+ }
1266
+ };
1267
+
1268
+ // src/replica-tailer.ts
1269
+ import { decodeStorageTableId, encodeStorageTableId } from "@helipod/id-codec";
1270
+
1271
+ // src/stable-prefix.ts
1272
+ function stablePrefixFromFrontier(raw) {
1273
+ return raw;
1274
+ }
1275
+
1276
+ // src/replica-tailer.ts
1277
+ var COMMIT_CHANNEL2 = "helipod_commits";
1278
+ var DEFAULT_POLL_MS = 1e3;
1279
+ var DEFAULT_BATCH_SIZE = 1e3;
1280
+ var DEFAULT_NUM_SHARDS = 1;
1281
+ function docDedupeKey(id) {
1282
+ return `${encodeStorageTableId(id.tableNumber)}|${Buffer.from(id.internalId).toString("hex")}`;
1283
+ }
1284
+ function docLabel(id) {
1285
+ return `table=${encodeStorageTableId(id.tableNumber)} internalId=${Buffer.from(id.internalId).toString("hex")}`;
1286
+ }
1287
+ function toBigInt(v) {
1288
+ if (typeof v === "bigint") return v;
1289
+ if (typeof v === "number" || typeof v === "string") return BigInt(v);
1290
+ throw new Error(`fleet: expected shard_leases.frontier_ts to be a BIGINT-like value, got ${typeof v}`);
1291
+ }
1292
+ var DensityViolationError = class extends Error {
1293
+ constructor(docId, expectedPrevTs, actualHeadTs) {
1294
+ super(
1295
+ `fleet: replica density violation for doc (${docLabel(docId)}) \u2014 expected prev_ts ${expectedPrevTs === null ? "null (insert \u2014 replica must have no live head)" : String(expectedPrevTs)}, but the replica's actual head ts is ${actualHeadTs === null ? "null (no live head)" : String(actualHeadTs)}. A commit touching this document was skipped between the primary and this replica; delete <replica path>/fleet-replica.db to re-bootstrap.`
1296
+ );
1297
+ this.docId = docId;
1298
+ this.expectedPrevTs = expectedPrevTs;
1299
+ this.actualHeadTs = actualHeadTs;
1300
+ this.name = "DensityViolationError";
1301
+ }
1302
+ docId;
1303
+ expectedPrevTs;
1304
+ actualHeadTs;
1305
+ };
1306
+ var ReplicaTailer = class {
1307
+ constructor(client, primary, replica, opts) {
1308
+ this.client = client;
1309
+ this.primary = primary;
1310
+ this.replica = replica;
1311
+ this.mode = opts.mode ?? "replica";
1312
+ this.pollMs = opts.pollMs ?? DEFAULT_POLL_MS;
1313
+ this.batchSize = opts.batchSize ?? DEFAULT_BATCH_SIZE;
1314
+ this.numShards = opts.numShards ?? DEFAULT_NUM_SHARDS;
1315
+ this.onInvalidation = opts.onInvalidation;
1316
+ }
1317
+ client;
1318
+ primary;
1319
+ replica;
1320
+ mode;
1321
+ pollMs;
1322
+ batchSize;
1323
+ numShards;
1324
+ onInvalidation;
1325
+ /** The tailer's own applied high-water mark — a `StablePrefixTs` (D6): only ever seeded from the
1326
+ * replica's own persisted `maxTimestamp()` (a prior run's watermark, on restart) or advanced to
1327
+ * a freshly-read/capped `F`. Never assigned a raw, un-branded `bigint` directly. */
1328
+ wm = stablePrefixFromFrontier(0n);
1329
+ /** The last-observed fenced frontier (D5) — tracked purely to assert F never regresses across
1330
+ * reads (`null` = no read yet, so the first `readFrontier()` establishes the baseline without
1331
+ * asserting anything). */
1332
+ lastF = null;
1333
+ timer;
1334
+ unlisten;
1335
+ stopped = true;
1336
+ /** Reentrancy guard: a NOTIFY wake and a poll tick can land back-to-back — only one
1337
+ * pull-apply-invalidate walk runs at a time, so `onInvalidation` never sees overlapping ranges,
1338
+ * and the bootstrap loop in `start()` never races a concurrent LISTEN-triggered tick. */
1339
+ draining = false;
1340
+ waiters = /* @__PURE__ */ new Set();
1341
+ /** Plain `bigint` on purpose (not the branded `StablePrefixTs`) — external callers (tests, the
1342
+ * `PromotionDeps` seam) only ever compare/print this value; widening away the internal brand at
1343
+ * the public boundary costs nothing and avoids forcing every caller to know about the brand. */
1344
+ watermark() {
1345
+ return this.wm;
1346
+ }
1347
+ /**
1348
+ * Reads the fenced frontier `F` (Fenced Frontier B1 D5, generalized to N shards in B2a) —
1349
+ * `F = min(frontier_ts)` over ALL `shard_leases` rows — via the same `CommitChannelClient` already
1350
+ * threaded in for LISTEN/NOTIFY. The min is the dense prefix the WHOLE fleet has durably fenced: a
1351
+ * replica may serve reads up to it and never past a commit that hasn't been fenced on every shard.
1352
+ *
1353
+ * Belt-and-braces (B2a — the F1×N hole otherwise recurs ×N): treat `count(*) < numShards` as
1354
+ * F=0-equivalent (NOT ready). A half-created `shard_leases` — some shards claimed+seeded, others not
1355
+ * yet — must not let `min` over the present rows fake a ready frontier; the writer's acquire-all +
1356
+ * seed-all completes (all N rows present, each seeded ≥ max) BEFORE it reports ready, so a real min
1357
+ * only appears once every shard is genuinely fenced. `count(*) = 0` (fresh fleet, pre-acquisition)
1358
+ * is the same F=0.
1359
+ *
1360
+ * Asserts F is monotonically non-decreasing across reads once the count gate is satisfied (D5's
1361
+ * defense-in-depth invariant) — a regression can only mean `shard_leases` was corrupted/hand-
1362
+ * tampered. The assertion is skipped while `count(*) < numShards` (F is a placeholder 0 there, so a
1363
+ * later real min is legitimately larger, not a regression).
1364
+ */
1365
+ async readFrontier() {
1366
+ const rows = await this.client.query(
1367
+ `SELECT COALESCE(MIN(frontier_ts), 0) AS min_frontier, COUNT(*) AS n FROM shard_leases`
1368
+ );
1369
+ const row = rows[0];
1370
+ const count = row === void 0 ? 0 : Number(toBigInt(row.n));
1371
+ if (count < this.numShards) return stablePrefixFromFrontier(0n);
1372
+ const raw = row === void 0 ? 0n : toBigInt(row.min_frontier);
1373
+ const f = stablePrefixFromFrontier(raw);
1374
+ if (this.lastF !== null && f < this.lastF) {
1375
+ throw new Error(
1376
+ `fleet: frontier regression detected \u2014 min(shard_leases.frontier_ts) went from ${this.lastF} to ${f}. F must be monotonically non-decreasing; this indicates a lease row was corrupted or hand-tampered.`
1377
+ );
1378
+ }
1379
+ this.lastF = f;
1380
+ return f;
1381
+ }
1382
+ /**
1383
+ * @param seedWm ONLY consulted in `"invalidateOnly"` mode (ignored in `"replica"` mode, which always
1384
+ * seeds from the replica's own persisted high-water mark — see below). When provided, seeds the
1385
+ * watermark from THIS value instead of a fresh `readFrontier()` read.
1386
+ *
1387
+ * Fixes the PROMOTION-HANDOFF INVALIDATION GAP (B2b whole-branch review): a promoting node stops
1388
+ * its `ReplicaTailer` (sync mode) at some watermark `W`, then starts this derive-only listener.
1389
+ * Between the tailer's stop and the listener's start, a co-writer's commit can land and bump the
1390
+ * fenced frontier `F` past `W` — if the listener then seeds from a FRESH `readFrontier()` (i.e.
1391
+ * that same later `F`), it treats everything up to `F` as already-known and never derives/
1392
+ * invalidates the `(W, F]` range, even though nothing ever actually invalidated it: the sync
1393
+ * tailer was already stopped, and this listener starts past it. A subscription open across the
1394
+ * promotion goes silently stale for exactly those commits.
1395
+ *
1396
+ * The caller (`node.ts`'s `startWriterInvalidationListener`) closes this gap by passing the
1397
+ * OUTGOING tailer's own final `watermark()` (captured right after `tailer.stop()`) as `seedWm` on
1398
+ * promotion, so the listener picks up exactly where the tailer left off — contiguous coverage,
1399
+ * with the tailer's own last-applied range harmlessly re-derived (idempotent double-invalidation).
1400
+ * Writer BOOT (no prior tailer, so no gap by construction) omits `seedWm` and keeps the original
1401
+ * fresh-`readFrontier()` behavior.
1402
+ */
1403
+ async start(seedWm) {
1404
+ this.stopped = false;
1405
+ if (this.mode === "invalidateOnly") {
1406
+ if (seedWm !== void 0) {
1407
+ this.wm = stablePrefixFromFrontier(seedWm);
1408
+ } else {
1409
+ this.wm = await this.readFrontier();
1410
+ }
1411
+ } else {
1412
+ this.wm = stablePrefixFromFrontier(await this.replica.maxTimestamp());
1413
+ const target = await this.readFrontier();
1414
+ while (!this.stopped && this.wm < target) {
1415
+ await this.tick();
1416
+ }
1417
+ }
1418
+ if (this.stopped) return;
1419
+ try {
1420
+ this.unlisten = await this.client.listen(COMMIT_CHANNEL2, () => {
1421
+ void this.tick();
1422
+ });
1423
+ } catch {
1424
+ this.unlisten = void 0;
1425
+ }
1426
+ if (this.stopped) {
1427
+ if (this.unlisten !== void 0) {
1428
+ const unlisten = this.unlisten;
1429
+ this.unlisten = void 0;
1430
+ await unlisten();
1431
+ }
1432
+ return;
1433
+ }
1434
+ this.timer = setInterval(() => void this.tick(), this.pollMs);
1435
+ }
1436
+ async stop() {
1437
+ this.stopped = true;
1438
+ if (this.timer !== void 0) {
1439
+ clearInterval(this.timer);
1440
+ this.timer = void 0;
1441
+ }
1442
+ if (this.unlisten !== void 0) {
1443
+ const unlisten = this.unlisten;
1444
+ this.unlisten = void 0;
1445
+ await unlisten();
1446
+ }
1447
+ }
1448
+ /** Resolves when `watermark() >= ts` (immediately if already true), or when `release()` fires.
1449
+ * Task 3's read-your-own-writes primitive: a client that just committed at `ts` can wait for
1450
+ * a follower's replica to have caught up before serving that client's next read from it. */
1451
+ waitFor(ts, timeoutMs) {
1452
+ if (this.wm >= ts) return Promise.resolve("reached");
1453
+ return new Promise((resolve) => {
1454
+ const waiter = {
1455
+ ts,
1456
+ settle: (outcome) => {
1457
+ clearTimeout(waiter.timer);
1458
+ this.waiters.delete(waiter);
1459
+ resolve(outcome);
1460
+ },
1461
+ timer: setTimeout(() => waiter.settle("timeout"), timeoutMs)
1462
+ };
1463
+ this.waiters.add(waiter);
1464
+ });
1465
+ }
1466
+ /** Releases ALL pending `waitFor()`s with `"released"` (e.g. this node was just promoted to
1467
+ * writer, or is shutting down, and callers should stop waiting on replica catch-up). */
1468
+ release() {
1469
+ for (const w of [...this.waiters]) w.settle("released");
1470
+ }
1471
+ wakeSatisfiedWaiters() {
1472
+ for (const w of [...this.waiters]) {
1473
+ if (this.wm >= w.ts) w.settle("reached");
1474
+ }
1475
+ }
1476
+ async tick() {
1477
+ if (this.stopped || this.draining) return;
1478
+ this.draining = true;
1479
+ try {
1480
+ await this.drainOnce();
1481
+ } catch (e) {
1482
+ if (e instanceof DensityViolationError) {
1483
+ console.error(e.message);
1484
+ await this.stop();
1485
+ return;
1486
+ }
1487
+ throw e;
1488
+ } finally {
1489
+ this.draining = false;
1490
+ }
1491
+ }
1492
+ /** One pull-apply-invalidate walk. Split out from `tick()` so `tick()` can wrap it with the
1493
+ * DensityViolationError halt handling without that catch swallowing the `draining` bookkeeping. */
1494
+ async drainOnce() {
1495
+ const F = await this.readFrontier();
1496
+ if (F <= this.wm) return;
1497
+ const { docs, cappedAt } = await this.pullDocs(this.wm, F);
1498
+ const appliedMax = stablePrefixFromFrontier(cappedAt ?? F);
1499
+ const indexRows = await this.client.query(
1500
+ `SELECT index_id, key, ts, table_id, internal_id, deleted FROM indexes WHERE ts > $1 AND ts <= $2 ORDER BY ts ASC`,
1501
+ [this.wm, appliedMax]
1502
+ );
1503
+ if (docs.length === 0 && indexRows.length === 0) {
1504
+ this.wm = appliedMax;
1505
+ this.wakeSatisfiedWaiters();
1506
+ return;
1507
+ }
1508
+ if (this.mode === "replica") {
1509
+ const indexWrites = indexRows.map((r) => {
1510
+ const deleted = r.deleted;
1511
+ const value = deleted ? { type: "Deleted" } : {
1512
+ type: "NonClustered",
1513
+ docId: {
1514
+ tableNumber: decodeStorageTableId(r.table_id),
1515
+ internalId: r.internal_id
1516
+ }
1517
+ };
1518
+ return {
1519
+ ts: r.ts,
1520
+ update: { indexId: r.index_id, key: r.key, value }
1521
+ };
1522
+ });
1523
+ await this.assertDensity(docs);
1524
+ await this.replica.write(docs, indexWrites, "Overwrite");
1525
+ }
1526
+ const tableIds = /* @__PURE__ */ new Set();
1527
+ for (const r of indexRows) if (r.table_id !== null) tableIds.add(String(r.table_id));
1528
+ const writtenTables = [...tableIds];
1529
+ const writtenKeys = indexRows.map((r) => ({ indexId: String(r.index_id), key: r.key }));
1530
+ const seenDocs = /* @__PURE__ */ new Set();
1531
+ const writtenDocs = [];
1532
+ for (const d of docs) {
1533
+ const dedupeKey = docDedupeKey(d.id);
1534
+ if (seenDocs.has(dedupeKey)) continue;
1535
+ seenDocs.add(dedupeKey);
1536
+ writtenDocs.push({ tableId: encodeStorageTableId(d.id.tableNumber), internalId: d.id.internalId });
1537
+ }
1538
+ await this.onInvalidation({ newMaxTs: appliedMax, writtenTables, writtenKeys, writtenDocs });
1539
+ this.wm = appliedMax;
1540
+ this.wakeSatisfiedWaiters();
1541
+ }
1542
+ /**
1543
+ * Density assertion (D5, defense-in-depth): for EVERY document entry in this batch, in
1544
+ * `DocumentLogEntry` order, the replica's head for that document IMMEDIATELY BEFORE this entry
1545
+ * lands must chain from `entry.prev_ts` exactly — `prev_ts !== null` requires a live head equal
1546
+ * to it; `prev_ts === null` (an insert) requires no live head at all. Throws `DensityViolationError`
1547
+ * on the first mismatch.
1548
+ *
1549
+ * IDEMPOTENT RE-APPLY exception: if the replica's head is ALREADY at this exact entry's own
1550
+ * `ts`, that's not a violation — it's this exact revision being re-applied (the whole class is
1551
+ * built around `"Overwrite"` idempotency, e.g. a restart re-walking an already-applied range).
1552
+ * Since `ts` is the log's globally unique per-shard commit position, a head landing on it can
1553
+ * only mean this precise commit already landed here, never an unrelated collision.
1554
+ *
1555
+ * A batch can carry more than one revision of the SAME document (it was written more than once
1556
+ * inside the pulled `(wm, F]` range), so "the head immediately before this entry" is a running
1557
+ * per-batch simulation — seeded from the replica's REAL state (`replica.get`) the first time a
1558
+ * doc is encountered, then advanced in-memory to each entry's own `ts` as it's validated —
1559
+ * rather than re-reading the replica for every entry (which would see later, not-yet-applied
1560
+ * entries' predecessor as still absent).
1561
+ */
1562
+ async assertDensity(docs) {
1563
+ const headTs = /* @__PURE__ */ new Map();
1564
+ for (const d of docs) {
1565
+ const key = docDedupeKey(d.id);
1566
+ let head = headTs.get(key);
1567
+ if (head === void 0) {
1568
+ const existing = await this.replica.get(d.id);
1569
+ head = existing ? existing.ts : null;
1570
+ }
1571
+ if (head !== d.ts) {
1572
+ if (d.prev_ts !== null) {
1573
+ if (head === null || head !== d.prev_ts) throw new DensityViolationError(d.id, d.prev_ts, head);
1574
+ } else if (head !== null) {
1575
+ throw new DensityViolationError(d.id, null, head);
1576
+ }
1577
+ }
1578
+ headTs.set(key, d.ts);
1579
+ }
1580
+ }
1581
+ /**
1582
+ * Pull `DocumentLogEntry` rows for `(after, upTo]` from `load_documents`, capped at
1583
+ * `this.batchSize` — but if the cap lands mid-way through a group of rows sharing the same
1584
+ * commit `ts`, keep draining until that ts group is fully collected before stopping. A single
1585
+ * transaction shares exactly one commit `ts` across every document it wrote (see
1586
+ * `postgres-docstore.ts`'s `write()`), so cutting a batch mid-group would apply a transaction's
1587
+ * writes partially, which `load_documents`'s `(after, cap]`-shaped next-tick range would then
1588
+ * never revisit (the excluded rows share `ts === cap`, which the next tick's range excludes as
1589
+ * its own lower bound).
1590
+ *
1591
+ * `TimestampRange` is `{minInclusive, maxExclusive}` (see `packages/docstore/src/types.ts`) —
1592
+ * the half-open opposite skew from the `(after, upTo]` shape this tailer needs, so the bounds
1593
+ * are translated with a `+1n` offset on both sides (safe: `ts` is a monotonic integer log
1594
+ * position, never a real number needing density guarantees).
1595
+ */
1596
+ async pullDocs(after, upTo) {
1597
+ const docs = [];
1598
+ let cappedAt = null;
1599
+ const gen = this.primary.load_documents({ minInclusive: after + 1n, maxExclusive: upTo + 1n }, "asc");
1600
+ for await (const entry of gen) {
1601
+ if (cappedAt !== null && entry.ts !== cappedAt) break;
1602
+ docs.push(entry);
1603
+ if (cappedAt === null && docs.length >= this.batchSize) cappedAt = entry.ts;
1604
+ }
1605
+ return { docs, cappedAt };
1606
+ }
1607
+ };
1608
+
1609
+ // src/ranges.ts
1610
+ import { keyToPointRange, docKeyToPointRange } from "@helipod/id-codec";
1611
+
1612
+ // src/node.ts
1613
+ var REPLICA_DB_FILENAME = "fleet-replica.db";
1614
+ var DEFAULT_LEASE_TTL_MS2 = 15e3;
1615
+ var DEFAULT_NUM_SHARDS2 = 8;
1616
+ var FRONTIER_BEAT_MS = 100;
1617
+ var FRONTIER_COALESCE_MS = 10;
1618
+ var FRONTIER_LAG_WARN_MS = 5e3;
1619
+ var FrontierMonitor = class {
1620
+ constructor(lease, opts) {
1621
+ this.lease = lease;
1622
+ this.opts = opts;
1623
+ this.minSinceMs = (opts.now ?? Date.now)();
1624
+ }
1625
+ lease;
1626
+ opts;
1627
+ timer = null;
1628
+ coalesceTimer = null;
1629
+ stopped = false;
1630
+ running = false;
1631
+ cached = null;
1632
+ /** The last-observed `min(frontier_ts)` and the wall-clock time it was first seen — lag is the age
1633
+ * of the CURRENT min (how long it's been stuck), so both reset whenever min advances. */
1634
+ lastMin = null;
1635
+ minSinceMs;
1636
+ warned = false;
1637
+ /** D4 — replica-lag tracking: wall-clock time this node's OWN tailer watermark was first observed
1638
+ * BEHIND the fleet frontier F, or `null` while it's caught up. Mirrors `minSinceMs`/`lastMin`'s
1639
+ * "age of the current stuck condition" shape, but for the replica catch-up gap rather than F's own
1640
+ * advance. Reset the instant `wm` reaches `F` again — a transient blip re-arms silently. */
1641
+ replicaLagSinceMs = null;
1642
+ replicaLagWarned = false;
1643
+ get now() {
1644
+ return this.opts.now ?? Date.now;
1645
+ }
1646
+ start() {
1647
+ if (this.stopped || this.timer !== null) return;
1648
+ void this.beat();
1649
+ this.timer = setInterval(() => void this.beat(), this.opts.beatMs ?? FRONTIER_BEAT_MS);
1650
+ }
1651
+ /** Schedule a single coalesced beat `coalesceMs` out — called on each local commit so an idle
1652
+ * sibling shard un-pins F within ~10ms instead of waiting out the periodic beat. Multiple commits
1653
+ * inside the window collapse onto one beat. */
1654
+ triggerCoalesced() {
1655
+ if (this.stopped || this.coalesceTimer !== null) return;
1656
+ this.coalesceTimer = setTimeout(() => {
1657
+ this.coalesceTimer = null;
1658
+ void this.beat();
1659
+ }, this.opts.coalesceMs ?? FRONTIER_COALESCE_MS);
1660
+ }
1661
+ async beat() {
1662
+ if (this.stopped || this.running) return;
1663
+ this.running = true;
1664
+ try {
1665
+ if (this.opts.closeIdle) {
1666
+ if (!this.opts.runExclusiveOnShard) {
1667
+ throw new Error("fleet: FrontierMonitor closeIdle requires a runExclusiveOnShard seam (bug)");
1668
+ }
1669
+ const ceiling = await this.lease.closeIdleFrontiers(this.opts.runExclusiveOnShard);
1670
+ await this.lease.bumpOrphanFrontiers(ceiling);
1671
+ }
1672
+ const rows = await this.lease.readAllFrontiers();
1673
+ if (rows.length === 0) return;
1674
+ const min = rows[0].frontierTs;
1675
+ const pinningShard = rows[0].shardId;
1676
+ const nowMs = this.now();
1677
+ const minBefore = this.lastMin;
1678
+ if (this.lastMin === null || min > this.lastMin) {
1679
+ this.lastMin = min;
1680
+ this.minSinceMs = nowMs;
1681
+ this.warned = false;
1682
+ if (this.opts.closeIdle && minBefore !== null && this.opts.notifyOnAdvance) {
1683
+ try {
1684
+ this.opts.notifyOnAdvance(min);
1685
+ } catch {
1686
+ }
1687
+ }
1688
+ }
1689
+ const lagMs = nowMs - this.minSinceMs;
1690
+ this.cached = { frontier: min, lagMs, pinningShard };
1691
+ const lagWarnMs = this.opts.lagWarnMs ?? FRONTIER_LAG_WARN_MS;
1692
+ if (lagMs > lagWarnMs && !this.warned) {
1693
+ this.warned = true;
1694
+ (this.opts.warn ?? ((m) => console.warn(m)))(
1695
+ `fleet: frontier stuck for ${lagMs}ms at ${min} \u2014 shard '${pinningShard}' is pinning F`
1696
+ );
1697
+ }
1698
+ if (this.opts.tailerWatermark) {
1699
+ const wm = this.opts.tailerWatermark();
1700
+ if (wm < min) {
1701
+ if (this.replicaLagSinceMs === null) this.replicaLagSinceMs = nowMs;
1702
+ const replicaLagMs = nowMs - this.replicaLagSinceMs;
1703
+ if (replicaLagMs > lagWarnMs && !this.replicaLagWarned) {
1704
+ this.replicaLagWarned = true;
1705
+ (this.opts.warn ?? ((m) => console.warn(m)))(
1706
+ `fleet: replica for '${this.lease.advertiseUrl}' lagging ${replicaLagMs}ms behind frontier ${min} (watermark ${wm})`
1707
+ );
1708
+ }
1709
+ } else {
1710
+ this.replicaLagSinceMs = null;
1711
+ this.replicaLagWarned = false;
1712
+ }
1713
+ }
1714
+ } catch {
1715
+ } finally {
1716
+ this.running = false;
1717
+ }
1718
+ }
1719
+ /** The most recent frontier reading, or null if no beat has completed yet. `lagMs` is recomputed
1720
+ * against the current clock so a caller between beats still sees a fresh age. */
1721
+ stats() {
1722
+ if (this.cached === null) return null;
1723
+ return { ...this.cached, lagMs: this.now() - this.minSinceMs };
1724
+ }
1725
+ stop() {
1726
+ this.stopped = true;
1727
+ if (this.timer !== null) {
1728
+ clearInterval(this.timer);
1729
+ this.timer = null;
1730
+ }
1731
+ if (this.coalesceTimer !== null) {
1732
+ clearTimeout(this.coalesceTimer);
1733
+ this.coalesceTimer = null;
1734
+ }
1735
+ }
1736
+ };
1737
+ function createAsyncChain() {
1738
+ let tail = Promise.resolve();
1739
+ return (op) => {
1740
+ const settled = tail.then(op, op);
1741
+ tail = settled.then(
1742
+ () => void 0,
1743
+ () => void 0
1744
+ );
1745
+ return settled;
1746
+ };
1747
+ }
1748
+ function fleetProbeMs(ttlMs) {
1749
+ return Math.max(1, Math.round(ttlMs / 3));
1750
+ }
1751
+ function fleetAcquireRetryMs(ttlMs) {
1752
+ return Math.max(1, Math.round(ttlMs * 2 / 15));
1753
+ }
1754
+ var FLEET_WRITER_SESSION_TIMEOUTS = { idleInTransactionMs: 5e3, statementMs: 1e4 };
1755
+ var FLEET_DEPLOYMENT_ID_KEY = "fleet:deploymentId";
1756
+ function deleteReplicaFile(path) {
1757
+ for (const suffix of ["", "-wal", "-shm"]) rmSync(path + suffix, { force: true });
1758
+ }
1759
+ async function documentsTableExists(client) {
1760
+ const rows = await client.query(`SELECT to_regclass('documents') IS NOT NULL AS present`);
1761
+ return rows[0]?.present === true;
1762
+ }
1763
+ function fleetApplicationName(advertiseUrl) {
1764
+ let discriminator = advertiseUrl;
1765
+ try {
1766
+ const port = new URL(advertiseUrl).port;
1767
+ if (port) discriminator = port;
1768
+ } catch {
1769
+ }
1770
+ return `helipod-fleet-${discriminator}`;
1771
+ }
1772
+ function replicaAdapter(path) {
1773
+ const isBun = typeof globalThis.Bun !== "undefined";
1774
+ return isBun ? new BunSqliteAdapter({ path }) : new NodeSqliteAdapter({ path });
1775
+ }
1776
+ function fleetMultiWriterEnabled() {
1777
+ return /^(1|true|yes)$/i.test(process.env.HELIPOD_FLEET_MULTI_WRITER ?? "");
1778
+ }
1779
+ function groupCommitEnabled() {
1780
+ return /^(1|true|yes)$/i.test(process.env.HELIPOD_GROUP_COMMIT ?? "");
1781
+ }
1782
+ async function openReplicaFile(replicaPath) {
1783
+ try {
1784
+ const replica = new SqliteDocStore(replicaAdapter(replicaPath));
1785
+ await replica.setupSchema();
1786
+ return replica;
1787
+ } catch (e) {
1788
+ console.warn(
1789
+ `fleet: local replica at ${replicaPath} failed to open (${e instanceof Error ? e.message : String(e)}) \u2014 deleting and rebuilding from the primary`
1790
+ );
1791
+ deleteReplicaFile(replicaPath);
1792
+ const replica = new SqliteDocStore(replicaAdapter(replicaPath));
1793
+ await replica.setupSchema();
1794
+ return replica;
1795
+ }
1796
+ }
1797
+ async function openSyncReplica(replicaPath) {
1798
+ const replica = await openReplicaFile(replicaPath);
1799
+ return { replica, switchable: new SwitchableDocStore(replica) };
1800
+ }
1801
+ async function reconcileReplicaIdentity(deps) {
1802
+ const { pgStore, replicaPath } = deps;
1803
+ let primaryId = await pgStore.getGlobal(FLEET_DEPLOYMENT_ID_KEY);
1804
+ if (primaryId === null) {
1805
+ await pgStore.writeGlobalIfAbsent(FLEET_DEPLOYMENT_ID_KEY, crypto.randomUUID());
1806
+ primaryId = await pgStore.getGlobal(FLEET_DEPLOYMENT_ID_KEY);
1807
+ }
1808
+ const replicaId = await deps.replica.getGlobal(FLEET_DEPLOYMENT_ID_KEY);
1809
+ if (replicaId === primaryId) {
1810
+ return { replica: deps.replica, switchable: deps.switchable };
1811
+ }
1812
+ const hasData = await deps.replica.maxTimestamp() > 0n;
1813
+ if (replicaId === null && !hasData) {
1814
+ await deps.replica.writeGlobalIfAbsent(FLEET_DEPLOYMENT_ID_KEY, primaryId);
1815
+ return { replica: deps.replica, switchable: deps.switchable };
1816
+ }
1817
+ console.warn(
1818
+ `fleet: local replica at ${replicaPath} does not match the primary's deployment id (foreign replica, or predates identity stamping) \u2014 deleting and rebuilding from the primary`
1819
+ );
1820
+ await deps.replica.close();
1821
+ deleteReplicaFile(replicaPath);
1822
+ const freshReplica = await openReplicaFile(replicaPath);
1823
+ await freshReplica.writeGlobalIfAbsent(FLEET_DEPLOYMENT_ID_KEY, primaryId);
1824
+ deps.switchable.swapTo(freshReplica);
1825
+ return { replica: freshReplica, switchable: deps.switchable };
1826
+ }
1827
+ async function prepareFleetNode(deps) {
1828
+ const leaseTtlMs = deps.leaseTtlMs ?? DEFAULT_LEASE_TTL_MS2;
1829
+ const numShards = deps.numShards ?? DEFAULT_NUM_SHARDS2;
1830
+ const shards = shardIdList2(numShards);
1831
+ const applicationName = fleetApplicationName(deps.advertiseUrl);
1832
+ const client = new NodePgClient({
1833
+ connectionString: deps.databaseUrl,
1834
+ applicationName,
1835
+ sessionTimeouts: FLEET_WRITER_SESSION_TIMEOUTS,
1836
+ // Per-shard commit-connection pool (B2a, D1): one dedicated connection per shard for `commitWrite`
1837
+ // transactions, so different shards' commits run as genuinely concurrent Postgres transactions
1838
+ // (the pinned connection keeps heartbeats/eviction/setup/queries + LISTEN). Each slot's advisory
1839
+ // lock is taken on its own connection, so a shard's connection death releases exactly that shard.
1840
+ commitPool: { shards }
1841
+ });
1842
+ const pgStore = new PostgresDocStore(client, { readOnly: true });
1843
+ const lease = new LeaseManager(client, {
1844
+ advertiseUrl: deps.advertiseUrl,
1845
+ applicationName,
1846
+ ttlMs: leaseTtlMs,
1847
+ retryMs: fleetAcquireRetryMs(leaseTtlMs)
1848
+ });
1849
+ const forwarder = new WriteForwarder(lease, { adminKey: deps.adminKey, selfUrl: deps.advertiseUrl });
1850
+ const fanoutAdapter = new NotifyingFanoutAdapter(new InMemoryWriteFanoutAdapter(), client);
1851
+ const documentsExist = await documentsTableExists(client);
1852
+ await lease.setup(shards, documentsExist);
1853
+ await lease.heartbeatPresence();
1854
+ const acquired = await lease.tryAcquire(DEFAULT_SHARD4, 0, documentsExist);
1855
+ const multiWriter = fleetMultiWriterEnabled();
1856
+ const replicaPath = join(deps.dataDir, REPLICA_DB_FILENAME);
1857
+ const beforeNotify = (commitTs) => forwarder.waitForReplica(commitTs);
1858
+ const groupCommit = groupCommitEnabled();
1859
+ const stablePrefix = async () => {
1860
+ const rows = await lease.readAllFrontiers();
1861
+ return rows.length > 0 ? rows[0].frontierTs : null;
1862
+ };
1863
+ if (acquired) {
1864
+ pgStore.setWritable();
1865
+ forwarder.promote();
1866
+ if (multiWriter) {
1867
+ const { replica: replica2, switchable: switchable2 } = await openSyncReplica(replicaPath);
1868
+ return {
1869
+ client,
1870
+ pgStore,
1871
+ replica: replica2,
1872
+ switchable: switchable2,
1873
+ replicaPath,
1874
+ lease,
1875
+ forwarder,
1876
+ role: "writer",
1877
+ numShards,
1878
+ runtimeOptions: {
1879
+ store: pgStore,
1880
+ writeRouter: forwarder,
1881
+ deferDrivers: false,
1882
+ fanoutAdapter,
1883
+ numShards,
1884
+ queryStore: switchable2,
1885
+ beforeNotify,
1886
+ groupCommit,
1887
+ stablePrefix,
1888
+ externalReceiptsGuard: true
1889
+ }
1890
+ };
1891
+ }
1892
+ return {
1893
+ client,
1894
+ pgStore,
1895
+ lease,
1896
+ forwarder,
1897
+ role: "writer",
1898
+ numShards,
1899
+ runtimeOptions: { store: pgStore, writeRouter: forwarder, deferDrivers: false, fanoutAdapter, numShards, groupCommit, stablePrefix, externalReceiptsGuard: true }
1900
+ };
1901
+ }
1902
+ const { replica, switchable } = await openSyncReplica(replicaPath);
1903
+ if (multiWriter) {
1904
+ return {
1905
+ client,
1906
+ pgStore,
1907
+ replica,
1908
+ switchable,
1909
+ replicaPath,
1910
+ lease,
1911
+ forwarder,
1912
+ role: "sync",
1913
+ numShards,
1914
+ runtimeOptions: {
1915
+ // `receiptsStore: pgStore` — the Connect handshake classifies/prunes against the PRIMARY, not
1916
+ // the replica (which carries no receipts). Redundant here (`store` IS `pgStore` for a hybrid),
1917
+ // but set explicitly so the sync-node invariant is uniform across both sync shapes.
1918
+ store: pgStore,
1919
+ writeRouter: forwarder,
1920
+ deferDrivers: true,
1921
+ fanoutAdapter,
1922
+ numShards,
1923
+ queryStore: switchable,
1924
+ receiptsStore: pgStore,
1925
+ beforeNotify,
1926
+ groupCommit,
1927
+ stablePrefix,
1928
+ externalReceiptsGuard: true
1929
+ }
1930
+ };
1931
+ }
1932
+ return {
1933
+ client,
1934
+ pgStore,
1935
+ replica,
1936
+ switchable,
1937
+ replicaPath,
1938
+ lease,
1939
+ forwarder,
1940
+ role: "sync",
1941
+ numShards,
1942
+ runtimeOptions: { store: switchable, writeRouter: forwarder, deferDrivers: true, fanoutAdapter, numShards, receiptsStore: pgStore, groupCommit, stablePrefix, externalReceiptsGuard: true }
1943
+ };
1944
+ }
1945
+ function defaultFleetExit(reason) {
1946
+ console.error(`fleet: ${reason} \u2014 exiting so this node restarts and rejoins as a sync replica`);
1947
+ process.exit(1);
1948
+ }
1949
+ async function runPromotion(deps) {
1950
+ try {
1951
+ await deps.promote();
1952
+ deps.startMonitor();
1953
+ deps.firePromoted();
1954
+ } catch (e) {
1955
+ deps.onExit(`promotion failed (${e instanceof Error ? e.message : String(e)})`);
1956
+ }
1957
+ }
1958
+ async function promoteFleetNode(deps) {
1959
+ deps.runtime.observeTimestamp(await deps.pgStore.maxTimestamp());
1960
+ deps.pgStore.setWritable();
1961
+ deps.switchable.swapTo(deps.pgStore);
1962
+ deps.forwarder.promote();
1963
+ await deps.tailer.stop();
1964
+ await deps.replica.close();
1965
+ await deps.runtime.startDrivers();
1966
+ }
1967
+ function installCommitGuard(pgStore, lease, onFenced) {
1968
+ return pgStore.addCommitGuard(async (q, units, shardId) => {
1969
+ const epoch = lease.currentEpoch(shardId);
1970
+ if (epoch === null) {
1971
+ onFenced(shardId, `commit guard invoked with no acquired epoch for shard '${shardId}'`);
1972
+ throw new FencedError(`commit fenced: this node has not acquired a shard_leases epoch for shard '${shardId}'`);
1973
+ }
1974
+ const tsN = units[units.length - 1].ts;
1975
+ const rows = await q.query(
1976
+ `UPDATE shard_leases SET prev_ts = frontier_ts, frontier_ts = GREATEST(frontier_ts, $1) WHERE shard_id = $2 AND epoch = $3 RETURNING epoch`,
1977
+ [tsN, shardId, epoch]
1978
+ );
1979
+ if (rows.length === 0) {
1980
+ onFenced(shardId, `commit guard found 0 rows for shard '${shardId}' epoch ${epoch} \u2014 superseded by another writer`);
1981
+ throw new FencedError(`commit fenced: epoch no longer current for shard '${shardId}'`);
1982
+ }
1983
+ for (let i = 0; i < units.length; i++) {
1984
+ const unit = units[i];
1985
+ if (unit.meta?.idempotencyKey) {
1986
+ try {
1987
+ await q.query(`INSERT INTO fleet_idempotency (key, commit_ts) VALUES ($1, $2)`, [
1988
+ unit.meta.idempotencyKey,
1989
+ unit.ts
1990
+ ]);
1991
+ } catch (e) {
1992
+ if (e.code === "23505") {
1993
+ throw new CommitGuardRejection(i, "FLEET_IDEMPOTENCY_CONFLICT", `key=${unit.meta.idempotencyKey}`, {
1994
+ cause: e
1995
+ });
1996
+ }
1997
+ throw e;
1998
+ }
1999
+ }
2000
+ }
2001
+ });
2002
+ }
2003
+ function relinquish(deps, shardId, reason, opts = {}) {
2004
+ if (deps.lease.currentEpoch(shardId) === null) return;
2005
+ deps.lease.forgetShard(shardId);
2006
+ const log = deps.log ?? ((m) => console.error(m));
2007
+ log(
2008
+ `fleet: relinquish shard='${shardId}' reason='${reason}'` + (opts.connectionLost ? " (via commit-connection loss \u2014 its slot lock is already gone)" : "")
2009
+ );
2010
+ if (shardId === DEFAULT_SHARD4) {
2011
+ log(`fleet: relinquished the default shard \u2014 stopping drivers (scheduler/workflow/cron/reaper)`);
2012
+ deps.onDefaultRelinquished?.();
2013
+ }
2014
+ if (opts.connectionLost) return;
2015
+ const slot = deps.shards.indexOf(shardId);
2016
+ if (slot < 0 || !deps.client.releaseShardLock) return;
2017
+ void deps.client.releaseShardLock(slot).catch((e) => {
2018
+ log(
2019
+ `fleet: releaseShardLock(${slot}) for shard '${shardId}' failed: ${e instanceof Error ? e.message : String(e)}`
2020
+ );
2021
+ });
2022
+ }
2023
+ async function acquireShardAsWriter(lease, shardId, slot, retryMs, seedFrontierFromDocuments = false) {
2024
+ const deadline = Date.now() + Math.max(1, retryMs) * 75;
2025
+ for (; ; ) {
2026
+ const state = await lease.tryAcquire(shardId, slot, seedFrontierFromDocuments);
2027
+ if (state) return;
2028
+ if (await lease.isExpired(shardId)) {
2029
+ const { fenced, oldAppName } = await lease.evictExpired(shardId);
2030
+ if (fenced && oldAppName !== null) await lease.terminateBackend(oldAppName);
2031
+ }
2032
+ if (Date.now() > deadline) {
2033
+ throw new Error(`fleet: could not acquire shard '${shardId}' (slot ${slot}) lease within the deadline`);
2034
+ }
2035
+ await new Promise((r) => setTimeout(r, Math.max(1, retryMs)));
2036
+ }
2037
+ }
2038
+ function deriveFlushesPerSec(prevReadMs, prevFlushCount, nowMs, nowFlushCount) {
2039
+ if (prevReadMs === null || nowMs <= prevReadMs) return 0;
2040
+ return Math.max(0, (nowFlushCount - prevFlushCount) / ((nowMs - prevReadMs) / 1e3));
2041
+ }
2042
+ async function startFleetNode(deps) {
2043
+ const { client, pgStore, runtime, lease, forwarder, switchable, replicaPath } = deps;
2044
+ const numShards = deps.numShards ?? 1;
2045
+ const shards = shardIdList2(numShards);
2046
+ let replica = deps.replica;
2047
+ const onExit = deps.onExit ?? defaultFleetExit;
2048
+ const promotedCbs = [];
2049
+ let frontierMonitor = null;
2050
+ let unsubscribeCommits = null;
2051
+ let unregisterCommitGuard = null;
2052
+ let unregisterReceiptsGuard = null;
2053
+ let lastGroupCommitReadMs = null;
2054
+ let lastFlushCount = 0;
2055
+ const groupCommitStats = () => {
2056
+ const stats = runtime.groupCommitStats();
2057
+ const now = Date.now();
2058
+ const flushesPerSec = deriveFlushesPerSec(lastGroupCommitReadMs, lastFlushCount, now, stats.flushCount);
2059
+ lastGroupCommitReadMs = now;
2060
+ lastFlushCount = stats.flushCount;
2061
+ return { ...stats, flushesPerSec };
2062
+ };
2063
+ const firePromoted = () => {
2064
+ for (const cb of promotedCbs) {
2065
+ try {
2066
+ cb();
2067
+ } catch {
2068
+ }
2069
+ }
2070
+ };
2071
+ let writerish = forwarder.isLocalWriter();
2072
+ let promoting = false;
2073
+ let doPromote = () => {
2074
+ };
2075
+ const chainDriverOp = createAsyncChain();
2076
+ const startDriversChained = () => chainDriverOp(() => runtime.startDrivers());
2077
+ const stopDriversOnlyChained = () => chainDriverOp(() => runtime.stopDriversOnly());
2078
+ const relinquishDeps = {
2079
+ lease,
2080
+ client,
2081
+ shards,
2082
+ onDefaultRelinquished: () => void stopDriversOnlyChained()
2083
+ };
2084
+ const tryAcquireShard = async (shardId) => {
2085
+ const slot = shards.indexOf(shardId);
2086
+ if (slot < 0) return false;
2087
+ let state = await lease.tryAcquire(shardId, slot, true);
2088
+ if (!state && await lease.isExpired(shardId)) {
2089
+ const { fenced, oldAppName } = await lease.evictExpired(shardId);
2090
+ if (fenced && oldAppName !== null) await lease.terminateBackend(oldAppName);
2091
+ state = await lease.tryAcquire(shardId, slot, true);
2092
+ }
2093
+ if (!state) return false;
2094
+ runtime.observeWriteTimestamp(state.frontierTs);
2095
+ if (shardId === DEFAULT_SHARD4) void startDriversChained();
2096
+ return true;
2097
+ };
2098
+ const releaseShard = async (shardId) => {
2099
+ const fenced = await runtime.tryRunExclusiveOnShard(shardId, async () => {
2100
+ await lease.selfFence(shardId);
2101
+ });
2102
+ if (!fenced) return;
2103
+ relinquish(relinquishDeps, shardId, "balancer graceful release (no longer a rendezvous target)");
2104
+ };
2105
+ const multiWriter = fleetMultiWriterEnabled();
2106
+ const balancer = new ShardLeaseBalancer({
2107
+ lease,
2108
+ myUrl: lease.advertiseUrl,
2109
+ numShards,
2110
+ multiWriter,
2111
+ isHeld: (shardId) => lease.currentEpoch(shardId) !== null,
2112
+ isWriterish: () => writerish,
2113
+ tryAcquireShard,
2114
+ releaseShard,
2115
+ requestPromotion: async () => {
2116
+ doPromote();
2117
+ },
2118
+ // Fleet B3, D3: reclaim expired `fleet_idempotency` rows on every writer-ish beat (cheap indexed
2119
+ // delete) — see `LeaseManager.sweepIdempotency`. The balancer runs `sweepIdempotency` only while
2120
+ // `isWriterish()`; a pure sync node holds no writer-side state to sweep.
2121
+ sweepIdempotency: () => lease.sweepIdempotency(),
2122
+ beatMs: fleetAcquireRetryMs(lease.ttlMs)
2123
+ });
2124
+ const invalidationSink = async (inv) => {
2125
+ try {
2126
+ runtime.observeTimestamp(inv.newMaxTs);
2127
+ const ranges = [
2128
+ ...inv.writtenKeys.map((k) => keyToPointRange(k.indexId, k.key)),
2129
+ ...inv.writtenDocs.map((d) => docKeyToPointRange(d.tableId, d.internalId))
2130
+ ];
2131
+ const commitTs = Number(inv.newMaxTs);
2132
+ await runtime.handler.notifyWrites({
2133
+ tables: inv.writtenTables,
2134
+ ranges,
2135
+ commitTs
2136
+ });
2137
+ runtime.notifyExternalCommit({ tables: inv.writtenTables, ranges, commitTs });
2138
+ } catch (e) {
2139
+ console.error("fleet: replica invalidation failed", e);
2140
+ }
2141
+ };
2142
+ const startHybridTailer = async () => {
2143
+ if (!replica || !switchable || !replicaPath) {
2144
+ throw new Error(
2145
+ "fleet: hybrid boot requires a replica + switchable store + replicaPath (bug: prepareFleetNode multi-writer path must provide them)"
2146
+ );
2147
+ }
2148
+ await pgStore.setupSchema();
2149
+ const reconciled2 = await reconcileReplicaIdentity({ pgStore, replica, switchable, replicaPath });
2150
+ replica = reconciled2.replica;
2151
+ const t = new ReplicaTailer(client, pgStore, replica, { numShards, onInvalidation: invalidationSink });
2152
+ await t.start();
2153
+ forwarder.attachTailer(t);
2154
+ return t;
2155
+ };
2156
+ let hybridTailer = null;
2157
+ let monitor = null;
2158
+ const startWriterMonitor = () => {
2159
+ monitor = new LeaseMonitor({
2160
+ // Heartbeat-as-probe (Fenced Frontier B1, D2): one round-trip serves liveness-probe + TTL
2161
+ // maintenance + fence verification — NEVER pg_try_advisory_lock (re-entrant on the holding
2162
+ // session). `lease.currentEpoch()` is read live so a re-promotion's epoch bump is honored.
2163
+ probe: async () => {
2164
+ await lease.heartbeatPresence();
2165
+ if (lease.heldPairs().length === 0) return;
2166
+ const { fencedShardIds } = await lease.heartbeatAll();
2167
+ for (const fencedShardId of fencedShardIds) {
2168
+ relinquish(
2169
+ relinquishDeps,
2170
+ fencedShardId,
2171
+ "batched heartbeat found a superseded epoch"
2172
+ );
2173
+ }
2174
+ },
2175
+ onExit: (reason) => onExit(`writer lease lost: ${reason}`),
2176
+ // Probe cadence scales with the lease TTL (same knob the LeaseManager stamps expires_at from),
2177
+ // so a live writer always renews several times per TTL — a shortened TTL (e.g. the wedged-writer
2178
+ // E2E's 4000ms) must not let a HEALTHY writer's own lease expire between probes. At the default
2179
+ // 15000ms TTL this is exactly the historical 5000ms probe.
2180
+ probeMs: fleetProbeMs(lease.ttlMs)
2181
+ });
2182
+ monitor.start();
2183
+ };
2184
+ client.onConnectionLost?.(() => monitor?.connectionLost());
2185
+ client.onShardConnectionLost?.(
2186
+ (shardId) => relinquish(relinquishDeps, shardId, "commit connection lost", { connectionLost: true })
2187
+ );
2188
+ const armWriter = async (seed) => {
2189
+ await balancer.acquireTargetsNow();
2190
+ if (seed) await lease.seedFrontierAll(await pgStore.maxTimestamp());
2191
+ unregisterReceiptsGuard?.();
2192
+ const receiptsGuard = clientReceiptsGuard();
2193
+ unregisterReceiptsGuard = pgStore.addCommitGuard(async (q, units, shardId) => {
2194
+ await receiptsGuard(q, units, shardId);
2195
+ });
2196
+ unregisterCommitGuard?.();
2197
+ unregisterCommitGuard = installCommitGuard(
2198
+ pgStore,
2199
+ lease,
2200
+ (fencedShardId, reason) => relinquish(relinquishDeps, fencedShardId, reason)
2201
+ );
2202
+ const closeIdle = numShards > 1;
2203
+ frontierMonitor = new FrontierMonitor(lease, {
2204
+ closeIdle,
2205
+ runExclusiveOnShard: (shardId, fn) => runtime.tryRunExclusiveOnShard(shardId, fn),
2206
+ // D4 — replica-lag warning: a hybrid writer keeps a local read-replica tailer (`hybridTailer`,
2207
+ // already started above/on the prior sync boot by the time `armWriter` runs) even though it
2208
+ // commits to the primary — watch ITS watermark against F too. A non-hybrid writer has no
2209
+ // replica (reads the primary directly), so this is omitted there — nothing to lag.
2210
+ tailerWatermark: multiWriter ? () => hybridTailer.watermark() : void 0,
2211
+ // T3.5: wake every follower's LISTEN the instant an idle/orphan shard close (silently, by bare
2212
+ // UPDATE) actually un-pins F — reusing the SAME channel `NotifyingFanoutAdapter` NOTIFYs on a
2213
+ // real commit, so a `ReplicaTailer` armed on either wakes identically. Fire-and-forget +
2214
+ // swallow, exactly like `NotifyingFanoutAdapter.publish`'s own NOTIFY: a transient connection
2215
+ // hiccup here must not break this beat — the poll fallback remains the correctness backstop.
2216
+ notifyOnAdvance: (ceiling) => {
2217
+ void client.notify(COMMIT_CHANNEL, String(ceiling)).catch(() => {
2218
+ });
2219
+ }
2220
+ });
2221
+ frontierMonitor.start();
2222
+ if (closeIdle) {
2223
+ unsubscribeCommits = runtime.writeFanoutAdapter?.subscribe(() => frontierMonitor?.triggerCoalesced()) ?? null;
2224
+ }
2225
+ };
2226
+ if (forwarder.isLocalWriter()) {
2227
+ if (multiWriter) hybridTailer = await startHybridTailer();
2228
+ await armWriter(true);
2229
+ startWriterMonitor();
2230
+ balancer.start();
2231
+ await pgStore.writeGlobalIfAbsent(FLEET_DEPLOYMENT_ID_KEY, crypto.randomUUID());
2232
+ return {
2233
+ role: () => "writer",
2234
+ writerUrl: async () => (await lease.read())?.writerUrl ?? "",
2235
+ onPromoted: (cb) => promotedCbs.push(cb),
2236
+ frontierStats: () => frontierMonitor?.stats() ?? null,
2237
+ groupCommitStats,
2238
+ isLocalWriter: (shardId) => forwarder.isLocalWriter(shardId),
2239
+ idempotencyLookup: (key) => lease.lookupIdempotency(key),
2240
+ idempotencyRecordValue: (key, value) => lease.recordIdempotencyValue(key, value),
2241
+ stop: async () => {
2242
+ monitor?.stop();
2243
+ balancer.stop();
2244
+ frontierMonitor?.stop();
2245
+ unsubscribeCommits?.();
2246
+ unregisterCommitGuard?.();
2247
+ lease.stop();
2248
+ await hybridTailer?.stop();
2249
+ if (hybridTailer) await switchable?.close();
2250
+ }
2251
+ };
2252
+ }
2253
+ if (!replica || !switchable || !replicaPath) {
2254
+ throw new Error(
2255
+ "fleet: sync node start requires a replica + switchable store + replicaPath (bug: prepareFleetNode sync boot must provide them)"
2256
+ );
2257
+ }
2258
+ await pgStore.setupSchema();
2259
+ const reconciled = await reconcileReplicaIdentity({ pgStore, replica, switchable, replicaPath });
2260
+ replica = reconciled.replica;
2261
+ const tailer = new ReplicaTailer(client, pgStore, replica, {
2262
+ // B2a: the ready gate is F = min(frontier_ts) over all N shard rows, and the tailer refuses to
2263
+ // treat a partial `shard_leases` (count < numShards) as ready.
2264
+ numShards,
2265
+ // Same sink the writer-ish derive-only listener uses (D5/T5-c) — observe the ts + fan invalidation
2266
+ // into the sync handler; only the replica-apply (this tailer's default mode) differs.
2267
+ onInvalidation: invalidationSink
2268
+ });
2269
+ await tailer.start();
2270
+ forwarder.attachTailer(tailer);
2271
+ if (multiWriter) hybridTailer = tailer;
2272
+ frontierMonitor = new FrontierMonitor(lease, { closeIdle: false, tailerWatermark: () => tailer.watermark() });
2273
+ frontierMonitor.start();
2274
+ const triggerPromotion = () => {
2275
+ if (promoting) return;
2276
+ promoting = true;
2277
+ void runPromotion({
2278
+ // The lease row (default shard) is already upserted by the tryAcquire() inside acquireLoop when
2279
+ // the election path fires; the balancer path promotes first, then acquires its targets below.
2280
+ promote: async () => {
2281
+ if (multiWriter) {
2282
+ pgStore.setWritable();
2283
+ frontierMonitor?.stop();
2284
+ unsubscribeCommits?.();
2285
+ unsubscribeCommits = null;
2286
+ await armWriter(false);
2287
+ writerish = true;
2288
+ if (lease.currentEpoch(DEFAULT_SHARD4) === null) void stopDriversOnlyChained();
2289
+ return;
2290
+ }
2291
+ await promoteFleetNode({
2292
+ // D4: `startDrivers` routed through the SAME driver chain as every other call site, so this
2293
+ // promotion's step 7 (`await runtime.startDrivers()`) is ordered against any overlapping
2294
+ // `stopDriversOnly()`/`startDrivers()` fired from the balancer or a relinquish, rather than
2295
+ // racing the runtime's own bare call directly.
2296
+ runtime: { observeTimestamp: (ts) => runtime.observeTimestamp(ts), startDrivers: startDriversChained },
2297
+ pgStore,
2298
+ switchable,
2299
+ forwarder,
2300
+ tailer,
2301
+ replica
2302
+ });
2303
+ frontierMonitor?.stop();
2304
+ unsubscribeCommits?.();
2305
+ unsubscribeCommits = null;
2306
+ await armWriter(false);
2307
+ writerish = true;
2308
+ if (lease.currentEpoch(DEFAULT_SHARD4) === null) void stopDriversOnlyChained();
2309
+ },
2310
+ startMonitor: startWriterMonitor,
2311
+ firePromoted,
2312
+ onExit
2313
+ });
2314
+ };
2315
+ doPromote = triggerPromotion;
2316
+ lease.acquireLoop((state) => {
2317
+ runtime.observeWriteTimestamp(state.frontierTs);
2318
+ triggerPromotion();
2319
+ void startDriversChained();
2320
+ });
2321
+ balancer.start();
2322
+ return {
2323
+ role: () => forwarder.isLocalWriter() ? "writer" : "sync",
2324
+ writerUrl: async () => (await lease.read())?.writerUrl ?? "",
2325
+ onPromoted: (cb) => promotedCbs.push(cb),
2326
+ frontierStats: () => frontierMonitor?.stats() ?? null,
2327
+ groupCommitStats,
2328
+ isLocalWriter: (shardId) => forwarder.isLocalWriter(shardId),
2329
+ idempotencyLookup: (key) => lease.lookupIdempotency(key),
2330
+ idempotencyRecordValue: (key, value) => lease.recordIdempotencyValue(key, value),
2331
+ stop: async () => {
2332
+ monitor?.stop();
2333
+ balancer.stop();
2334
+ frontierMonitor?.stop();
2335
+ unsubscribeCommits?.();
2336
+ unregisterCommitGuard?.();
2337
+ lease.stop();
2338
+ await tailer.stop();
2339
+ if (multiWriter) await switchable?.close();
2340
+ await pgStore.close();
2341
+ }
2342
+ };
2343
+ }
2344
+
2345
+ // src/reshard.ts
2346
+ import { DEFAULT_SHARD as DEFAULT_SHARD5, shardIdList as shardIdList3 } from "@helipod/id-codec";
2347
+ var NUM_SHARDS_GLOBAL_KEY = "fleet:numShards";
2348
+ var ReshardFleetLiveError = class extends Error {
2349
+ liveUrls;
2350
+ constructor(liveUrls) {
2351
+ super(
2352
+ `refusing to reshard: ${liveUrls.length} node(s) still live: ${liveUrls.join(", ")} \u2014 scale the fleet to zero first`
2353
+ );
2354
+ this.name = "ReshardFleetLiveError";
2355
+ this.liveUrls = liveUrls;
2356
+ }
2357
+ };
2358
+ var ReshardVerificationError = class extends Error {
2359
+ constructor(message) {
2360
+ super(message);
2361
+ this.name = "ReshardVerificationError";
2362
+ }
2363
+ };
2364
+ var ReshardNotAFleetError = class extends Error {
2365
+ constructor() {
2366
+ super(
2367
+ "this database is not a fleet store (no shard_leases/fleet_nodes tables) \u2014 a fleet is initialized by `helipod serve --fleet`; run the fleet at least once before resharding"
2368
+ );
2369
+ this.name = "ReshardNotAFleetError";
2370
+ }
2371
+ };
2372
+ function toBigIntOrZero2(v) {
2373
+ if (v === null || v === void 0) return 0n;
2374
+ return typeof v === "bigint" ? v : BigInt(v);
2375
+ }
2376
+ async function documentsTableExists2(q) {
2377
+ const rows = await q.query(`SELECT to_regclass('documents') IS NOT NULL AS exists`);
2378
+ return rows[0]?.exists === true;
2379
+ }
2380
+ async function maxDocumentsTs(q) {
2381
+ if (!await documentsTableExists2(q)) return 0n;
2382
+ const rows = await q.query(`SELECT COALESCE(MAX(ts), 0) AS m FROM documents`);
2383
+ return toBigIntOrZero2(rows[0]?.m);
2384
+ }
2385
+ async function readNumShardsGlobal(q) {
2386
+ const rows = await q.query(`SELECT value FROM persistence_globals WHERE key = $1`, [NUM_SHARDS_GLOBAL_KEY]);
2387
+ const row = rows[0];
2388
+ if (!row) return null;
2389
+ return Number(JSON.parse(row.value));
2390
+ }
2391
+ function shardIdSet(rows) {
2392
+ return new Set(rows.map((r) => r.shard_id));
2393
+ }
2394
+ async function reshardFleet(client, opts) {
2395
+ const { targetShards } = opts;
2396
+ if (!Number.isInteger(targetShards) || targetShards < 1) {
2397
+ throw new RangeError(`reshardFleet: targetShards must be an integer >= 1, got ${targetShards}`);
2398
+ }
2399
+ const fleetProbe = await client.query(
2400
+ `SELECT to_regclass('shard_leases') IS NOT NULL AND to_regclass('fleet_nodes') IS NOT NULL AS is_fleet`
2401
+ );
2402
+ if (fleetProbe[0]?.is_fleet !== true) {
2403
+ throw new ReshardNotAFleetError();
2404
+ }
2405
+ const liveRows = await client.query(
2406
+ `SELECT advertise_url AS url FROM fleet_nodes WHERE expires_at >= now()
2407
+ UNION
2408
+ SELECT writer_url AS url FROM shard_leases WHERE writer_url IS NOT NULL AND expires_at >= now()`
2409
+ );
2410
+ const liveUrls = liveRows.map((r) => r.url).filter((u) => typeof u === "string" && u.length > 0);
2411
+ if (liveUrls.length > 0) throw new ReshardFleetLiveError(liveUrls);
2412
+ const laneRows = await client.query(`SELECT shard_id FROM shard_leases`);
2413
+ const currentLanes = shardIdSet(laneRows);
2414
+ const persistedNumShards = await readNumShardsGlobal(client);
2415
+ const previousShards = persistedNumShards ?? currentLanes.size;
2416
+ const target = shardIdList3(targetShards);
2417
+ const targetSet = new Set(target);
2418
+ const created = target.filter((id) => !currentLanes.has(id));
2419
+ const deleted = [...currentLanes].filter((id) => !targetSet.has(id));
2420
+ if (deleted.includes(DEFAULT_SHARD5)) {
2421
+ throw new ReshardVerificationError(`internal invariant violation: "${DEFAULT_SHARD5}" lane marked for deletion`);
2422
+ }
2423
+ await client.transaction(async (tx) => {
2424
+ const seedFragment = await documentsTableExists2(tx) ? `(SELECT COALESCE(MAX(ts), 0) FROM documents)` : `0`;
2425
+ await tx.query(
2426
+ `INSERT INTO persistence_globals (key, value) VALUES ($1, $2)
2427
+ ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
2428
+ [NUM_SHARDS_GLOBAL_KEY, JSON.stringify(String(targetShards))]
2429
+ );
2430
+ for (const shardId of created) {
2431
+ await tx.query(
2432
+ `INSERT INTO shard_leases (shard_id, epoch, writer_url, writer_app_name, expires_at, frontier_ts, prev_ts)
2433
+ VALUES ($1, 0, NULL, NULL, now(), ${seedFragment}, 0)`,
2434
+ [shardId]
2435
+ );
2436
+ }
2437
+ for (const shardId of deleted) {
2438
+ await tx.query(`DELETE FROM shard_leases WHERE shard_id = $1`, [shardId]);
2439
+ }
2440
+ await tx.query(`UPDATE shard_leases SET frontier_ts = GREATEST(frontier_ts, ${seedFragment})`);
2441
+ });
2442
+ const postLaneRows = await client.query(`SELECT shard_id, frontier_ts FROM shard_leases`);
2443
+ const postLaneIds = postLaneRows.map((r) => r.shard_id);
2444
+ const postSet = new Set(postLaneIds);
2445
+ const setMatches = postLaneIds.length === targetShards && target.every((id) => postSet.has(id));
2446
+ if (!setMatches) {
2447
+ throw new ReshardVerificationError(
2448
+ `reshard post-verify failed: expected shard_leases set ${JSON.stringify(target)}, got ${JSON.stringify(postLaneIds)}`
2449
+ );
2450
+ }
2451
+ const frontierValues = postLaneRows.map((r) => toBigIntOrZero2(r.frontier_ts));
2452
+ const minFrontier = frontierValues.reduce((a, b) => b < a ? b : a);
2453
+ const maxTs = await maxDocumentsTs(client);
2454
+ if (minFrontier < maxTs) {
2455
+ throw new ReshardVerificationError(
2456
+ `reshard post-verify failed: min(frontier_ts)=${minFrontier} < MAX(ts)=${maxTs} (the F1 floor)`
2457
+ );
2458
+ }
2459
+ const globalNumShards = await readNumShardsGlobal(client);
2460
+ if (globalNumShards !== targetShards) {
2461
+ throw new ReshardVerificationError(
2462
+ `reshard post-verify failed: fleet:numShards reads back ${globalNumShards}, expected ${targetShards}`
2463
+ );
2464
+ }
2465
+ return {
2466
+ previousShards,
2467
+ newShards: targetShards,
2468
+ created,
2469
+ deleted,
2470
+ frontierFloor: minFrontier.toString()
2471
+ };
2472
+ }
2473
+
2474
+ // src/index.ts
2475
+ var FLEET_VERSION = "0.0.0";
2476
+ export {
2477
+ DEFAULT_NUM_SHARDS2 as DEFAULT_NUM_SHARDS,
2478
+ DensityViolationError,
2479
+ FLEET_VERSION,
2480
+ FLEET_WRITER_SESSION_TIMEOUTS,
2481
+ FencedError,
2482
+ FrontierMonitor,
2483
+ IDEMPOTENCY_VALUE_CAP_BYTES,
2484
+ LeaseManager,
2485
+ LeaseMonitor,
2486
+ NUM_SHARDS_GLOBAL_KEY,
2487
+ NotifyingFanoutAdapter,
2488
+ REPLICA_DB_FILENAME,
2489
+ ReplicaTailer,
2490
+ ReshardFleetLiveError,
2491
+ ReshardNotAFleetError,
2492
+ ReshardVerificationError,
2493
+ ShardLeaseBalancer,
2494
+ SwitchableDocStore,
2495
+ WriteForwarder,
2496
+ acquireShardAsWriter,
2497
+ createAsyncChain,
2498
+ deriveFlushesPerSec,
2499
+ docKeyToPointRange,
2500
+ fleetApplicationName,
2501
+ fleetMultiWriterEnabled,
2502
+ groupCommitEnabled,
2503
+ installCommitGuard,
2504
+ keyToPointRange,
2505
+ prepareFleetNode,
2506
+ relinquish,
2507
+ rendezvousOwner,
2508
+ rendezvousWeight,
2509
+ reshardFleet,
2510
+ runPromotion,
2511
+ stablePrefixFromFrontier,
2512
+ startFleetNode
2513
+ };
2514
+ //# sourceMappingURL=index.js.map