@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.
- package/LICENSE +202 -0
- package/README.md +131 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +26 -0
- package/dist/src/index.d.ts +10 -0
- package/dist/src/index.js +61 -0
- package/dist/src/inngest-budget.d.ts +83 -0
- package/dist/src/inngest-budget.js +404 -0
- package/dist/src/inngest-capabilities.d.ts +4 -0
- package/dist/src/inngest-capabilities.js +512 -0
- package/dist/src/inngest-conformance.d.ts +7 -0
- package/dist/src/inngest-conformance.js +39 -0
- package/dist/src/inngest-connector.d.ts +60 -0
- package/dist/src/inngest-connector.js +109 -0
- package/dist/src/inngest-runtime.d.ts +98 -0
- package/dist/src/inngest-runtime.js +329 -0
- package/dist/src/inngest-server.d.ts +14 -0
- package/dist/src/inngest-server.js +42 -0
- package/dist/src/inngest-signing.d.ts +32 -0
- package/dist/src/inngest-signing.js +173 -0
- package/dist/src/inngest-twin.d.ts +30 -0
- package/dist/src/inngest-twin.js +332 -0
- package/package.json +51 -0
- package/src/cli.ts +25 -0
- package/src/index.ts +107 -0
- package/src/inngest-budget.ts +450 -0
- package/src/inngest-capabilities.ts +554 -0
- package/src/inngest-conformance.ts +43 -0
- package/src/inngest-connector.ts +133 -0
- package/src/inngest-runtime.ts +391 -0
- package/src/inngest-server.ts +50 -0
- package/src/inngest-signing.ts +180 -0
- package/src/inngest-twin.ts +366 -0
|
@@ -0,0 +1,133 @@
|
|
|
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
|
+
import type { SyncResource } from '@volter/world-core';
|
|
17
|
+
//
|
|
18
|
+
// ── The client-side RATE BUDGET is not optional here ────────────────────────────────────────
|
|
19
|
+
// Inngest's REST reference documents NO rate limit, no 429 and no Retry-After for the endpoints this
|
|
20
|
+
// connector calls; the one endpoint it does annotate as rate limited ("fetch function run jobs",
|
|
21
|
+
// cached for 5 seconds) gives no number and is not in this surface.
|
|
22
|
+
// Every entrypoint below GUARDS the injected client before touching it (`guardInngestClient`, which is
|
|
23
|
+
// idempotent — a caller who already wrapped is not double-charged, a caller who forgot is protected
|
|
24
|
+
// anyway); there is deliberately no option that turns the budget off. See inngest-budget.ts.
|
|
25
|
+
import { inngestBudgetOf, guardInngestClient, type InngestBudgetedOptions } from './inngest-budget.ts';
|
|
26
|
+
|
|
27
|
+
const SERVICE = 'inngest';
|
|
28
|
+
|
|
29
|
+
export type { InngestBudgetedOptions };
|
|
30
|
+
|
|
31
|
+
export type InngestEventHandle = { eventId: string };
|
|
32
|
+
export type InngestRunHandle = { runId: string };
|
|
33
|
+
|
|
34
|
+
export type InngestRealEvent = { name: string; data?: unknown; user?: unknown; idempotencyId?: string | null; ts?: number };
|
|
35
|
+
export type InngestRealRun = { id: string; functionId: string; eventId: string; status: string; output?: unknown; error?: unknown; cursor?: number };
|
|
36
|
+
|
|
37
|
+
// The injected client exposes the minimal subset the connector calls; a real client wrapping the
|
|
38
|
+
// v2 REST API's `GET /runs/{runId}` / `GET /events/{eventId}/runs` is structurally assignable.
|
|
39
|
+
export interface InngestLikeClient {
|
|
40
|
+
events?: { get: (eventId: string) => Promise<InngestRealEvent> };
|
|
41
|
+
runs?: { get: (runId: string) => Promise<InngestRealRun> };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function kid(type: string, id: string): string {
|
|
45
|
+
return `${type}:${id}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Pure mapper (real event -> SyncResource) — never touches a client, so the mutation-test
|
|
49
|
+
* connector-seam sweep (which sabotages every export matching the sync-or-push-or-pull-or-
|
|
50
|
+
* fullSync naming convention) leaves this real, per the pack convention (fal-connector.ts's
|
|
51
|
+
* `mapQueueRequest`). Field names avoid the kernel's reserved `type`/`id`/`updatedAt` meta keys
|
|
52
|
+
* (see inngest-runtime.ts header) — `idempotency_id`, never a bare `id`, inside `fields`. */
|
|
53
|
+
export function mapEvent(handle: InngestEventHandle, real: InngestRealEvent): SyncResource {
|
|
54
|
+
return {
|
|
55
|
+
type: 'event',
|
|
56
|
+
id: kid('event', handle.eventId),
|
|
57
|
+
fields: {
|
|
58
|
+
name: real.name,
|
|
59
|
+
data: real.data ?? {},
|
|
60
|
+
user: real.user ?? null,
|
|
61
|
+
idempotency_id: real.idempotencyId ?? null,
|
|
62
|
+
ts: real.ts ?? null,
|
|
63
|
+
received_at: null,
|
|
64
|
+
v: null,
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Pure mapper (real run -> SyncResource). */
|
|
70
|
+
export function mapRun(handle: InngestRunHandle, real: InngestRealRun): SyncResource {
|
|
71
|
+
return {
|
|
72
|
+
type: 'run',
|
|
73
|
+
id: kid('run', handle.runId),
|
|
74
|
+
fields: {
|
|
75
|
+
function_id: real.functionId,
|
|
76
|
+
event_id: real.eventId,
|
|
77
|
+
status: real.status,
|
|
78
|
+
cursor: real.cursor ?? 0,
|
|
79
|
+
output: real.output ?? null,
|
|
80
|
+
error: real.error ?? null,
|
|
81
|
+
created_at: null,
|
|
82
|
+
started_at: null,
|
|
83
|
+
ended_at: null,
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Pull the CURRENT real state of every named event handle. No `client.events` (or an empty
|
|
89
|
+
* handle list) observes nothing — there is nothing to enumerate without a handle. */
|
|
90
|
+
export async function pullInngestEvents(rawClient: InngestLikeClient, handles: InngestEventHandle[], opts: InngestBudgetedOptions = {}): Promise<SyncResource[]> {
|
|
91
|
+
const client = guardInngestClient(rawClient, opts);
|
|
92
|
+
if (!client.events || handles.length === 0) return [];
|
|
93
|
+
const out: SyncResource[] = [];
|
|
94
|
+
for (const handle of handles) {
|
|
95
|
+
const real = await client.events.get(handle.eventId);
|
|
96
|
+
out.push(mapEvent(handle, real));
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Pull the CURRENT real state of every named run handle. */
|
|
102
|
+
export async function pullInngestRuns(rawClient: InngestLikeClient, handles: InngestRunHandle[], opts: InngestBudgetedOptions = {}): Promise<SyncResource[]> {
|
|
103
|
+
const client = guardInngestClient(rawClient, opts);
|
|
104
|
+
if (!client.runs || handles.length === 0) return [];
|
|
105
|
+
const out: SyncResource[] = [];
|
|
106
|
+
for (const handle of handles) {
|
|
107
|
+
const real = await client.runs.get(handle.runId);
|
|
108
|
+
out.push(mapRun({ runId: handle.runId }, real));
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* D7 entry point: pull the current real state of every explicitly-named event/run handle and
|
|
115
|
+
* fold it into the twin via ONE `syncPull` (shadow-diff dedup). Returns
|
|
116
|
+
* `{observed, deltasAppended}` — a re-pull of identical state appends ZERO deltas.
|
|
117
|
+
*/
|
|
118
|
+
export async function syncInngestFromReal(
|
|
119
|
+
rawClient: InngestLikeClient,
|
|
120
|
+
opts: { root?: string; occurredAt?: string; events?: InngestEventHandle[]; runs?: InngestRunHandle[] } & InngestBudgetedOptions = {},
|
|
121
|
+
): Promise<{ observed: number; deltasAppended: number }> {
|
|
122
|
+
// Guard ONCE here and hand the guarded client down: the per-handle loops below are unbounded in
|
|
123
|
+
// handle count, so this is the entrypoint that must be unable to run unbudgeted.
|
|
124
|
+
const client = guardInngestClient(rawClient, inngestBudgetOf(opts));
|
|
125
|
+
const occurredAt = opts.occurredAt ?? new Date().toISOString();
|
|
126
|
+
const [events, runs] = await Promise.all([
|
|
127
|
+
pullInngestEvents(client, opts.events ?? []),
|
|
128
|
+
pullInngestRuns(client, opts.runs ?? []),
|
|
129
|
+
]);
|
|
130
|
+
const resources = [...events, ...runs];
|
|
131
|
+
const result = syncPull({ service: SERVICE, resources, occurredAt, ...(opts.root !== undefined ? { root: opts.root } : {}) });
|
|
132
|
+
return { observed: result.observed, deltasAppended: result.deltasAppended };
|
|
133
|
+
}
|
|
@@ -0,0 +1,391 @@
|
|
|
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
|
+
|
|
32
|
+
const SERVICE = 'inngest';
|
|
33
|
+
|
|
34
|
+
// Kernel META reserves the field names `type`/`id`/`updatedAt` on every projected resource
|
|
35
|
+
// (packages/world-core/src/actions.ts's `META` set) — a `fields` write using any of
|
|
36
|
+
// those three keys is SILENTLY DROPPED by `projectResources`. The real Inngest step-op wire
|
|
37
|
+
// shape names its opcode field `type`; this twin instead stores it as `op` (never `type`) for
|
|
38
|
+
// exactly that reason. Likewise a real Inngest event payload's user-supplied idempotency field
|
|
39
|
+
// is named `id`; this twin stores it as `idempotency_id`. Neither collision reaches the kernel.
|
|
40
|
+
export type InngestStepOp = 'StepRun' | 'Sleep' | 'WaitForEvent' | 'InvokeFunction';
|
|
41
|
+
|
|
42
|
+
export type DeclaredStep = {
|
|
43
|
+
id: string;
|
|
44
|
+
op: InngestStepOp;
|
|
45
|
+
/** WaitForEvent only: the event name this step gates on. */
|
|
46
|
+
eventName?: string;
|
|
47
|
+
/** WaitForEvent only: resolve with a deterministic timeout (output=null) after this many
|
|
48
|
+
* non-matching advance calls — a poll-COUNT timeout, never a wall-clock one (§13 ⚠5). */
|
|
49
|
+
timeoutAfterPolls?: number;
|
|
50
|
+
/** StepRun only: fail this many times (attempts 1..failTimes) before succeeding on the next
|
|
51
|
+
* advance — the deterministic retry-count fixture. */
|
|
52
|
+
failTimes?: number;
|
|
53
|
+
/** StepRun only: an explicit, test-declared output (skips the deterministic-stub hash). */
|
|
54
|
+
output?: unknown;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export type CancelOnRule = { event: string; if?: string };
|
|
58
|
+
|
|
59
|
+
export type DeclaredFunction = {
|
|
60
|
+
id: string;
|
|
61
|
+
triggers: Array<{ event?: string; cron?: string }>;
|
|
62
|
+
steps: DeclaredStep[];
|
|
63
|
+
config?: Record<string, unknown>;
|
|
64
|
+
cancelOn?: CancelOnRule[];
|
|
65
|
+
url?: string;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const DEFAULT_MAX_ATTEMPTS = 4; // modeled default (§13 ⚠4 — doc-UNVERIFIED exact vendor default; config.retries overrides it, see inngest-twin.ts)
|
|
69
|
+
|
|
70
|
+
function kid(type: string, id: string): string {
|
|
71
|
+
return `${type}:${id}`;
|
|
72
|
+
}
|
|
73
|
+
export function nowIso(occurredAt?: string): string {
|
|
74
|
+
return occurredAt ?? new Date().toISOString();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── canonical JSON + deterministic stub hashing (ported convention: fal-twin.ts:186-195) ──────
|
|
78
|
+
export function canonicalJson(v: unknown): string {
|
|
79
|
+
if (v === null || typeof v !== 'object') return JSON.stringify(v);
|
|
80
|
+
if (Array.isArray(v)) return `[${v.map(canonicalJson).join(',')}]`;
|
|
81
|
+
const keys = Object.keys(v as Record<string, unknown>).sort();
|
|
82
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson((v as Record<string, unknown>)[k])}`).join(',')}}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** step.run output = declared output or hash(runId::stepId::canonical-JSON(input)) (build spec
|
|
86
|
+
* §1 divergence 1). `input` includes every PRIOR completed step's persisted output, so step-2's
|
|
87
|
+
* value is a genuine function of step-1's REPLAYED (kernel-read) result — the load-bearing fact
|
|
88
|
+
* `steps.replay_deterministic` proves. */
|
|
89
|
+
export function stubStepOutput(runId: string, stepId: string, input: unknown): string {
|
|
90
|
+
const hash = createHash('sha256').update(`${runId}::${stepId}::${canonicalJson(input)}`).digest('hex').slice(0, 8);
|
|
91
|
+
return `[twin-stub:inngest:${hash}]`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// A ULID-SHAPED (Crockford base32, 26 chars, time-prefixed) id — real Inngest event ids are
|
|
95
|
+
// ULIDs, and this models the shape faithfully AND DETERMINISTICALLY (R9). Both halves used to
|
|
96
|
+
// carry entropy — `Date.now()` for the time prefix and `randomUUID()` for the tail — so two
|
|
97
|
+
// identical worlds sending the same event served different ids as soon as anything read the
|
|
98
|
+
// event state back (`GET /twin/store/events`, `GET /api/v2/events/{id}/runs`). The time prefix
|
|
99
|
+
// is now the WORLD instant (which is what a ULID's prefix MEANS, so this is also the more
|
|
100
|
+
// faithful shape) and the tail is a stable digest of the seed the caller passes.
|
|
101
|
+
const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
|
102
|
+
export function newUlid(atMs: number, seed: string): string {
|
|
103
|
+
let timePart = '';
|
|
104
|
+
let t = Math.max(0, Math.floor(atMs));
|
|
105
|
+
for (let i = 0; i < 10; i++) { timePart = CROCKFORD[t % 32] + timePart; t = Math.floor(t / 32); }
|
|
106
|
+
let randPart = '';
|
|
107
|
+
const digest = createHash('sha256').update(seed).digest('hex');
|
|
108
|
+
for (let i = 0; i < 16; i++) { randPart += CROCKFORD[parseInt(digest[i]!, 16) % 32]; }
|
|
109
|
+
return timePart + randPart;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A UUID-shaped digest of `seed` — a run id keeps the vendor's shape without the entropy. */
|
|
113
|
+
function uuidFrom(seed: string): string {
|
|
114
|
+
const h = createHash('sha256').update(seed).digest('hex');
|
|
115
|
+
return `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-8${h.slice(17, 20)}-${h.slice(20, 32)}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── projection helpers (mirrors fal-twin.ts:154-168) ──────────────────────────────────────────
|
|
119
|
+
export function rows(type: string, root?: string): Array<Record<string, unknown>> {
|
|
120
|
+
const prefix = `${type}:`;
|
|
121
|
+
return projectResources(SERVICE, root)
|
|
122
|
+
.filter((r) => r.type === type && r.id.startsWith(prefix) && (r as Record<string, unknown>)._deleted !== true)
|
|
123
|
+
.map((r) => ({ ...r, id: r.id.slice(prefix.length) }));
|
|
124
|
+
}
|
|
125
|
+
export function getRow(type: string, id: string, root?: string): Record<string, unknown> | undefined {
|
|
126
|
+
return rows(type, root).find((r) => r.id === id);
|
|
127
|
+
}
|
|
128
|
+
function view(r: Record<string, unknown>): Record<string, unknown> {
|
|
129
|
+
const { type: _t, updatedAt: _u, ...rest } = r;
|
|
130
|
+
const out: Record<string, unknown> = {};
|
|
131
|
+
for (const [k, v] of Object.entries(rest)) if (!k.startsWith('_')) out[k] = v;
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
export async function write(
|
|
135
|
+
type: string,
|
|
136
|
+
id: string,
|
|
137
|
+
fields: Record<string, unknown>,
|
|
138
|
+
op: string,
|
|
139
|
+
root: string | undefined,
|
|
140
|
+
occurredAt: string | undefined,
|
|
141
|
+
): Promise<Record<string, unknown>> {
|
|
142
|
+
const { resource } = await applyTwinWrite(
|
|
143
|
+
SERVICE,
|
|
144
|
+
{ operation: op, subjectType: type, subjectId: kid(type, id), fields, ...(occurredAt ? { occurredAt } : {}), actor: { kind: 'agent' } },
|
|
145
|
+
root,
|
|
146
|
+
);
|
|
147
|
+
return view({ ...resource, id });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── functions: register / lookup ───────────────────────────────────────────────────────────────
|
|
151
|
+
export async function registerFunction(fn: DeclaredFunction, root: string | undefined, occurredAt: string | undefined): Promise<{ row: Record<string, unknown>; modified: boolean; skipped: boolean }> {
|
|
152
|
+
const existing = getRow('function', fn.id, root);
|
|
153
|
+
const nextFields = {
|
|
154
|
+
fn_id: fn.id,
|
|
155
|
+
triggers: fn.triggers,
|
|
156
|
+
step_plan: fn.steps,
|
|
157
|
+
config: fn.config ?? {},
|
|
158
|
+
cancel_on: fn.cancelOn ?? [],
|
|
159
|
+
url: fn.url ?? null,
|
|
160
|
+
registered_at: nowIso(occurredAt),
|
|
161
|
+
};
|
|
162
|
+
// Compare only the caller-meaningful shape — never `fn_id`/`registered_at` (registration
|
|
163
|
+
// metadata, not config) and never `existing`'s extra `id`/`type` projection keys (present on a
|
|
164
|
+
// `getRow` read, absent from a fresh `write()`-shaped object) — an asymmetric key set would
|
|
165
|
+
// make canonicalJson ALWAYS mismatch, permanently defeating the skip-if-unchanged check.
|
|
166
|
+
const comparable = (f: { triggers: unknown; step_plan: unknown; config: unknown; cancel_on: unknown; url: unknown }) => ({
|
|
167
|
+
triggers: f.triggers, step_plan: f.step_plan, config: f.config, cancel_on: f.cancel_on, url: f.url,
|
|
168
|
+
});
|
|
169
|
+
if (existing) {
|
|
170
|
+
const prev = comparable(existing as { triggers: unknown; step_plan: unknown; config: unknown; cancel_on: unknown; url: unknown });
|
|
171
|
+
const next = comparable(nextFields);
|
|
172
|
+
if (canonicalJson(prev) === canonicalJson(next)) {
|
|
173
|
+
return { row: existing, modified: false, skipped: true };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const row = await write('function', fn.id, nextFields, existing ? 'function.reregister' : 'function.register', root, occurredAt);
|
|
177
|
+
return { row, modified: true, skipped: false };
|
|
178
|
+
}
|
|
179
|
+
export function listFunctions(root?: string): Array<Record<string, unknown>> {
|
|
180
|
+
return rows('function', root);
|
|
181
|
+
}
|
|
182
|
+
export function functionsMatchingEvent(eventName: string, root?: string): Array<Record<string, unknown>> {
|
|
183
|
+
return listFunctions(root).filter((f) => (f.triggers as Array<{ event?: string }>).some((t) => t.event === eventName));
|
|
184
|
+
}
|
|
185
|
+
export function functionsCancelledByEvent(eventName: string, root?: string): Array<Record<string, unknown>> {
|
|
186
|
+
return listFunctions(root).filter((f) => ((f.cancel_on as CancelOnRule[] | undefined) ?? []).some((c) => c.event === eventName));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── events ──────────────────────────────────────────────────────────────────────────────────
|
|
190
|
+
export type SendEventInput = { name: string; data?: unknown; user?: unknown; id?: string; ts?: number; v?: string };
|
|
191
|
+
|
|
192
|
+
/** Find an already-received event with the same (name, idempotency_id) — a minimal single-run
|
|
193
|
+
* dedupe window (build spec §13 ⚠7: modeled minimal, not the real vendor's full TTL window). */
|
|
194
|
+
export function findIdempotentEvent(name: string, idempotencyId: string, root?: string): Record<string, unknown> | undefined {
|
|
195
|
+
return rows('event', root).find((e) => e.name === name && e.idempotency_id === idempotencyId);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function recordEvent(input: SendEventInput, root: string | undefined, occurredAt: string | undefined): Promise<{ id: string; deduped: boolean }> {
|
|
199
|
+
if (input.id) {
|
|
200
|
+
const dup = findIdempotentEvent(input.name, input.id, root);
|
|
201
|
+
if (dup) return { id: dup.id as string, deduped: true };
|
|
202
|
+
}
|
|
203
|
+
// Seeded from the state this write lands on: the world instant plus the ordinal the event
|
|
204
|
+
// takes among the events already in the root, so two sends in one millisecond still differ.
|
|
205
|
+
const at = nowIso(occurredAt);
|
|
206
|
+
const id = newUlid(Date.parse(at), `event:${input.name}:${at}:${rows('event', root).length}`);
|
|
207
|
+
await write('event', id, {
|
|
208
|
+
name: input.name,
|
|
209
|
+
data: input.data ?? {},
|
|
210
|
+
user: input.user ?? null,
|
|
211
|
+
idempotency_id: input.id ?? null,
|
|
212
|
+
ts: input.ts ?? Date.parse(nowIso(occurredAt)),
|
|
213
|
+
received_at: nowIso(occurredAt),
|
|
214
|
+
v: input.v ?? null,
|
|
215
|
+
}, 'event.send', root, occurredAt);
|
|
216
|
+
return { id, deduped: false };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export async function createRun(functionId: string, eventId: string, root: string | undefined, occurredAt: string | undefined): Promise<Record<string, unknown>> {
|
|
220
|
+
const id = uuidFrom(`run:${functionId}:${eventId}:${nowIso(occurredAt)}:${rows('run', root).length}`);
|
|
221
|
+
return write('run', id, {
|
|
222
|
+
function_id: functionId,
|
|
223
|
+
event_id: eventId,
|
|
224
|
+
status: 'QUEUED',
|
|
225
|
+
cursor: 0,
|
|
226
|
+
output: null,
|
|
227
|
+
error: null,
|
|
228
|
+
created_at: nowIso(occurredAt),
|
|
229
|
+
started_at: null,
|
|
230
|
+
ended_at: null,
|
|
231
|
+
}, 'run.create', root, occurredAt);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function runsForEvent(eventId: string, root?: string): Array<Record<string, unknown>> {
|
|
235
|
+
return rows('run', root).filter((r) => r.event_id === eventId);
|
|
236
|
+
}
|
|
237
|
+
export function runsForFunction(functionId: string, root?: string): Array<Record<string, unknown>> {
|
|
238
|
+
return rows('run', root).filter((r) => r.function_id === functionId);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const TERMINAL_STATUSES = new Set(['COMPLETED', 'FAILED', 'CANCELLED']);
|
|
242
|
+
|
|
243
|
+
/** Cancel every non-terminal run of `functionId` — the REAL Inngest cancellation mechanism
|
|
244
|
+
* (grounded live, 2026-07-09, inngest.com/docs/features/inngest-functions/cancellation): a
|
|
245
|
+
* function declares `cancelOn: [{event, if}]`; when a matching event is sent, in-flight runs of
|
|
246
|
+
* that function transition to CANCELLED. `if` is a CEL-style expression against
|
|
247
|
+
* `event.data`/`async.data` — this twin matches on EVENT NAME ONLY (config-acceptance done, per
|
|
248
|
+
* build spec §0's "config knobs = echo back" tier); full `if`-expression evaluation is todo
|
|
249
|
+
* (`inngest.scheduling.cancel_if_expression`). NOT a REST DELETE call — the real v2 REST API
|
|
250
|
+
* (api-docs.inngest.com/api-specs/v2.json, fetched read-only during this build) has no
|
|
251
|
+
* cancel-run endpoint at all; this is the one and only real mechanism. */
|
|
252
|
+
export async function cancelRunsForFunction(functionId: string, root: string | undefined, occurredAt: string | undefined): Promise<Record<string, unknown>[]> {
|
|
253
|
+
const cancelled: Record<string, unknown>[] = [];
|
|
254
|
+
for (const run of runsForFunction(functionId, root)) {
|
|
255
|
+
if (TERMINAL_STATUSES.has(run.status as string)) continue;
|
|
256
|
+
const updated = await write('run', run.id as string, { status: 'CANCELLED', ended_at: nowIso(occurredAt) }, 'run.cancel_via_event', root, occurredAt);
|
|
257
|
+
cancelled.push(updated);
|
|
258
|
+
}
|
|
259
|
+
return cancelled;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ── the step machine ───────────────────────────────────────────────────────────────────────────
|
|
263
|
+
function stepRowId(runId: string, stepId: string): string {
|
|
264
|
+
return `${runId}::${stepId}`;
|
|
265
|
+
}
|
|
266
|
+
export function getStep(runId: string, stepId: string, root?: string): Record<string, unknown> | undefined {
|
|
267
|
+
return getRow('step', stepRowId(runId, stepId), root);
|
|
268
|
+
}
|
|
269
|
+
export function stepsForRun(runId: string, root?: string): Array<Record<string, unknown>> {
|
|
270
|
+
return rows('step', root).filter((s) => s.run_id === runId);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Every COMPLETED step's persisted output, in declared-plan order — read FRESH from the kernel
|
|
274
|
+
* on every call (never a JS-local cache), so a re-invocation genuinely REPLAYS them rather than
|
|
275
|
+
* recomputing. This is what makes `steps.replay_deterministic` die under the `httpEmpty`
|
|
276
|
+
* saboteur: an empty kernel means this returns `{}`, and any downstream step's stub hash — which
|
|
277
|
+
* folds this object into its input — comes out WRONG. */
|
|
278
|
+
export function priorOutputs(runId: string, plan: DeclaredStep[], uptoIndex: number, root?: string): Record<string, unknown> {
|
|
279
|
+
const out: Record<string, unknown> = {};
|
|
280
|
+
for (let i = 0; i < uptoIndex; i++) {
|
|
281
|
+
const s = plan[i]!;
|
|
282
|
+
const row = getStep(runId, s.id, root);
|
|
283
|
+
if (row && row.state === 'completed') out[s.id] = row.output ?? null;
|
|
284
|
+
}
|
|
285
|
+
return out;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export type AdvanceResult = { run: Record<string, unknown>; step?: Record<string, unknown> };
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* ONE poll-fold advance of a run (fal-twin.ts progressOnce pattern, build spec §6):
|
|
292
|
+
* QUEUED -> RUNNING (echo, no step touched — mirrors fal's poll-1 IN_QUEUE echo)
|
|
293
|
+
* cursor < plan.length -> process plan[cursor] by its declared op (memoize on first touch,
|
|
294
|
+
* REPLAY — read, never recompute — on every subsequent touch)
|
|
295
|
+
* cursor >= plan.length -> COMPLETED, output = last completed step's output
|
|
296
|
+
* A run already in a terminal state (COMPLETED/FAILED/CANCELLED) is a no-op (idempotent).
|
|
297
|
+
*/
|
|
298
|
+
export async function advanceRun(runId: string, root: string | undefined, occurredAt: string | undefined): Promise<AdvanceResult | undefined> {
|
|
299
|
+
const run = getRow('run', runId, root);
|
|
300
|
+
if (!run) return undefined;
|
|
301
|
+
const status = run.status as string;
|
|
302
|
+
if (TERMINAL_STATUSES.has(status)) return { run };
|
|
303
|
+
|
|
304
|
+
if (status === 'QUEUED') {
|
|
305
|
+
const updated = await write('run', runId, { status: 'RUNNING', started_at: nowIso(occurredAt) }, 'run.start', root, occurredAt);
|
|
306
|
+
return { run: updated };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const fn = getRow('function', run.function_id as string, root);
|
|
310
|
+
// No function row for this run's `function_id` — e.g. a connector-pulled run whose owning
|
|
311
|
+
// function was never (re-)registered locally. There is no declared step_plan to advance
|
|
312
|
+
// against, so this is a defensive no-op (never fabricate a plan-less COMPLETED transition) —
|
|
313
|
+
// the run's REAL vendor-observed `status` (folded in by the connector) is authoritative.
|
|
314
|
+
if (!fn) return { run };
|
|
315
|
+
const plan = (fn.step_plan as DeclaredStep[] | undefined) ?? [];
|
|
316
|
+
const cursor = run.cursor as number;
|
|
317
|
+
|
|
318
|
+
if (cursor >= plan.length) {
|
|
319
|
+
if (status !== 'COMPLETED') {
|
|
320
|
+
const lastStep = plan.length > 0 ? getStep(runId, plan[plan.length - 1]!.id, root) : undefined;
|
|
321
|
+
const updated = await write('run', runId, { status: 'COMPLETED', output: lastStep?.output ?? null, ended_at: nowIso(occurredAt) }, 'run.complete', root, occurredAt);
|
|
322
|
+
return { run: updated };
|
|
323
|
+
}
|
|
324
|
+
return { run };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const stepDef = plan[cursor]!;
|
|
328
|
+
const existing = getStep(runId, stepDef.id, root);
|
|
329
|
+
|
|
330
|
+
if (stepDef.op === 'Sleep') {
|
|
331
|
+
if (existing?.state === 'completed') {
|
|
332
|
+
const updatedRun = await write('run', runId, { cursor: cursor + 1 }, 'run.advance', root, occurredAt);
|
|
333
|
+
return { run: updatedRun, step: existing };
|
|
334
|
+
}
|
|
335
|
+
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);
|
|
336
|
+
const updatedRun = await write('run', runId, { cursor: cursor + 1 }, 'run.advance', root, occurredAt);
|
|
337
|
+
return { run: updatedRun, step };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (stepDef.op === 'WaitForEvent') {
|
|
341
|
+
const eventName = stepDef.eventName ?? '';
|
|
342
|
+
if (!existing) {
|
|
343
|
+
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);
|
|
344
|
+
return { run, step };
|
|
345
|
+
}
|
|
346
|
+
if (existing.state === 'waiting') {
|
|
347
|
+
const waitingSince = existing.waiting_since as string;
|
|
348
|
+
const match = rows('event', root).find((e) => e.name === eventName && (e.received_at as string) >= waitingSince);
|
|
349
|
+
if (match) {
|
|
350
|
+
const step = await write('step', stepRowId(runId, stepDef.id), { state: 'completed', output: { name: match.name, data: match.data } }, 'step.wait_resolved', root, occurredAt);
|
|
351
|
+
const updatedRun = await write('run', runId, { cursor: cursor + 1 }, 'run.advance', root, occurredAt);
|
|
352
|
+
return { run: updatedRun, step };
|
|
353
|
+
}
|
|
354
|
+
const attempts = (existing.attempts as number) + 1;
|
|
355
|
+
if (stepDef.timeoutAfterPolls !== undefined && attempts >= stepDef.timeoutAfterPolls) {
|
|
356
|
+
const step = await write('step', stepRowId(runId, stepDef.id), { state: 'completed', output: null, attempts }, 'step.wait_timeout', root, occurredAt);
|
|
357
|
+
const updatedRun = await write('run', runId, { cursor: cursor + 1 }, 'run.advance', root, occurredAt);
|
|
358
|
+
return { run: updatedRun, step };
|
|
359
|
+
}
|
|
360
|
+
const step = await write('step', stepRowId(runId, stepDef.id), { attempts }, 'step.wait_poll', root, occurredAt);
|
|
361
|
+
return { run, step };
|
|
362
|
+
}
|
|
363
|
+
// already completed -> replay (cursor should already be past it; defensive no-op)
|
|
364
|
+
return { run, step: existing };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// StepRun / InvokeFunction (both modeled identically: memoize + replay + optional retry fixture)
|
|
368
|
+
if (existing?.state === 'completed') {
|
|
369
|
+
const updatedRun = await write('run', runId, { cursor: cursor + 1 }, 'run.advance', root, occurredAt);
|
|
370
|
+
return { run: updatedRun, step: existing };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const failTimes = stepDef.failTimes ?? 0;
|
|
374
|
+
const maxAttempts = ((fn?.config as Record<string, unknown> | undefined)?.retries as number | undefined) ?? DEFAULT_MAX_ATTEMPTS;
|
|
375
|
+
const currentAttempts = ((existing?.attempts as number | undefined) ?? 0) + 1;
|
|
376
|
+
|
|
377
|
+
if (currentAttempts > maxAttempts) {
|
|
378
|
+
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);
|
|
379
|
+
const updatedRun = await write('run', runId, { status: 'FAILED', error: { step: stepDef.id, message: 'max attempts exceeded' }, ended_at: nowIso(occurredAt) }, 'run.fail', root, occurredAt);
|
|
380
|
+
return { run: updatedRun, step };
|
|
381
|
+
}
|
|
382
|
+
if (currentAttempts <= failTimes) {
|
|
383
|
+
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);
|
|
384
|
+
return { run, step };
|
|
385
|
+
}
|
|
386
|
+
const input = { priorOutputs: priorOutputs(runId, plan, cursor, root), stepId: stepDef.id };
|
|
387
|
+
const output = stepDef.output !== undefined ? stepDef.output : stubStepOutput(runId, stepDef.id, input);
|
|
388
|
+
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);
|
|
389
|
+
const updatedRun = await write('run', runId, { cursor: cursor + 1 }, 'run.advance', root, occurredAt);
|
|
390
|
+
return { run: updatedRun, step };
|
|
391
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
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.ts';
|
|
15
|
+
import { rows } from './inngest-runtime.ts';
|
|
16
|
+
import { createTwinFetchFromHandler, statefulTwinManifest } from '@volter/world-core';
|
|
17
|
+
|
|
18
|
+
/** Options every Inngest-twin HTTP surface needs, independent of who owns the socket. */
|
|
19
|
+
export interface InngestTwinFetchOptions {
|
|
20
|
+
root?: string;
|
|
21
|
+
readOnly?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createInngestTwinFetch(options: InngestTwinFetchOptions = {}): (request: Request) => Promise<Response> {
|
|
25
|
+
return createTwinFetchFromHandler(handleInngestTwinRequest, {
|
|
26
|
+
...options,
|
|
27
|
+
manifest: statefulTwinManifest({ vendor: 'inngest', twinOf: 'the Inngest durable-execution surface', stores: 'events, registered functions and their kernel-persisted run state' }),
|
|
28
|
+
// THE STORE DOOR (R5c). The vendor's own read surface is addressed BY ID
|
|
29
|
+
// (`GET /api/v2/runs/{id}`, `GET /api/v2/events/{id}/runs`) and has no listing endpoint at
|
|
30
|
+
// all, so the ingested event feed and the runs it fanned out to were write-only state that
|
|
31
|
+
// nothing could read back without already knowing an id — which is exactly how the entropy
|
|
32
|
+
// in their ids went unseen (R9). These are the same deterministic projections the handler
|
|
33
|
+
// serves per-id, listed oldest-first.
|
|
34
|
+
stores: {
|
|
35
|
+
events: () => rows('event', options.root),
|
|
36
|
+
runs: () => rows('run', options.root),
|
|
37
|
+
},
|
|
38
|
+
extras: (_request, url) => ({ host: url.host }),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function createInngestTwinServer(options: { root?: string; port?: number; readOnly?: boolean } = {}): Promise<{ port: number; stop: () => void }> {
|
|
43
|
+
const server = await serveHttp({
|
|
44
|
+
hostname: '127.0.0.1',
|
|
45
|
+
port: options.port ?? 0,
|
|
46
|
+
idleTimeout: 60,
|
|
47
|
+
fetch: createInngestTwinFetch(options),
|
|
48
|
+
});
|
|
49
|
+
return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
|
|
50
|
+
}
|