@volter/twin-inngest 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,60 @@
1
+ import type { SyncResource } from '@volter/world-core';
2
+ import { type InngestBudgetedOptions } from './inngest-budget.js';
3
+ export type { InngestBudgetedOptions };
4
+ export type InngestEventHandle = {
5
+ eventId: string;
6
+ };
7
+ export type InngestRunHandle = {
8
+ runId: string;
9
+ };
10
+ export type InngestRealEvent = {
11
+ name: string;
12
+ data?: unknown;
13
+ user?: unknown;
14
+ idempotencyId?: string | null;
15
+ ts?: number;
16
+ };
17
+ export type InngestRealRun = {
18
+ id: string;
19
+ functionId: string;
20
+ eventId: string;
21
+ status: string;
22
+ output?: unknown;
23
+ error?: unknown;
24
+ cursor?: number;
25
+ };
26
+ export interface InngestLikeClient {
27
+ events?: {
28
+ get: (eventId: string) => Promise<InngestRealEvent>;
29
+ };
30
+ runs?: {
31
+ get: (runId: string) => Promise<InngestRealRun>;
32
+ };
33
+ }
34
+ /** Pure mapper (real event -> SyncResource) — never touches a client, so the mutation-test
35
+ * connector-seam sweep (which sabotages every export matching the sync-or-push-or-pull-or-
36
+ * fullSync naming convention) leaves this real, per the pack convention (fal-connector.ts's
37
+ * `mapQueueRequest`). Field names avoid the kernel's reserved `type`/`id`/`updatedAt` meta keys
38
+ * (see inngest-runtime.ts header) — `idempotency_id`, never a bare `id`, inside `fields`. */
39
+ export declare function mapEvent(handle: InngestEventHandle, real: InngestRealEvent): SyncResource;
40
+ /** Pure mapper (real run -> SyncResource). */
41
+ export declare function mapRun(handle: InngestRunHandle, real: InngestRealRun): SyncResource;
42
+ /** Pull the CURRENT real state of every named event handle. No `client.events` (or an empty
43
+ * handle list) observes nothing — there is nothing to enumerate without a handle. */
44
+ export declare function pullInngestEvents(rawClient: InngestLikeClient, handles: InngestEventHandle[], opts?: InngestBudgetedOptions): Promise<SyncResource[]>;
45
+ /** Pull the CURRENT real state of every named run handle. */
46
+ export declare function pullInngestRuns(rawClient: InngestLikeClient, handles: InngestRunHandle[], opts?: InngestBudgetedOptions): Promise<SyncResource[]>;
47
+ /**
48
+ * D7 entry point: pull the current real state of every explicitly-named event/run handle and
49
+ * fold it into the twin via ONE `syncPull` (shadow-diff dedup). Returns
50
+ * `{observed, deltasAppended}` — a re-pull of identical state appends ZERO deltas.
51
+ */
52
+ export declare function syncInngestFromReal(rawClient: InngestLikeClient, opts?: {
53
+ root?: string;
54
+ occurredAt?: string;
55
+ events?: InngestEventHandle[];
56
+ runs?: InngestRunHandle[];
57
+ } & InngestBudgetedOptions): Promise<{
58
+ observed: number;
59
+ deltasAppended: number;
60
+ }>;
@@ -0,0 +1,109 @@
1
+ // inngest CONNECTOR — the live-vendor pull path that gives the inngest twin the "git for SaaS"
2
+ // lifecycle over an INJECTED client (the auth boundary).
3
+ //
4
+ // PULL (real -> twin) is HANDLE-DRIVEN, like fal-connector.ts and pinecone's vector pull — the
5
+ // real Inngest v2 REST API (grounded, api-docs.inngest.com/api-specs/v2.json, fetched read-only
6
+ // during this build) has NO bulk "list every event" or "list every run" endpoint: only
7
+ // `GET /runs/{runId}` (by known id) and `GET /events/{eventId}/runs` (runs for a KNOWN event id).
8
+ // So a caller of this connector must already know which event/run ids it cares about (e.g. from
9
+ // its own application's persisted correlation ids) — the connector pulls the CURRENT real state
10
+ // of each named handle and folds it into the twin via `syncPull` (shadow-diff dedup, so a re-pull
11
+ // of identical state is a no-op).
12
+ //
13
+ // The vendor I/O is an INJECTED client interface (`InngestLikeClient`): a fake in tests, a thin
14
+ // real-fetch wrapper in prod. The pack imports NO SDK and holds NO key.
15
+ import { syncPull } from '@volter/world-core';
16
+ //
17
+ // ── The client-side RATE BUDGET is not optional here ────────────────────────────────────────
18
+ // Inngest's REST reference documents NO rate limit, no 429 and no Retry-After for the endpoints this
19
+ // connector calls; the one endpoint it does annotate as rate limited ("fetch function run jobs",
20
+ // cached for 5 seconds) gives no number and is not in this surface.
21
+ // Every entrypoint below GUARDS the injected client before touching it (`guardInngestClient`, which is
22
+ // idempotent — a caller who already wrapped is not double-charged, a caller who forgot is protected
23
+ // anyway); there is deliberately no option that turns the budget off. See inngest-budget.ts.
24
+ import { inngestBudgetOf, guardInngestClient } from "./inngest-budget.js";
25
+ const SERVICE = 'inngest';
26
+ function kid(type, id) {
27
+ return `${type}:${id}`;
28
+ }
29
+ /** Pure mapper (real event -> SyncResource) — never touches a client, so the mutation-test
30
+ * connector-seam sweep (which sabotages every export matching the sync-or-push-or-pull-or-
31
+ * fullSync naming convention) leaves this real, per the pack convention (fal-connector.ts's
32
+ * `mapQueueRequest`). Field names avoid the kernel's reserved `type`/`id`/`updatedAt` meta keys
33
+ * (see inngest-runtime.ts header) — `idempotency_id`, never a bare `id`, inside `fields`. */
34
+ export function mapEvent(handle, real) {
35
+ return {
36
+ type: 'event',
37
+ id: kid('event', handle.eventId),
38
+ fields: {
39
+ name: real.name,
40
+ data: real.data ?? {},
41
+ user: real.user ?? null,
42
+ idempotency_id: real.idempotencyId ?? null,
43
+ ts: real.ts ?? null,
44
+ received_at: null,
45
+ v: null,
46
+ },
47
+ };
48
+ }
49
+ /** Pure mapper (real run -> SyncResource). */
50
+ export function mapRun(handle, real) {
51
+ return {
52
+ type: 'run',
53
+ id: kid('run', handle.runId),
54
+ fields: {
55
+ function_id: real.functionId,
56
+ event_id: real.eventId,
57
+ status: real.status,
58
+ cursor: real.cursor ?? 0,
59
+ output: real.output ?? null,
60
+ error: real.error ?? null,
61
+ created_at: null,
62
+ started_at: null,
63
+ ended_at: null,
64
+ },
65
+ };
66
+ }
67
+ /** Pull the CURRENT real state of every named event handle. No `client.events` (or an empty
68
+ * handle list) observes nothing — there is nothing to enumerate without a handle. */
69
+ export async function pullInngestEvents(rawClient, handles, opts = {}) {
70
+ const client = guardInngestClient(rawClient, opts);
71
+ if (!client.events || handles.length === 0)
72
+ return [];
73
+ const out = [];
74
+ for (const handle of handles) {
75
+ const real = await client.events.get(handle.eventId);
76
+ out.push(mapEvent(handle, real));
77
+ }
78
+ return out;
79
+ }
80
+ /** Pull the CURRENT real state of every named run handle. */
81
+ export async function pullInngestRuns(rawClient, handles, opts = {}) {
82
+ const client = guardInngestClient(rawClient, opts);
83
+ if (!client.runs || handles.length === 0)
84
+ return [];
85
+ const out = [];
86
+ for (const handle of handles) {
87
+ const real = await client.runs.get(handle.runId);
88
+ out.push(mapRun({ runId: handle.runId }, real));
89
+ }
90
+ return out;
91
+ }
92
+ /**
93
+ * D7 entry point: pull the current real state of every explicitly-named event/run handle and
94
+ * fold it into the twin via ONE `syncPull` (shadow-diff dedup). Returns
95
+ * `{observed, deltasAppended}` — a re-pull of identical state appends ZERO deltas.
96
+ */
97
+ export async function syncInngestFromReal(rawClient, opts = {}) {
98
+ // Guard ONCE here and hand the guarded client down: the per-handle loops below are unbounded in
99
+ // handle count, so this is the entrypoint that must be unable to run unbudgeted.
100
+ const client = guardInngestClient(rawClient, inngestBudgetOf(opts));
101
+ const occurredAt = opts.occurredAt ?? new Date().toISOString();
102
+ const [events, runs] = await Promise.all([
103
+ pullInngestEvents(client, opts.events ?? []),
104
+ pullInngestRuns(client, opts.runs ?? []),
105
+ ]);
106
+ const resources = [...events, ...runs];
107
+ const result = syncPull({ service: SERVICE, resources, occurredAt, ...(opts.root !== undefined ? { root: opts.root } : {}) });
108
+ return { observed: result.observed, deltasAppended: result.deltasAppended };
109
+ }
@@ -0,0 +1,98 @@
1
+ export type InngestStepOp = 'StepRun' | 'Sleep' | 'WaitForEvent' | 'InvokeFunction';
2
+ export type DeclaredStep = {
3
+ id: string;
4
+ op: InngestStepOp;
5
+ /** WaitForEvent only: the event name this step gates on. */
6
+ eventName?: string;
7
+ /** WaitForEvent only: resolve with a deterministic timeout (output=null) after this many
8
+ * non-matching advance calls — a poll-COUNT timeout, never a wall-clock one (§13 ⚠5). */
9
+ timeoutAfterPolls?: number;
10
+ /** StepRun only: fail this many times (attempts 1..failTimes) before succeeding on the next
11
+ * advance — the deterministic retry-count fixture. */
12
+ failTimes?: number;
13
+ /** StepRun only: an explicit, test-declared output (skips the deterministic-stub hash). */
14
+ output?: unknown;
15
+ };
16
+ export type CancelOnRule = {
17
+ event: string;
18
+ if?: string;
19
+ };
20
+ export type DeclaredFunction = {
21
+ id: string;
22
+ triggers: Array<{
23
+ event?: string;
24
+ cron?: string;
25
+ }>;
26
+ steps: DeclaredStep[];
27
+ config?: Record<string, unknown>;
28
+ cancelOn?: CancelOnRule[];
29
+ url?: string;
30
+ };
31
+ export declare function nowIso(occurredAt?: string): string;
32
+ export declare function canonicalJson(v: unknown): string;
33
+ /** step.run output = declared output or hash(runId::stepId::canonical-JSON(input)) (build spec
34
+ * §1 divergence 1). `input` includes every PRIOR completed step's persisted output, so step-2's
35
+ * value is a genuine function of step-1's REPLAYED (kernel-read) result — the load-bearing fact
36
+ * `steps.replay_deterministic` proves. */
37
+ export declare function stubStepOutput(runId: string, stepId: string, input: unknown): string;
38
+ export declare function newUlid(atMs: number, seed: string): string;
39
+ export declare function rows(type: string, root?: string): Array<Record<string, unknown>>;
40
+ export declare function getRow(type: string, id: string, root?: string): Record<string, unknown> | undefined;
41
+ export declare function write(type: string, id: string, fields: Record<string, unknown>, op: string, root: string | undefined, occurredAt: string | undefined): Promise<Record<string, unknown>>;
42
+ export declare function registerFunction(fn: DeclaredFunction, root: string | undefined, occurredAt: string | undefined): Promise<{
43
+ row: Record<string, unknown>;
44
+ modified: boolean;
45
+ skipped: boolean;
46
+ }>;
47
+ export declare function listFunctions(root?: string): Array<Record<string, unknown>>;
48
+ export declare function functionsMatchingEvent(eventName: string, root?: string): Array<Record<string, unknown>>;
49
+ export declare function functionsCancelledByEvent(eventName: string, root?: string): Array<Record<string, unknown>>;
50
+ export type SendEventInput = {
51
+ name: string;
52
+ data?: unknown;
53
+ user?: unknown;
54
+ id?: string;
55
+ ts?: number;
56
+ v?: string;
57
+ };
58
+ /** Find an already-received event with the same (name, idempotency_id) — a minimal single-run
59
+ * dedupe window (build spec §13 ⚠7: modeled minimal, not the real vendor's full TTL window). */
60
+ export declare function findIdempotentEvent(name: string, idempotencyId: string, root?: string): Record<string, unknown> | undefined;
61
+ export declare function recordEvent(input: SendEventInput, root: string | undefined, occurredAt: string | undefined): Promise<{
62
+ id: string;
63
+ deduped: boolean;
64
+ }>;
65
+ export declare function createRun(functionId: string, eventId: string, root: string | undefined, occurredAt: string | undefined): Promise<Record<string, unknown>>;
66
+ export declare function runsForEvent(eventId: string, root?: string): Array<Record<string, unknown>>;
67
+ export declare function runsForFunction(functionId: string, root?: string): Array<Record<string, unknown>>;
68
+ /** Cancel every non-terminal run of `functionId` — the REAL Inngest cancellation mechanism
69
+ * (grounded live, 2026-07-09, inngest.com/docs/features/inngest-functions/cancellation): a
70
+ * function declares `cancelOn: [{event, if}]`; when a matching event is sent, in-flight runs of
71
+ * that function transition to CANCELLED. `if` is a CEL-style expression against
72
+ * `event.data`/`async.data` — this twin matches on EVENT NAME ONLY (config-acceptance done, per
73
+ * build spec §0's "config knobs = echo back" tier); full `if`-expression evaluation is todo
74
+ * (`inngest.scheduling.cancel_if_expression`). NOT a REST DELETE call — the real v2 REST API
75
+ * (api-docs.inngest.com/api-specs/v2.json, fetched read-only during this build) has no
76
+ * cancel-run endpoint at all; this is the one and only real mechanism. */
77
+ export declare function cancelRunsForFunction(functionId: string, root: string | undefined, occurredAt: string | undefined): Promise<Record<string, unknown>[]>;
78
+ export declare function getStep(runId: string, stepId: string, root?: string): Record<string, unknown> | undefined;
79
+ export declare function stepsForRun(runId: string, root?: string): Array<Record<string, unknown>>;
80
+ /** Every COMPLETED step's persisted output, in declared-plan order — read FRESH from the kernel
81
+ * on every call (never a JS-local cache), so a re-invocation genuinely REPLAYS them rather than
82
+ * recomputing. This is what makes `steps.replay_deterministic` die under the `httpEmpty`
83
+ * saboteur: an empty kernel means this returns `{}`, and any downstream step's stub hash — which
84
+ * folds this object into its input — comes out WRONG. */
85
+ export declare function priorOutputs(runId: string, plan: DeclaredStep[], uptoIndex: number, root?: string): Record<string, unknown>;
86
+ export type AdvanceResult = {
87
+ run: Record<string, unknown>;
88
+ step?: Record<string, unknown>;
89
+ };
90
+ /**
91
+ * ONE poll-fold advance of a run (fal-twin.ts progressOnce pattern, build spec §6):
92
+ * QUEUED -> RUNNING (echo, no step touched — mirrors fal's poll-1 IN_QUEUE echo)
93
+ * cursor < plan.length -> process plan[cursor] by its declared op (memoize on first touch,
94
+ * REPLAY — read, never recompute — on every subsequent touch)
95
+ * cursor >= plan.length -> COMPLETED, output = last completed step's output
96
+ * A run already in a terminal state (COMPLETED/FAILED/CANCELLED) is a no-op (idempotent).
97
+ */
98
+ export declare function advanceRun(runId: string, root: string | undefined, occurredAt: string | undefined): Promise<AdvanceResult | undefined>;
@@ -0,0 +1,329 @@
1
+ // inngest DURABLE-EXECUTION STATE MACHINE — written FRESH for this pack (build spec §1
2
+ // divergence 1). This is the novel core: Inngest's defining product idea is that a function
3
+ // runs as a sequence of independently-checkpointed STEPS — each step's output is durably
4
+ // persisted, and re-invoking a run REPLAYS every already-completed step by reading its
5
+ // persisted output back out (never recomputing it) before advancing to the next uncompleted
6
+ // step. That is the exact semantic this file models, kernel-backed (no in-memory side-store):
7
+ // every step's result lives in a `step:<runId>::<stepId>` kernel row, and `advanceRun` below
8
+ // is the ONLY function that ever creates or completes one — it always re-reads state from
9
+ // `projectResources` first, so a "replay" is genuinely a kernel read, not a JS-local cache hit.
10
+ //
11
+ // HONESTY (build spec §13 ⚠3, README ## Coverage): this twin does NOT execute real user code —
12
+ // there is no sandboxed JS runtime here. A function's steps are DECLARED up front (at
13
+ // `functions.register` time, or directly by a test) as a `step_plan: [{id, op, ...}]` array —
14
+ // a twin-only extension of the real wire register body (the real Inngest register payload has
15
+ // no such field; a real SDK infers steps by re-running the handler function up to its next
16
+ // `step.run()` call). Given a declared plan, this twin faithfully reproduces the DURABLE-
17
+ // EXECUTION SEMANTICS real Inngest promises — memoization, replay-from-storage, sleep/wait
18
+ // gating, exact retry counting — without ever running arbitrary code. `exec.sdk_executor_
19
+ // roundtrip` (a real running app + real step functions) is the filed `todo`; see the pack
20
+ // README.
21
+ //
22
+ // Step opcodes GROUNDED against the installed `inngest@4.12.0` package's own compiled
23
+ // `StepOpCode` enum (types.js — read-only `npm pack` fetch during this build, not just docs):
24
+ // 'StepRun' | 'Sleep' | 'WaitForEvent' | 'InvokeFunction' — see spec-sources.json.
25
+ //
26
+ // Poll-fold pattern ported from fal-twin.ts's `progressOnce` (fal-twin.ts:245-255): each call to
27
+ // `advanceRun` performs AT MOST ONE meaningful state transition (no wall clock, no timers), so a
28
+ // test suite re-running this file is bit-for-bit reproducible regardless of real elapsed time.
29
+ import { applyTwinWrite, projectResources } from '@volter/world-core';
30
+ import { createHash } from 'node:crypto';
31
+ const SERVICE = 'inngest';
32
+ const DEFAULT_MAX_ATTEMPTS = 4; // modeled default (§13 ⚠4 — doc-UNVERIFIED exact vendor default; config.retries overrides it, see inngest-twin.ts)
33
+ function kid(type, id) {
34
+ return `${type}:${id}`;
35
+ }
36
+ export function nowIso(occurredAt) {
37
+ return occurredAt ?? new Date().toISOString();
38
+ }
39
+ // ── canonical JSON + deterministic stub hashing (ported convention: fal-twin.ts:186-195) ──────
40
+ export function canonicalJson(v) {
41
+ if (v === null || typeof v !== 'object')
42
+ return JSON.stringify(v);
43
+ if (Array.isArray(v))
44
+ return `[${v.map(canonicalJson).join(',')}]`;
45
+ const keys = Object.keys(v).sort();
46
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(v[k])}`).join(',')}}`;
47
+ }
48
+ /** step.run output = declared output or hash(runId::stepId::canonical-JSON(input)) (build spec
49
+ * §1 divergence 1). `input` includes every PRIOR completed step's persisted output, so step-2's
50
+ * value is a genuine function of step-1's REPLAYED (kernel-read) result — the load-bearing fact
51
+ * `steps.replay_deterministic` proves. */
52
+ export function stubStepOutput(runId, stepId, input) {
53
+ const hash = createHash('sha256').update(`${runId}::${stepId}::${canonicalJson(input)}`).digest('hex').slice(0, 8);
54
+ return `[twin-stub:inngest:${hash}]`;
55
+ }
56
+ // A ULID-SHAPED (Crockford base32, 26 chars, time-prefixed) id — real Inngest event ids are
57
+ // ULIDs, and this models the shape faithfully AND DETERMINISTICALLY (R9). Both halves used to
58
+ // carry entropy — `Date.now()` for the time prefix and `randomUUID()` for the tail — so two
59
+ // identical worlds sending the same event served different ids as soon as anything read the
60
+ // event state back (`GET /twin/store/events`, `GET /api/v2/events/{id}/runs`). The time prefix
61
+ // is now the WORLD instant (which is what a ULID's prefix MEANS, so this is also the more
62
+ // faithful shape) and the tail is a stable digest of the seed the caller passes.
63
+ const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
64
+ export function newUlid(atMs, seed) {
65
+ let timePart = '';
66
+ let t = Math.max(0, Math.floor(atMs));
67
+ for (let i = 0; i < 10; i++) {
68
+ timePart = CROCKFORD[t % 32] + timePart;
69
+ t = Math.floor(t / 32);
70
+ }
71
+ let randPart = '';
72
+ const digest = createHash('sha256').update(seed).digest('hex');
73
+ for (let i = 0; i < 16; i++) {
74
+ randPart += CROCKFORD[parseInt(digest[i], 16) % 32];
75
+ }
76
+ return timePart + randPart;
77
+ }
78
+ /** A UUID-shaped digest of `seed` — a run id keeps the vendor's shape without the entropy. */
79
+ function uuidFrom(seed) {
80
+ const h = createHash('sha256').update(seed).digest('hex');
81
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-8${h.slice(17, 20)}-${h.slice(20, 32)}`;
82
+ }
83
+ // ── projection helpers (mirrors fal-twin.ts:154-168) ──────────────────────────────────────────
84
+ export function rows(type, root) {
85
+ const prefix = `${type}:`;
86
+ return projectResources(SERVICE, root)
87
+ .filter((r) => r.type === type && r.id.startsWith(prefix) && r._deleted !== true)
88
+ .map((r) => ({ ...r, id: r.id.slice(prefix.length) }));
89
+ }
90
+ export function getRow(type, id, root) {
91
+ return rows(type, root).find((r) => r.id === id);
92
+ }
93
+ function view(r) {
94
+ const { type: _t, updatedAt: _u, ...rest } = r;
95
+ const out = {};
96
+ for (const [k, v] of Object.entries(rest))
97
+ if (!k.startsWith('_'))
98
+ out[k] = v;
99
+ return out;
100
+ }
101
+ export async function write(type, id, fields, op, root, occurredAt) {
102
+ const { resource } = await applyTwinWrite(SERVICE, { operation: op, subjectType: type, subjectId: kid(type, id), fields, ...(occurredAt ? { occurredAt } : {}), actor: { kind: 'agent' } }, root);
103
+ return view({ ...resource, id });
104
+ }
105
+ // ── functions: register / lookup ───────────────────────────────────────────────────────────────
106
+ export async function registerFunction(fn, root, occurredAt) {
107
+ const existing = getRow('function', fn.id, root);
108
+ const nextFields = {
109
+ fn_id: fn.id,
110
+ triggers: fn.triggers,
111
+ step_plan: fn.steps,
112
+ config: fn.config ?? {},
113
+ cancel_on: fn.cancelOn ?? [],
114
+ url: fn.url ?? null,
115
+ registered_at: nowIso(occurredAt),
116
+ };
117
+ // Compare only the caller-meaningful shape — never `fn_id`/`registered_at` (registration
118
+ // metadata, not config) and never `existing`'s extra `id`/`type` projection keys (present on a
119
+ // `getRow` read, absent from a fresh `write()`-shaped object) — an asymmetric key set would
120
+ // make canonicalJson ALWAYS mismatch, permanently defeating the skip-if-unchanged check.
121
+ const comparable = (f) => ({
122
+ triggers: f.triggers, step_plan: f.step_plan, config: f.config, cancel_on: f.cancel_on, url: f.url,
123
+ });
124
+ if (existing) {
125
+ const prev = comparable(existing);
126
+ const next = comparable(nextFields);
127
+ if (canonicalJson(prev) === canonicalJson(next)) {
128
+ return { row: existing, modified: false, skipped: true };
129
+ }
130
+ }
131
+ const row = await write('function', fn.id, nextFields, existing ? 'function.reregister' : 'function.register', root, occurredAt);
132
+ return { row, modified: true, skipped: false };
133
+ }
134
+ export function listFunctions(root) {
135
+ return rows('function', root);
136
+ }
137
+ export function functionsMatchingEvent(eventName, root) {
138
+ return listFunctions(root).filter((f) => f.triggers.some((t) => t.event === eventName));
139
+ }
140
+ export function functionsCancelledByEvent(eventName, root) {
141
+ return listFunctions(root).filter((f) => (f.cancel_on ?? []).some((c) => c.event === eventName));
142
+ }
143
+ /** Find an already-received event with the same (name, idempotency_id) — a minimal single-run
144
+ * dedupe window (build spec §13 ⚠7: modeled minimal, not the real vendor's full TTL window). */
145
+ export function findIdempotentEvent(name, idempotencyId, root) {
146
+ return rows('event', root).find((e) => e.name === name && e.idempotency_id === idempotencyId);
147
+ }
148
+ export async function recordEvent(input, root, occurredAt) {
149
+ if (input.id) {
150
+ const dup = findIdempotentEvent(input.name, input.id, root);
151
+ if (dup)
152
+ return { id: dup.id, deduped: true };
153
+ }
154
+ // Seeded from the state this write lands on: the world instant plus the ordinal the event
155
+ // takes among the events already in the root, so two sends in one millisecond still differ.
156
+ const at = nowIso(occurredAt);
157
+ const id = newUlid(Date.parse(at), `event:${input.name}:${at}:${rows('event', root).length}`);
158
+ await write('event', id, {
159
+ name: input.name,
160
+ data: input.data ?? {},
161
+ user: input.user ?? null,
162
+ idempotency_id: input.id ?? null,
163
+ ts: input.ts ?? Date.parse(nowIso(occurredAt)),
164
+ received_at: nowIso(occurredAt),
165
+ v: input.v ?? null,
166
+ }, 'event.send', root, occurredAt);
167
+ return { id, deduped: false };
168
+ }
169
+ export async function createRun(functionId, eventId, root, occurredAt) {
170
+ const id = uuidFrom(`run:${functionId}:${eventId}:${nowIso(occurredAt)}:${rows('run', root).length}`);
171
+ return write('run', id, {
172
+ function_id: functionId,
173
+ event_id: eventId,
174
+ status: 'QUEUED',
175
+ cursor: 0,
176
+ output: null,
177
+ error: null,
178
+ created_at: nowIso(occurredAt),
179
+ started_at: null,
180
+ ended_at: null,
181
+ }, 'run.create', root, occurredAt);
182
+ }
183
+ export function runsForEvent(eventId, root) {
184
+ return rows('run', root).filter((r) => r.event_id === eventId);
185
+ }
186
+ export function runsForFunction(functionId, root) {
187
+ return rows('run', root).filter((r) => r.function_id === functionId);
188
+ }
189
+ const TERMINAL_STATUSES = new Set(['COMPLETED', 'FAILED', 'CANCELLED']);
190
+ /** Cancel every non-terminal run of `functionId` — the REAL Inngest cancellation mechanism
191
+ * (grounded live, 2026-07-09, inngest.com/docs/features/inngest-functions/cancellation): a
192
+ * function declares `cancelOn: [{event, if}]`; when a matching event is sent, in-flight runs of
193
+ * that function transition to CANCELLED. `if` is a CEL-style expression against
194
+ * `event.data`/`async.data` — this twin matches on EVENT NAME ONLY (config-acceptance done, per
195
+ * build spec §0's "config knobs = echo back" tier); full `if`-expression evaluation is todo
196
+ * (`inngest.scheduling.cancel_if_expression`). NOT a REST DELETE call — the real v2 REST API
197
+ * (api-docs.inngest.com/api-specs/v2.json, fetched read-only during this build) has no
198
+ * cancel-run endpoint at all; this is the one and only real mechanism. */
199
+ export async function cancelRunsForFunction(functionId, root, occurredAt) {
200
+ const cancelled = [];
201
+ for (const run of runsForFunction(functionId, root)) {
202
+ if (TERMINAL_STATUSES.has(run.status))
203
+ continue;
204
+ const updated = await write('run', run.id, { status: 'CANCELLED', ended_at: nowIso(occurredAt) }, 'run.cancel_via_event', root, occurredAt);
205
+ cancelled.push(updated);
206
+ }
207
+ return cancelled;
208
+ }
209
+ // ── the step machine ───────────────────────────────────────────────────────────────────────────
210
+ function stepRowId(runId, stepId) {
211
+ return `${runId}::${stepId}`;
212
+ }
213
+ export function getStep(runId, stepId, root) {
214
+ return getRow('step', stepRowId(runId, stepId), root);
215
+ }
216
+ export function stepsForRun(runId, root) {
217
+ return rows('step', root).filter((s) => s.run_id === runId);
218
+ }
219
+ /** Every COMPLETED step's persisted output, in declared-plan order — read FRESH from the kernel
220
+ * on every call (never a JS-local cache), so a re-invocation genuinely REPLAYS them rather than
221
+ * recomputing. This is what makes `steps.replay_deterministic` die under the `httpEmpty`
222
+ * saboteur: an empty kernel means this returns `{}`, and any downstream step's stub hash — which
223
+ * folds this object into its input — comes out WRONG. */
224
+ export function priorOutputs(runId, plan, uptoIndex, root) {
225
+ const out = {};
226
+ for (let i = 0; i < uptoIndex; i++) {
227
+ const s = plan[i];
228
+ const row = getStep(runId, s.id, root);
229
+ if (row && row.state === 'completed')
230
+ out[s.id] = row.output ?? null;
231
+ }
232
+ return out;
233
+ }
234
+ /**
235
+ * ONE poll-fold advance of a run (fal-twin.ts progressOnce pattern, build spec §6):
236
+ * QUEUED -> RUNNING (echo, no step touched — mirrors fal's poll-1 IN_QUEUE echo)
237
+ * cursor < plan.length -> process plan[cursor] by its declared op (memoize on first touch,
238
+ * REPLAY — read, never recompute — on every subsequent touch)
239
+ * cursor >= plan.length -> COMPLETED, output = last completed step's output
240
+ * A run already in a terminal state (COMPLETED/FAILED/CANCELLED) is a no-op (idempotent).
241
+ */
242
+ export async function advanceRun(runId, root, occurredAt) {
243
+ const run = getRow('run', runId, root);
244
+ if (!run)
245
+ return undefined;
246
+ const status = run.status;
247
+ if (TERMINAL_STATUSES.has(status))
248
+ return { run };
249
+ if (status === 'QUEUED') {
250
+ const updated = await write('run', runId, { status: 'RUNNING', started_at: nowIso(occurredAt) }, 'run.start', root, occurredAt);
251
+ return { run: updated };
252
+ }
253
+ const fn = getRow('function', run.function_id, root);
254
+ // No function row for this run's `function_id` — e.g. a connector-pulled run whose owning
255
+ // function was never (re-)registered locally. There is no declared step_plan to advance
256
+ // against, so this is a defensive no-op (never fabricate a plan-less COMPLETED transition) —
257
+ // the run's REAL vendor-observed `status` (folded in by the connector) is authoritative.
258
+ if (!fn)
259
+ return { run };
260
+ const plan = fn.step_plan ?? [];
261
+ const cursor = run.cursor;
262
+ if (cursor >= plan.length) {
263
+ if (status !== 'COMPLETED') {
264
+ const lastStep = plan.length > 0 ? getStep(runId, plan[plan.length - 1].id, root) : undefined;
265
+ const updated = await write('run', runId, { status: 'COMPLETED', output: lastStep?.output ?? null, ended_at: nowIso(occurredAt) }, 'run.complete', root, occurredAt);
266
+ return { run: updated };
267
+ }
268
+ return { run };
269
+ }
270
+ const stepDef = plan[cursor];
271
+ const existing = getStep(runId, stepDef.id, root);
272
+ if (stepDef.op === 'Sleep') {
273
+ if (existing?.state === 'completed') {
274
+ const updatedRun = await write('run', runId, { cursor: cursor + 1 }, 'run.advance', root, occurredAt);
275
+ return { run: updatedRun, step: existing };
276
+ }
277
+ const step = await write('step', stepRowId(runId, stepDef.id), { run_id: runId, step_id: stepDef.id, op: 'Sleep', output: null, attempts: 1, state: 'completed' }, 'step.sleep', root, occurredAt);
278
+ const updatedRun = await write('run', runId, { cursor: cursor + 1 }, 'run.advance', root, occurredAt);
279
+ return { run: updatedRun, step };
280
+ }
281
+ if (stepDef.op === 'WaitForEvent') {
282
+ const eventName = stepDef.eventName ?? '';
283
+ if (!existing) {
284
+ const step = await write('step', stepRowId(runId, stepDef.id), { run_id: runId, step_id: stepDef.id, op: 'WaitForEvent', wait_event_name: eventName, waiting_since: nowIso(occurredAt), output: null, attempts: 1, state: 'waiting' }, 'step.wait_created', root, occurredAt);
285
+ return { run, step };
286
+ }
287
+ if (existing.state === 'waiting') {
288
+ const waitingSince = existing.waiting_since;
289
+ const match = rows('event', root).find((e) => e.name === eventName && e.received_at >= waitingSince);
290
+ if (match) {
291
+ const step = await write('step', stepRowId(runId, stepDef.id), { state: 'completed', output: { name: match.name, data: match.data } }, 'step.wait_resolved', root, occurredAt);
292
+ const updatedRun = await write('run', runId, { cursor: cursor + 1 }, 'run.advance', root, occurredAt);
293
+ return { run: updatedRun, step };
294
+ }
295
+ const attempts = existing.attempts + 1;
296
+ if (stepDef.timeoutAfterPolls !== undefined && attempts >= stepDef.timeoutAfterPolls) {
297
+ const step = await write('step', stepRowId(runId, stepDef.id), { state: 'completed', output: null, attempts }, 'step.wait_timeout', root, occurredAt);
298
+ const updatedRun = await write('run', runId, { cursor: cursor + 1 }, 'run.advance', root, occurredAt);
299
+ return { run: updatedRun, step };
300
+ }
301
+ const step = await write('step', stepRowId(runId, stepDef.id), { attempts }, 'step.wait_poll', root, occurredAt);
302
+ return { run, step };
303
+ }
304
+ // already completed -> replay (cursor should already be past it; defensive no-op)
305
+ return { run, step: existing };
306
+ }
307
+ // StepRun / InvokeFunction (both modeled identically: memoize + replay + optional retry fixture)
308
+ if (existing?.state === 'completed') {
309
+ const updatedRun = await write('run', runId, { cursor: cursor + 1 }, 'run.advance', root, occurredAt);
310
+ return { run: updatedRun, step: existing };
311
+ }
312
+ const failTimes = stepDef.failTimes ?? 0;
313
+ const maxAttempts = fn?.config?.retries ?? DEFAULT_MAX_ATTEMPTS;
314
+ const currentAttempts = (existing?.attempts ?? 0) + 1;
315
+ if (currentAttempts > maxAttempts) {
316
+ const step = await write('step', stepRowId(runId, stepDef.id), { run_id: runId, step_id: stepDef.id, op: stepDef.op, state: 'failed', attempts: currentAttempts, error: 'max attempts exceeded', output: null }, 'step.retry_exhausted', root, occurredAt);
317
+ const updatedRun = await write('run', runId, { status: 'FAILED', error: { step: stepDef.id, message: 'max attempts exceeded' }, ended_at: nowIso(occurredAt) }, 'run.fail', root, occurredAt);
318
+ return { run: updatedRun, step };
319
+ }
320
+ if (currentAttempts <= failTimes) {
321
+ const step = await write('step', stepRowId(runId, stepDef.id), { run_id: runId, step_id: stepDef.id, op: stepDef.op, state: 'failed', attempts: currentAttempts, error: `stub failure ${currentAttempts}`, output: null }, 'step.retry', root, occurredAt);
322
+ return { run, step };
323
+ }
324
+ const input = { priorOutputs: priorOutputs(runId, plan, cursor, root), stepId: stepDef.id };
325
+ const output = stepDef.output !== undefined ? stepDef.output : stubStepOutput(runId, stepDef.id, input);
326
+ const step = await write('step', stepRowId(runId, stepDef.id), { run_id: runId, step_id: stepDef.id, op: stepDef.op, state: 'completed', attempts: currentAttempts, error: null, output }, 'step.run', root, occurredAt);
327
+ const updatedRun = await write('run', runId, { cursor: cursor + 1 }, 'run.advance', root, occurredAt);
328
+ return { run: updatedRun, step };
329
+ }
@@ -0,0 +1,14 @@
1
+ /** Options every Inngest-twin HTTP surface needs, independent of who owns the socket. */
2
+ export interface InngestTwinFetchOptions {
3
+ root?: string;
4
+ readOnly?: boolean;
5
+ }
6
+ export declare function createInngestTwinFetch(options?: InngestTwinFetchOptions): (request: Request) => Promise<Response>;
7
+ export declare function createInngestTwinServer(options?: {
8
+ root?: string;
9
+ port?: number;
10
+ readOnly?: boolean;
11
+ }): Promise<{
12
+ port: number;
13
+ stop: () => void;
14
+ }>;
@@ -0,0 +1,42 @@
1
+ // inngest twin HTTP server — serve the full Inngest API twin handler over HTTP so the real
2
+ // `inngest` SDK's `.send()` (via its `baseUrl` constructor option / `INNGEST_BASE_URL` env
3
+ // override — SDK-source grounded, see inngest-sdk.integration.test.ts) works unmodified against
4
+ // it. Every route is plain JSON. The incoming HTTP `Host` header is forwarded as-is (unlike
5
+ // fal-server.ts, this twin's surface routing is PATH-primary — see inngest-twin.ts's
6
+ // `routeInngestSurface` header note — so no proxy-header recovery dance is needed here).
7
+ // Writable by default; pass `readOnly` to reject writes with 405 (D3). State is the kernel
8
+ // projection (no side-store) — see inngest-twin.ts / inngest-runtime.ts.
9
+ //
10
+ // FETCH-FIRST (runtime contract R12b): the serve path is the plain fetch below, built from the
11
+ // kernel's ONE adaptation (`createTwinFetchFromHandler`) with the host threading as its
12
+ // per-request `extras`; the server is one line of Bun.serve around that same closure.
13
+ import { serveHttp } from '@volter/world-core';
14
+ import { handleInngestTwinRequest } from "./inngest-twin.js";
15
+ import { rows } from "./inngest-runtime.js";
16
+ import { createTwinFetchFromHandler, statefulTwinManifest } from '@volter/world-core';
17
+ export function createInngestTwinFetch(options = {}) {
18
+ return createTwinFetchFromHandler(handleInngestTwinRequest, {
19
+ ...options,
20
+ manifest: statefulTwinManifest({ vendor: 'inngest', twinOf: 'the Inngest durable-execution surface', stores: 'events, registered functions and their kernel-persisted run state' }),
21
+ // THE STORE DOOR (R5c). The vendor's own read surface is addressed BY ID
22
+ // (`GET /api/v2/runs/{id}`, `GET /api/v2/events/{id}/runs`) and has no listing endpoint at
23
+ // all, so the ingested event feed and the runs it fanned out to were write-only state that
24
+ // nothing could read back without already knowing an id — which is exactly how the entropy
25
+ // in their ids went unseen (R9). These are the same deterministic projections the handler
26
+ // serves per-id, listed oldest-first.
27
+ stores: {
28
+ events: () => rows('event', options.root),
29
+ runs: () => rows('run', options.root),
30
+ },
31
+ extras: (_request, url) => ({ host: url.host }),
32
+ });
33
+ }
34
+ export async function createInngestTwinServer(options = {}) {
35
+ const server = await serveHttp({
36
+ hostname: '127.0.0.1',
37
+ port: options.port ?? 0,
38
+ idleTimeout: 60,
39
+ fetch: createInngestTwinFetch(options),
40
+ });
41
+ return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
42
+ }