@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.
@@ -0,0 +1,722 @@
1
+ import * as _helipod_docstore_d1 from '@helipod/docstore-d1';
2
+ import { WakeHost, Driver } from '@helipod/component';
3
+ import { ShardId } from '@helipod/id-codec';
4
+ import { SerializedKeyRange } from '@helipod/index-key-codec';
5
+ import { JSONValue } from '@helipod/values';
6
+ import { CommitGuardUnit, DocStore } from '@helipod/docstore';
7
+ import { InlineUdfExecutor, IndexCatalog, RegisteredFunction, LogSink, ContextProvider, TablePolicy, PolicyContextProvider, RelationRegistry, GuestDatabaseWriter, WriteRouter, UdfResult } from '@helipod/executor';
8
+ export { ClientReplay, WriteRouter } from '@helipod/executor';
9
+ import { ClientMessage, ServerMessage, SyncWebSocket, SyncProtocolHandler } from '@helipod/sync';
10
+ import { WrittenDoc, WriteFanout, OplogDelta } from '@helipod/transactor';
11
+
12
+ /**
13
+ * The transactor→sync fan-out seam (scale-seam #4). A committed `OplogDelta` is published as
14
+ * a fully-serializable payload to a swappable `EmbeddedWriteFanoutAdapter`. At Tier 0 the
15
+ * adapter is in-memory; swap it for BroadcastChannel / Redis / Queues and the SAME fan-out
16
+ * spans many processes — with no change to app code. Each fan-out tags its `originId` so a
17
+ * subscriber can ignore its own writes (avoiding a self-loop across processes).
18
+ */
19
+
20
+ interface EmbeddedWriteFanoutPayload {
21
+ commitTs: number;
22
+ tables: string[];
23
+ ranges: SerializedKeyRange[];
24
+ originId: string;
25
+ /**
26
+ * G4 origin-frontier tag (client-sync verdict §(d) item 2) — the originating sync SESSION id,
27
+ * sourced verbatim from `OplogDelta.origin`. Distinct from `originId` (the fleet-node/process
28
+ * origin used for the cross-process self-loop guard): this is a per-commit ephemeral session tag
29
+ * the drain hands to `handler.notifyWrites(inv, origin)` so the origin session's `version.ts` is
30
+ * advanced past its own commit. Undefined for commits with no originating session.
31
+ */
32
+ origin?: string;
33
+ /** The shard this commit landed on (Fenced Frontier B1, D6) — sourced verbatim from
34
+ * `OplogDelta.shardId`. Additive: single-shard (Tier 0/B1) deployments always see `"default"`
35
+ * (`DEFAULT_SHARD`); a multi-shard fan-out consumer (B2+) can use it to route/filter, but every
36
+ * existing consumer today ignores it. */
37
+ shardId: string;
38
+ /** Written documents for local row-diffing (§DLR 2a) — sourced verbatim from
39
+ * `OplogDelta.writtenDocs`. Present only on the local in-process fan-out (this adapter always
40
+ * runs in-process at Tier 0/1); absent if a future cross-process adapter swap doesn't carry it. */
41
+ writtenDocs?: WrittenDoc[];
42
+ }
43
+ type FanoutListener = (payload: EmbeddedWriteFanoutPayload) => void;
44
+ interface EmbeddedWriteFanoutAdapter {
45
+ publish(payload: EmbeddedWriteFanoutPayload): void;
46
+ subscribe(listener: FanoutListener): () => void;
47
+ }
48
+ /** The default Tier 0 adapter: an in-process channel (also records what it published). */
49
+ declare class InMemoryWriteFanoutAdapter implements EmbeddedWriteFanoutAdapter {
50
+ private readonly listeners;
51
+ readonly published: EmbeddedWriteFanoutPayload[];
52
+ publish(payload: EmbeddedWriteFanoutPayload): void;
53
+ subscribe(listener: FanoutListener): () => void;
54
+ }
55
+ /** Implements the transactor's `WriteFanout` over a swappable adapter. */
56
+ declare class EmbeddedWriteFanout implements WriteFanout {
57
+ private readonly adapter;
58
+ private readonly originId;
59
+ constructor(adapter: EmbeddedWriteFanoutAdapter, originId: string);
60
+ publish(delta: OplogDelta): void;
61
+ /** Subscribe to deltas from OTHER origins (ignores our own — the Tier 2 self-loop guard). */
62
+ subscribe(listener: FanoutListener): () => void;
63
+ }
64
+
65
+ /**
66
+ * In-memory loopback transport. A `LoopbackConnection` is the client end of an in-process
67
+ * "WebSocket": `send` delivers a client message straight to the sync handler, and the
68
+ * handler's server-side socket delivers messages straight back to the connection's
69
+ * listeners. No sockets, no serialization-over-the-wire — but the EXACT same `SyncWebSocket`
70
+ * the handler talks to, so a real WebSocket transport drops in unchanged at Tier 2.
71
+ */
72
+
73
+ type ServerMessageListener = (msg: ServerMessage) => void;
74
+ interface LoopbackConnection {
75
+ readonly sessionId: string;
76
+ /** Client → server. Resolves once the handler has finished processing (loopback is synchronous). */
77
+ send(message: ClientMessage | string): Promise<void>;
78
+ /** Register a server → client listener; returns an unsubscribe. */
79
+ onMessage(listener: ServerMessageListener): () => void;
80
+ close(): void;
81
+ }
82
+ interface LoopbackHandler {
83
+ connect(sessionId: string, socket: SyncWebSocket): void;
84
+ disconnect(sessionId: string): void;
85
+ handleMessage(sessionId: string, raw: string): Promise<void>;
86
+ }
87
+ declare function createLoopbackConnection(handler: LoopbackHandler, sessionId: string): LoopbackConnection;
88
+
89
+ /** The durable per-tab dedup key threaded from the wire (`Mutation.clientId`/`seq`). */
90
+ interface DedupKey {
91
+ clientId: string;
92
+ seq: number;
93
+ }
94
+ /**
95
+ * The `applied`-receipt commit guard (registered ONCE at runtime construction, BEFORE fleet's epoch
96
+ * fence — verdict §(c) Risk R6). For every unit whose `meta` carries a dedup key, INSERTs the
97
+ * `client_mutations` receipt at that unit's own `ts`, inside the mutation's commit transaction. The
98
+ * INSERT is PLAIN (never `ON CONFLICT DO NOTHING`): a PK collision IS the dedup signal — it means a
99
+ * concurrent duplicate already committed this seq, so we throw a typed {@link CommitGuardRejection}
100
+ * (`CLIENT_MUTATION_DUP`) carrying this unit's index. On the single-commit path it propagates as this
101
+ * mutation's own rejection (the caller replay-reads the winner); under group commit the transactor's
102
+ * split-retry rejects ONLY this unit and re-flushes the innocent remainder. A unit with no dedup key
103
+ * (every ordinary mutation) is skipped — the whole guard costs a deployment nothing until the outbox
104
+ * is used.
105
+ *
106
+ * Store-agnostic by querier shape (verdict Risk R9 — the guard is ONE closure, not two): a Postgres
107
+ * `PgQuerier` exposes async `query()`; a `SqliteGuardQuerier` exposes synchronous `run()`. The SQLite
108
+ * branch runs fully synchronously and returns `undefined` (void) — it MUST, because SQLite's commit
109
+ * is one synchronous transaction that cannot await a guard (a returned thenable there is a dev-time
110
+ * error). The Postgres branch returns a Promise the async `commitWriteBatch` awaits.
111
+ */
112
+ declare function clientReceiptsGuard(): (q: unknown, units: readonly CommitGuardUnit[], shardId: ShardId) => void | Promise<void>;
113
+
114
+ interface EmbeddedRuntimeOptions {
115
+ store: DocStore;
116
+ catalog: IndexCatalog;
117
+ modules: Record<string, RegisteredFunction>;
118
+ /** Privileged built-in functions (`_system:*`). Kept off the public run/sync surface. */
119
+ systemModules?: Record<string, RegisteredFunction>;
120
+ /** Privileged admin functions (`_admin:*`). Served over the admin sync channel. */
121
+ adminModules?: Record<string, RegisteredFunction>;
122
+ /** Validate an admin key presented via `SetAdminAuth`. Defaults to `() => false`. */
123
+ verifyAdmin?: (key: string) => boolean;
124
+ /** Set of component names; used to resolve the namespace for each function path. */
125
+ componentNames?: ReadonlySet<string>;
126
+ /** Swap the Tier 0 in-memory fan-out for a cross-process adapter (no app-code change). */
127
+ fanoutAdapter?: EmbeddedWriteFanoutAdapter;
128
+ originId?: string;
129
+ logSink?: LogSink;
130
+ /** Context providers contributed by composed components; attached as ctx[name] on every function call. */
131
+ contextProviders?: ReadonlyArray<ContextProvider>;
132
+ /** Row-policy registry contributed by components; enforced on every non-privileged run. */
133
+ policyRegistry?: ReadonlyMap<string, TablePolicy>;
134
+ /** Policy context providers contributed by components; used to build rule context for row policies. */
135
+ policyProviders?: ReadonlyArray<PolicyContextProvider>;
136
+ /** Declared relations, consulted by the kernel when resolving relation predicates. */
137
+ relationRegistry?: RelationRegistry;
138
+ /** Wall-clock source; defaults to `Date.now`. Injected for deterministic testing. */
139
+ now?: () => number;
140
+ /**
141
+ * The host's single alarm, for a host that STOPS THE PROCESS between requests (Cloudflare
142
+ * Containers: stopped ~5s after the last incoming request; internal timer activity does not keep
143
+ * it alive, so `setTimeout` silently never fires and every driver goes dead). Set → this runtime
144
+ * multiplexes every live driver timer down to ONE pending wake and hands the minimum to
145
+ * `armWake`; the host is then the SOLE thing that fires them, via `fireDueTimers()` (the engine
146
+ * arms no `setTimeout` of its own — two schedulers would double-fire the same callback).
147
+ *
148
+ * UNSET (the default, and every existing deployment — Docker/VPS/Fly/Railway/single-binary): the
149
+ * plain `setTimeout` path below, byte-for-byte unchanged.
150
+ */
151
+ wakeHost?: WakeHost;
152
+ /**
153
+ * Answers `DriverContext.backstopMs` — a driver's declared cadence for a PURE BACKSTOP poll
154
+ * (scheduler lease-reclaim sweep, triggers beat, storage reaper). Defaults to IDENTITY (the
155
+ * driver's own 30s/60s constants, unchanged). A host where every wake costs a cold start stretches
156
+ * it: on Cloudflare a 30s backstop is a container boot every 30s forever — a ~15% duty cycle for a
157
+ * completely idle app, which would destroy scale-to-zero.
158
+ */
159
+ backstopMs?: (defaultMs: number) => number;
160
+ /**
161
+ * Disarm the sync handler's process-shaped background timers (its `setInterval` flush/resume sweep
162
+ * and every per-session ping heartbeat). Defaults to `false` — every long-lived process host is
163
+ * byte-for-byte unchanged. Set ONLY by the Cloudflare Durable Object host (Slice 3), where those
164
+ * timers are actively harmful (a `setInterval` is lost on hibernation; an app-level ping would wake
165
+ * the hibernated object every beat, defeating scale-to-zero). Just a boolean — no host type crosses
166
+ * this seam. See `SyncProtocolHandlerOptions.disableBackgroundTimers`.
167
+ */
168
+ disableSyncBackgroundTimers?: boolean;
169
+ /** Component boot steps to run once at create, namespaced + non-user (before serving traffic). */
170
+ bootSteps?: {
171
+ name: string;
172
+ run: (ctx: {
173
+ db: GuestDatabaseWriter;
174
+ now: number;
175
+ }) => Promise<void>;
176
+ }[];
177
+ /** Component drivers to start once at create, after boot steps + the commit fan-out are wired. */
178
+ drivers?: Driver[];
179
+ /**
180
+ * `fullTableName → tableNumber` (the same map `composeComponents`/`loadProject` produce).
181
+ * Used ONLY to translate the encoded storage-table ids on `adapter.subscribe`'s commit payload
182
+ * (e.g. `"3"`) back into full names (e.g. `"scheduler/jobs"`) before handing them to driver
183
+ * `onCommit` callbacks — drivers filter `inv.tables` by name (see `components/scheduler/src/
184
+ * driver.ts`), and a raw storage id never matches. Optional: a runtime with no components (or
185
+ * whose drivers don't inspect `inv.tables`) can omit it and driver callbacks just see raw ids.
186
+ */
187
+ tableNumbers?: Record<string, number>;
188
+ /**
189
+ * Route mutations/actions to another node instead of executing them locally, when
190
+ * `writeRouter.isLocalWriter()` is false. Applies to EVERY mutation/action entry point (the
191
+ * WS `syncExecutor.runMutation`/`runAction` and the public `run`/`runAction` methods) —
192
+ * queries are never routed. See `WriteRouter`.
193
+ */
194
+ writeRouter?: WriteRouter;
195
+ /**
196
+ * Skip starting component drivers at `create()` time; call the returned instance's
197
+ * `startDrivers()` later instead. For a fleet node that boots as a non-writer and only
198
+ * wants drivers running once/if it becomes the writer.
199
+ */
200
+ deferDrivers?: boolean;
201
+ /**
202
+ * Receipted Outbox — receipts-guard ownership handoff (the promotion-barrier hole). Normally
203
+ * `create()` registers the `clientReceiptsGuard()` exactly-once barrier on `options.store` here at
204
+ * construction, because for every non-fleet deployment `options.store` IS the concrete store its
205
+ * commits land on. A fleet SYNC node is the exception: `options.store` is a `SwitchableDocStore`
206
+ * over the read-only replica, and on writer promotion the runtime store is swapped to the concrete
207
+ * Postgres store WITHOUT re-forwarding registered commit guards (by `SwitchableDocStore`'s
208
+ * documented design) — so a guard registered here would silently vanish on promotion, and a
209
+ * promoted single-writer node's client mutations would commit with NO receipt (dedup miss → a
210
+ * resent (clientId, seq) re-executes). When this is set, `create()` SKIPS its own registration and
211
+ * the caller (fleet's `armWriter`) owns installing the receipts guard on the CONCRETE write store —
212
+ * BEFORE the epoch fence, with the same release-on-re-arm discipline the fence uses. Ownership rule:
213
+ * whoever owns the concrete store owns the receipts guard. Unset → the runtime owns it (today's
214
+ * behavior, byte-identical for every non-fleet deployment). See `ee/fleet`'s `armWriter`.
215
+ */
216
+ externalReceiptsGuard?: boolean;
217
+ /**
218
+ * Number of shards to run (Shards B2a). When `> 1`, the runtime builds ONE `ShardedTransactor`
219
+ * (N independent per-shard mutexes + OCC rings + oracles) over the store instead of the
220
+ * single-shard `SingleWriterTransactor`, so mutations routed to different shards commit in
221
+ * parallel. Unset / `1` → the single-shard transactor, byte-identical to before (the existing
222
+ * suites are the proof). The executor resolves each mutation's shard (`shardBy`) and passes it
223
+ * through `runInTransaction({ shardId })`; a fleet writer pairs this with the per-shard commit
224
+ * pool so different shards' commits are genuinely concurrent Postgres transactions.
225
+ */
226
+ numShards?: number;
227
+ /**
228
+ * Fleet B4 (group commit): route commits through the two-buffer stage-then-flush committer loop
229
+ * instead of the byte-identical single-commit path. Threaded straight into the transactor
230
+ * (`ShardedTransactor`/`SingleWriterTransactor`) constructed below — every shard batches when this
231
+ * is set, none do when it's unset/false. The CLI resolves this from `HELIPOD_GROUP_COMMIT`
232
+ * (default OFF at Fleet B4/T4 — T5 owns flipping the production default); leaving it unset keeps a
233
+ * non-fleet / flag-off deployment structurally on today's path, byte-identical to before this
234
+ * option existed. See `ShardedTransactorOptions.groupCommit`'s doc comment for the mechanism.
235
+ */
236
+ groupCommit?: boolean;
237
+ /**
238
+ * Hybrid-node split-read seam (Fleet B3, D1). When set, `create()` builds a SECOND, separate
239
+ * query-path transactor (`SingleWriterTransactor` — queries never commit, so no sharding is
240
+ * needed here regardless of the write side) + `QueryRuntime` over this store, seeded from ITS
241
+ * OWN `maxTimestamp()` (not the write store's) and wired onto `ExecutorDeps.queryPath`, so
242
+ * `fn.type === "query"` runs against `queryStore` instead of `store`. Its oracle is advanced
243
+ * ONLY by `observeTimestamp` (see below) — never by this runtime's own local commits, which
244
+ * only ever land on the WRITE store. Unset → `queryPath` is never built; every query runs
245
+ * against `store` through the primary pair, byte-identical to before this option existed.
246
+ */
247
+ queryStore?: DocStore;
248
+ /**
249
+ * M2b: the D1 store for `.global()` tables, pre-built by the boot layer (which holds
250
+ * `schemaJson`). Mirrors the `queryStore` precedent above — handed in already-constructed,
251
+ * not built inside `create()`. Threaded straight into `ExecutorDeps.globalStore`. Absent →
252
+ * `.global()` ops fail fast in the kernel (`requireGlobalTxn`), byte-identical to before this
253
+ * option existed. NOT `DocStore`-shaped; it never enters a transactor/`QueryRuntime`.
254
+ */
255
+ globalStore?: _helipod_docstore_d1.D1DocStore;
256
+ /**
257
+ * The AUTHORITATIVE receipts store for the `Connect` resume handshake — where the outbox verdict
258
+ * classification (`classifyClientMutation`) and ack-prune (`pruneClientMutations`) reads/writes
259
+ * land (verdict §(c): "the classification read runs where the commit runs... never against a
260
+ * follower's embedded replica"). The `client_mutations`/`client_floors` receipts tables are NOT
261
+ * part of the replicated MVCC document log — they live ONLY on the authoritative primary. On a
262
+ * fleet SYNC node, `options.store` is a `SwitchableDocStore` over the read-only replica, which
263
+ * carries NONE of those receipts: classifying a committed seq there returns `unknown` for every
264
+ * lookup, so the handshake spuriously reports `known:false` (a reset that re-mints the clientId)
265
+ * and — worse — a kill-after-commit reload via a sync node reports a FALSE failure for a mutation
266
+ * that actually committed. Set this to the primary Postgres store so the handshake reads/prunes the
267
+ * real receipts. Safe from a read-only-until-promoted primary: `getClientVerdict` is a pure read
268
+ * and `pruneClientMutations` is a monotonic (`GREATEST`-floored) direct query on standalone,
269
+ * unsharded receipt tables — architecturally identical to what the WRITER's own handshake does
270
+ * outside the OCC transactor, needing no advisory lock or write-forward hop. Unset → the handshake
271
+ * uses `options.store` (byte-identical for every non-fleet/writer deployment, whose `store` IS the
272
+ * primary; the mutation-path dedup already places its own classification on the owner via
273
+ * `writeRouter`, so only the Connect handshake needed this seam). See `ee/fleet`'s sync boot.
274
+ */
275
+ receiptsStore?: DocStore;
276
+ /**
277
+ * Hybrid-node RYOW gate (Fleet B3, D2). Awaited in the runtime's serial fan-out `drain()`
278
+ * BEFORE each queued invalidation's `handler.notifyWrites` — e.g. a hybrid node's
279
+ * `tailer.waitFor(commitTs)`, so a locally-committed mutation's subscription re-runs don't fire
280
+ * against a replica that hasn't applied that commit yet. A rejection is caught and logged; the
281
+ * drain loop continues to the next queued invalidation rather than wedging the whole queue on
282
+ * one bad wait. Unset → `drain()` is byte-identical to before this hook existed.
283
+ */
284
+ beforeNotify?: (commitTs: bigint) => Promise<void>;
285
+ /**
286
+ * The stable-prefix accessor for `DriverContext.readLog` (triggers D1). Returns the highest log
287
+ * timestamp below which the log is GAP-FREE — the upper bound `readLog` scans to. Non-null ONLY in
288
+ * a fleet, where N per-shard commit connections land timestamps out of order (a scan can see ts 10
289
+ * while ts 9 is still in flight), so the bound must be `min(shard_leases.frontier_ts)` — the fleet
290
+ * node wires it to exactly that. A `null` return (or an unset accessor) means "no fleet gap": every
291
+ * non-fleet topology commits over one serialized session (SQLite's single connection; the single
292
+ * PINNED Postgres connection — the commit pool is fleet-only), so `readLog` falls back to
293
+ * `store.maxTimestamp()`. Consulted per `readLog` call; only ever invoked on the default-shard
294
+ * holder by the driver lifecycle.
295
+ */
296
+ stablePrefix?: () => Promise<bigint | null>;
297
+ }
298
+ declare class EmbeddedRuntime {
299
+ readonly store: DocStore;
300
+ readonly executor: InlineUdfExecutor;
301
+ readonly handler: SyncProtocolHandler;
302
+ readonly writeFanoutAdapter: EmbeddedWriteFanoutAdapter;
303
+ private readonly modules;
304
+ private readonly systemModules;
305
+ private readonly adminModules;
306
+ private readonly componentNames;
307
+ private readonly contextProviders;
308
+ private readonly policyRegistry;
309
+ private readonly policyProviders;
310
+ private readonly relationRegistry;
311
+ private readonly drivers;
312
+ private readonly timers;
313
+ /** Threaded from `create()` (like `driverCtx`) so the public `fireDueTimers()` below runs against
314
+ * the SAME timer map + wake-arming closures the `DriverContext` writes to. */
315
+ private readonly fireDue;
316
+ /**
317
+ * Inverse of `tableNumbers` (tableNumber → fullTableName). Mutable (not `readonly`) so
318
+ * `setTableNumbers` can rebuild it in place after an additive deploy — the very same `Map`
319
+ * object `create()`'s `namesForCommit` closure captured, so mutating it here (rather than
320
+ * reassigning the field) keeps that closure correct without any circular-reference dance.
321
+ */
322
+ private tableNumberToName;
323
+ /** The WRITE transactor's timestamp observer (the single-shard oracle, or the
324
+ * `ShardedTransactor` itself). `observeTimestamp` delegates to this UNLESS `queryOracle` is
325
+ * set (see it below) — this oracle's own local commits always advance it directly via
326
+ * `ShardWriter.commit`'s `oracle.publishCommitted`; `observeTimestamp` is the SEPARATE
327
+ * fleet-follower/tailer-observation channel, which a hybrid node must NOT point back at the
328
+ * write oracle (D1 — own local commits must never advance the query oracle, and observed
329
+ * foreign timestamps must never advance the write oracle either, once a hybrid has a real
330
+ * query-path oracle of its own). */
331
+ private readonly oracle;
332
+ /** Fleet B3 (D1): the QUERY-path oracle, set only when `EmbeddedRuntimeOptions.queryStore` was
333
+ * configured. When set, `observeTimestamp` routes to THIS oracle instead of `oracle` — its
334
+ * purpose is tailer post-apply feeding (a hybrid node's replica-backed query snapshot rises
335
+ * only as the tailer confirms applied writes), never this runtime's own local commits (those
336
+ * land on the write store and advance `oracle` directly, as shipped). Undefined when
337
+ * `queryStore` is unset → `observeTimestamp` keeps the exact shipped write-oracle routing. */
338
+ private readonly queryOracle;
339
+ /** The write path — retained so a fleet writer can take a shard's commit mutex non-blockingly
340
+ * (`tryRunExclusiveOnShard`) to close idle frontiers without racing that shard's own commits. */
341
+ private readonly transactor;
342
+ /** Threaded from `create()` so `startDrivers()` can start deferred drivers later. */
343
+ private readonly driverCtx;
344
+ /** Mutable: false when `deferDrivers` was set and `startDrivers()` hasn't run yet. */
345
+ private driversStarted;
346
+ /** When set and `isLocalWriter()` is false, every mutation/action entry point forwards
347
+ * through it instead of executing locally. Queries never consult this. */
348
+ private readonly writeRouter;
349
+ /** Shards B2a (T5): the SAME resolved count `create()` used to build the transactor and every
350
+ * closure's `RunOptions.numShards` — instance methods (`run`/`runAction`/`runHttpAction`/
351
+ * `runSystem`/`runAdmin`) thread it through here so a call made AFTER construction routes
352
+ * identically to one made during `create()`. Defaults to 1 (byte-identical to before) when
353
+ * `EmbeddedRuntimeOptions.numShards` is unset. */
354
+ private readonly numShards;
355
+ /** The SAME catalog the executor/kernel enforce ownership against — so `runSystem`'s privileged
356
+ * doc-mutation routing (`resolveDocMutationShard`) reads a table's `shardKey` from the exact
357
+ * source of truth the guards use, and can never disagree with them. */
358
+ private readonly catalog;
359
+ /** The SAME `commitSubs` set `create()`'s `adapter.subscribe` callback fires on every LOCAL
360
+ * commit — also fired by `notifyExternalCommit` (below) for a FOREIGN (fleet) commit, so a
361
+ * driver's `onCommit` wakes on both without the two paths ever drifting on which set they
362
+ * target. */
363
+ private readonly commitSubs;
364
+ private sessionCounter;
365
+ private constructor();
366
+ static create(options: EmbeddedRuntimeOptions): Promise<EmbeddedRuntime>;
367
+ /**
368
+ * Resolves a target path's REAL registered kind against the live `this.modules` map — same
369
+ * resolver shape as `create()`'s local `functionKind` closure, but bound to the instance so it
370
+ * stays correct across `setModules` hot-swaps. See `ComponentContext.functionKind`'s doc
371
+ * comment (packages/executor/src/executor.ts).
372
+ */
373
+ private functionKind;
374
+ /** Hot-swap the function map (dev reload) without disturbing the store/transactor. */
375
+ setModules(modules: Record<string, RegisteredFunction>): void;
376
+ /**
377
+ * Rebuild the tableNumber→name map after an additive deploy so driver commit fan-out
378
+ * (`namesForCommit` in `create()`, which closed over this same `Map` instance) keeps
379
+ * translating newly-added tables' encoded storage ids to their full names correctly.
380
+ * Additive deploys keep existing numbers, so this only ever adds entries in practice.
381
+ */
382
+ setTableNumbers(tableNumbers: Record<string, number>): void;
383
+ /**
384
+ * The FOREIGN-COMMIT driver wake for a multi-writer fleet hybrid (Fleet B3, trigger-wake gap
385
+ * fix). `commitSubs` (driver `onCommit` wakes) normally fires only from `create()`'s
386
+ * `adapter.subscribe` callback — the LOCAL commit fan-out. In an opt-in multi-writer fleet, a
387
+ * co-writer's commit reaches THIS node only through the hybrid-tailer `invalidationSink`
388
+ * (`ee/packages/fleet/src/node.ts`), which calls `handler.notifyWrites` directly — bypassing
389
+ * `adapter.subscribe` entirely, so a driver here (e.g. `@helipod/triggers`) never woke on a
390
+ * foreign writer's commit and instead slept up to its own wall-clock beat. Delivery was always
391
+ * guaranteed (the durable cursor over the log) — this is a LATENCY fix, not a correctness one.
392
+ *
393
+ * The caller (the fleet's `invalidationSink`) invokes this after `notifyWrites`, passing the
394
+ * SAME derived invalidation. Translates `inv.tables` with the identical `translateTableIds`
395
+ * helper the local path uses, so a driver's `t.startsWith("scheduler/")`-style filter matches
396
+ * either source the same way. A local commit and a foreign commit that happen to touch the same
397
+ * table may both wake a driver for what is conceptually "the same" change window — harmless,
398
+ * since driver wakes are level-triggered (a driver re-checks its own state, it doesn't trust the
399
+ * wake payload as the sole source of truth).
400
+ *
401
+ * A no-op on any node with no registered drivers (`commitSubs` empty) — cheap to call
402
+ * unconditionally from every fleet node, writer-ish or not.
403
+ */
404
+ notifyExternalCommit(inv: {
405
+ tables: string[];
406
+ ranges: readonly SerializedKeyRange[];
407
+ commitTs: number;
408
+ }): void;
409
+ /**
410
+ * Live view of the registered app+component function paths. Reads the same mutable `modules`
411
+ * map `setModules` hot-swaps in place, so counts stay correct across a dev reload or deploy
412
+ * (the boot-time snapshot the server used to cache went stale after the first hot-swap).
413
+ */
414
+ functionPaths(): string[];
415
+ /** Live view of the registered table names (mirrors `functionPaths`, reads the live map). */
416
+ tableNames(): string[];
417
+ /** Open an in-process connection an unmodified client can talk to. */
418
+ connect(sessionId?: string): LoopbackConnection;
419
+ /**
420
+ * Synthesizes a `UdfResult` for a write forwarded to the writer node: `forward` only returns
421
+ * the function's JSON result, not read ranges / an oplog, so those come back empty/zero here.
422
+ * Shared by `run()` and `runAction()` — both route through the same `WriteRouter`.
423
+ */
424
+ private forwardedResult;
425
+ /** Directly invoke a function (for HTTP routes / the CLI `run` command). A MUTATION routes
426
+ * per-shard inside the executor (`executor.run` forwards a shard this node doesn't own); an
427
+ * ACTION forwards wholesale here to the default-shard holder; queries always run locally. */
428
+ run<T = unknown>(path: string, args: JSONValue, opts?: {
429
+ identity?: string | null;
430
+ commitMeta?: Record<string, string>;
431
+ dedup?: DedupKey;
432
+ }): Promise<UdfResult<T>>;
433
+ /**
434
+ * The OWNER-side dedup classification for a mutation reaching `run()` with a dedup key (the fleet
435
+ * `/_fleet/run` forward path). Shares the exact classify → run-with-guard → replay-on-collision
436
+ * logic the sync `runMutation` uses; a replay is surfaced as a `UdfResult` with `clientReplay` set
437
+ * (the `/_fleet/run` handler serializes it to a replay body). `base` is any pre-existing commit
438
+ * meta (e.g. fleet's `idempotencyKey`) — the dedup keys merge onto it (disjoint keys).
439
+ */
440
+ private runMutationClassified;
441
+ /** Wrap a {@link ClientReplay} as a `UdfResult` carrying `clientReplay` — no commit happened. */
442
+ private replayResult;
443
+ /** Directly invoke an action (for HTTP routes / the CLI `run` command). Public gate: blocks
444
+ * `_`-prefixed paths. Routes through `writeRouter` when set and this node isn't the writer. */
445
+ runAction<T = unknown>(path: string, args: JSONValue, opts?: {
446
+ identity?: string | null;
447
+ }): Promise<UdfResult<T>>;
448
+ /** Directly invoke an httpAction (for the public HTTP router). Passes the raw `Request` through
449
+ * untouched and returns the handler's `Response`. Public gate: blocks `_`-prefixed paths. */
450
+ runHttpAction(path: string, request: Request, opts?: {
451
+ identity?: string | null;
452
+ }): Promise<Response>;
453
+ /**
454
+ * The privileged built-in doc mutations whose target is a USER table (and thus may be sharded).
455
+ * These are the ONLY `runSystem` paths that need shard routing — every other `_system:*`/
456
+ * `_storage:*`/`_test:*` built-in writes UNSHARDED component-internal tables, which are owned by
457
+ * the default ring (INSERT from any ring; RMW on default), so they need no override.
458
+ */
459
+ private static readonly DOC_MUTATION_PATHS;
460
+ /** Run a privileged built-in (`_system:*`) function. Trusted callers only (the admin API).
461
+ * For a doc mutation on a user table, the target document's OWNING shard is resolved and passed
462
+ * through so the privileged write lands on the same ring a user's sharded mutation of that doc
463
+ * would — one-doc-one-ring. `opts.shardId` lets a trusted caller override the resolution. */
464
+ runSystem<T = unknown>(path: string, args: JSONValue, opts?: {
465
+ shardId?: ShardId;
466
+ commitMeta?: Record<string, string>;
467
+ }): Promise<UdfResult<T>>;
468
+ /**
469
+ * Resolve the owning shard for a privileged admin doc mutation so the write commits on the SAME
470
+ * ring a user's sharded mutation of that document would use (the one-doc-one-ring invariant).
471
+ * Without this, a dashboard edit of a sharded doc runs on the default ring and forks the doc's
472
+ * prev_ts chain against its home-shard writer — a permanent tailer halt + a silently-lost update.
473
+ *
474
+ * The shard-key field is IMMUTABLE after insert, so peeking the current doc's key value BEFORE the
475
+ * transaction is race-free (its shard can't have changed by the time the txn opens). An unsharded
476
+ * target (component tables, or app tables with no `.shardKey`) resolves to `"default"` — its RMW
477
+ * ring per the same invariant. Called only for `DOC_MUTATION_PATHS` when `numShards > 1`.
478
+ */
479
+ private resolveDocMutationShard;
480
+ /** Run a privileged admin built-in (`_admin:*`) once (e.g. for the HTTP fallback). Trusted callers only. */
481
+ runAdmin<T = unknown>(path: string, args: JSONValue): Promise<UdfResult<T>>;
482
+ /**
483
+ * Run every driver timer that is due (`atMs <= now()`), drop it, and re-arm the host to the new
484
+ * minimum — the firing half of the `EmbeddedRuntimeOptions.wakeHost` seam, called when the host's
485
+ * alarm goes off (`serve`'s `POST /_admin/wake`). It cannot be a direct call: the alarm lives in
486
+ * the host (a Durable Object), across a network boundary from this process.
487
+ *
488
+ * Safe and cheap on both paths through it, so a host never branches:
489
+ * - the process was STOPPED (the common case) — the wake request BOOTS it, the drivers' own
490
+ * `start()` re-derives everything from committed state, and this finds nothing due: a no-op.
491
+ * - the process was RUNNING — this runs the actual pending callback.
492
+ *
493
+ * Also a no-op without a `wakeHost` (nothing arms a wake, and `setTimeout` already fired
494
+ * everything due), so the route being registered on every deployment costs nothing.
495
+ */
496
+ fireDueTimers(): void;
497
+ /**
498
+ * Stop the component drivers and clear their pending timers, resetting `driversStarted` so a later
499
+ * `startDrivers()` can bring them back up. Shared by the driver-only `stopDriversOnly()` (B2b, D5)
500
+ * and the full-shutdown `stopDrivers()` — the ONLY difference is whether the sync handler is also
501
+ * disposed, so the two can never drift on how a driver is torn down.
502
+ */
503
+ private stopDriversInternal;
504
+ /**
505
+ * Stop all component drivers and clear all pending driver timers. Call on runtime shutdown.
506
+ * ALSO disposes the sync handler's background flush sweep — this is the full-teardown path.
507
+ */
508
+ stopDrivers(): Promise<void>;
509
+ /**
510
+ * Driver-only stop (B2b, D5 — "drivers follow the default shard"): stop the scheduler/workflow/cron/
511
+ * reaper drivers and clear their timers, WITHOUT disposing the sync handler. A fleet node that
512
+ * relinquishes (or gracefully releases) the default shard keeps serving reads, subscriptions, and
513
+ * mutations for every OTHER shard — only its drivers go quiet, because a different node now owns the
514
+ * default ring the scheduler tables live on. Symmetric with `startDrivers()` and idempotent both
515
+ * ways (the reset flag makes a later `startDrivers()` a real restart, and a second stop a no-op),
516
+ * so callers never need to track whether drivers are currently running. Deliberately NEVER touches
517
+ * `handler.dispose()` (the shipped `stopDrivers()` did — fatal on a default-relinquish, since the
518
+ * node stays live).
519
+ */
520
+ stopDriversOnly(): Promise<void>;
521
+ /**
522
+ * Start component drivers deferred via `EmbeddedRuntimeOptions.deferDrivers`, OR restart them after
523
+ * a `stopDriversOnly()` (B2b, D5). Idempotent — a second (or later) call is a no-op once drivers are
524
+ * running, so callers don't need to track whether they've already called it (e.g. a fleet node
525
+ * calling this on every default-shard acquisition attempt).
526
+ */
527
+ startDrivers(): Promise<void>;
528
+ /**
529
+ * Lets a non-writer fleet node advance its local timestamp oracle past timestamps it learns
530
+ * from the writer's change stream, so its own next allocated timestamp (if/when it becomes
531
+ * the writer) never collides with or precedes one it already observed.
532
+ *
533
+ * Fleet B3 (D1) routing: WITHOUT a `queryStore` (`queryOracle` undefined), this is the shipped
534
+ * behavior — delegates straight to the WRITE oracle (`this.oracle`; a `ShardedTransactor` fans
535
+ * it to every shard). WITH a `queryStore` configured, a hybrid node has a real query-path oracle
536
+ * whose sole purpose IS tailer post-apply feeding — so this routes to `queryOracle` INSTEAD, and
537
+ * the write oracle is left untouched by tailer observations (it advances only via this runtime's
538
+ * own local commits, through `ShardWriter.commit`'s `oracle.publishCommitted`). Sharing one
539
+ * oracle between the two would let a query snapshot ABOVE the replica's actual watermark and
540
+ * read holes (D1's spec-review requirement) — this branch is what keeps them separate.
541
+ *
542
+ * This is the READ-PATH observer (tailer/follower freshness → query snapshot). Its write-path
543
+ * counterpart, `observeWriteTimestamp` (below), always targets the WRITE oracle and exists
544
+ * precisely because this method stopped feeding the write side once a hybrid has a query oracle.
545
+ */
546
+ observeTimestamp(ts: bigint): void;
547
+ /**
548
+ * Advance the WRITE transactor's timestamp oracle(s) past `ts` — the write-path counterpart to
549
+ * `observeTimestamp` above, and the pre-T1 semantics of what `observeTimestamp` used to do before
550
+ * hybrid routing split the two. ALWAYS targets `this.oracle` (the write side), independent of
551
+ * `queryStore`/`queryOracle` routing: a `ShardedTransactor` fans `ts` to every existing shard
552
+ * oracle AND raises its `observedHighWater` floor (so a shard writer CREATED later seeds at-or-past
553
+ * `ts`); the single-shard `MonotonicTimestampOracle` takes it directly.
554
+ *
555
+ * Purpose (distinct from `observeTimestamp`): re-floor the WRITE snapshot on a shard OWNERSHIP
556
+ * CHANGE. On a hybrid node `observeTimestamp` feeds the QUERY oracle, so nothing feeds the write
557
+ * oracle from foreign observations anymore. A shard this node held, RELEASED (its `ShardWriter`
558
+ * stays in the transactor's Map — only the fleet epoch is dropped), and later RE-ACQUIRES would
559
+ * keep that `ShardWriter`'s oracle frozen at this node's own last commit; the next mutation would
560
+ * snapshot BELOW an interim owner's commits and an RMW handler would compute on stale state (the
561
+ * durable chain stays intact via latest-`prev_ts`, but the update is semantically lost). The fleet
562
+ * calls this on EVERY shard acquisition with the lease row's `frontier_ts` — which is >= every
563
+ * prior commit on that shard by the fence invariant (the commit guard writes `frontier_ts =
564
+ * GREATEST(frontier_ts, commitTs)` inside each commit txn) — so it is the exact correct floor.
565
+ * Idempotent/monotone: a `ts` at or below the oracle's position is a no-op (harmless on a
566
+ * fresh/never-released shard, and on every non-hybrid node where it is called uniformly too).
567
+ */
568
+ observeWriteTimestamp(ts: bigint): void;
569
+ /**
570
+ * Run `fn` under shard `shardId`'s commit mutex IFF that shard is idle right now — the seam a fleet
571
+ * writer's idle-frontier closer uses to publish a shard's frontier atomically with respect to that
572
+ * shard's own commits (see `ShardedTransactor.tryRunExclusiveOnShard`). A shard is idle only when
573
+ * the commit mutex is free AND no group-commit batch is staged/flushing (Fleet B4: the flush runs
574
+ * OFF the mutex, so mutex-freedom alone is not enough — a mid-flush batch has ts's drawn but rows
575
+ * not yet landed, and must read as busy to keep the closer from publishing a frontier above them).
576
+ * Returns `true` if `fn` ran, `false` if the shard is busy (skip; retry next beat). Total across
577
+ * sharded/single-shard runtimes — the single-shard transactor ignores `shardId` and uses its one
578
+ * writer.
579
+ */
580
+ tryRunExclusiveOnShard(shardId: ShardId, fn: () => Promise<void>): Promise<boolean>;
581
+ /**
582
+ * Group-commit counters (Fleet B4, T4 health) — total across the transactor, whichever shape it
583
+ * is: `ShardedTransactor.groupCommitStats()` aggregates over every live shard,
584
+ * `SingleWriterTransactor.groupCommitStats()` mirrors it for the one writer. Both are
585
+ * structurally all-zero when `EmbeddedRuntimeOptions.groupCommit` is unset/false (the underlying
586
+ * `ShardWriter` never touches these fields on the single-commit path) — callers need no separate
587
+ * on/off branch. The fleet health seam (`@helipod/fleet`'s `node.ts`) reads this to derive
588
+ * `flushesPerSec` between successive `/api/health` reads.
589
+ */
590
+ groupCommitStats(): {
591
+ lastBatchSize: number;
592
+ maxBatchSize: number;
593
+ flushCount: number;
594
+ };
595
+ }
596
+ declare function createEmbeddedRuntime(options: EmbeddedRuntimeOptions): Promise<EmbeddedRuntime>;
597
+
598
+ /**
599
+ * `RuntimeHost` — the neutral seam between the engine and whatever binds it to a transport
600
+ * (HTTP + WebSocket). It exists so the SAME `EmbeddedRuntime` can run on a long-lived process
601
+ * (`Bun.serve` / `node:http` + `ws`) AND on a Cloudflare Durable Object (Worker `fetch` +
602
+ * `WebSocketPair`/hibernation), with each backend implementing this one method.
603
+ *
604
+ * NEUTRALITY RULE (a Slice-1 gate — asserted mechanically in
605
+ * `packages/runtime-embedded/test/host-neutral.test.ts`): this file imports ONLY `@helipod/*`
606
+ * symbols and TS type-only imports. It contains NO host I/O primitive — no `bun`, `node:*`, `ws`,
607
+ * no cloudflare type, no `DurableObjectNamespace`. Where a concept cannot be expressed neutrally it
608
+ * is documented, not papered over (see `ServerHandle.close` and `ServeOptions` below).
609
+ *
610
+ * WHY GENERIC: `ServeOptions`/`ServerHandle` are lifted verbatim (no field changes) from the
611
+ * process host's `DevServerOptions`/`DevServer`. Several of those fields reference types that live
612
+ * in `@helipod/cli`, `@helipod/admin`, and `@helipod/storage` — all of which depend ON
613
+ * `@helipod/runtime-embedded`, so importing them here would be a dependency cycle AND would drag
614
+ * a host I/O concern into the neutral seam. The route/admin/storage/deploy/fleet shapes are
615
+ * therefore carried as type parameters (defaulting to `unknown`): the neutral seam stays parametric
616
+ * and each concrete host pins the parameters (`@helipod/cli` re-aliases them as `DevServer`/
617
+ * `DevServerOptions`). The FIELDS are unchanged; only their concrete types are supplied by the host.
618
+ */
619
+
620
+ /**
621
+ * The handle a host returns from {@link RuntimeHost.serve}. Its `close()`/`setRoutes()` are the ONLY
622
+ * lifecycle a caller drives post-serve. Lifted verbatim from the process host's `DevServer`.
623
+ */
624
+ interface ServerHandle<Route = unknown> {
625
+ url: string;
626
+ /**
627
+ * The bound TCP port. A portless host (a Durable Object — the Worker owns ingress, there is no
628
+ * socket to bind) returns `0` as a sentinel. Do not treat `0` as "unbound/failed".
629
+ */
630
+ port: number;
631
+ /**
632
+ * Stop serving and release resources. **MAY never be called.** A host with no shutdown moment —
633
+ * a Durable Object hibernates and is evicted silently, with no `SIGTERM` — is a valid host, and
634
+ * such a host's `close()` may legitimately be a no-op. Do NOT rely on `close()` (and therefore
635
+ * on `runtime.stopDrivers()` / `store.close()`) running: durable work must survive without it.
636
+ * The process host DOES call `close()` on `SIGTERM`/`SIGINT`, exactly as before this seam existed.
637
+ */
638
+ close(): Promise<void>;
639
+ /** Replace the httpAction route table, e.g. after a hot reload re-resolves `http.ts`. */
640
+ setRoutes(routes: Route[]): void;
641
+ }
642
+ /**
643
+ * The options a caller passes to {@link RuntimeHost.serve}. Lifted verbatim from the process host's
644
+ * `DevServerOptions` — no field changes.
645
+ *
646
+ * IMPEDANCE CONTRACT (do not design against it): the sync handler's per-session state (each
647
+ * session's read-set, held behind `runtime.handler.connect`/`handleMessage`) is NOT guaranteed to
648
+ * survive a single `serve()` lifetime. On a Durable Object, WebSocket hibernation discards
649
+ * in-memory state; a host may reconstruct a session from its durable attachment and call
650
+ * `handler.connect` again on revival. A host must therefore treat
651
+ * `handler.connect(sessionId, socket)` / `handler.disconnect(sessionId)` as the transport boundary
652
+ * and never assume the handler's in-memory session map persists for the server's lifetime.
653
+ */
654
+ interface ServeOptions<Route = unknown, Admin = unknown, StorageRt = unknown, Deploy = unknown, Fleet = unknown> {
655
+ port: number;
656
+ ip: string;
657
+ webDir?: string;
658
+ admin?: {
659
+ api: Admin;
660
+ key: string;
661
+ };
662
+ /**
663
+ * The dashboard SPA. Two variants:
664
+ * - `dev`/`serve`: `{ distDir, html }` — dist dir (hashed Vite assets) + key-injected index.html.
665
+ * - a compiled `helipod build` binary: `{ assets, html }` — a urlPath→embedded-path map.
666
+ */
667
+ dashboard?: {
668
+ distDir: string;
669
+ html: string;
670
+ } | {
671
+ assets: Record<string, string>;
672
+ html: string;
673
+ };
674
+ /** The app's `http.ts` routes, resolved to `path:name` function paths for dispatch. */
675
+ routes?: Route[];
676
+ /** Engine-owned `/api/storage/*` handlers (always-on file storage). Reserved — matched before
677
+ * user routes; stable across reload/deploy. */
678
+ storageRoutes?: StorageRt[];
679
+ /** Reserved routes contributed by composed components (e.g. auth's OAuth callbacks). Matched
680
+ * after storage routes, before user routes. Engine-owned `{method,pathPrefix,handler}` closures.
681
+ * Shares the `StorageRt` shape (`{method,pathPrefix,handler}`) — the CLI pins both to `StorageRoute`. */
682
+ componentRoutes?: StorageRt[];
683
+ /** `POST /_admin/deploy` + `GET /_admin/deploy/modules` handlers — present only when deploy is
684
+ * enabled. `apply` accepts a legacy `{files}` OR a delta `{changed, unchanged}` payload; `modules`
685
+ * returns the current per-path hashes for the client's delta partition. */
686
+ deploy?: {
687
+ apply: (payload: {
688
+ files: Array<{
689
+ path: string;
690
+ code: string;
691
+ }>;
692
+ } | {
693
+ changed: Array<{
694
+ path: string;
695
+ code: string;
696
+ }>;
697
+ unchanged: Array<{
698
+ path: string;
699
+ sha256: string;
700
+ }>;
701
+ }) => Promise<Deploy>;
702
+ modules: () => Record<string, string>;
703
+ };
704
+ /** Fleet node handle — present only under `serve --fleet`. Absent → byte-for-byte non-fleet. */
705
+ fleet?: Fleet;
706
+ /** Present only when THIS node is a `--replica` configured with `--writer-url` — arms `/api/run`'s
707
+ * single-hop defensive guard. Absent → byte-for-byte unchanged behavior. */
708
+ replicaWriterUrl?: string;
709
+ }
710
+ /**
711
+ * The seam. A host binds an `EmbeddedRuntime` to a transport and starts serving; the returned
712
+ * {@link ServerHandle} is the whole post-serve lifecycle surface. Slice 1 ships one implementation
713
+ * (`@helipod/cli`'s `ProcessRuntimeHost`); a Durable Object host (Slice 3) implements the same
714
+ * method, wiring Worker `fetch` → the engine's HTTP dispatch and `WebSocketPair`/hibernation →
715
+ * `runtime.handler.connect`. `serve()` is called once per host instance (one call per process, or
716
+ * one per DO incarnation) — it must not assume it is the only call in a process's lifetime.
717
+ */
718
+ interface RuntimeHost<Route = unknown, Admin = unknown, StorageRt = unknown, Deploy = unknown, Fleet = unknown> {
719
+ serve(runtime: EmbeddedRuntime, options: ServeOptions<Route, Admin, StorageRt, Deploy, Fleet>): Promise<ServerHandle<Route>>;
720
+ }
721
+
722
+ export { EmbeddedRuntime, type EmbeddedRuntimeOptions, EmbeddedWriteFanout, type EmbeddedWriteFanoutAdapter, type EmbeddedWriteFanoutPayload, type FanoutListener, InMemoryWriteFanoutAdapter, type LoopbackConnection, type LoopbackHandler, type RuntimeHost, type ServeOptions, type ServerHandle, type ServerMessageListener, clientReceiptsGuard, createEmbeddedRuntime, createLoopbackConnection };