@helipod/runtime-embedded 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,1143 @@
1
+ // src/runtime.ts
2
+ import { namespaceForPath } from "@helipod/component";
3
+ import { FunctionNotFoundError } from "@helipod/errors";
4
+ import { decodeStorageTableId, decodeDocumentId, encodeInternalDocumentId, shardIdForKeyValue, DEFAULT_SHARD } from "@helipod/id-codec";
5
+ import { writtenTablesFromRanges, serializeKeyRange } from "@helipod/index-key-codec";
6
+ import { jsonToConvex, convexToJson as convexToJson2 } from "@helipod/values";
7
+ import { MonotonicTimestampOracle } from "@helipod/docstore";
8
+ import { SingleWriterTransactor, ShardedTransactor } from "@helipod/transactor";
9
+ import { QueryRuntime } from "@helipod/query-engine";
10
+ import { InlineUdfExecutor, mutation } from "@helipod/executor";
11
+ import { SyncProtocolHandler } from "@helipod/sync";
12
+
13
+ // src/write-fanout.ts
14
+ var InMemoryWriteFanoutAdapter = class {
15
+ listeners = /* @__PURE__ */ new Set();
16
+ published = [];
17
+ publish(payload) {
18
+ this.published.push(payload);
19
+ for (const listener of this.listeners) listener(payload);
20
+ }
21
+ subscribe(listener) {
22
+ this.listeners.add(listener);
23
+ return () => this.listeners.delete(listener);
24
+ }
25
+ };
26
+ var EmbeddedWriteFanout = class {
27
+ constructor(adapter, originId) {
28
+ this.adapter = adapter;
29
+ this.originId = originId;
30
+ }
31
+ adapter;
32
+ originId;
33
+ publish(delta) {
34
+ this.adapter.publish({
35
+ commitTs: Number(delta.commitTs),
36
+ tables: delta.writtenTables,
37
+ ranges: delta.writtenRanges,
38
+ originId: this.originId,
39
+ origin: delta.origin,
40
+ shardId: delta.shardId,
41
+ writtenDocs: delta.writtenDocs
42
+ });
43
+ }
44
+ /** Subscribe to deltas from OTHER origins (ignores our own — the Tier 2 self-loop guard). */
45
+ subscribe(listener) {
46
+ return this.adapter.subscribe((payload) => {
47
+ if (payload.originId !== this.originId) listener(payload);
48
+ });
49
+ }
50
+ };
51
+
52
+ // src/loopback.ts
53
+ import { parseClientMessage } from "@helipod/sync";
54
+ var LoopbackServerSocket = class {
55
+ bufferedAmount = 0;
56
+ listeners = /* @__PURE__ */ new Set();
57
+ open = true;
58
+ send(data) {
59
+ if (!this.open) return;
60
+ const msg = JSON.parse(data);
61
+ for (const listener of this.listeners) listener(msg);
62
+ }
63
+ close() {
64
+ this.open = false;
65
+ }
66
+ addListener(listener) {
67
+ this.listeners.add(listener);
68
+ return () => this.listeners.delete(listener);
69
+ }
70
+ };
71
+ function createLoopbackConnection(handler, sessionId) {
72
+ const socket = new LoopbackServerSocket();
73
+ handler.connect(sessionId, socket);
74
+ return {
75
+ sessionId,
76
+ async send(message) {
77
+ const raw = typeof message === "string" ? message : JSON.stringify(message);
78
+ parseClientMessage(raw);
79
+ await handler.handleMessage(sessionId, raw);
80
+ },
81
+ onMessage(listener) {
82
+ return socket.addListener(listener);
83
+ },
84
+ close() {
85
+ socket.close();
86
+ handler.disconnect(sessionId);
87
+ }
88
+ };
89
+ }
90
+
91
+ // src/client-dedup.ts
92
+ import {
93
+ CommitGuardRejection,
94
+ isRetryableError,
95
+ isHelipodError
96
+ } from "@helipod/errors";
97
+ import { convexToJson } from "@helipod/values";
98
+ var DEDUP_META_CLIENT_ID = "clientId";
99
+ var DEDUP_META_SEQ = "seq";
100
+ var DEDUP_META_IDENTITY = "identity";
101
+ function identityKey(identity) {
102
+ return identity ?? "";
103
+ }
104
+ function replayFromRecord(rec) {
105
+ if (rec.verdict === "applied") {
106
+ return {
107
+ verdict: "applied",
108
+ commitTs: Number(rec.commitTs),
109
+ ...rec.hasValue ? { value: rec.value } : { valueMissing: true }
110
+ };
111
+ }
112
+ return { verdict: "failed", commitTs: Number(rec.commitTs), code: rec.errorCode ?? "MUTATION_FAILED" };
113
+ }
114
+ async function classifyDedup(store, identity, dedup) {
115
+ const id = identityKey(identity);
116
+ const rec = await store.getClientVerdict(id, dedup.clientId, dedup.seq);
117
+ if (rec) return replayFromRecord(rec);
118
+ const floor = await store.getClientFloor(id, dedup.clientId);
119
+ if (floor !== null && dedup.seq <= floor) return { verdict: "stale", code: "STALE_CLIENT" };
120
+ return null;
121
+ }
122
+ async function classifyForConnect(store, identity, clientId, seq) {
123
+ const replay = await classifyDedup(store, identity, { clientId, seq });
124
+ if (replay === null) return { clientId, seq, verdict: "unknown" };
125
+ return { clientId, seq, ...replay };
126
+ }
127
+ function dedupCommitMeta(identity, dedup, base) {
128
+ return {
129
+ ...base ?? {},
130
+ [DEDUP_META_IDENTITY]: identityKey(identity),
131
+ [DEDUP_META_CLIENT_ID]: dedup.clientId,
132
+ [DEDUP_META_SEQ]: String(dedup.seq)
133
+ };
134
+ }
135
+ async function handleDedupError(store, identity, dedup, e) {
136
+ const id = identityKey(identity);
137
+ if (e instanceof CommitGuardRejection && e.rejectionCode === "CLIENT_MUTATION_DUP") {
138
+ const rec = await store.getClientVerdict(id, dedup.clientId, dedup.seq);
139
+ return rec ? replayFromRecord(rec) : null;
140
+ }
141
+ if (!isRetryableError(e)) {
142
+ const code = isHelipodError(e) ? e.code : "MUTATION_FAILED";
143
+ await store.recordClientVerdict(id, dedup.clientId, dedup.seq, { verdict: "failed", commitTs: 0n, errorCode: code });
144
+ }
145
+ return null;
146
+ }
147
+ async function recordZeroWriteApplied(store, identity, dedup, commitTs, value) {
148
+ await store.recordClientVerdict(identityKey(identity), dedup.clientId, dedup.seq, {
149
+ verdict: "applied",
150
+ commitTs,
151
+ value: convexToJson(value)
152
+ });
153
+ }
154
+ async function fillWriteMutationValue(store, identity, dedup, value) {
155
+ try {
156
+ await store.updateClientVerdictValue(identityKey(identity), dedup.clientId, dedup.seq, convexToJson(value));
157
+ } catch {
158
+ }
159
+ }
160
+ function clientReceiptsGuard() {
161
+ const INSERT = `INSERT INTO client_mutations (identity, client_id, seq, verdict, commit_ts, value_json, error_code, created_at)`;
162
+ return (q, units) => {
163
+ const pg = q;
164
+ if (typeof pg.query === "function") {
165
+ return (async () => {
166
+ for (let i = 0; i < units.length; i++) {
167
+ const dedup = readUnitDedup(units[i]);
168
+ if (!dedup) continue;
169
+ try {
170
+ await pg.query(
171
+ `${INSERT} VALUES ($1, $2, $3, 'applied', $4, NULL, NULL, $5)`,
172
+ [dedup.identity, dedup.clientId, BigInt(dedup.seq), units[i].ts, BigInt(Date.now())]
173
+ );
174
+ } catch (e) {
175
+ throw toDupOrRethrow(e, i, dedup);
176
+ }
177
+ }
178
+ })();
179
+ }
180
+ const sq = q;
181
+ for (let i = 0; i < units.length; i++) {
182
+ const dedup = readUnitDedup(units[i]);
183
+ if (!dedup) continue;
184
+ try {
185
+ sq.run(
186
+ `${INSERT} VALUES (?, ?, ?, 'applied', ?, NULL, NULL, ?)`,
187
+ dedup.identity,
188
+ dedup.clientId,
189
+ dedup.seq,
190
+ units[i].ts,
191
+ Date.now()
192
+ );
193
+ } catch (e) {
194
+ throw toDupOrRethrow(e, i, dedup);
195
+ }
196
+ }
197
+ return void 0;
198
+ };
199
+ }
200
+ function readUnitDedup(unit) {
201
+ const meta = unit.meta;
202
+ const clientId = meta?.[DEDUP_META_CLIENT_ID];
203
+ const seqStr = meta?.[DEDUP_META_SEQ];
204
+ if (clientId === void 0 || seqStr === void 0) return null;
205
+ return { identity: meta[DEDUP_META_IDENTITY] ?? "", clientId, seq: Number(seqStr) };
206
+ }
207
+ function toDupOrRethrow(e, unitIndex, dedup) {
208
+ const code = String(e.code ?? "");
209
+ const message = String(e?.message ?? "");
210
+ const isUniqueViolation = code === "23505" || code.startsWith("SQLITE_CONSTRAINT") || /unique constraint failed/i.test(message);
211
+ if (isUniqueViolation) {
212
+ return new CommitGuardRejection(unitIndex, "CLIENT_MUTATION_DUP", `client=${dedup.clientId} seq=${dedup.seq}`, {
213
+ cause: e
214
+ });
215
+ }
216
+ return e;
217
+ }
218
+
219
+ // src/runtime.ts
220
+ var DEPLOYMENT_ID_GLOBAL_KEY = "fleet:deploymentId";
221
+ function isInternalPath(path) {
222
+ return path.split(":").some((seg) => seg.startsWith("_"));
223
+ }
224
+ function rebuildTableNumberToName(map, tableNumbers) {
225
+ map.clear();
226
+ for (const [name, num] of Object.entries(tableNumbers)) map.set(num, name);
227
+ }
228
+ function replayToRunResult(replay) {
229
+ const out = { replayed: true, verdict: replay.verdict };
230
+ if (replay.commitTs !== void 0) out.commitTs = replay.commitTs;
231
+ if (replay.value !== void 0) out.value = jsonToConvex(replay.value);
232
+ if (replay.valueMissing) out.valueMissing = true;
233
+ if (replay.code !== void 0) out.code = replay.code;
234
+ return out;
235
+ }
236
+ function translateTableIds(tableIds, tableNumberToName) {
237
+ return tableIds.map((id) => {
238
+ try {
239
+ return tableNumberToName.get(decodeStorageTableId(id)) ?? id;
240
+ } catch {
241
+ return id;
242
+ }
243
+ });
244
+ }
245
+ function fireCommitSubs(commitSubs, inv) {
246
+ for (const cb of commitSubs) {
247
+ try {
248
+ cb(inv);
249
+ } catch (e) {
250
+ console.error("[runtime] driver onCommit callback threw:", e);
251
+ }
252
+ }
253
+ }
254
+ var EmbeddedRuntime = class _EmbeddedRuntime {
255
+ constructor(store, executor, handler, writeFanoutAdapter, modules, systemModules, adminModules, componentNames, contextProviders, policyRegistry, policyProviders, relationRegistry, drivers, timers, fireDue, tableNumberToName, oracle, queryOracle, transactor, driverCtx, driversStarted, writeRouter, numShards, catalog, commitSubs) {
256
+ this.store = store;
257
+ this.executor = executor;
258
+ this.handler = handler;
259
+ this.writeFanoutAdapter = writeFanoutAdapter;
260
+ this.modules = modules;
261
+ this.systemModules = systemModules;
262
+ this.adminModules = adminModules;
263
+ this.componentNames = componentNames;
264
+ this.contextProviders = contextProviders;
265
+ this.policyRegistry = policyRegistry;
266
+ this.policyProviders = policyProviders;
267
+ this.relationRegistry = relationRegistry;
268
+ this.drivers = drivers;
269
+ this.timers = timers;
270
+ this.fireDue = fireDue;
271
+ this.tableNumberToName = tableNumberToName;
272
+ this.oracle = oracle;
273
+ this.queryOracle = queryOracle;
274
+ this.transactor = transactor;
275
+ this.driverCtx = driverCtx;
276
+ this.driversStarted = driversStarted;
277
+ this.writeRouter = writeRouter;
278
+ this.numShards = numShards;
279
+ this.catalog = catalog;
280
+ this.commitSubs = commitSubs;
281
+ }
282
+ store;
283
+ executor;
284
+ handler;
285
+ writeFanoutAdapter;
286
+ modules;
287
+ systemModules;
288
+ adminModules;
289
+ componentNames;
290
+ contextProviders;
291
+ policyRegistry;
292
+ policyProviders;
293
+ relationRegistry;
294
+ drivers;
295
+ timers;
296
+ fireDue;
297
+ tableNumberToName;
298
+ oracle;
299
+ queryOracle;
300
+ transactor;
301
+ driverCtx;
302
+ driversStarted;
303
+ writeRouter;
304
+ numShards;
305
+ catalog;
306
+ commitSubs;
307
+ sessionCounter = 0;
308
+ static async create(options) {
309
+ await options.store.setupSchema();
310
+ if (!options.externalReceiptsGuard) options.store.addCommitGuard(clientReceiptsGuard());
311
+ let deploymentId = await options.store.getGlobal(DEPLOYMENT_ID_GLOBAL_KEY);
312
+ if (deploymentId === null) {
313
+ try {
314
+ await options.store.writeGlobalIfAbsent(DEPLOYMENT_ID_GLOBAL_KEY, crypto.randomUUID());
315
+ deploymentId = await options.store.getGlobal(DEPLOYMENT_ID_GLOBAL_KEY);
316
+ } catch {
317
+ deploymentId = null;
318
+ }
319
+ }
320
+ const resolvedDeploymentId = deploymentId ?? "";
321
+ if (options.queryStore) await options.queryStore.setupSchema();
322
+ const adapter = options.fanoutAdapter ?? new InMemoryWriteFanoutAdapter();
323
+ const fanout = new EmbeddedWriteFanout(adapter, options.originId ?? "embedded");
324
+ const startTs = await options.store.maxTimestamp();
325
+ let transactor;
326
+ let oracle;
327
+ if ((options.numShards ?? 1) > 1) {
328
+ const sharded = new ShardedTransactor(options.store, { fanout, groupCommit: options.groupCommit ?? false });
329
+ sharded.observeTimestamp(startTs);
330
+ transactor = sharded;
331
+ oracle = sharded;
332
+ } else {
333
+ const singleOracle = new MonotonicTimestampOracle(startTs);
334
+ transactor = new SingleWriterTransactor(options.store, singleOracle, { fanout, groupCommit: options.groupCommit ?? false });
335
+ oracle = singleOracle;
336
+ }
337
+ const queryRuntime = new QueryRuntime(options.store);
338
+ let queryPath;
339
+ let queryOracle;
340
+ if (options.queryStore) {
341
+ const queryStartTs = await options.queryStore.maxTimestamp();
342
+ const qOracle = new MonotonicTimestampOracle(queryStartTs);
343
+ const queryTransactor = new SingleWriterTransactor(options.queryStore, qOracle);
344
+ queryPath = { transactor: queryTransactor, queryRuntime: new QueryRuntime(options.queryStore) };
345
+ queryOracle = qOracle;
346
+ }
347
+ const numShards = options.numShards ?? 1;
348
+ let executorRef;
349
+ const invoke = async (path, args, opts) => {
350
+ const fn = modules[path];
351
+ if (!fn) throw new FunctionNotFoundError(`unknown function: ${path}`);
352
+ return executorRef.run(fn, jsonToConvex(args), {
353
+ path,
354
+ namespace: namespaceForPath(path, componentNames),
355
+ contextProviders,
356
+ policyRegistry,
357
+ policyProviders,
358
+ relationRegistry,
359
+ functionKind,
360
+ identity: opts?.identity ?? null,
361
+ numShards
362
+ });
363
+ };
364
+ const executor = new InlineUdfExecutor({ transactor, queryRuntime, catalog: options.catalog, logSink: options.logSink, now: options.now, invoke, writeRouter: options.writeRouter, queryPath, globalStore: options.globalStore });
365
+ executorRef = executor;
366
+ for (const step of options.bootSteps ?? []) {
367
+ const bootFn = mutation(async (ctx) => {
368
+ await step.run({ db: ctx.db, now: ctx.now() });
369
+ return null;
370
+ });
371
+ await executor.run(bootFn, {}, { path: `_boot:${step.name}`, namespace: step.name, identity: null, numShards, localOnly: true });
372
+ }
373
+ const componentNames = options.componentNames ?? /* @__PURE__ */ new Set();
374
+ const contextProviders = options.contextProviders ?? [];
375
+ const policyRegistry = options.policyRegistry ?? /* @__PURE__ */ new Map();
376
+ const policyProviders = options.policyProviders ?? [];
377
+ const relationRegistry = options.relationRegistry;
378
+ const modules = { ...options.modules };
379
+ const systemModules = { ...options.systemModules ?? {} };
380
+ const adminModules = { ...options.adminModules ?? {} };
381
+ const functionKind = (path) => modules[path]?.type;
382
+ const resolve = (path) => {
383
+ if (isInternalPath(path)) throw new FunctionNotFoundError(`unknown function: ${path}`);
384
+ const fn = modules[path];
385
+ if (!fn) throw new FunctionNotFoundError(`unknown function: ${path}`);
386
+ return fn;
387
+ };
388
+ const syncExecutor = {
389
+ async runQuery(path, args, identity) {
390
+ const r = await executor.run(resolve(path), jsonToConvex(args), { path, namespace: namespaceForPath(path, componentNames), contextProviders, policyRegistry, policyProviders, relationRegistry, functionKind, identity: identity ?? null, numShards });
391
+ return {
392
+ value: r.value,
393
+ tables: writtenTablesFromRanges(r.readRanges),
394
+ readRanges: r.readRanges.map(serializeKeyRange),
395
+ globalTables: r.globalTables ?? [],
396
+ ...r.diffableRange ? { diffableRange: r.diffableRange } : {},
397
+ ...r.diffablePage ? { diffablePage: r.diffablePage } : {}
398
+ };
399
+ },
400
+ async runMutation(path, args, identity, origin, dedup) {
401
+ const fn = resolve(path);
402
+ const willForward = dedup !== void 0 && !!options.writeRouter && !options.writeRouter.isLocalWriter(DEFAULT_SHARD);
403
+ const classifyLocally = dedup !== void 0 && !willForward;
404
+ if (classifyLocally) {
405
+ const pre = await classifyDedup(options.store, identity ?? null, dedup);
406
+ if (pre) return replayToRunResult(pre);
407
+ }
408
+ const runOpts = {
409
+ path,
410
+ namespace: namespaceForPath(path, componentNames),
411
+ contextProviders,
412
+ policyRegistry,
413
+ policyProviders,
414
+ relationRegistry,
415
+ functionKind,
416
+ identity: identity ?? null,
417
+ numShards,
418
+ origin,
419
+ dedup,
420
+ commitMeta: classifyLocally ? dedupCommitMeta(identity ?? null, dedup) : void 0
421
+ };
422
+ let r;
423
+ try {
424
+ r = await executor.run(fn, jsonToConvex(args), runOpts);
425
+ } catch (e) {
426
+ if (classifyLocally) {
427
+ const replay = await handleDedupError(options.store, identity ?? null, dedup, e);
428
+ if (replay) return replayToRunResult(replay);
429
+ }
430
+ throw e;
431
+ }
432
+ if (r.clientReplay) return replayToRunResult(r.clientReplay);
433
+ if (classifyLocally && !r.committed) {
434
+ await recordZeroWriteApplied(options.store, identity ?? null, dedup, r.commitTs, r.value);
435
+ } else if (classifyLocally && r.oplog !== null) {
436
+ await fillWriteMutationValue(options.store, identity ?? null, dedup, r.value);
437
+ }
438
+ return {
439
+ replayed: false,
440
+ value: r.value,
441
+ tables: r.oplog?.writtenTables ?? [],
442
+ writeRanges: r.oplog?.writtenRanges ?? [],
443
+ // `commitTs` (B2b, T2): a LOCAL commit reports it via `r.oplog.commitTs`; a FORWARDED
444
+ // mutation has no local oplog (null), but the executor's forward branch already threads
445
+ // the owner's real commitTs onto `r.commitTs` itself — fall back to that.
446
+ commitTs: Number(r.oplog?.commitTs ?? r.commitTs ?? 0n),
447
+ // G4 fleet fallback signal: a committed write with NO local oplog was forwarded to another
448
+ // shard's owner — its origin tag can't reach this node's `doNotifyWrites`.
449
+ forwarded: r.committed && r.oplog === null
450
+ };
451
+ },
452
+ async runAdminQuery(path, args) {
453
+ const fn = adminModules[path];
454
+ if (!fn) throw new Error(`unknown admin function: ${path}`);
455
+ const r = await executor.run(fn, jsonToConvex(args), { path, privileged: true, numShards });
456
+ return {
457
+ value: r.value,
458
+ tables: writtenTablesFromRanges(r.readRanges),
459
+ readRanges: r.readRanges.map(serializeKeyRange),
460
+ globalTables: r.globalTables ?? [],
461
+ ...r.diffableRange ? { diffableRange: r.diffableRange } : {},
462
+ ...r.diffablePage ? { diffablePage: r.diffablePage } : {}
463
+ };
464
+ },
465
+ async runAction(path, args, identity) {
466
+ const fn = resolve(path);
467
+ if (fn.type !== "action") throw new Error(`${path} is not an action`);
468
+ if (options.writeRouter && !options.writeRouter.isLocalWriter(DEFAULT_SHARD)) {
469
+ const res = await options.writeRouter.forward("action", path, args, identity ?? null, DEFAULT_SHARD);
470
+ return { value: jsonToConvex(res.value) };
471
+ }
472
+ const r = await executor.run(fn, jsonToConvex(args), { path, namespace: namespaceForPath(path, componentNames), contextProviders, policyRegistry, policyProviders, relationRegistry, functionKind, identity: identity ?? null, numShards });
473
+ return { value: r.value };
474
+ },
475
+ // ── Receipted Outbox resume-handshake support (verdict §(e)) ──────────────────────────────
476
+ // Both reads/writes go to the AUTHORITATIVE receipts store (the primary), NOT `options.store`:
477
+ // on a fleet sync node the latter is the replica, which carries no receipts (see
478
+ // `receiptsStore`'s doc comment). Unset → `options.store`, byte-identical off the fleet.
479
+ async classifyClientMutation(identity, clientId, seq) {
480
+ return classifyForConnect(options.receiptsStore ?? options.store, identity ?? null, clientId, seq);
481
+ },
482
+ async pruneClientMutations(identity, clientId, ackedThrough) {
483
+ await (options.receiptsStore ?? options.store).pruneClientMutations(identity ?? "", clientId, { ackedThrough });
484
+ },
485
+ deploymentId() {
486
+ return resolvedDeploymentId;
487
+ }
488
+ };
489
+ const handler = new SyncProtocolHandler(syncExecutor, {
490
+ autoNotifyOnMutation: false,
491
+ verifyAdmin: options.verifyAdmin,
492
+ // The DO host (Slice 3) sets this so the handler arms no `setInterval` sweep / ping heartbeat;
493
+ // unset everywhere else (byte-identical to before this option existed).
494
+ disableBackgroundTimers: options.disableSyncBackgroundTimers
495
+ });
496
+ const queue = [];
497
+ let draining = false;
498
+ const drain = async () => {
499
+ if (draining) return;
500
+ draining = true;
501
+ try {
502
+ while (queue.length > 0) {
503
+ const inv = queue.shift();
504
+ if (options.beforeNotify) {
505
+ try {
506
+ await options.beforeNotify(BigInt(inv.commitTs));
507
+ } catch (e) {
508
+ console.error("[runtime] beforeNotify hook threw:", e);
509
+ }
510
+ }
511
+ await handler.notifyWrites(inv, inv.origin);
512
+ }
513
+ } finally {
514
+ draining = false;
515
+ }
516
+ };
517
+ const commitSubs = /* @__PURE__ */ new Set();
518
+ const timers = /* @__PURE__ */ new Map();
519
+ let timerSeq = 0;
520
+ const wakeHost = options.wakeHost;
521
+ let armedAt = null;
522
+ const rearm = () => {
523
+ if (!wakeHost) return;
524
+ let min = null;
525
+ for (const t of timers.values()) if (min === null || t.atMs < min) min = t.atMs;
526
+ if (min === armedAt) return;
527
+ armedAt = min;
528
+ wakeHost.armWake(min);
529
+ };
530
+ const fireDueTimers = () => {
531
+ const t0 = options.now?.() ?? Date.now();
532
+ armedAt = null;
533
+ const due = [];
534
+ for (const [h, t] of timers) {
535
+ if (t.atMs <= t0) {
536
+ due.push(t);
537
+ timers.delete(h);
538
+ }
539
+ }
540
+ try {
541
+ for (const t of due) {
542
+ try {
543
+ t.cb();
544
+ } catch (e) {
545
+ console.error("[runtime] driver timer callback threw:", e);
546
+ }
547
+ }
548
+ } finally {
549
+ rearm();
550
+ }
551
+ };
552
+ const backstopMs = options.backstopMs ?? ((d) => d);
553
+ const driverCtx = {
554
+ runFunction: async (path, args) => {
555
+ const fn = modules[path];
556
+ if (!fn) throw new Error(`driver: unknown function ${path}`);
557
+ const ns = namespaceForPath(path, componentNames);
558
+ const res = await executor.run(fn, jsonToConvex(args), {
559
+ path,
560
+ namespace: ns,
561
+ contextProviders,
562
+ policyRegistry,
563
+ policyProviders,
564
+ relationRegistry,
565
+ functionKind,
566
+ identity: null,
567
+ privileged: true,
568
+ numShards,
569
+ // A driver runs on the writer that owns its control tables and must read-its-own-writes:
570
+ // force its queries onto the PRIMARY, never a hybrid node's lagging replica queryPath, so
571
+ // a just-enqueued scheduler job is visible on the very next peek. No-op off a hybrid.
572
+ primaryRead: true
573
+ });
574
+ return res.value;
575
+ },
576
+ onCommit: (cb) => {
577
+ commitSubs.add(cb);
578
+ return () => commitSubs.delete(cb);
579
+ },
580
+ setTimer: (atMs, cb) => {
581
+ const h = ++timerSeq;
582
+ const t = { atMs, cb };
583
+ if (!wakeHost) {
584
+ t.timeout = setTimeout(() => {
585
+ timers.delete(h);
586
+ cb();
587
+ }, Math.max(0, atMs - (options.now?.() ?? Date.now())));
588
+ }
589
+ timers.set(h, t);
590
+ rearm();
591
+ return h;
592
+ },
593
+ clearTimer: (h) => {
594
+ const t = timers.get(h);
595
+ if (t) {
596
+ if (t.timeout !== void 0) clearTimeout(t.timeout);
597
+ timers.delete(h);
598
+ rearm();
599
+ }
600
+ },
601
+ now: () => options.now?.() ?? Date.now(),
602
+ backstopMs,
603
+ // Same resolver as `create()`'s local `functionKind` closure (used elsewhere for
604
+ // `ComponentContext.functionKind`) — reused here, not recomputed, so a driver's path
605
+ // validation (e.g. `@helipod/triggers`' boot-time handler check) and every other kind
606
+ // lookup in this runtime always agree.
607
+ functionKind,
608
+ readLog: async (opts) => {
609
+ const store = options.store;
610
+ const afterTs = BigInt(Math.trunc(opts.afterTs));
611
+ const stable = options.stablePrefix ? await options.stablePrefix() : null;
612
+ const bound = stable ?? await store.maxTimestamp();
613
+ if (bound <= afterTs) return { changes: [], maxScannedTs: Number(afterTs) };
614
+ if (opts.limit === 0) return { changes: [], maxScannedTs: Number(bound) };
615
+ const limit = opts.limit;
616
+ const scanned = [];
617
+ for await (const e of store.load_documents(
618
+ { minInclusive: afterTs + 1n, maxExclusive: bound + 1n },
619
+ "asc",
620
+ limit
621
+ )) {
622
+ scanned.push(e);
623
+ }
624
+ let maxScannedTs;
625
+ let rows;
626
+ const limitHit = limit !== void 0 && scanned.length === limit;
627
+ if (!limitHit) {
628
+ maxScannedTs = bound;
629
+ rows = scanned;
630
+ } else {
631
+ const lastTs = scanned[scanned.length - 1].ts;
632
+ if (lastTs > afterTs + 1n) {
633
+ maxScannedTs = lastTs - 1n;
634
+ rows = scanned.filter((e) => e.ts < lastTs);
635
+ } else {
636
+ maxScannedTs = lastTs;
637
+ rows = [];
638
+ for await (const e of store.load_documents(
639
+ { minInclusive: lastTs, maxExclusive: lastTs + 1n },
640
+ "asc"
641
+ )) {
642
+ rows.push(e);
643
+ }
644
+ }
645
+ }
646
+ const tableFilter = opts.tables ? new Set(opts.tables) : null;
647
+ const changes = [];
648
+ for (const e of rows) {
649
+ const name = tableNumberToName.get(e.id.tableNumber);
650
+ if (name === void 0 || name.includes("/") || name.startsWith("_")) continue;
651
+ if (tableFilter && !tableFilter.has(name)) continue;
652
+ const op = e.value === null ? "delete" : e.prev_ts === null ? "insert" : "update";
653
+ const newDoc = e.value === null ? null : convexToJson2(e.value.value);
654
+ let oldDoc = null;
655
+ if (e.prev_ts !== null) {
656
+ const prev = await store.get(e.id, e.prev_ts);
657
+ oldDoc = prev === null ? null : convexToJson2(prev.value.value);
658
+ }
659
+ const idStr = encodeInternalDocumentId(e.id);
660
+ const tsNum = Number(e.ts);
661
+ changes.push({
662
+ table: name,
663
+ id: idStr,
664
+ op,
665
+ newDoc,
666
+ oldDoc,
667
+ ts: tsNum,
668
+ changeId: `${name}:${idStr}:${tsNum}`
669
+ });
670
+ }
671
+ return { changes, maxScannedTs: Number(maxScannedTs) };
672
+ },
673
+ // M2c Task 6: `handler` (the `SyncProtocolHandler` instance) is already constructed above
674
+ // (`const handler = new SyncProtocolHandler(...)`) by the time this `driverCtx` object is
675
+ // built, so both hooks are plain delegations — no chicken-and-egg problem the way a
676
+ // boot-layer-constructed poller would have (`runtime.handler` doesn't exist until
677
+ // `createEmbeddedRuntime` RETURNS; this closure runs before that, with `handler` already live).
678
+ notifyWrites: (inv) => handler.notifyWrites(inv),
679
+ subscribedGlobalTables: () => handler.subscribedGlobalTables(),
680
+ // M2c fix: plain delegation, same reasoning as the two hooks above — `handler` is already
681
+ // constructed by the time this `driverCtx` object is built.
682
+ onGlobalSubscribe: (cb) => handler.onGlobalSubscribe(cb)
683
+ };
684
+ const tableNumberToName = /* @__PURE__ */ new Map();
685
+ rebuildTableNumberToName(tableNumberToName, options.tableNumbers ?? {});
686
+ const namesForCommit = (tableIds) => translateTableIds(tableIds, tableNumberToName);
687
+ adapter.subscribe((payload) => {
688
+ handler.registerOriginResponseGate(payload.commitTs, payload.origin);
689
+ queue.push({
690
+ tables: payload.tables,
691
+ ranges: payload.ranges,
692
+ commitTs: payload.commitTs,
693
+ origin: payload.origin,
694
+ writtenDocs: payload.writtenDocs
695
+ });
696
+ void drain();
697
+ if (commitSubs.size > 0) {
698
+ fireCommitSubs(commitSubs, { tables: namesForCommit(payload.tables), ranges: payload.ranges, commitTs: payload.commitTs });
699
+ }
700
+ });
701
+ if ((options.drivers?.length ?? 0) > 0 && !options.tableNumbers) {
702
+ console.warn(
703
+ "[runtime] drivers registered but no `tableNumbers` provided \u2014 driver reactive wake (onCommit) will not match table-name filters; drivers will fall back to their own timers only."
704
+ );
705
+ }
706
+ const drivers = options.drivers ?? [];
707
+ let driversStarted = false;
708
+ if (!options.deferDrivers) {
709
+ for (const d of drivers) await d.start(driverCtx);
710
+ driversStarted = true;
711
+ }
712
+ return new _EmbeddedRuntime(
713
+ options.store,
714
+ executor,
715
+ handler,
716
+ adapter,
717
+ modules,
718
+ systemModules,
719
+ adminModules,
720
+ componentNames,
721
+ contextProviders,
722
+ policyRegistry,
723
+ policyProviders,
724
+ relationRegistry,
725
+ drivers,
726
+ timers,
727
+ fireDueTimers,
728
+ tableNumberToName,
729
+ oracle,
730
+ queryOracle,
731
+ transactor,
732
+ driverCtx,
733
+ driversStarted,
734
+ options.writeRouter,
735
+ numShards,
736
+ options.catalog,
737
+ commitSubs
738
+ );
739
+ }
740
+ /**
741
+ * Resolves a target path's REAL registered kind against the live `this.modules` map — same
742
+ * resolver shape as `create()`'s local `functionKind` closure, but bound to the instance so it
743
+ * stays correct across `setModules` hot-swaps. See `ComponentContext.functionKind`'s doc
744
+ * comment (packages/executor/src/executor.ts).
745
+ */
746
+ functionKind = (path) => this.modules[path]?.type;
747
+ /** Hot-swap the function map (dev reload) without disturbing the store/transactor. */
748
+ setModules(modules) {
749
+ for (const key of Object.keys(this.modules)) delete this.modules[key];
750
+ Object.assign(this.modules, modules);
751
+ }
752
+ /**
753
+ * Rebuild the tableNumber→name map after an additive deploy so driver commit fan-out
754
+ * (`namesForCommit` in `create()`, which closed over this same `Map` instance) keeps
755
+ * translating newly-added tables' encoded storage ids to their full names correctly.
756
+ * Additive deploys keep existing numbers, so this only ever adds entries in practice.
757
+ */
758
+ setTableNumbers(tableNumbers) {
759
+ rebuildTableNumberToName(this.tableNumberToName, tableNumbers);
760
+ }
761
+ /**
762
+ * The FOREIGN-COMMIT driver wake for a multi-writer fleet hybrid (Fleet B3, trigger-wake gap
763
+ * fix). `commitSubs` (driver `onCommit` wakes) normally fires only from `create()`'s
764
+ * `adapter.subscribe` callback — the LOCAL commit fan-out. In an opt-in multi-writer fleet, a
765
+ * co-writer's commit reaches THIS node only through the hybrid-tailer `invalidationSink`
766
+ * (`ee/packages/fleet/src/node.ts`), which calls `handler.notifyWrites` directly — bypassing
767
+ * `adapter.subscribe` entirely, so a driver here (e.g. `@helipod/triggers`) never woke on a
768
+ * foreign writer's commit and instead slept up to its own wall-clock beat. Delivery was always
769
+ * guaranteed (the durable cursor over the log) — this is a LATENCY fix, not a correctness one.
770
+ *
771
+ * The caller (the fleet's `invalidationSink`) invokes this after `notifyWrites`, passing the
772
+ * SAME derived invalidation. Translates `inv.tables` with the identical `translateTableIds`
773
+ * helper the local path uses, so a driver's `t.startsWith("scheduler/")`-style filter matches
774
+ * either source the same way. A local commit and a foreign commit that happen to touch the same
775
+ * table may both wake a driver for what is conceptually "the same" change window — harmless,
776
+ * since driver wakes are level-triggered (a driver re-checks its own state, it doesn't trust the
777
+ * wake payload as the sole source of truth).
778
+ *
779
+ * A no-op on any node with no registered drivers (`commitSubs` empty) — cheap to call
780
+ * unconditionally from every fleet node, writer-ish or not.
781
+ */
782
+ notifyExternalCommit(inv) {
783
+ if (this.commitSubs.size === 0) return;
784
+ fireCommitSubs(this.commitSubs, { tables: translateTableIds(inv.tables, this.tableNumberToName), ranges: inv.ranges, commitTs: inv.commitTs });
785
+ }
786
+ /**
787
+ * Live view of the registered app+component function paths. Reads the same mutable `modules`
788
+ * map `setModules` hot-swaps in place, so counts stay correct across a dev reload or deploy
789
+ * (the boot-time snapshot the server used to cache went stale after the first hot-swap).
790
+ */
791
+ functionPaths() {
792
+ return Object.keys(this.modules);
793
+ }
794
+ /** Live view of the registered table names (mirrors `functionPaths`, reads the live map). */
795
+ tableNames() {
796
+ return [...this.tableNumberToName.values()];
797
+ }
798
+ /** Open an in-process connection an unmodified client can talk to. */
799
+ connect(sessionId) {
800
+ return createLoopbackConnection(this.handler, sessionId ?? `session-${++this.sessionCounter}`);
801
+ }
802
+ /**
803
+ * Synthesizes a `UdfResult` for a write forwarded to the writer node: `forward` only returns
804
+ * the function's JSON result, not read ranges / an oplog, so those come back empty/zero here.
805
+ * Shared by `run()` and `runAction()` — both route through the same `WriteRouter`.
806
+ */
807
+ forwardedResult(value) {
808
+ return { value: jsonToConvex(value), logs: [], committed: true, commitTs: 0n, readRanges: [], oplog: null };
809
+ }
810
+ /** Directly invoke a function (for HTTP routes / the CLI `run` command). A MUTATION routes
811
+ * per-shard inside the executor (`executor.run` forwards a shard this node doesn't own); an
812
+ * ACTION forwards wholesale here to the default-shard holder; queries always run locally. */
813
+ async run(path, args, opts) {
814
+ if (isInternalPath(path)) throw new FunctionNotFoundError(`unknown function: ${path}`);
815
+ const fn = this.modules[path];
816
+ if (!fn) throw new FunctionNotFoundError(`unknown function: ${path}`);
817
+ if (fn.type === "action" && this.writeRouter && !this.writeRouter.isLocalWriter(DEFAULT_SHARD)) {
818
+ const res = await this.writeRouter.forward("action", path, args, opts?.identity ?? null, DEFAULT_SHARD);
819
+ return this.forwardedResult(res.value);
820
+ }
821
+ if (fn.type === "mutation" && opts?.dedup) {
822
+ return this.runMutationClassified(fn, path, args, opts.identity ?? null, opts.dedup, opts.commitMeta);
823
+ }
824
+ return this.executor.run(fn, jsonToConvex(args), {
825
+ path,
826
+ namespace: namespaceForPath(path, this.componentNames),
827
+ contextProviders: this.contextProviders,
828
+ policyRegistry: this.policyRegistry,
829
+ policyProviders: this.policyProviders,
830
+ relationRegistry: this.relationRegistry,
831
+ functionKind: this.functionKind,
832
+ identity: opts?.identity ?? null,
833
+ numShards: this.numShards,
834
+ // Fleet B3, D3 (effectively-once forwarding): opaque commit metadata threaded straight through
835
+ // to `Transactor.runInTransaction`'s `commitMeta` — meaningful only for a mutation that
836
+ // actually commits (see `RunOptions.commitMeta`'s doc comment). `packages/cli`'s `/_fleet/run`
837
+ // handler is the one caller that sets this, carrying a forwarded write's idempotency key.
838
+ commitMeta: opts?.commitMeta
839
+ });
840
+ }
841
+ /**
842
+ * The OWNER-side dedup classification for a mutation reaching `run()` with a dedup key (the fleet
843
+ * `/_fleet/run` forward path). Shares the exact classify → run-with-guard → replay-on-collision
844
+ * logic the sync `runMutation` uses; a replay is surfaced as a `UdfResult` with `clientReplay` set
845
+ * (the `/_fleet/run` handler serializes it to a replay body). `base` is any pre-existing commit
846
+ * meta (e.g. fleet's `idempotencyKey`) — the dedup keys merge onto it (disjoint keys).
847
+ */
848
+ async runMutationClassified(fn, path, args, identity, dedup, base) {
849
+ const pre = await classifyDedup(this.store, identity, dedup);
850
+ if (pre) return this.replayResult(pre);
851
+ const commitMeta = dedupCommitMeta(identity, dedup, base);
852
+ let r;
853
+ try {
854
+ r = await this.executor.run(fn, jsonToConvex(args), {
855
+ path,
856
+ namespace: namespaceForPath(path, this.componentNames),
857
+ contextProviders: this.contextProviders,
858
+ policyRegistry: this.policyRegistry,
859
+ policyProviders: this.policyProviders,
860
+ relationRegistry: this.relationRegistry,
861
+ functionKind: this.functionKind,
862
+ identity,
863
+ numShards: this.numShards,
864
+ commitMeta
865
+ });
866
+ } catch (e) {
867
+ const replay = await handleDedupError(this.store, identity, dedup, e);
868
+ if (replay) return this.replayResult(replay);
869
+ throw e;
870
+ }
871
+ if (!r.committed) {
872
+ await recordZeroWriteApplied(this.store, identity, dedup, r.commitTs, r.value);
873
+ } else if (r.oplog !== null) {
874
+ await fillWriteMutationValue(this.store, identity, dedup, r.value);
875
+ }
876
+ return r;
877
+ }
878
+ /** Wrap a {@link ClientReplay} as a `UdfResult` carrying `clientReplay` — no commit happened. */
879
+ replayResult(replay) {
880
+ return {
881
+ value: jsonToConvex(replay.value ?? null),
882
+ logs: [],
883
+ committed: false,
884
+ commitTs: replay.commitTs !== void 0 ? BigInt(replay.commitTs) : 0n,
885
+ readRanges: [],
886
+ oplog: null,
887
+ clientReplay: replay
888
+ };
889
+ }
890
+ /** Directly invoke an action (for HTTP routes / the CLI `run` command). Public gate: blocks
891
+ * `_`-prefixed paths. Routes through `writeRouter` when set and this node isn't the writer. */
892
+ async runAction(path, args, opts) {
893
+ if (isInternalPath(path)) throw new FunctionNotFoundError(`unknown function: ${path}`);
894
+ const fn = this.modules[path];
895
+ if (!fn) throw new FunctionNotFoundError(`unknown function: ${path}`);
896
+ if (fn.type !== "action") throw new Error(`${path} is not an action`);
897
+ if (this.writeRouter && !this.writeRouter.isLocalWriter(DEFAULT_SHARD)) {
898
+ const res = await this.writeRouter.forward("action", path, args, opts?.identity ?? null, DEFAULT_SHARD);
899
+ return this.forwardedResult(res.value);
900
+ }
901
+ return this.executor.run(fn, jsonToConvex(args), {
902
+ path,
903
+ namespace: namespaceForPath(path, this.componentNames),
904
+ contextProviders: this.contextProviders,
905
+ policyRegistry: this.policyRegistry,
906
+ policyProviders: this.policyProviders,
907
+ relationRegistry: this.relationRegistry,
908
+ functionKind: this.functionKind,
909
+ identity: opts?.identity ?? null,
910
+ numShards: this.numShards
911
+ });
912
+ }
913
+ /** Directly invoke an httpAction (for the public HTTP router). Passes the raw `Request` through
914
+ * untouched and returns the handler's `Response`. Public gate: blocks `_`-prefixed paths. */
915
+ async runHttpAction(path, request, opts) {
916
+ if (isInternalPath(path)) throw new FunctionNotFoundError(`unknown function: ${path}`);
917
+ const fn = this.modules[path];
918
+ if (!fn) throw new FunctionNotFoundError(`unknown function: ${path}`);
919
+ if (fn.type !== "httpAction") throw new Error(`${path} is not an httpAction`);
920
+ const result = await this.executor.run(fn, request, {
921
+ path,
922
+ namespace: namespaceForPath(path, this.componentNames),
923
+ contextProviders: this.contextProviders,
924
+ policyRegistry: this.policyRegistry,
925
+ policyProviders: this.policyProviders,
926
+ relationRegistry: this.relationRegistry,
927
+ functionKind: this.functionKind,
928
+ identity: opts?.identity ?? null,
929
+ numShards: this.numShards
930
+ });
931
+ return result.value;
932
+ }
933
+ /**
934
+ * The privileged built-in doc mutations whose target is a USER table (and thus may be sharded).
935
+ * These are the ONLY `runSystem` paths that need shard routing — every other `_system:*`/
936
+ * `_storage:*`/`_test:*` built-in writes UNSHARDED component-internal tables, which are owned by
937
+ * the default ring (INSERT from any ring; RMW on default), so they need no override.
938
+ */
939
+ static DOC_MUTATION_PATHS = /* @__PURE__ */ new Set([
940
+ "_system:patchDocument",
941
+ "_system:deleteDocument",
942
+ "_system:insertDocument"
943
+ ]);
944
+ /** Run a privileged built-in (`_system:*`) function. Trusted callers only (the admin API).
945
+ * For a doc mutation on a user table, the target document's OWNING shard is resolved and passed
946
+ * through so the privileged write lands on the same ring a user's sharded mutation of that doc
947
+ * would — one-doc-one-ring. `opts.shardId` lets a trusted caller override the resolution. */
948
+ async runSystem(path, args, opts) {
949
+ const fn = this.systemModules[path];
950
+ if (!fn) throw new FunctionNotFoundError(`unknown system function: ${path}`);
951
+ let shardId = opts?.shardId;
952
+ if (shardId === void 0 && this.numShards > 1 && _EmbeddedRuntime.DOC_MUTATION_PATHS.has(path)) {
953
+ shardId = await this.resolveDocMutationShard(path, args);
954
+ }
955
+ return this.executor.run(fn, jsonToConvex(args), {
956
+ path,
957
+ privileged: true,
958
+ numShards: this.numShards,
959
+ shardId,
960
+ // Fleet B3, D3: see `run()`'s doc comment above — the forwarded-`_system:*`-doc-mutation path
961
+ // (an admin dashboard edit landing on a non-owner) threads the same idempotency key through.
962
+ commitMeta: opts?.commitMeta
963
+ });
964
+ }
965
+ /**
966
+ * Resolve the owning shard for a privileged admin doc mutation so the write commits on the SAME
967
+ * ring a user's sharded mutation of that document would use (the one-doc-one-ring invariant).
968
+ * Without this, a dashboard edit of a sharded doc runs on the default ring and forks the doc's
969
+ * prev_ts chain against its home-shard writer — a permanent tailer halt + a silently-lost update.
970
+ *
971
+ * The shard-key field is IMMUTABLE after insert, so peeking the current doc's key value BEFORE the
972
+ * transaction is race-free (its shard can't have changed by the time the txn opens). An unsharded
973
+ * target (component tables, or app tables with no `.shardKey`) resolves to `"default"` — its RMW
974
+ * ring per the same invariant. Called only for `DOC_MUTATION_PATHS` when `numShards > 1`.
975
+ */
976
+ async resolveDocMutationShard(path, args) {
977
+ const a = args;
978
+ if (path === "_system:insertDocument") {
979
+ const meta2 = this.catalog.getTable(a.table);
980
+ if (!meta2?.shardKey) return DEFAULT_SHARD;
981
+ const fields = a.fields;
982
+ return shardIdForKeyValue(fields?.[meta2.shardKey], this.numShards);
983
+ }
984
+ const internalId = decodeDocumentId(a.id);
985
+ const meta = this.catalog.getTableByNumber(internalId.tableNumber);
986
+ if (!meta?.shardKey) return DEFAULT_SHARD;
987
+ const latest = await this.store.get(internalId);
988
+ if (!latest) return DEFAULT_SHARD;
989
+ const doc = latest.value.value;
990
+ return shardIdForKeyValue(doc[meta.shardKey], this.numShards);
991
+ }
992
+ /** Run a privileged admin built-in (`_admin:*`) once (e.g. for the HTTP fallback). Trusted callers only. */
993
+ async runAdmin(path, args) {
994
+ const fn = this.adminModules[path];
995
+ if (!fn) throw new FunctionNotFoundError(`unknown admin function: ${path}`);
996
+ return this.executor.run(fn, jsonToConvex(args), { path, privileged: true, numShards: this.numShards });
997
+ }
998
+ /**
999
+ * Run every driver timer that is due (`atMs <= now()`), drop it, and re-arm the host to the new
1000
+ * minimum — the firing half of the `EmbeddedRuntimeOptions.wakeHost` seam, called when the host's
1001
+ * alarm goes off (`serve`'s `POST /_admin/wake`). It cannot be a direct call: the alarm lives in
1002
+ * the host (a Durable Object), across a network boundary from this process.
1003
+ *
1004
+ * Safe and cheap on both paths through it, so a host never branches:
1005
+ * - the process was STOPPED (the common case) — the wake request BOOTS it, the drivers' own
1006
+ * `start()` re-derives everything from committed state, and this finds nothing due: a no-op.
1007
+ * - the process was RUNNING — this runs the actual pending callback.
1008
+ *
1009
+ * Also a no-op without a `wakeHost` (nothing arms a wake, and `setTimeout` already fired
1010
+ * everything due), so the route being registered on every deployment costs nothing.
1011
+ */
1012
+ fireDueTimers() {
1013
+ this.fireDue();
1014
+ }
1015
+ /**
1016
+ * Stop the component drivers and clear their pending timers, resetting `driversStarted` so a later
1017
+ * `startDrivers()` can bring them back up. Shared by the driver-only `stopDriversOnly()` (B2b, D5)
1018
+ * and the full-shutdown `stopDrivers()` — the ONLY difference is whether the sync handler is also
1019
+ * disposed, so the two can never drift on how a driver is torn down.
1020
+ */
1021
+ async stopDriversInternal() {
1022
+ for (const t of this.timers.values()) if (t.timeout !== void 0) clearTimeout(t.timeout);
1023
+ this.timers.clear();
1024
+ for (const d of this.drivers) await d.stop?.();
1025
+ this.driversStarted = false;
1026
+ }
1027
+ /**
1028
+ * Stop all component drivers and clear all pending driver timers. Call on runtime shutdown.
1029
+ * ALSO disposes the sync handler's background flush sweep — this is the full-teardown path.
1030
+ */
1031
+ async stopDrivers() {
1032
+ await this.stopDriversInternal();
1033
+ this.handler.dispose();
1034
+ }
1035
+ /**
1036
+ * Driver-only stop (B2b, D5 — "drivers follow the default shard"): stop the scheduler/workflow/cron/
1037
+ * reaper drivers and clear their timers, WITHOUT disposing the sync handler. A fleet node that
1038
+ * relinquishes (or gracefully releases) the default shard keeps serving reads, subscriptions, and
1039
+ * mutations for every OTHER shard — only its drivers go quiet, because a different node now owns the
1040
+ * default ring the scheduler tables live on. Symmetric with `startDrivers()` and idempotent both
1041
+ * ways (the reset flag makes a later `startDrivers()` a real restart, and a second stop a no-op),
1042
+ * so callers never need to track whether drivers are currently running. Deliberately NEVER touches
1043
+ * `handler.dispose()` (the shipped `stopDrivers()` did — fatal on a default-relinquish, since the
1044
+ * node stays live).
1045
+ */
1046
+ async stopDriversOnly() {
1047
+ await this.stopDriversInternal();
1048
+ }
1049
+ /**
1050
+ * Start component drivers deferred via `EmbeddedRuntimeOptions.deferDrivers`, OR restart them after
1051
+ * a `stopDriversOnly()` (B2b, D5). Idempotent — a second (or later) call is a no-op once drivers are
1052
+ * running, so callers don't need to track whether they've already called it (e.g. a fleet node
1053
+ * calling this on every default-shard acquisition attempt).
1054
+ */
1055
+ async startDrivers() {
1056
+ if (this.driversStarted) return;
1057
+ this.driversStarted = true;
1058
+ for (const d of this.drivers) await d.start(this.driverCtx);
1059
+ }
1060
+ /**
1061
+ * Lets a non-writer fleet node advance its local timestamp oracle past timestamps it learns
1062
+ * from the writer's change stream, so its own next allocated timestamp (if/when it becomes
1063
+ * the writer) never collides with or precedes one it already observed.
1064
+ *
1065
+ * Fleet B3 (D1) routing: WITHOUT a `queryStore` (`queryOracle` undefined), this is the shipped
1066
+ * behavior — delegates straight to the WRITE oracle (`this.oracle`; a `ShardedTransactor` fans
1067
+ * it to every shard). WITH a `queryStore` configured, a hybrid node has a real query-path oracle
1068
+ * whose sole purpose IS tailer post-apply feeding — so this routes to `queryOracle` INSTEAD, and
1069
+ * the write oracle is left untouched by tailer observations (it advances only via this runtime's
1070
+ * own local commits, through `ShardWriter.commit`'s `oracle.publishCommitted`). Sharing one
1071
+ * oracle between the two would let a query snapshot ABOVE the replica's actual watermark and
1072
+ * read holes (D1's spec-review requirement) — this branch is what keeps them separate.
1073
+ *
1074
+ * This is the READ-PATH observer (tailer/follower freshness → query snapshot). Its write-path
1075
+ * counterpart, `observeWriteTimestamp` (below), always targets the WRITE oracle and exists
1076
+ * precisely because this method stopped feeding the write side once a hybrid has a query oracle.
1077
+ */
1078
+ observeTimestamp(ts) {
1079
+ (this.queryOracle ?? this.oracle).observeTimestamp(ts);
1080
+ }
1081
+ /**
1082
+ * Advance the WRITE transactor's timestamp oracle(s) past `ts` — the write-path counterpart to
1083
+ * `observeTimestamp` above, and the pre-T1 semantics of what `observeTimestamp` used to do before
1084
+ * hybrid routing split the two. ALWAYS targets `this.oracle` (the write side), independent of
1085
+ * `queryStore`/`queryOracle` routing: a `ShardedTransactor` fans `ts` to every existing shard
1086
+ * oracle AND raises its `observedHighWater` floor (so a shard writer CREATED later seeds at-or-past
1087
+ * `ts`); the single-shard `MonotonicTimestampOracle` takes it directly.
1088
+ *
1089
+ * Purpose (distinct from `observeTimestamp`): re-floor the WRITE snapshot on a shard OWNERSHIP
1090
+ * CHANGE. On a hybrid node `observeTimestamp` feeds the QUERY oracle, so nothing feeds the write
1091
+ * oracle from foreign observations anymore. A shard this node held, RELEASED (its `ShardWriter`
1092
+ * stays in the transactor's Map — only the fleet epoch is dropped), and later RE-ACQUIRES would
1093
+ * keep that `ShardWriter`'s oracle frozen at this node's own last commit; the next mutation would
1094
+ * snapshot BELOW an interim owner's commits and an RMW handler would compute on stale state (the
1095
+ * durable chain stays intact via latest-`prev_ts`, but the update is semantically lost). The fleet
1096
+ * calls this on EVERY shard acquisition with the lease row's `frontier_ts` — which is >= every
1097
+ * prior commit on that shard by the fence invariant (the commit guard writes `frontier_ts =
1098
+ * GREATEST(frontier_ts, commitTs)` inside each commit txn) — so it is the exact correct floor.
1099
+ * Idempotent/monotone: a `ts` at or below the oracle's position is a no-op (harmless on a
1100
+ * fresh/never-released shard, and on every non-hybrid node where it is called uniformly too).
1101
+ */
1102
+ observeWriteTimestamp(ts) {
1103
+ this.oracle.observeTimestamp(ts);
1104
+ }
1105
+ /**
1106
+ * Run `fn` under shard `shardId`'s commit mutex IFF that shard is idle right now — the seam a fleet
1107
+ * writer's idle-frontier closer uses to publish a shard's frontier atomically with respect to that
1108
+ * shard's own commits (see `ShardedTransactor.tryRunExclusiveOnShard`). A shard is idle only when
1109
+ * the commit mutex is free AND no group-commit batch is staged/flushing (Fleet B4: the flush runs
1110
+ * OFF the mutex, so mutex-freedom alone is not enough — a mid-flush batch has ts's drawn but rows
1111
+ * not yet landed, and must read as busy to keep the closer from publishing a frontier above them).
1112
+ * Returns `true` if `fn` ran, `false` if the shard is busy (skip; retry next beat). Total across
1113
+ * sharded/single-shard runtimes — the single-shard transactor ignores `shardId` and uses its one
1114
+ * writer.
1115
+ */
1116
+ tryRunExclusiveOnShard(shardId, fn) {
1117
+ return this.transactor.tryRunExclusiveOnShard(shardId, fn);
1118
+ }
1119
+ /**
1120
+ * Group-commit counters (Fleet B4, T4 health) — total across the transactor, whichever shape it
1121
+ * is: `ShardedTransactor.groupCommitStats()` aggregates over every live shard,
1122
+ * `SingleWriterTransactor.groupCommitStats()` mirrors it for the one writer. Both are
1123
+ * structurally all-zero when `EmbeddedRuntimeOptions.groupCommit` is unset/false (the underlying
1124
+ * `ShardWriter` never touches these fields on the single-commit path) — callers need no separate
1125
+ * on/off branch. The fleet health seam (`@helipod/fleet`'s `node.ts`) reads this to derive
1126
+ * `flushesPerSec` between successive `/api/health` reads.
1127
+ */
1128
+ groupCommitStats() {
1129
+ return this.transactor.groupCommitStats();
1130
+ }
1131
+ };
1132
+ function createEmbeddedRuntime(options) {
1133
+ return EmbeddedRuntime.create(options);
1134
+ }
1135
+ export {
1136
+ EmbeddedRuntime,
1137
+ EmbeddedWriteFanout,
1138
+ InMemoryWriteFanoutAdapter,
1139
+ clientReceiptsGuard,
1140
+ createEmbeddedRuntime,
1141
+ createLoopbackConnection
1142
+ };
1143
+ //# sourceMappingURL=index.js.map