@shware/workflow 3.0.0 → 3.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.
Files changed (66) hide show
  1. package/README.md +13 -3
  2. package/dist/aws/index.cjs +6 -6
  3. package/dist/aws/index.cjs.map +1 -1
  4. package/dist/aws/index.d.cts +6 -0
  5. package/dist/aws/index.d.cts.map +1 -1
  6. package/dist/aws/index.d.mts +6 -0
  7. package/dist/aws/index.d.mts.map +1 -1
  8. package/dist/aws/index.mjs +6 -6
  9. package/dist/aws/index.mjs.map +1 -1
  10. package/dist/aws/instances.cjs +29 -9
  11. package/dist/aws/instances.cjs.map +1 -1
  12. package/dist/aws/instances.d.cts +24 -5
  13. package/dist/aws/instances.d.cts.map +1 -1
  14. package/dist/aws/instances.d.mts +24 -5
  15. package/dist/aws/instances.d.mts.map +1 -1
  16. package/dist/aws/instances.mjs +29 -9
  17. package/dist/aws/instances.mjs.map +1 -1
  18. package/dist/cloudflare/bindings.cjs.map +1 -1
  19. package/dist/cloudflare/bindings.d.cts +2 -0
  20. package/dist/cloudflare/bindings.d.cts.map +1 -1
  21. package/dist/cloudflare/bindings.d.mts +2 -0
  22. package/dist/cloudflare/bindings.d.mts.map +1 -1
  23. package/dist/cloudflare/bindings.mjs.map +1 -1
  24. package/dist/host/execute.cjs +6 -7
  25. package/dist/host/execute.cjs.map +1 -1
  26. package/dist/host/execute.d.cts +0 -4
  27. package/dist/host/execute.d.cts.map +1 -1
  28. package/dist/host/execute.d.mts +0 -4
  29. package/dist/host/execute.d.mts.map +1 -1
  30. package/dist/host/execute.mjs +6 -7
  31. package/dist/host/execute.mjs.map +1 -1
  32. package/dist/host/router.cjs +7 -5
  33. package/dist/host/router.cjs.map +1 -1
  34. package/dist/host/router.d.cts.map +1 -1
  35. package/dist/host/router.d.mts.map +1 -1
  36. package/dist/host/router.mjs +7 -5
  37. package/dist/host/router.mjs.map +1 -1
  38. package/dist/http2-client.cjs +7 -5
  39. package/dist/http2-client.cjs.map +1 -1
  40. package/dist/http2-client.d.cts +5 -2
  41. package/dist/http2-client.d.cts.map +1 -1
  42. package/dist/http2-client.d.mts +5 -2
  43. package/dist/http2-client.d.mts.map +1 -1
  44. package/dist/http2-client.mjs +7 -5
  45. package/dist/http2-client.mjs.map +1 -1
  46. package/dist/store/index.cjs.map +1 -1
  47. package/dist/store/index.d.cts +11 -0
  48. package/dist/store/index.d.cts.map +1 -1
  49. package/dist/store/index.d.mts +11 -0
  50. package/dist/store/index.d.mts.map +1 -1
  51. package/dist/store/index.mjs.map +1 -1
  52. package/dist/store/postgres.cjs +37 -13
  53. package/dist/store/postgres.cjs.map +1 -1
  54. package/dist/store/postgres.d.cts +6 -0
  55. package/dist/store/postgres.d.cts.map +1 -1
  56. package/dist/store/postgres.d.mts +6 -0
  57. package/dist/store/postgres.d.mts.map +1 -1
  58. package/dist/store/postgres.mjs +37 -13
  59. package/dist/store/postgres.mjs.map +1 -1
  60. package/dist/store/tables.cjs +6 -3
  61. package/dist/store/tables.cjs.map +1 -1
  62. package/dist/store/tables.d.cts.map +1 -1
  63. package/dist/store/tables.d.mts.map +1 -1
  64. package/dist/store/tables.mjs +6 -3
  65. package/dist/store/tables.mjs.map +1 -1
  66. package/package.json +1 -1
@@ -14,16 +14,16 @@ function isRunInput(event) {
14
14
  return candidate.type === "$workflow.run" && candidate.requestContext === void 0 && typeof candidate.instanceId === "string" && typeof candidate.enteredAtMs === "number" && typeof candidate.params === "object" && candidate.params !== null;
15
15
  }
16
16
  const utf8 = new TextEncoder();
17
- const NAME = /^[a-zA-Z0-9_-]{1,64}$/;
18
17
  /**
19
- * The durable execution name for an instance: the instance id itself when it fits, otherwise a
20
- * prefix of it with a fingerprint of the whole — stable, so every retry of the start reuses it.
18
+ * The durable execution name for one start of an instance: the instance id, cut to fit, with a
19
+ * fingerprint of the whole id (so a cut never makes two instances collide) and the attempt's
20
+ * token. Every retry of that start reuses the recorded name; a new attempt gets a new token.
21
21
  */
22
- function executionName(instanceId) {
23
- if (NAME.test(instanceId)) return instanceId;
22
+ function executionName(instanceId, attempt) {
24
23
  const safe = instanceId.replace(/[^a-zA-Z0-9_-]/g, "-");
25
- const fingerprint = `${murmur3(utf8.encode(instanceId)).toString(36)}${murmur3(utf8.encode(instanceId), 2538058380).toString(36)}`;
26
- return `${safe.slice(0, 63 - fingerprint.length)}-${fingerprint}`;
24
+ const token = attempt.replace(/[^a-zA-Z0-9]/g, "").slice(0, 8);
25
+ const suffix = `-${`${murmur3(utf8.encode(instanceId)).toString(36)}${murmur3(utf8.encode(instanceId), 2538058380).toString(36)}`}-${token}`;
26
+ return `${safe.slice(0, 64 - suffix.length)}${suffix}`;
27
27
  }
28
28
  const MINUTE = 6e4;
29
29
  var AwsInstances = class {
@@ -31,12 +31,19 @@ var AwsInstances = class {
31
31
  now;
32
32
  startRetryMs;
33
33
  reconcileEveryMs;
34
+ stopDrainMs;
34
35
  constructor(options) {
35
36
  this.options = options;
36
37
  this.now = options.now ?? Date.now;
37
38
  this.startRetryMs = options.startRetryMs ?? 720 * MINUTE;
38
39
  this.reconcileEveryMs = options.reconcileEveryMs ?? 15 * MINUTE;
40
+ this.stopDrainMs = options.stopDrainMs ?? 15 * MINUTE;
39
41
  }
42
+ /**
43
+ * A start, planned for one entry attempt. Its name is new every time: a failed entry reclaimed
44
+ * under the same instance id (an event replayed with its ts) must start a new execution — the
45
+ * old name would return the closed one. Retries of this attempt reuse the recorded name.
46
+ */
40
47
  async plan(options) {
41
48
  const input = {
42
49
  type: RUN_EVENT_TYPE,
@@ -45,8 +52,8 @@ var AwsInstances = class {
45
52
  enteredAtMs: this.now()
46
53
  };
47
54
  return {
48
- name: executionName(options.id),
49
- target: await this.options.target(options.params.contentHash),
55
+ name: executionName(options.id, crypto.randomUUID().slice(0, 8)),
56
+ target: await this.options.target(options.params.contentHash, options.version),
50
57
  input: JSON.stringify(input)
51
58
  };
52
59
  }
@@ -112,6 +119,10 @@ var AwsInstances = class {
112
119
  vanished(record) {
113
120
  return this.now() - record.createdAt > this.startRetryMs;
114
121
  }
122
+ /** Stopped so recently that an invocation running at the time may still be acting. */
123
+ draining(record) {
124
+ return record.stoppedAt !== void 0 && this.now() - record.stoppedAt < this.stopDrainMs;
125
+ }
115
126
  async reconcile(record, now) {
116
127
  const arn = await this.arnOf(record);
117
128
  const state = arn === void 0 ? void 0 : await this.options.lambda.executionState(arn);
@@ -145,16 +156,25 @@ var AwsInstances = class {
145
156
  },
146
157
  terminate: async () => {
147
158
  const record = await current();
159
+ if (record.stoppedAt !== void 0) {
160
+ if (this.draining(record)) throw new Error(`${id} was stopped; its running invocation may still be finishing`);
161
+ return;
162
+ }
148
163
  if (record.submittedAt === void 0 && record.unresolvedAt === void 0) await executions.markUnresolved(id, this.now());
149
164
  const arn = await this.arnOf(record);
150
165
  if (arn === void 0) {
151
166
  if (this.vanished(record)) return;
152
167
  throw new Error(`cannot stop ${id} yet: its execution is not visible`);
153
168
  }
169
+ const state = await lambda.executionState(arn);
170
+ if (state === "succeeded" || state === "failed" || state === "timed_out") return;
154
171
  if (!await lambda.stopExecution(arn)) throw new Error(`stopping ${id} failed`);
172
+ await executions.markStopped(id, this.now());
173
+ throw new Error(`${id} is stopped; its running invocation may still be finishing`);
155
174
  },
156
175
  status: async () => {
157
176
  const record = await current();
177
+ if (this.draining(record)) return { status: "running" };
158
178
  const arn = await this.arnOf(record);
159
179
  if (arn === void 0) return { status: this.vanished(record) ? "terminated" : "running" };
160
180
  const state = await lambda.executionState(arn);
@@ -1 +1 @@
1
- {"version":3,"file":"instances.mjs","names":[],"sources":["../../src/aws/instances.ts"],"sourcesContent":["import {\n InstanceNotFoundError,\n type JourneyParams,\n type WorkflowBindingLike,\n type WorkflowInstanceLike,\n} from '../cloudflare/bindings';\nimport { murmur3 } from '../engine/bucket';\nimport type {\n ExecutionRecord,\n ExecutionRegistry,\n ExecutionStart,\n JourneyStore,\n} from '../store/index';\nimport type { LambdaApi } from './lambda-api';\n\n/**\n * The router's instance port over Lambda durable executions: an instance is one execution of\n * this same function, started by an asynchronous self-invoke under a stable execution name;\n * a wake completes the callback its current wait parks on; stopping stops the execution.\n */\n\n/** The top-level shape of an internal run: what the self-invoke sends, never an HTTP body. */\nexport const RUN_EVENT_TYPE = '$workflow.run';\n\nexport interface RunInput {\n type: typeof RUN_EVENT_TYPE;\n instanceId: string;\n params: JourneyParams;\n /** When the instance was created (as Cloudflare's event timestamp): its entry anchor, fixed with the start. */\n enteredAtMs: number;\n}\n\nexport function isRunInput(event: unknown): event is RunInput {\n if (typeof event !== 'object' || event === null) return false;\n const candidate = event as {\n type?: unknown;\n instanceId?: unknown;\n enteredAtMs?: unknown;\n params?: unknown;\n requestContext?: unknown;\n };\n return (\n candidate.type === RUN_EVENT_TYPE &&\n candidate.requestContext === undefined &&\n typeof candidate.instanceId === 'string' &&\n typeof candidate.enteredAtMs === 'number' &&\n typeof candidate.params === 'object' &&\n candidate.params !== null\n );\n}\n\nconst utf8 = new TextEncoder();\nconst NAME = /^[a-zA-Z0-9_-]{1,64}$/;\n\n/**\n * The durable execution name for an instance: the instance id itself when it fits, otherwise a\n * prefix of it with a fingerprint of the whole — stable, so every retry of the start reuses it.\n */\nexport function executionName(instanceId: string): string {\n if (NAME.test(instanceId)) return instanceId;\n const safe = instanceId.replace(/[^a-zA-Z0-9_-]/g, '-');\n const fingerprint = `${murmur3(utf8.encode(instanceId)).toString(36)}${murmur3(utf8.encode(instanceId), 0x9747b28c).toString(36)}`;\n return `${safe.slice(0, 63 - fingerprint.length)}-${fingerprint}`;\n}\n\nexport interface AwsInstancesOptions {\n store: JourneyStore;\n executions: ExecutionRegistry;\n lambda: LambdaApi;\n /** The qualified function ARN an execution pinned to `contentHash` must run on. */\n target(contentHash: string): Promise<string>;\n /**\n * How long an unconfirmed start keeps being retried. It must stay shorter than the durable\n * execution retention: past that the platform forgets the name, and a retry would start a\n * second execution. Twelve hours by default.\n */\n startRetryMs?: number;\n /** How stale a running instance's last check must be before maintenance looks again. */\n reconcileEveryMs?: number;\n now?: () => number;\n}\n\nconst MINUTE = 60_000;\n\nexport class AwsInstances implements WorkflowBindingLike {\n private readonly now: () => number;\n private readonly startRetryMs: number;\n private readonly reconcileEveryMs: number;\n\n constructor(private readonly options: AwsInstancesOptions) {\n this.now = options.now ?? Date.now;\n this.startRetryMs = options.startRetryMs ?? 12 * 60 * MINUTE;\n this.reconcileEveryMs = options.reconcileEveryMs ?? 15 * MINUTE;\n }\n\n async plan(options: { id: string; params: JourneyParams; ts: number }): Promise<ExecutionStart> {\n const input: RunInput = {\n type: RUN_EVENT_TYPE,\n instanceId: options.id,\n params: options.params,\n // The moment the instance is created, like Cloudflare's: an event's own ts may be long past\n enteredAtMs: this.now(),\n };\n return {\n name: executionName(options.id),\n target: await this.options.target(options.params.contentHash),\n input: JSON.stringify(input),\n };\n }\n\n async create(options: { id: string; params: unknown }): Promise<WorkflowInstanceLike> {\n const record = await this.options.executions.get(options.id);\n if (record === undefined) {\n throw new Error(`no start is recorded for ${options.id}: the entry must be planned first`);\n }\n await this.submit(record);\n return this.instance(options.id);\n }\n\n async get(id: string): Promise<WorkflowInstanceLike> {\n // A read of the registry alone: a Lambda failure must never read as \"no such instance\"\n if ((await this.options.executions.get(id)) === undefined) {\n throw new InstanceNotFoundError(id);\n }\n return this.instance(id);\n }\n\n async resume(entry: { workflow: string; userId: string }): Promise<void> {\n for (const record of await this.options.executions.pendingStarts({ ...entry, limit: 1 })) {\n if (!(await this.giveUpIfStale(record))) await this.submit(record);\n }\n }\n\n async maintain(): Promise<void> {\n const now = this.now();\n const failures: unknown[] = [];\n // Starts whose submit never confirmed: retried with the recorded name and payload\n for (const record of await this.options.executions.pendingStarts({\n createdBefore: now - MINUTE,\n limit: 25,\n })) {\n try {\n if (!(await this.giveUpIfStale(record))) await this.submit(record);\n } catch (error) {\n failures.push(error);\n }\n }\n // Executions the platform ended without the run finalizing (a timeout, a stop, a crash)\n for (const record of await this.options.executions.toReconcile(\n 25,\n now - this.reconcileEveryMs\n )) {\n try {\n await this.reconcile(record, now);\n } catch (error) {\n failures.push(error);\n }\n }\n if (failures.length > 0) throw failures[0];\n }\n\n private async submit(record: ExecutionRecord): Promise<void> {\n const { arn } = await this.options.lambda.startExecution({\n target: record.target,\n name: record.name,\n payload: record.input,\n });\n await this.options.executions.markSubmitted(record.instanceId, arn, this.now());\n }\n\n /** Past the retry budget a start is unresolved for good: no retry could tell a duplicate apart. */\n private async giveUpIfStale(record: ExecutionRecord): Promise<boolean> {\n if (this.now() - record.createdAt <= this.startRetryMs) return false;\n await this.options.executions.markUnresolved(record.instanceId, this.now());\n console.error(\n `[workflow] the start of ${record.instanceId} (${record.name}) was never confirmed: left unresolved`\n );\n return true;\n }\n\n private async arnOf(record: ExecutionRecord): Promise<string | undefined> {\n if (record.arn !== undefined) return record.arn;\n const arn = await this.options.lambda.findExecution(record.target, record.name);\n if (arn !== undefined) await this.options.executions.setArn(record.instanceId, arn);\n return arn;\n }\n\n /**\n * Past the start retry budget, an execution still nowhere to be seen never started: any queued\n * invoke has expired by then. Before that, not finding it says nothing.\n */\n private vanished(record: ExecutionRecord): boolean {\n return this.now() - record.createdAt > this.startRetryMs;\n }\n\n private async reconcile(record: ExecutionRecord, now: number): Promise<void> {\n const arn = await this.arnOf(record);\n const state = arn === undefined ? undefined : await this.options.lambda.executionState(arn);\n // Only a state that will never run another step — or one missing for good — ends the entry;\n // a run still queued, or not visible yet, is left alone\n const ended =\n state === 'failed' ||\n state === 'stopped' ||\n state === 'timed_out' ||\n (state === undefined && this.vanished(record));\n if (ended) {\n // Ended without its finalize step: the ledger must not show it running forever\n const { store } = this.options;\n await store.setEntryStatus(record.instanceId, 'failed');\n await store.reporting?.instanceFinished({\n workflow: record.workflow,\n instanceId: record.instanceId,\n userId: record.userId,\n status: 'failed',\n at: now,\n });\n console.warn(\n `[workflow] ${record.instanceId} ended on the platform (${state ?? 'unknown'}) without finishing: recorded as failed`\n );\n }\n await this.options.executions.markChecked(record.instanceId, now);\n }\n\n private instance(id: string): WorkflowInstanceLike {\n const { executions, lambda } = this.options;\n const current = async () => {\n const record = await executions.get(id);\n if (record === undefined) throw new InstanceNotFoundError(id);\n return record;\n };\n return {\n id,\n sendEvent: async (event) => {\n // The wait parked now, read at the moment of the wake: a newer wait's callback replaces\n // an older one, and a wake for none (the instance is between waits) is nothing to do —\n // its next wait re-reads the facts before parking\n const { callbackId } = await current();\n if (callbackId === undefined) return;\n await lambda.completeCallback(callbackId, JSON.stringify(event.payload ?? {}));\n },\n terminate: async () => {\n const record = await current();\n if (record.submittedAt === undefined && record.unresolvedAt === undefined) {\n // No retry may start it after this\n await executions.markUnresolved(id, this.now());\n }\n const arn = await this.arnOf(record);\n if (arn === undefined) {\n // Not visible yet is not stopped: the erasure stays pending, and the sweep retries\n if (this.vanished(record)) return;\n throw new Error(`cannot stop ${id} yet: its execution is not visible`);\n }\n if (!(await lambda.stopExecution(arn))) throw new Error(`stopping ${id} failed`);\n },\n status: async () => {\n const record = await current();\n const arn = await this.arnOf(record);\n // Not visible yet may still run: only one missing past the budget is gone\n if (arn === undefined) return { status: this.vanished(record) ? 'terminated' : 'running' };\n const state = await lambda.executionState(arn);\n // In the router's vocabulary (Cloudflare's): what will never run another step\n if (state === 'succeeded') return { status: 'complete' };\n if (state === 'failed') return { status: 'errored' };\n if (state === 'running') return { status: 'running' };\n return { status: 'terminated' };\n },\n };\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"instances.mjs","names":[],"sources":["../../src/aws/instances.ts"],"sourcesContent":["import {\n InstanceNotFoundError,\n type JourneyParams,\n type WorkflowBindingLike,\n type WorkflowInstanceLike,\n} from '../cloudflare/bindings';\nimport { murmur3 } from '../engine/bucket';\nimport type {\n ExecutionRecord,\n ExecutionRegistry,\n ExecutionStart,\n JourneyStore,\n} from '../store/index';\nimport type { LambdaApi } from './lambda-api';\n\n/**\n * The router's instance port over Lambda durable executions: an instance is one execution of\n * this same function, started by an asynchronous self-invoke under a stable execution name;\n * a wake completes the callback its current wait parks on; stopping stops the execution.\n */\n\n/** The top-level shape of an internal run: what the self-invoke sends, never an HTTP body. */\nexport const RUN_EVENT_TYPE = '$workflow.run';\n\nexport interface RunInput {\n type: typeof RUN_EVENT_TYPE;\n instanceId: string;\n params: JourneyParams;\n /** When the instance was created (as Cloudflare's event timestamp): its entry anchor, fixed with the start. */\n enteredAtMs: number;\n}\n\nexport function isRunInput(event: unknown): event is RunInput {\n if (typeof event !== 'object' || event === null) return false;\n const candidate = event as {\n type?: unknown;\n instanceId?: unknown;\n enteredAtMs?: unknown;\n params?: unknown;\n requestContext?: unknown;\n };\n return (\n candidate.type === RUN_EVENT_TYPE &&\n candidate.requestContext === undefined &&\n typeof candidate.instanceId === 'string' &&\n typeof candidate.enteredAtMs === 'number' &&\n typeof candidate.params === 'object' &&\n candidate.params !== null\n );\n}\n\nconst utf8 = new TextEncoder();\n\n/**\n * The durable execution name for one start of an instance: the instance id, cut to fit, with a\n * fingerprint of the whole id (so a cut never makes two instances collide) and the attempt's\n * token. Every retry of that start reuses the recorded name; a new attempt gets a new token.\n */\nexport function executionName(instanceId: string, attempt: string): string {\n const safe = instanceId.replace(/[^a-zA-Z0-9_-]/g, '-');\n const token = attempt.replace(/[^a-zA-Z0-9]/g, '').slice(0, 8);\n const fingerprint = `${murmur3(utf8.encode(instanceId)).toString(36)}${murmur3(utf8.encode(instanceId), 0x9747b28c).toString(36)}`;\n const suffix = `-${fingerprint}-${token}`;\n return `${safe.slice(0, 64 - suffix.length)}${suffix}`;\n}\n\nexport interface AwsInstancesOptions {\n store: JourneyStore;\n executions: ExecutionRegistry;\n lambda: LambdaApi;\n /**\n * The qualified function ARN an execution pinned to `contentHash` must run on; `version` is\n * the code version whose routes named it.\n */\n target(contentHash: string, version?: string): Promise<string>;\n /**\n * How long an unconfirmed start keeps being retried. It must stay shorter than the durable\n * execution retention: past that the platform forgets the name, and a retry would start a\n * second execution. Twelve hours by default.\n */\n startRetryMs?: number;\n /** How stale a running instance's last check must be before maintenance looks again. */\n reconcileEveryMs?: number;\n /**\n * How long after a stop an invocation already running may still act: Lambda cannot interrupt\n * one, and it runs on to its next checkpoint. An erasure waits this long before it erases.\n * The function's timeout is enough; 15 minutes (Lambda's maximum) by default.\n */\n stopDrainMs?: number;\n now?: () => number;\n}\n\nconst MINUTE = 60_000;\n\nexport class AwsInstances implements WorkflowBindingLike {\n private readonly now: () => number;\n private readonly startRetryMs: number;\n private readonly reconcileEveryMs: number;\n private readonly stopDrainMs: number;\n\n constructor(private readonly options: AwsInstancesOptions) {\n this.now = options.now ?? Date.now;\n this.startRetryMs = options.startRetryMs ?? 12 * 60 * MINUTE;\n this.reconcileEveryMs = options.reconcileEveryMs ?? 15 * MINUTE;\n this.stopDrainMs = options.stopDrainMs ?? 15 * MINUTE;\n }\n\n /**\n * A start, planned for one entry attempt. Its name is new every time: a failed entry reclaimed\n * under the same instance id (an event replayed with its ts) must start a new execution — the\n * old name would return the closed one. Retries of this attempt reuse the recorded name.\n */\n async plan(options: {\n id: string;\n params: JourneyParams;\n ts: number;\n version?: string | undefined;\n }): Promise<ExecutionStart> {\n const input: RunInput = {\n type: RUN_EVENT_TYPE,\n instanceId: options.id,\n params: options.params,\n // The moment the instance is created, like Cloudflare's: an event's own ts may be long past\n enteredAtMs: this.now(),\n };\n return {\n name: executionName(options.id, crypto.randomUUID().slice(0, 8)),\n target: await this.options.target(options.params.contentHash, options.version),\n input: JSON.stringify(input),\n };\n }\n\n async create(options: { id: string; params: unknown }): Promise<WorkflowInstanceLike> {\n const record = await this.options.executions.get(options.id);\n if (record === undefined) {\n throw new Error(`no start is recorded for ${options.id}: the entry must be planned first`);\n }\n await this.submit(record);\n return this.instance(options.id);\n }\n\n async get(id: string): Promise<WorkflowInstanceLike> {\n // A read of the registry alone: a Lambda failure must never read as \"no such instance\"\n if ((await this.options.executions.get(id)) === undefined) {\n throw new InstanceNotFoundError(id);\n }\n return this.instance(id);\n }\n\n async resume(entry: { workflow: string; userId: string }): Promise<void> {\n for (const record of await this.options.executions.pendingStarts({ ...entry, limit: 1 })) {\n if (!(await this.giveUpIfStale(record))) await this.submit(record);\n }\n }\n\n async maintain(): Promise<void> {\n const now = this.now();\n const failures: unknown[] = [];\n // Starts whose submit never confirmed: retried with the recorded name and payload\n for (const record of await this.options.executions.pendingStarts({\n createdBefore: now - MINUTE,\n limit: 25,\n })) {\n try {\n if (!(await this.giveUpIfStale(record))) await this.submit(record);\n } catch (error) {\n failures.push(error);\n }\n }\n // Executions the platform ended without the run finalizing (a timeout, a stop, a crash)\n for (const record of await this.options.executions.toReconcile(\n 25,\n now - this.reconcileEveryMs\n )) {\n try {\n await this.reconcile(record, now);\n } catch (error) {\n failures.push(error);\n }\n }\n if (failures.length > 0) throw failures[0];\n }\n\n private async submit(record: ExecutionRecord): Promise<void> {\n const { arn } = await this.options.lambda.startExecution({\n target: record.target,\n name: record.name,\n payload: record.input,\n });\n await this.options.executions.markSubmitted(record.instanceId, arn, this.now());\n }\n\n /** Past the retry budget a start is unresolved for good: no retry could tell a duplicate apart. */\n private async giveUpIfStale(record: ExecutionRecord): Promise<boolean> {\n if (this.now() - record.createdAt <= this.startRetryMs) return false;\n await this.options.executions.markUnresolved(record.instanceId, this.now());\n console.error(\n `[workflow] the start of ${record.instanceId} (${record.name}) was never confirmed: left unresolved`\n );\n return true;\n }\n\n private async arnOf(record: ExecutionRecord): Promise<string | undefined> {\n if (record.arn !== undefined) return record.arn;\n const arn = await this.options.lambda.findExecution(record.target, record.name);\n if (arn !== undefined) await this.options.executions.setArn(record.instanceId, arn);\n return arn;\n }\n\n /**\n * Past the start retry budget, an execution still nowhere to be seen never started: any queued\n * invoke has expired by then. Before that, not finding it says nothing.\n */\n private vanished(record: ExecutionRecord): boolean {\n return this.now() - record.createdAt > this.startRetryMs;\n }\n\n /** Stopped so recently that an invocation running at the time may still be acting. */\n private draining(record: ExecutionRecord): boolean {\n return record.stoppedAt !== undefined && this.now() - record.stoppedAt < this.stopDrainMs;\n }\n\n private async reconcile(record: ExecutionRecord, now: number): Promise<void> {\n const arn = await this.arnOf(record);\n const state = arn === undefined ? undefined : await this.options.lambda.executionState(arn);\n // Only a state that will never run another step — or one missing for good — ends the entry;\n // a run still queued, or not visible yet, is left alone\n const ended =\n state === 'failed' ||\n state === 'stopped' ||\n state === 'timed_out' ||\n (state === undefined && this.vanished(record));\n if (ended) {\n // Ended without its finalize step: the ledger must not show it running forever\n const { store } = this.options;\n await store.setEntryStatus(record.instanceId, 'failed');\n await store.reporting?.instanceFinished({\n workflow: record.workflow,\n instanceId: record.instanceId,\n userId: record.userId,\n status: 'failed',\n at: now,\n });\n console.warn(\n `[workflow] ${record.instanceId} ended on the platform (${state ?? 'unknown'}) without finishing: recorded as failed`\n );\n }\n await this.options.executions.markChecked(record.instanceId, now);\n }\n\n private instance(id: string): WorkflowInstanceLike {\n const { executions, lambda } = this.options;\n const current = async () => {\n const record = await executions.get(id);\n if (record === undefined) throw new InstanceNotFoundError(id);\n return record;\n };\n return {\n id,\n sendEvent: async (event) => {\n // The wait parked now, read at the moment of the wake: a newer wait's callback replaces\n // an older one, and a wake for none (the instance is between waits) is nothing to do —\n // its next wait re-reads the facts before parking\n const { callbackId } = await current();\n if (callbackId === undefined) return;\n await lambda.completeCallback(callbackId, JSON.stringify(event.payload ?? {}));\n },\n terminate: async () => {\n const record = await current();\n // Stopped before: done once an invocation that was running then has had time to end\n if (record.stoppedAt !== undefined) {\n if (this.draining(record)) {\n throw new Error(`${id} was stopped; its running invocation may still be finishing`);\n }\n return;\n }\n if (record.submittedAt === undefined && record.unresolvedAt === undefined) {\n // No retry may start it after this\n await executions.markUnresolved(id, this.now());\n }\n const arn = await this.arnOf(record);\n if (arn === undefined) {\n // Not visible yet is not stopped: the erasure stays pending, and the sweep retries\n if (this.vanished(record)) return;\n throw new Error(`cannot stop ${id} yet: its execution is not visible`);\n }\n // One that finished on its own has no invocation left to drain\n const state = await lambda.executionState(arn);\n if (state === 'succeeded' || state === 'failed' || state === 'timed_out') return;\n if (!(await lambda.stopExecution(arn))) throw new Error(`stopping ${id} failed`);\n // A stop does not interrupt an invocation already running: it goes on to its next\n // checkpoint, side effects included. The erasure waits (see stopDrainMs)\n await executions.markStopped(id, this.now());\n throw new Error(`${id} is stopped; its running invocation may still be finishing`);\n },\n status: async () => {\n const record = await current();\n if (this.draining(record)) return { status: 'running' };\n const arn = await this.arnOf(record);\n // Not visible yet may still run: only one missing past the budget is gone\n if (arn === undefined) return { status: this.vanished(record) ? 'terminated' : 'running' };\n const state = await lambda.executionState(arn);\n // In the router's vocabulary (Cloudflare's): what will never run another step\n if (state === 'succeeded') return { status: 'complete' };\n if (state === 'failed') return { status: 'errored' };\n if (state === 'running') return { status: 'running' };\n return { status: 'terminated' };\n },\n };\n }\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"bindings.cjs","names":[],"sources":["../../src/cloudflare/bindings.ts"],"sourcesContent":["import type { EmailAddress } from '../email';\nimport type { ExecutionStart } from '../store/index';\n\n/**\n * A minimal structural interface over the Cloudflare bindings.\n *\n * @cloudflare/workers-types is deliberately not used: the global types it\n * injects clash with the DOM lib this package's react side needs. A structural\n * subset plus the ambient cloudflare:workers declaration is enough to compile,\n * and the real bindings satisfy it structurally at runtime.\n */\n\nexport interface KVNamespaceLike {\n get(key: string): Promise<string | null>;\n put(key: string, value: string): Promise<void>;\n}\n\n/** Structural subset of D1Result: `meta.changes` is how INSERT OR IGNORE reports whether it inserted. */\nexport interface D1RunResultLike {\n meta?: { changes?: number };\n}\n\nexport interface D1PreparedStatementLike {\n bind(...values: unknown[]): D1PreparedStatementLike;\n first<T = Record<string, unknown>>(): Promise<T | null>;\n all<T = Record<string, unknown>>(): Promise<{ results: T[] }>;\n run(): Promise<D1RunResultLike>;\n}\n\nexport interface D1DatabaseLike {\n prepare(sql: string): D1PreparedStatementLike;\n /** Statements run in order inside one transaction (real D1 semantics) — deploy relies on the atomicity. */\n batch(statements: D1PreparedStatementLike[]): Promise<unknown>;\n}\n\nexport interface WorkflowInstanceLike {\n id: string;\n sendEvent(event: { type: string; payload?: unknown }): Promise<void>;\n /** Stop the instance for good (Cloudflare Workflows' `terminate`). */\n terminate?(): Promise<void>;\n /** Where it stands (`complete`, `errored`, `terminated`, `running`, …). */\n status?(): Promise<{ status: string }>;\n}\n\n/**\n * How the router starts, wakes and stops instances: Cloudflare's workflow binding, or the AWS\n * adapter's equivalent over durable executions.\n */\nexport interface WorkflowBindingLike {\n create(options: { id: string; params: unknown }): Promise<WorkflowInstanceLike>;\n get(id: string): Promise<WorkflowInstanceLike>;\n /**\n * For a runtime whose start is a submit that can be lost in flight (AWS): the start to record\n * with the entry, in one transaction. A failed `create` then keeps the entry — its recorded\n * start is retried by the next ingest for the pair (`resume`) or by maintenance, never rolled\n * back and re-entered as a second execution.\n */\n plan?(options: { id: string; params: JourneyParams; ts: number }): Promise<ExecutionStart>;\n /** Retry the recorded, unconfirmed start of the (workflow, user) entry still running, if any. */\n resume?(entry: { workflow: string; userId: string }): Promise<void>;\n /**\n * Maintenance the sweep runs first: old unconfirmed starts retried (or given up on), and\n * executions the platform ended on its own (timeout, stop, crash) reconciled into the ledger.\n */\n maintain?(): Promise<void>;\n}\n\n/**\n * Cloudflare Email Service's send_email binding (structural subset).\n *\n * `idempotencyKey` is part of the call because the send happens inside a\n * `step.do`: a step body re-runs after its fn resolved but before the\n * checkpoint committed, so a binding that drops the key mails the user twice.\n * The binding is app-supplied glue (nothing here can de-duplicate — there is\n * no state between two runs of the same step), which is why the port makes the\n * key impossible to miss rather than merely available.\n */\nexport interface EmailBindingLike {\n send(message: {\n from: EmailAddress;\n to: EmailAddress;\n replyTo?: EmailAddress;\n subject: string;\n html: string;\n /** `${instanceId}:${nodeId}`: stable across replays and retries — drop a send whose key was already delivered. */\n idempotencyKey: string;\n }): Promise<unknown>;\n}\n\n/** Every binding the journey engine needs (names match the wrangler config). */\nexport interface JourneyEnv {\n /**\n * BundleIR storage for the default D1 store (`wf:${contentHash}` → WorkflowIR JSON). Optional:\n * a Worker on another JourneyStore (Postgres, say) does not declare it.\n */\n WORKFLOW_KV?: KVNamespaceLike;\n /** Tables of the default D1 store (events / profiles / segments / triggers / entries / subscriptions). Optional, as above. */\n DB?: D1DatabaseLike;\n /** JourneyRunner's workflow binding (creating instances and waking them). */\n JOURNEY: WorkflowBindingLike;\n /** Optional message-delivery webhook (defaults to the console logging sender). */\n MESSAGE_WEBHOOK_URL?: string;\n /**\n * Bearer token required on every mutating HTTP endpoint (`Authorization:\n * Bearer <token>`). Unset = open — acceptable only for local dev; /deploy in\n * particular rewrites the whole routing table, so production must set this.\n */\n API_TOKEN?: string;\n /**\n * This Worker's public origin. With a store that keeps reporting (Postgres), journey emails'\n * links and open pixel point at its `/t/...` routes; without it emails go out untracked.\n */\n WORKFLOW_ORIGIN?: string;\n /** Which `workflow.config.ts` entry applies (`development`, `production`, …); production when unset. */\n ENVIRONMENT?: string;\n /** Cloudflare Email Sending, when the config has `emails`. */\n EMAIL?: EmailBindingLike;\n /** The APNs auth key (`.p8`, base64) named by the config's `apns.keyId`. */\n APNS_PRIVATE_KEY_BASE64?: string;\n /** The Firebase service account (base64 JSON) the config's `fcm` sends with. */\n GOOGLE_APPLICATION_CREDENTIALS_BASE64?: string;\n /** The VAPID key pair web pushes are signed with (`generateVapidKeys`); the config's `webPush` turns the channel on. */\n VAPID_PUBLIC_KEY?: string;\n VAPID_PRIVATE_KEY?: string;\n /**\n * Cloudflare's version metadata binding (wrangler `\"version_metadata\": { \"binding\":\n * \"CF_VERSION_METADATA\" }`). With it, and a store that records the live version (Postgres),\n * journeyWorker makes the bundle compiled into each new version live by itself: `wrangler\n * deploy` is the whole deploy.\n */\n CF_VERSION_METADATA?: { id: string; tag?: string; timestamp: string };\n /** Development only: the HTTP/2 relay `wrangler dev` sends APNs requests through (see `@shware/workflow/apns-relay`). */\n APNS_RELAY_ORIGIN?: string;\n}\n\n/** The runtime does not know the instance: the only failure that lets the router drop its wakes. */\nexport class InstanceNotFoundError extends Error {\n override readonly name = 'InstanceNotFoundError';\n constructor(readonly instanceId: string) {\n super(`no such instance: ${instanceId}`);\n }\n}\n\n/**\n * Does this lookup failure say the instance is gone, rather than that the lookup failed?\n * Cloudflare reports an unknown id as `instance.not_found`.\n */\nexport function isInstanceNotFound(error: unknown): boolean {\n if (error instanceof InstanceNotFoundError) return true;\n return /not[_ ]found|no such instance/i.test(String(error));\n}\n\n/** The event type the router uses to wake waiting instances (sendEvent's `type`). */\nexport const WAKE_EVENT_TYPE = 'wake';\n\n/** Journey instance parameters, passed by the ingest router when it creates one. */\nexport interface JourneyParams {\n workflowId: string;\n /** The IR version pinned at entry. */\n contentHash: string;\n userId: string;\n trigger: { event: string; payload: Record<string, unknown> };\n [key: string]: unknown;\n}\n\n/**\n * Slack on the deadline when deciding whether a thrown wait was a timeout: the\n * platform may surface the expiry a moment early, and a wait shorter than this\n * has no room to tell the two apart anyway.\n */\nexport const WAKE_TIMEOUT_TOLERANCE_MS = 5_000;\n\n/**\n * Did a wait that threw actually reach its deadline?\n *\n * CF signals a wait timeout by throwing, and so does every other failure in\n * there — a broken binding, an evicted instance, a malformed timeout. The\n * adapter used to read them all as timeouts, which made an infrastructure\n * fault indistinguishable from \"the user never did it\": the journey took its\n * onTimeout branch, early and silently. Only the clock can tell them apart.\n */\nexport function wakeExpired(startedAt: number, timeoutMs: number, now: number): boolean {\n return now - startedAt >= Math.max(0, timeoutMs - WAKE_TIMEOUT_TOLERANCE_MS);\n}\n"],"mappings":";;;AAwIA,IAAa,wBAAb,cAA2C,MAAM;CAE1B;CADrB,OAAyB;CACzB,YAAY,YAA6B;EACvC,MAAM,qBAAqB,YAAY;EADpB,KAAA,aAAA;CAErB;AACF;;;;;AAMA,SAAgB,mBAAmB,OAAyB;CAC1D,IAAI,iBAAiB,uBAAuB,OAAO;CACnD,OAAO,iCAAiC,KAAK,OAAO,KAAK,CAAC;AAC5D;;AAGA,MAAa,kBAAkB;;;;;;AAiB/B,MAAa,4BAA4B;;;;;;;;;;AAWzC,SAAgB,YAAY,WAAmB,WAAmB,KAAsB;CACtF,OAAO,MAAM,aAAa,KAAK,IAAI,GAAG,YAAY,yBAAyB;AAC7E"}
1
+ {"version":3,"file":"bindings.cjs","names":[],"sources":["../../src/cloudflare/bindings.ts"],"sourcesContent":["import type { EmailAddress } from '../email';\nimport type { ExecutionStart } from '../store/index';\n\n/**\n * A minimal structural interface over the Cloudflare bindings.\n *\n * @cloudflare/workers-types is deliberately not used: the global types it\n * injects clash with the DOM lib this package's react side needs. A structural\n * subset plus the ambient cloudflare:workers declaration is enough to compile,\n * and the real bindings satisfy it structurally at runtime.\n */\n\nexport interface KVNamespaceLike {\n get(key: string): Promise<string | null>;\n put(key: string, value: string): Promise<void>;\n}\n\n/** Structural subset of D1Result: `meta.changes` is how INSERT OR IGNORE reports whether it inserted. */\nexport interface D1RunResultLike {\n meta?: { changes?: number };\n}\n\nexport interface D1PreparedStatementLike {\n bind(...values: unknown[]): D1PreparedStatementLike;\n first<T = Record<string, unknown>>(): Promise<T | null>;\n all<T = Record<string, unknown>>(): Promise<{ results: T[] }>;\n run(): Promise<D1RunResultLike>;\n}\n\nexport interface D1DatabaseLike {\n prepare(sql: string): D1PreparedStatementLike;\n /** Statements run in order inside one transaction (real D1 semantics) — deploy relies on the atomicity. */\n batch(statements: D1PreparedStatementLike[]): Promise<unknown>;\n}\n\nexport interface WorkflowInstanceLike {\n id: string;\n sendEvent(event: { type: string; payload?: unknown }): Promise<void>;\n /** Stop the instance for good (Cloudflare Workflows' `terminate`). */\n terminate?(): Promise<void>;\n /** Where it stands (`complete`, `errored`, `terminated`, `running`, …). */\n status?(): Promise<{ status: string }>;\n}\n\n/**\n * How the router starts, wakes and stops instances: Cloudflare's workflow binding, or the AWS\n * adapter's equivalent over durable executions.\n */\nexport interface WorkflowBindingLike {\n create(options: { id: string; params: unknown }): Promise<WorkflowInstanceLike>;\n get(id: string): Promise<WorkflowInstanceLike>;\n /**\n * For a runtime whose start is a submit that can be lost in flight (AWS): the start to record\n * with the entry, in one transaction. A failed `create` then keeps the entry — its recorded\n * start is retried by the next ingest for the pair (`resume`) or by maintenance, never rolled\n * back and re-entered as a second execution.\n */\n plan?(options: {\n id: string;\n params: JourneyParams;\n ts: number;\n /** The code version whose routes named `params.contentHash` (absent after a forced deploy). */\n version?: string | undefined;\n }): Promise<ExecutionStart>;\n /** Retry the recorded, unconfirmed start of the (workflow, user) entry still running, if any. */\n resume?(entry: { workflow: string; userId: string }): Promise<void>;\n /**\n * Maintenance the sweep runs first: old unconfirmed starts retried (or given up on), and\n * executions the platform ended on its own (timeout, stop, crash) reconciled into the ledger.\n */\n maintain?(): Promise<void>;\n}\n\n/**\n * Cloudflare Email Service's send_email binding (structural subset).\n *\n * `idempotencyKey` is part of the call because the send happens inside a\n * `step.do`: a step body re-runs after its fn resolved but before the\n * checkpoint committed, so a binding that drops the key mails the user twice.\n * The binding is app-supplied glue (nothing here can de-duplicate — there is\n * no state between two runs of the same step), which is why the port makes the\n * key impossible to miss rather than merely available.\n */\nexport interface EmailBindingLike {\n send(message: {\n from: EmailAddress;\n to: EmailAddress;\n replyTo?: EmailAddress;\n subject: string;\n html: string;\n /** `${instanceId}:${nodeId}`: stable across replays and retries — drop a send whose key was already delivered. */\n idempotencyKey: string;\n }): Promise<unknown>;\n}\n\n/** Every binding the journey engine needs (names match the wrangler config). */\nexport interface JourneyEnv {\n /**\n * BundleIR storage for the default D1 store (`wf:${contentHash}` → WorkflowIR JSON). Optional:\n * a Worker on another JourneyStore (Postgres, say) does not declare it.\n */\n WORKFLOW_KV?: KVNamespaceLike;\n /** Tables of the default D1 store (events / profiles / segments / triggers / entries / subscriptions). Optional, as above. */\n DB?: D1DatabaseLike;\n /** JourneyRunner's workflow binding (creating instances and waking them). */\n JOURNEY: WorkflowBindingLike;\n /** Optional message-delivery webhook (defaults to the console logging sender). */\n MESSAGE_WEBHOOK_URL?: string;\n /**\n * Bearer token required on every mutating HTTP endpoint (`Authorization:\n * Bearer <token>`). Unset = open — acceptable only for local dev; /deploy in\n * particular rewrites the whole routing table, so production must set this.\n */\n API_TOKEN?: string;\n /**\n * This Worker's public origin. With a store that keeps reporting (Postgres), journey emails'\n * links and open pixel point at its `/t/...` routes; without it emails go out untracked.\n */\n WORKFLOW_ORIGIN?: string;\n /** Which `workflow.config.ts` entry applies (`development`, `production`, …); production when unset. */\n ENVIRONMENT?: string;\n /** Cloudflare Email Sending, when the config has `emails`. */\n EMAIL?: EmailBindingLike;\n /** The APNs auth key (`.p8`, base64) named by the config's `apns.keyId`. */\n APNS_PRIVATE_KEY_BASE64?: string;\n /** The Firebase service account (base64 JSON) the config's `fcm` sends with. */\n GOOGLE_APPLICATION_CREDENTIALS_BASE64?: string;\n /** The VAPID key pair web pushes are signed with (`generateVapidKeys`); the config's `webPush` turns the channel on. */\n VAPID_PUBLIC_KEY?: string;\n VAPID_PRIVATE_KEY?: string;\n /**\n * Cloudflare's version metadata binding (wrangler `\"version_metadata\": { \"binding\":\n * \"CF_VERSION_METADATA\" }`). With it, and a store that records the live version (Postgres),\n * journeyWorker makes the bundle compiled into each new version live by itself: `wrangler\n * deploy` is the whole deploy.\n */\n CF_VERSION_METADATA?: { id: string; tag?: string; timestamp: string };\n /** Development only: the HTTP/2 relay `wrangler dev` sends APNs requests through (see `@shware/workflow/apns-relay`). */\n APNS_RELAY_ORIGIN?: string;\n}\n\n/** The runtime does not know the instance: the only failure that lets the router drop its wakes. */\nexport class InstanceNotFoundError extends Error {\n override readonly name = 'InstanceNotFoundError';\n constructor(readonly instanceId: string) {\n super(`no such instance: ${instanceId}`);\n }\n}\n\n/**\n * Does this lookup failure say the instance is gone, rather than that the lookup failed?\n * Cloudflare reports an unknown id as `instance.not_found`.\n */\nexport function isInstanceNotFound(error: unknown): boolean {\n if (error instanceof InstanceNotFoundError) return true;\n return /not[_ ]found|no such instance/i.test(String(error));\n}\n\n/** The event type the router uses to wake waiting instances (sendEvent's `type`). */\nexport const WAKE_EVENT_TYPE = 'wake';\n\n/** Journey instance parameters, passed by the ingest router when it creates one. */\nexport interface JourneyParams {\n workflowId: string;\n /** The IR version pinned at entry. */\n contentHash: string;\n userId: string;\n trigger: { event: string; payload: Record<string, unknown> };\n [key: string]: unknown;\n}\n\n/**\n * Slack on the deadline when deciding whether a thrown wait was a timeout: the\n * platform may surface the expiry a moment early, and a wait shorter than this\n * has no room to tell the two apart anyway.\n */\nexport const WAKE_TIMEOUT_TOLERANCE_MS = 5_000;\n\n/**\n * Did a wait that threw actually reach its deadline?\n *\n * CF signals a wait timeout by throwing, and so does every other failure in\n * there — a broken binding, an evicted instance, a malformed timeout. The\n * adapter used to read them all as timeouts, which made an infrastructure\n * fault indistinguishable from \"the user never did it\": the journey took its\n * onTimeout branch, early and silently. Only the clock can tell them apart.\n */\nexport function wakeExpired(startedAt: number, timeoutMs: number, now: number): boolean {\n return now - startedAt >= Math.max(0, timeoutMs - WAKE_TIMEOUT_TOLERANCE_MS);\n}\n"],"mappings":";;;AA8IA,IAAa,wBAAb,cAA2C,MAAM;CAE1B;CADrB,OAAyB;CACzB,YAAY,YAA6B;EACvC,MAAM,qBAAqB,YAAY;EADpB,KAAA,aAAA;CAErB;AACF;;;;;AAMA,SAAgB,mBAAmB,OAAyB;CAC1D,IAAI,iBAAiB,uBAAuB,OAAO;CACnD,OAAO,iCAAiC,KAAK,OAAO,KAAK,CAAC;AAC5D;;AAGA,MAAa,kBAAkB;;;;;;AAiB/B,MAAa,4BAA4B;;;;;;;;;;AAWzC,SAAgB,YAAY,WAAmB,WAAmB,KAAsB;CACtF,OAAO,MAAM,aAAa,KAAK,IAAI,GAAG,YAAY,yBAAyB;AAC7E"}
@@ -65,6 +65,8 @@ export interface WorkflowBindingLike {
65
65
  id: string;
66
66
  params: JourneyParams;
67
67
  ts: number;
68
+ /** The code version whose routes named `params.contentHash` (absent after a forced deploy). */
69
+ version?: string | undefined;
68
70
  }): Promise<ExecutionStart>;
69
71
  /** Retry the recorded, unconfirmed start of the (workflow, user) entry still running, if any. */
70
72
  resume?(entry: {
@@ -1 +1 @@
1
- {"version":3,"file":"bindings.d.cts","names":[],"sources":["../../src/cloudflare/bindings.ts"],"mappings":";;;;;;;;;;;iBAYiB;EACf,IAAI,cAAc;EAClB,IAAI,aAAa,gBAAgB;;;iBAIlB;EACf;IAAS;;;iBAGM;EACf,QAAQ,oBAAoB;EAC5B,MAAM,IAAI,4BAA4B,QAAQ;EAC9C,IAAI,IAAI,4BAA4B;IAAU,SAAS;;EACvD,OAAO,QAAQ;;iBAGA;EACf,QAAQ,cAAc;;EAEtB,MAAM,YAAY,4BAA4B;;iBAG/B;EACf;EACA,UAAU;IAAS;IAAc;MAAsB;;EAEvD,cAAc;;EAEd,WAAW;IAAU;;;;;;;iBAON;EACf,OAAO;IAAW;IAAY;MAAoB,QAAQ;EAC1D,IAAI,aAAa,QAAQ;;;;;;;EAOzB,MAAM;IAAW;IAAY,QAAQ;IAAe;MAAe,QAAQ;;EAE3E,QAAQ;IAAS;IAAkB;MAAmB;;;;;EAKtD,aAAa;;;;;;;;;;;;iBAaE;EACf,KAAK;IACH,MAAM;IACN,IAAI;IACJ,UAAU;IACV;IACA;;IAEA;MACE;;;iBAIW;;;;;EAKf,cAAc;;EAEd,KAAK;;EAEL,SAAS;;EAET;;;;;;EAMA;;;;;EAKA;;EAEA;;EAEA,QAAQ;;EAER;;EAEA;;EAEA;EACA;;;;;;;EAOA;IAAwB;IAAY;IAAc;;;EAElD;;;qBAIW,8BAA8B;WAEpB;WADH;EAClB,YAAqB;;;;;;wBASP,mBAAmB;;qBAMtB;;iBAGI;EACf;;EAEA;EACA;EACA;IAAW;IAAe,SAAS;;GAClC;;;;;;;qBAQU;;;;;;;;;;wBAWG,YAAY,mBAAmB,mBAAmB"}
1
+ {"version":3,"file":"bindings.d.cts","names":[],"sources":["../../src/cloudflare/bindings.ts"],"mappings":";;;;;;;;;;;iBAYiB;EACf,IAAI,cAAc;EAClB,IAAI,aAAa,gBAAgB;;;iBAIlB;EACf;IAAS;;;iBAGM;EACf,QAAQ,oBAAoB;EAC5B,MAAM,IAAI,4BAA4B,QAAQ;EAC9C,IAAI,IAAI,4BAA4B;IAAU,SAAS;;EACvD,OAAO,QAAQ;;iBAGA;EACf,QAAQ,cAAc;;EAEtB,MAAM,YAAY,4BAA4B;;iBAG/B;EACf;EACA,UAAU;IAAS;IAAc;MAAsB;;EAEvD,cAAc;;EAEd,WAAW;IAAU;;;;;;;iBAON;EACf,OAAO;IAAW;IAAY;MAAoB,QAAQ;EAC1D,IAAI,aAAa,QAAQ;;;;;;;EAOzB,MAAM;IACJ;IACA,QAAQ;IACR;;IAEA;MACE,QAAQ;;EAEZ,QAAQ;IAAS;IAAkB;MAAmB;;;;;EAKtD,aAAa;;;;;;;;;;;;iBAaE;EACf,KAAK;IACH,MAAM;IACN,IAAI;IACJ,UAAU;IACV;IACA;;IAEA;MACE;;;iBAIW;;;;;EAKf,cAAc;;EAEd,KAAK;;EAEL,SAAS;;EAET;;;;;;EAMA;;;;;EAKA;;EAEA;;EAEA,QAAQ;;EAER;;EAEA;;EAEA;EACA;;;;;;;EAOA;IAAwB;IAAY;IAAc;;;EAElD;;;qBAIW,8BAA8B;WAEpB;WADH;EAClB,YAAqB;;;;;;wBASP,mBAAmB;;qBAMtB;;iBAGI;EACf;;EAEA;EACA;EACA;IAAW;IAAe,SAAS;;GAClC;;;;;;;qBAQU;;;;;;;;;;wBAWG,YAAY,mBAAmB,mBAAmB"}
@@ -65,6 +65,8 @@ export interface WorkflowBindingLike {
65
65
  id: string;
66
66
  params: JourneyParams;
67
67
  ts: number;
68
+ /** The code version whose routes named `params.contentHash` (absent after a forced deploy). */
69
+ version?: string | undefined;
68
70
  }): Promise<ExecutionStart>;
69
71
  /** Retry the recorded, unconfirmed start of the (workflow, user) entry still running, if any. */
70
72
  resume?(entry: {
@@ -1 +1 @@
1
- {"version":3,"file":"bindings.d.mts","names":[],"sources":["../../src/cloudflare/bindings.ts"],"mappings":";;;;;;;;;;;iBAYiB;EACf,IAAI,cAAc;EAClB,IAAI,aAAa,gBAAgB;;;iBAIlB;EACf;IAAS;;;iBAGM;EACf,QAAQ,oBAAoB;EAC5B,MAAM,IAAI,4BAA4B,QAAQ;EAC9C,IAAI,IAAI,4BAA4B;IAAU,SAAS;;EACvD,OAAO,QAAQ;;iBAGA;EACf,QAAQ,cAAc;;EAEtB,MAAM,YAAY,4BAA4B;;iBAG/B;EACf;EACA,UAAU;IAAS;IAAc;MAAsB;;EAEvD,cAAc;;EAEd,WAAW;IAAU;;;;;;;iBAON;EACf,OAAO;IAAW;IAAY;MAAoB,QAAQ;EAC1D,IAAI,aAAa,QAAQ;;;;;;;EAOzB,MAAM;IAAW;IAAY,QAAQ;IAAe;MAAe,QAAQ;;EAE3E,QAAQ;IAAS;IAAkB;MAAmB;;;;;EAKtD,aAAa;;;;;;;;;;;;iBAaE;EACf,KAAK;IACH,MAAM;IACN,IAAI;IACJ,UAAU;IACV;IACA;;IAEA;MACE;;;iBAIW;;;;;EAKf,cAAc;;EAEd,KAAK;;EAEL,SAAS;;EAET;;;;;;EAMA;;;;;EAKA;;EAEA;;EAEA,QAAQ;;EAER;;EAEA;;EAEA;EACA;;;;;;;EAOA;IAAwB;IAAY;IAAc;;;EAElD;;;qBAIW,8BAA8B;WAEpB;WADH;EAClB,YAAqB;;;;;;wBASP,mBAAmB;;qBAMtB;;iBAGI;EACf;;EAEA;EACA;EACA;IAAW;IAAe,SAAS;;GAClC;;;;;;;qBAQU;;;;;;;;;;wBAWG,YAAY,mBAAmB,mBAAmB"}
1
+ {"version":3,"file":"bindings.d.mts","names":[],"sources":["../../src/cloudflare/bindings.ts"],"mappings":";;;;;;;;;;;iBAYiB;EACf,IAAI,cAAc;EAClB,IAAI,aAAa,gBAAgB;;;iBAIlB;EACf;IAAS;;;iBAGM;EACf,QAAQ,oBAAoB;EAC5B,MAAM,IAAI,4BAA4B,QAAQ;EAC9C,IAAI,IAAI,4BAA4B;IAAU,SAAS;;EACvD,OAAO,QAAQ;;iBAGA;EACf,QAAQ,cAAc;;EAEtB,MAAM,YAAY,4BAA4B;;iBAG/B;EACf;EACA,UAAU;IAAS;IAAc;MAAsB;;EAEvD,cAAc;;EAEd,WAAW;IAAU;;;;;;;iBAON;EACf,OAAO;IAAW;IAAY;MAAoB,QAAQ;EAC1D,IAAI,aAAa,QAAQ;;;;;;;EAOzB,MAAM;IACJ;IACA,QAAQ;IACR;;IAEA;MACE,QAAQ;;EAEZ,QAAQ;IAAS;IAAkB;MAAmB;;;;;EAKtD,aAAa;;;;;;;;;;;;iBAaE;EACf,KAAK;IACH,MAAM;IACN,IAAI;IACJ,UAAU;IACV;IACA;;IAEA;MACE;;;iBAIW;;;;;EAKf,cAAc;;EAEd,KAAK;;EAEL,SAAS;;EAET;;;;;;EAMA;;;;;EAKA;;EAEA;;EAEA,QAAQ;;EAER;;EAEA;;EAEA;EACA;;;;;;;EAOA;IAAwB;IAAY;IAAc;;;EAElD;;;qBAIW,8BAA8B;WAEpB;WADH;EAClB,YAAqB;;;;;;wBASP,mBAAmB;;qBAMtB;;iBAGI;EACf;;EAEA;EACA;EACA;IAAW;IAAe,SAAS;;GAClC;;;;;;;qBAQU;;;;;;;;;;wBAWG,YAAY,mBAAmB,mBAAmB"}
@@ -1 +1 @@
1
- {"version":3,"file":"bindings.mjs","names":[],"sources":["../../src/cloudflare/bindings.ts"],"sourcesContent":["import type { EmailAddress } from '../email';\nimport type { ExecutionStart } from '../store/index';\n\n/**\n * A minimal structural interface over the Cloudflare bindings.\n *\n * @cloudflare/workers-types is deliberately not used: the global types it\n * injects clash with the DOM lib this package's react side needs. A structural\n * subset plus the ambient cloudflare:workers declaration is enough to compile,\n * and the real bindings satisfy it structurally at runtime.\n */\n\nexport interface KVNamespaceLike {\n get(key: string): Promise<string | null>;\n put(key: string, value: string): Promise<void>;\n}\n\n/** Structural subset of D1Result: `meta.changes` is how INSERT OR IGNORE reports whether it inserted. */\nexport interface D1RunResultLike {\n meta?: { changes?: number };\n}\n\nexport interface D1PreparedStatementLike {\n bind(...values: unknown[]): D1PreparedStatementLike;\n first<T = Record<string, unknown>>(): Promise<T | null>;\n all<T = Record<string, unknown>>(): Promise<{ results: T[] }>;\n run(): Promise<D1RunResultLike>;\n}\n\nexport interface D1DatabaseLike {\n prepare(sql: string): D1PreparedStatementLike;\n /** Statements run in order inside one transaction (real D1 semantics) — deploy relies on the atomicity. */\n batch(statements: D1PreparedStatementLike[]): Promise<unknown>;\n}\n\nexport interface WorkflowInstanceLike {\n id: string;\n sendEvent(event: { type: string; payload?: unknown }): Promise<void>;\n /** Stop the instance for good (Cloudflare Workflows' `terminate`). */\n terminate?(): Promise<void>;\n /** Where it stands (`complete`, `errored`, `terminated`, `running`, …). */\n status?(): Promise<{ status: string }>;\n}\n\n/**\n * How the router starts, wakes and stops instances: Cloudflare's workflow binding, or the AWS\n * adapter's equivalent over durable executions.\n */\nexport interface WorkflowBindingLike {\n create(options: { id: string; params: unknown }): Promise<WorkflowInstanceLike>;\n get(id: string): Promise<WorkflowInstanceLike>;\n /**\n * For a runtime whose start is a submit that can be lost in flight (AWS): the start to record\n * with the entry, in one transaction. A failed `create` then keeps the entry — its recorded\n * start is retried by the next ingest for the pair (`resume`) or by maintenance, never rolled\n * back and re-entered as a second execution.\n */\n plan?(options: { id: string; params: JourneyParams; ts: number }): Promise<ExecutionStart>;\n /** Retry the recorded, unconfirmed start of the (workflow, user) entry still running, if any. */\n resume?(entry: { workflow: string; userId: string }): Promise<void>;\n /**\n * Maintenance the sweep runs first: old unconfirmed starts retried (or given up on), and\n * executions the platform ended on its own (timeout, stop, crash) reconciled into the ledger.\n */\n maintain?(): Promise<void>;\n}\n\n/**\n * Cloudflare Email Service's send_email binding (structural subset).\n *\n * `idempotencyKey` is part of the call because the send happens inside a\n * `step.do`: a step body re-runs after its fn resolved but before the\n * checkpoint committed, so a binding that drops the key mails the user twice.\n * The binding is app-supplied glue (nothing here can de-duplicate — there is\n * no state between two runs of the same step), which is why the port makes the\n * key impossible to miss rather than merely available.\n */\nexport interface EmailBindingLike {\n send(message: {\n from: EmailAddress;\n to: EmailAddress;\n replyTo?: EmailAddress;\n subject: string;\n html: string;\n /** `${instanceId}:${nodeId}`: stable across replays and retries — drop a send whose key was already delivered. */\n idempotencyKey: string;\n }): Promise<unknown>;\n}\n\n/** Every binding the journey engine needs (names match the wrangler config). */\nexport interface JourneyEnv {\n /**\n * BundleIR storage for the default D1 store (`wf:${contentHash}` → WorkflowIR JSON). Optional:\n * a Worker on another JourneyStore (Postgres, say) does not declare it.\n */\n WORKFLOW_KV?: KVNamespaceLike;\n /** Tables of the default D1 store (events / profiles / segments / triggers / entries / subscriptions). Optional, as above. */\n DB?: D1DatabaseLike;\n /** JourneyRunner's workflow binding (creating instances and waking them). */\n JOURNEY: WorkflowBindingLike;\n /** Optional message-delivery webhook (defaults to the console logging sender). */\n MESSAGE_WEBHOOK_URL?: string;\n /**\n * Bearer token required on every mutating HTTP endpoint (`Authorization:\n * Bearer <token>`). Unset = open — acceptable only for local dev; /deploy in\n * particular rewrites the whole routing table, so production must set this.\n */\n API_TOKEN?: string;\n /**\n * This Worker's public origin. With a store that keeps reporting (Postgres), journey emails'\n * links and open pixel point at its `/t/...` routes; without it emails go out untracked.\n */\n WORKFLOW_ORIGIN?: string;\n /** Which `workflow.config.ts` entry applies (`development`, `production`, …); production when unset. */\n ENVIRONMENT?: string;\n /** Cloudflare Email Sending, when the config has `emails`. */\n EMAIL?: EmailBindingLike;\n /** The APNs auth key (`.p8`, base64) named by the config's `apns.keyId`. */\n APNS_PRIVATE_KEY_BASE64?: string;\n /** The Firebase service account (base64 JSON) the config's `fcm` sends with. */\n GOOGLE_APPLICATION_CREDENTIALS_BASE64?: string;\n /** The VAPID key pair web pushes are signed with (`generateVapidKeys`); the config's `webPush` turns the channel on. */\n VAPID_PUBLIC_KEY?: string;\n VAPID_PRIVATE_KEY?: string;\n /**\n * Cloudflare's version metadata binding (wrangler `\"version_metadata\": { \"binding\":\n * \"CF_VERSION_METADATA\" }`). With it, and a store that records the live version (Postgres),\n * journeyWorker makes the bundle compiled into each new version live by itself: `wrangler\n * deploy` is the whole deploy.\n */\n CF_VERSION_METADATA?: { id: string; tag?: string; timestamp: string };\n /** Development only: the HTTP/2 relay `wrangler dev` sends APNs requests through (see `@shware/workflow/apns-relay`). */\n APNS_RELAY_ORIGIN?: string;\n}\n\n/** The runtime does not know the instance: the only failure that lets the router drop its wakes. */\nexport class InstanceNotFoundError extends Error {\n override readonly name = 'InstanceNotFoundError';\n constructor(readonly instanceId: string) {\n super(`no such instance: ${instanceId}`);\n }\n}\n\n/**\n * Does this lookup failure say the instance is gone, rather than that the lookup failed?\n * Cloudflare reports an unknown id as `instance.not_found`.\n */\nexport function isInstanceNotFound(error: unknown): boolean {\n if (error instanceof InstanceNotFoundError) return true;\n return /not[_ ]found|no such instance/i.test(String(error));\n}\n\n/** The event type the router uses to wake waiting instances (sendEvent's `type`). */\nexport const WAKE_EVENT_TYPE = 'wake';\n\n/** Journey instance parameters, passed by the ingest router when it creates one. */\nexport interface JourneyParams {\n workflowId: string;\n /** The IR version pinned at entry. */\n contentHash: string;\n userId: string;\n trigger: { event: string; payload: Record<string, unknown> };\n [key: string]: unknown;\n}\n\n/**\n * Slack on the deadline when deciding whether a thrown wait was a timeout: the\n * platform may surface the expiry a moment early, and a wait shorter than this\n * has no room to tell the two apart anyway.\n */\nexport const WAKE_TIMEOUT_TOLERANCE_MS = 5_000;\n\n/**\n * Did a wait that threw actually reach its deadline?\n *\n * CF signals a wait timeout by throwing, and so does every other failure in\n * there — a broken binding, an evicted instance, a malformed timeout. The\n * adapter used to read them all as timeouts, which made an infrastructure\n * fault indistinguishable from \"the user never did it\": the journey took its\n * onTimeout branch, early and silently. Only the clock can tell them apart.\n */\nexport function wakeExpired(startedAt: number, timeoutMs: number, now: number): boolean {\n return now - startedAt >= Math.max(0, timeoutMs - WAKE_TIMEOUT_TOLERANCE_MS);\n}\n"],"mappings":";;AAwIA,IAAa,wBAAb,cAA2C,MAAM;CAE1B;CADrB,OAAyB;CACzB,YAAY,YAA6B;EACvC,MAAM,qBAAqB,YAAY;EADpB,KAAA,aAAA;CAErB;AACF;;;;;AAMA,SAAgB,mBAAmB,OAAyB;CAC1D,IAAI,iBAAiB,uBAAuB,OAAO;CACnD,OAAO,iCAAiC,KAAK,OAAO,KAAK,CAAC;AAC5D;;AAGA,MAAa,kBAAkB;;;;;;AAiB/B,MAAa,4BAA4B;;;;;;;;;;AAWzC,SAAgB,YAAY,WAAmB,WAAmB,KAAsB;CACtF,OAAO,MAAM,aAAa,KAAK,IAAI,GAAG,YAAY,yBAAyB;AAC7E"}
1
+ {"version":3,"file":"bindings.mjs","names":[],"sources":["../../src/cloudflare/bindings.ts"],"sourcesContent":["import type { EmailAddress } from '../email';\nimport type { ExecutionStart } from '../store/index';\n\n/**\n * A minimal structural interface over the Cloudflare bindings.\n *\n * @cloudflare/workers-types is deliberately not used: the global types it\n * injects clash with the DOM lib this package's react side needs. A structural\n * subset plus the ambient cloudflare:workers declaration is enough to compile,\n * and the real bindings satisfy it structurally at runtime.\n */\n\nexport interface KVNamespaceLike {\n get(key: string): Promise<string | null>;\n put(key: string, value: string): Promise<void>;\n}\n\n/** Structural subset of D1Result: `meta.changes` is how INSERT OR IGNORE reports whether it inserted. */\nexport interface D1RunResultLike {\n meta?: { changes?: number };\n}\n\nexport interface D1PreparedStatementLike {\n bind(...values: unknown[]): D1PreparedStatementLike;\n first<T = Record<string, unknown>>(): Promise<T | null>;\n all<T = Record<string, unknown>>(): Promise<{ results: T[] }>;\n run(): Promise<D1RunResultLike>;\n}\n\nexport interface D1DatabaseLike {\n prepare(sql: string): D1PreparedStatementLike;\n /** Statements run in order inside one transaction (real D1 semantics) — deploy relies on the atomicity. */\n batch(statements: D1PreparedStatementLike[]): Promise<unknown>;\n}\n\nexport interface WorkflowInstanceLike {\n id: string;\n sendEvent(event: { type: string; payload?: unknown }): Promise<void>;\n /** Stop the instance for good (Cloudflare Workflows' `terminate`). */\n terminate?(): Promise<void>;\n /** Where it stands (`complete`, `errored`, `terminated`, `running`, …). */\n status?(): Promise<{ status: string }>;\n}\n\n/**\n * How the router starts, wakes and stops instances: Cloudflare's workflow binding, or the AWS\n * adapter's equivalent over durable executions.\n */\nexport interface WorkflowBindingLike {\n create(options: { id: string; params: unknown }): Promise<WorkflowInstanceLike>;\n get(id: string): Promise<WorkflowInstanceLike>;\n /**\n * For a runtime whose start is a submit that can be lost in flight (AWS): the start to record\n * with the entry, in one transaction. A failed `create` then keeps the entry — its recorded\n * start is retried by the next ingest for the pair (`resume`) or by maintenance, never rolled\n * back and re-entered as a second execution.\n */\n plan?(options: {\n id: string;\n params: JourneyParams;\n ts: number;\n /** The code version whose routes named `params.contentHash` (absent after a forced deploy). */\n version?: string | undefined;\n }): Promise<ExecutionStart>;\n /** Retry the recorded, unconfirmed start of the (workflow, user) entry still running, if any. */\n resume?(entry: { workflow: string; userId: string }): Promise<void>;\n /**\n * Maintenance the sweep runs first: old unconfirmed starts retried (or given up on), and\n * executions the platform ended on its own (timeout, stop, crash) reconciled into the ledger.\n */\n maintain?(): Promise<void>;\n}\n\n/**\n * Cloudflare Email Service's send_email binding (structural subset).\n *\n * `idempotencyKey` is part of the call because the send happens inside a\n * `step.do`: a step body re-runs after its fn resolved but before the\n * checkpoint committed, so a binding that drops the key mails the user twice.\n * The binding is app-supplied glue (nothing here can de-duplicate — there is\n * no state between two runs of the same step), which is why the port makes the\n * key impossible to miss rather than merely available.\n */\nexport interface EmailBindingLike {\n send(message: {\n from: EmailAddress;\n to: EmailAddress;\n replyTo?: EmailAddress;\n subject: string;\n html: string;\n /** `${instanceId}:${nodeId}`: stable across replays and retries — drop a send whose key was already delivered. */\n idempotencyKey: string;\n }): Promise<unknown>;\n}\n\n/** Every binding the journey engine needs (names match the wrangler config). */\nexport interface JourneyEnv {\n /**\n * BundleIR storage for the default D1 store (`wf:${contentHash}` → WorkflowIR JSON). Optional:\n * a Worker on another JourneyStore (Postgres, say) does not declare it.\n */\n WORKFLOW_KV?: KVNamespaceLike;\n /** Tables of the default D1 store (events / profiles / segments / triggers / entries / subscriptions). Optional, as above. */\n DB?: D1DatabaseLike;\n /** JourneyRunner's workflow binding (creating instances and waking them). */\n JOURNEY: WorkflowBindingLike;\n /** Optional message-delivery webhook (defaults to the console logging sender). */\n MESSAGE_WEBHOOK_URL?: string;\n /**\n * Bearer token required on every mutating HTTP endpoint (`Authorization:\n * Bearer <token>`). Unset = open — acceptable only for local dev; /deploy in\n * particular rewrites the whole routing table, so production must set this.\n */\n API_TOKEN?: string;\n /**\n * This Worker's public origin. With a store that keeps reporting (Postgres), journey emails'\n * links and open pixel point at its `/t/...` routes; without it emails go out untracked.\n */\n WORKFLOW_ORIGIN?: string;\n /** Which `workflow.config.ts` entry applies (`development`, `production`, …); production when unset. */\n ENVIRONMENT?: string;\n /** Cloudflare Email Sending, when the config has `emails`. */\n EMAIL?: EmailBindingLike;\n /** The APNs auth key (`.p8`, base64) named by the config's `apns.keyId`. */\n APNS_PRIVATE_KEY_BASE64?: string;\n /** The Firebase service account (base64 JSON) the config's `fcm` sends with. */\n GOOGLE_APPLICATION_CREDENTIALS_BASE64?: string;\n /** The VAPID key pair web pushes are signed with (`generateVapidKeys`); the config's `webPush` turns the channel on. */\n VAPID_PUBLIC_KEY?: string;\n VAPID_PRIVATE_KEY?: string;\n /**\n * Cloudflare's version metadata binding (wrangler `\"version_metadata\": { \"binding\":\n * \"CF_VERSION_METADATA\" }`). With it, and a store that records the live version (Postgres),\n * journeyWorker makes the bundle compiled into each new version live by itself: `wrangler\n * deploy` is the whole deploy.\n */\n CF_VERSION_METADATA?: { id: string; tag?: string; timestamp: string };\n /** Development only: the HTTP/2 relay `wrangler dev` sends APNs requests through (see `@shware/workflow/apns-relay`). */\n APNS_RELAY_ORIGIN?: string;\n}\n\n/** The runtime does not know the instance: the only failure that lets the router drop its wakes. */\nexport class InstanceNotFoundError extends Error {\n override readonly name = 'InstanceNotFoundError';\n constructor(readonly instanceId: string) {\n super(`no such instance: ${instanceId}`);\n }\n}\n\n/**\n * Does this lookup failure say the instance is gone, rather than that the lookup failed?\n * Cloudflare reports an unknown id as `instance.not_found`.\n */\nexport function isInstanceNotFound(error: unknown): boolean {\n if (error instanceof InstanceNotFoundError) return true;\n return /not[_ ]found|no such instance/i.test(String(error));\n}\n\n/** The event type the router uses to wake waiting instances (sendEvent's `type`). */\nexport const WAKE_EVENT_TYPE = 'wake';\n\n/** Journey instance parameters, passed by the ingest router when it creates one. */\nexport interface JourneyParams {\n workflowId: string;\n /** The IR version pinned at entry. */\n contentHash: string;\n userId: string;\n trigger: { event: string; payload: Record<string, unknown> };\n [key: string]: unknown;\n}\n\n/**\n * Slack on the deadline when deciding whether a thrown wait was a timeout: the\n * platform may surface the expiry a moment early, and a wait shorter than this\n * has no room to tell the two apart anyway.\n */\nexport const WAKE_TIMEOUT_TOLERANCE_MS = 5_000;\n\n/**\n * Did a wait that threw actually reach its deadline?\n *\n * CF signals a wait timeout by throwing, and so does every other failure in\n * there — a broken binding, an evicted instance, a malformed timeout. The\n * adapter used to read them all as timeouts, which made an infrastructure\n * fault indistinguishable from \"the user never did it\": the journey took its\n * onTimeout branch, early and silently. Only the clock can tell them apart.\n */\nexport function wakeExpired(startedAt: number, timeoutMs: number, now: number): boolean {\n return now - startedAt >= Math.max(0, timeoutMs - WAKE_TIMEOUT_TOLERANCE_MS);\n}\n"],"mappings":";;AA8IA,IAAa,wBAAb,cAA2C,MAAM;CAE1B;CADrB,OAAyB;CACzB,YAAY,YAA6B;EACvC,MAAM,qBAAqB,YAAY;EADpB,KAAA,aAAA;CAErB;AACF;;;;;AAMA,SAAgB,mBAAmB,OAAyB;CAC1D,IAAI,iBAAiB,uBAAuB,OAAO;CACnD,OAAO,iCAAiC,KAAK,OAAO,KAAK,CAAC;AAC5D;;AAGA,MAAa,kBAAkB;;;;;;AAiB/B,MAAa,4BAA4B;;;;;;;;;;AAWzC,SAAgB,YAAY,WAAmB,WAAmB,KAAsB;CACtF,OAAO,MAAM,aAAa,KAAK,IAAI,GAAG,YAAY,yBAAyB;AAC7E"}
@@ -13,18 +13,11 @@ function logging(sender) {
13
13
  return sender.send(message);
14
14
  } };
15
15
  }
16
- /**
17
- * The IR is read outside a step: content addressing makes it immutable, so the read is
18
- * replay-safe.
19
- */
20
16
  async function executeJourney(input, parts) {
21
17
  const { instanceId, params, step } = input;
22
18
  const { workflowId, contentHash, userId } = params;
23
19
  const { store, env, observer, contract } = parts;
24
- const stored = await store.getWorkflowIR(contentHash);
25
- if (stored === void 0) throw new Error(`WorkflowIR not found: ${workflowId}@${contentHash}`);
26
20
  const runtime = require_runtime.resolveRuntime({ current: parts.runtime }, "current");
27
- const ir = require_engine_time_scale.scaleDurations(stored, runtime.timeScale);
28
21
  const finished = (status, convertedAt) => observer?.finished?.({
29
22
  workflow: workflowId,
30
23
  instanceId,
@@ -50,6 +43,12 @@ async function executeJourney(input, parts) {
50
43
  } };
51
44
  let outcome;
52
45
  try {
46
+ const stored = await step.do("$workflow:ir", async () => {
47
+ const found = await store.getWorkflowIR(contentHash);
48
+ if (found === void 0) throw new Error(`WorkflowIR not found: ${workflowId}@${contentHash}`);
49
+ return found;
50
+ });
51
+ const ir = require_engine_time_scale.scaleDurations(stored, runtime.timeScale);
53
52
  outcome = await require_engine_interpreter.runJourney(ir, {
54
53
  userId,
55
54
  instanceId,
@@ -1 +1 @@
1
- {"version":3,"file":"execute.cjs","names":["resolveRuntime","scaleDurations","checkEvent","ContractValidationError","ingestEvent","runJourney","JourneyFactSource"],"sources":["../../src/host/execute.ts"],"sourcesContent":["import type { JourneyEnv, JourneyParams } from '../cloudflare/bindings';\nimport { type Contract, ContractValidationError, checkEvent } from '../contract';\nimport { runJourney } from '../engine/interpreter';\nimport type {\n ActionInvoker,\n EngineStep,\n EventSink,\n JourneyObserver,\n JourneyOutcome,\n MessageSender,\n MessageValidator,\n} from '../engine/ports';\nimport { scaleDurations } from '../engine/time-scale';\nimport { type JourneyRuntimeOptions, resolveRuntime } from '../runtime';\nimport { JourneyFactSource } from '../store/facts';\nimport type { JourneyStore } from '../store/index';\nimport { ingestEvent } from './router';\n\n/**\n * One journey instance run to its end, on whatever durable runtime supplies the `EngineStep`:\n * the IR loaded by the hash pinned at entry, the outlets wired, the interpreter run, and the\n * ledger finalized. Cloudflare's runner and the Lambda handler both come through here; only\n * their EngineStep differs.\n */\n\n/** Everything a run is wired to, assembled by the host. */\nexport interface JourneyRunParts {\n store: JourneyStore;\n /**\n * The env the emitted events are ingested with: its `JOURNEY` starts and wakes instances on\n * this runtime.\n */\n env: JourneyEnv;\n runtime: JourneyRuntimeOptions;\n messages: MessageSender;\n actions: ActionInvoker;\n contract?: Contract | undefined;\n validator?: MessageValidator | undefined;\n observer?: JourneyObserver | undefined;\n}\n\nexport interface JourneyRunInput {\n instanceId: string;\n params: JourneyParams;\n /** When the trigger happened: replay-stable, never Date.now() at run time. */\n enteredAtMs: number;\n step: EngineStep;\n}\n\n/** Development aid: echo each outbound message before handing it to the real sender. */\nfunction logging(sender: MessageSender): MessageSender {\n return {\n async send(message) {\n console.log(\n `[journey] ${message.channel} ${message.template} → ${message.recipient ?? message.userId} ${JSON.stringify(message.props)}`\n );\n return sender.send(message);\n },\n };\n}\n\n/**\n * The IR is read outside a step: content addressing makes it immutable, so the read is\n * replay-safe.\n */\nexport async function executeJourney(\n input: JourneyRunInput,\n parts: JourneyRunParts\n): Promise<JourneyOutcome> {\n const { instanceId, params, step } = input;\n const { workflowId, contentHash, userId } = params;\n const { store, env, observer, contract } = parts;\n\n const stored = await store.getWorkflowIR(contentHash);\n if (stored === undefined) {\n throw new Error(`WorkflowIR not found: ${workflowId}@${contentHash}`);\n }\n const runtime = resolveRuntime({ current: parts.runtime }, 'current');\n // Development runs the journey in fast-forward; the pinned hash still names the version.\n const ir = scaleDurations(stored, runtime.timeScale);\n\n const finished = (status: JourneyOutcome['status'] | 'failed', convertedAt?: number) =>\n observer?.finished?.({\n workflow: workflowId,\n instanceId,\n userId,\n status,\n at: Date.now(),\n ...(convertedAt === undefined ? {} : { convertedAt }),\n });\n\n const messages = runtime.logMessages ? logging(parts.messages) : parts.messages;\n\n // send_event feeds straight back into the router logic — an in-process call\n // forming the event edge between workflows. The step's identity travels as\n // the event id, so a retried emit records the occurrence once.\n // An emitted event passes the same contract check as one arriving over HTTP.\n const events: EventSink = {\n emit: async (name, payload, id) => {\n let accepted: Record<string, unknown> = payload;\n if (contract !== undefined) {\n const verdict = checkEvent(contract, name, payload);\n if (!verdict.ok) throw new ContractValidationError(name, verdict.issues);\n accepted = verdict.data;\n }\n await ingestEvent(env, { id, userId, event: name, payload: accepted }, store);\n },\n };\n\n let outcome: JourneyOutcome;\n try {\n outcome = await runJourney(ir, {\n userId,\n instanceId,\n enteredAtMs: input.enteredAtMs,\n step,\n facts: new JourneyFactSource(store, userId),\n messages,\n events,\n actions: parts.actions,\n validator: parts.validator,\n observer,\n });\n } catch (error) {\n /*\n * An error escaping run() ends the instance, and the ledger is the only\n * place anyone looks afterwards: leaving it at 'running' meant a dead\n * journey stayed indistinguishable from a waiting one, in the dashboard\n * and in the once-policy, forever.\n *\n * Subscriptions are deliberately left in place. They are cleaned up on\n * every ordinary ending, but here the wake rows are the only evidence of\n * what the instance was waiting for, and dropping them would also break\n * the instance if the platform does re-run it.\n *\n * A paused instance never gets here: a durable wait suspends the run\n * without throwing through it.\n */\n await step.do('finalize:failed', async () => {\n await store.setEntryStatus(instanceId, 'failed');\n await finished('failed');\n });\n throw error;\n }\n\n await step.do('finalize', async () => {\n await store.setEntryStatus(instanceId, outcome.status);\n await store.unsubscribe(instanceId);\n await finished(outcome.status, outcome.convertedAt);\n });\n\n return outcome;\n}\n"],"mappings":";;;;;;;;;AAkDA,SAAS,QAAQ,QAAsC;CACrD,OAAO,EACL,MAAM,KAAK,SAAS;EAClB,QAAQ,IACN,aAAa,QAAQ,QAAQ,GAAG,QAAQ,SAAS,KAAK,QAAQ,aAAa,QAAQ,OAAO,GAAG,KAAK,UAAU,QAAQ,KAAK,GAC3H;EACA,OAAO,OAAO,KAAK,OAAO;CAC5B,EACF;AACF;;;;;AAMA,eAAsB,eACpB,OACA,OACyB;CACzB,MAAM,EAAE,YAAY,QAAQ,SAAS;CACrC,MAAM,EAAE,YAAY,aAAa,WAAW;CAC5C,MAAM,EAAE,OAAO,KAAK,UAAU,aAAa;CAE3C,MAAM,SAAS,MAAM,MAAM,cAAc,WAAW;CACpD,IAAI,WAAW,KAAA,GACb,MAAM,IAAI,MAAM,yBAAyB,WAAW,GAAG,aAAa;CAEtE,MAAM,UAAUA,gBAAAA,eAAe,EAAE,SAAS,MAAM,QAAQ,GAAG,SAAS;CAEpE,MAAM,KAAKC,0BAAAA,eAAe,QAAQ,QAAQ,SAAS;CAEnD,MAAM,YAAY,QAA6C,gBAC7D,UAAU,WAAW;EACnB,UAAU;EACV;EACA;EACA;EACA,IAAI,KAAK,IAAI;EACb,GAAI,gBAAgB,KAAA,IAAY,CAAC,IAAI,EAAE,YAAY;CACrD,CAAC;CAEH,MAAM,WAAW,QAAQ,cAAc,QAAQ,MAAM,QAAQ,IAAI,MAAM;CAMvE,MAAM,SAAoB,EACxB,MAAM,OAAO,MAAM,SAAS,OAAO;EACjC,IAAI,WAAoC;EACxC,IAAI,aAAa,KAAA,GAAW;GAC1B,MAAM,UAAUC,iBAAAA,WAAW,UAAU,MAAM,OAAO;GAClD,IAAI,CAAC,QAAQ,IAAI,MAAM,IAAIC,iBAAAA,wBAAwB,MAAM,QAAQ,MAAM;GACvE,WAAW,QAAQ;EACrB;EACA,MAAMC,oBAAAA,YAAY,KAAK;GAAE;GAAI;GAAQ,OAAO;GAAM,SAAS;EAAS,GAAG,KAAK;CAC9E,EACF;CAEA,IAAI;CACJ,IAAI;EACF,UAAU,MAAMC,2BAAAA,WAAW,IAAI;GAC7B;GACA;GACA,aAAa,MAAM;GACnB;GACA,OAAO,IAAIC,oBAAAA,kBAAkB,OAAO,MAAM;GAC1C;GACA;GACA,SAAS,MAAM;GACf,WAAW,MAAM;GACjB;EACF,CAAC;CACH,SAAS,OAAO;EAed,MAAM,KAAK,GAAG,mBAAmB,YAAY;GAC3C,MAAM,MAAM,eAAe,YAAY,QAAQ;GAC/C,MAAM,SAAS,QAAQ;EACzB,CAAC;EACD,MAAM;CACR;CAEA,MAAM,KAAK,GAAG,YAAY,YAAY;EACpC,MAAM,MAAM,eAAe,YAAY,QAAQ,MAAM;EACrD,MAAM,MAAM,YAAY,UAAU;EAClC,MAAM,SAAS,QAAQ,QAAQ,QAAQ,WAAW;CACpD,CAAC;CAED,OAAO;AACT"}
1
+ {"version":3,"file":"execute.cjs","names":["resolveRuntime","checkEvent","ContractValidationError","ingestEvent","scaleDurations","runJourney","JourneyFactSource"],"sources":["../../src/host/execute.ts"],"sourcesContent":["import type { JourneyEnv, JourneyParams } from '../cloudflare/bindings';\nimport { type Contract, ContractValidationError, checkEvent } from '../contract';\nimport { runJourney } from '../engine/interpreter';\nimport type {\n ActionInvoker,\n EngineStep,\n EventSink,\n JourneyObserver,\n JourneyOutcome,\n MessageSender,\n MessageValidator,\n} from '../engine/ports';\nimport { scaleDurations } from '../engine/time-scale';\nimport { type JourneyRuntimeOptions, resolveRuntime } from '../runtime';\nimport { JourneyFactSource } from '../store/facts';\nimport type { JourneyStore } from '../store/index';\nimport { ingestEvent } from './router';\n\n/**\n * One journey instance run to its end, on whatever durable runtime supplies the `EngineStep`:\n * the IR loaded by the hash pinned at entry, the outlets wired, the interpreter run, and the\n * ledger finalized. Cloudflare's runner and the Lambda handler both come through here; only\n * their EngineStep differs.\n */\n\n/** Everything a run is wired to, assembled by the host. */\nexport interface JourneyRunParts {\n store: JourneyStore;\n /**\n * The env the emitted events are ingested with: its `JOURNEY` starts and wakes instances on\n * this runtime.\n */\n env: JourneyEnv;\n runtime: JourneyRuntimeOptions;\n messages: MessageSender;\n actions: ActionInvoker;\n contract?: Contract | undefined;\n validator?: MessageValidator | undefined;\n observer?: JourneyObserver | undefined;\n}\n\nexport interface JourneyRunInput {\n instanceId: string;\n params: JourneyParams;\n /** When the trigger happened: replay-stable, never Date.now() at run time. */\n enteredAtMs: number;\n step: EngineStep;\n}\n\n/** Development aid: echo each outbound message before handing it to the real sender. */\nfunction logging(sender: MessageSender): MessageSender {\n return {\n async send(message) {\n console.log(\n `[journey] ${message.channel} ${message.template} → ${message.recipient ?? message.userId} ${JSON.stringify(message.props)}`\n );\n return sender.send(message);\n },\n };\n}\n\nexport async function executeJourney(\n input: JourneyRunInput,\n parts: JourneyRunParts\n): Promise<JourneyOutcome> {\n const { instanceId, params, step } = input;\n const { workflowId, contentHash, userId } = params;\n const { store, env, observer, contract } = parts;\n\n const runtime = resolveRuntime({ current: parts.runtime }, 'current');\n\n const finished = (status: JourneyOutcome['status'] | 'failed', convertedAt?: number) =>\n observer?.finished?.({\n workflow: workflowId,\n instanceId,\n userId,\n status,\n at: Date.now(),\n ...(convertedAt === undefined ? {} : { convertedAt }),\n });\n\n const messages = runtime.logMessages ? logging(parts.messages) : parts.messages;\n\n // send_event feeds straight back into the router logic — an in-process call\n // forming the event edge between workflows. The step's identity travels as\n // the event id, so a retried emit records the occurrence once.\n // An emitted event passes the same contract check as one arriving over HTTP.\n const events: EventSink = {\n emit: async (name, payload, id) => {\n let accepted: Record<string, unknown> = payload;\n if (contract !== undefined) {\n const verdict = checkEvent(contract, name, payload);\n if (!verdict.ok) throw new ContractValidationError(name, verdict.issues);\n accepted = verdict.data;\n }\n await ingestEvent(env, { id, userId, event: name, payload: accepted }, store);\n },\n };\n\n let outcome: JourneyOutcome;\n try {\n // In a step: a read that fails is retried like any other, and one that keeps failing ends\n // the instance through the failure path below rather than leaving its entry 'running'.\n // Content addressing makes the IR immutable, so the checkpointed copy is the one to replay\n const stored = await step.do('$workflow:ir', async () => {\n const found = await store.getWorkflowIR(contentHash);\n if (found === undefined)\n throw new Error(`WorkflowIR not found: ${workflowId}@${contentHash}`);\n return found;\n });\n // Development runs the journey in fast-forward; the pinned hash still names the version.\n const ir = scaleDurations(stored, runtime.timeScale);\n outcome = await runJourney(ir, {\n userId,\n instanceId,\n enteredAtMs: input.enteredAtMs,\n step,\n facts: new JourneyFactSource(store, userId),\n messages,\n events,\n actions: parts.actions,\n validator: parts.validator,\n observer,\n });\n } catch (error) {\n /*\n * An error escaping run() ends the instance, and the ledger is the only\n * place anyone looks afterwards: leaving it at 'running' meant a dead\n * journey stayed indistinguishable from a waiting one, in the dashboard\n * and in the once-policy, forever.\n *\n * Subscriptions are deliberately left in place. They are cleaned up on\n * every ordinary ending, but here the wake rows are the only evidence of\n * what the instance was waiting for, and dropping them would also break\n * the instance if the platform does re-run it.\n *\n * A paused instance never gets here: a durable wait suspends the run\n * without throwing through it.\n */\n await step.do('finalize:failed', async () => {\n await store.setEntryStatus(instanceId, 'failed');\n await finished('failed');\n });\n throw error;\n }\n\n await step.do('finalize', async () => {\n await store.setEntryStatus(instanceId, outcome.status);\n await store.unsubscribe(instanceId);\n await finished(outcome.status, outcome.convertedAt);\n });\n\n return outcome;\n}\n"],"mappings":";;;;;;;;;AAkDA,SAAS,QAAQ,QAAsC;CACrD,OAAO,EACL,MAAM,KAAK,SAAS;EAClB,QAAQ,IACN,aAAa,QAAQ,QAAQ,GAAG,QAAQ,SAAS,KAAK,QAAQ,aAAa,QAAQ,OAAO,GAAG,KAAK,UAAU,QAAQ,KAAK,GAC3H;EACA,OAAO,OAAO,KAAK,OAAO;CAC5B,EACF;AACF;AAEA,eAAsB,eACpB,OACA,OACyB;CACzB,MAAM,EAAE,YAAY,QAAQ,SAAS;CACrC,MAAM,EAAE,YAAY,aAAa,WAAW;CAC5C,MAAM,EAAE,OAAO,KAAK,UAAU,aAAa;CAE3C,MAAM,UAAUA,gBAAAA,eAAe,EAAE,SAAS,MAAM,QAAQ,GAAG,SAAS;CAEpE,MAAM,YAAY,QAA6C,gBAC7D,UAAU,WAAW;EACnB,UAAU;EACV;EACA;EACA;EACA,IAAI,KAAK,IAAI;EACb,GAAI,gBAAgB,KAAA,IAAY,CAAC,IAAI,EAAE,YAAY;CACrD,CAAC;CAEH,MAAM,WAAW,QAAQ,cAAc,QAAQ,MAAM,QAAQ,IAAI,MAAM;CAMvE,MAAM,SAAoB,EACxB,MAAM,OAAO,MAAM,SAAS,OAAO;EACjC,IAAI,WAAoC;EACxC,IAAI,aAAa,KAAA,GAAW;GAC1B,MAAM,UAAUC,iBAAAA,WAAW,UAAU,MAAM,OAAO;GAClD,IAAI,CAAC,QAAQ,IAAI,MAAM,IAAIC,iBAAAA,wBAAwB,MAAM,QAAQ,MAAM;GACvE,WAAW,QAAQ;EACrB;EACA,MAAMC,oBAAAA,YAAY,KAAK;GAAE;GAAI;GAAQ,OAAO;GAAM,SAAS;EAAS,GAAG,KAAK;CAC9E,EACF;CAEA,IAAI;CACJ,IAAI;EAIF,MAAM,SAAS,MAAM,KAAK,GAAG,gBAAgB,YAAY;GACvD,MAAM,QAAQ,MAAM,MAAM,cAAc,WAAW;GACnD,IAAI,UAAU,KAAA,GACZ,MAAM,IAAI,MAAM,yBAAyB,WAAW,GAAG,aAAa;GACtE,OAAO;EACT,CAAC;EAED,MAAM,KAAKC,0BAAAA,eAAe,QAAQ,QAAQ,SAAS;EACnD,UAAU,MAAMC,2BAAAA,WAAW,IAAI;GAC7B;GACA;GACA,aAAa,MAAM;GACnB;GACA,OAAO,IAAIC,oBAAAA,kBAAkB,OAAO,MAAM;GAC1C;GACA;GACA,SAAS,MAAM;GACf,WAAW,MAAM;GACjB;EACF,CAAC;CACH,SAAS,OAAO;EAed,MAAM,KAAK,GAAG,mBAAmB,YAAY;GAC3C,MAAM,MAAM,eAAe,YAAY,QAAQ;GAC/C,MAAM,SAAS,QAAQ;EACzB,CAAC;EACD,MAAM;CACR;CAEA,MAAM,KAAK,GAAG,YAAY,YAAY;EACpC,MAAM,MAAM,eAAe,YAAY,QAAQ,MAAM;EACrD,MAAM,MAAM,YAAY,UAAU;EAClC,MAAM,SAAS,QAAQ,QAAQ,QAAQ,WAAW;CACpD,CAAC;CAED,OAAO;AACT"}
@@ -32,10 +32,6 @@ export interface JourneyRunInput {
32
32
  enteredAtMs: number;
33
33
  step: EngineStep;
34
34
  }
35
- /**
36
- * The IR is read outside a step: content addressing makes it immutable, so the read is
37
- * replay-safe.
38
- */
39
35
  export declare function executeJourney(input: JourneyRunInput, parts: JourneyRunParts): Promise<JourneyOutcome>;
40
36
  //#endregion
41
37
  //# sourceMappingURL=execute.d.cts.map
@@ -1 +1 @@
1
- {"version":3,"file":"execute.d.cts","names":[],"sources":["../../src/host/execute.ts"],"mappings":";;;;;;;;;;;;;iBA0BiB;EACf,OAAO;;;;;EAKP,KAAK;EACL,SAAS;EACT,UAAU;EACV,SAAS;EACT,WAAW;EACX,YAAY;EACZ,WAAW;;iBAGI;EACf;EACA,QAAQ;;EAER;EACA,MAAM;;;;;;wBAmBc,eACpB,OAAO,iBACP,OAAO,kBACN,QAAQ"}
1
+ {"version":3,"file":"execute.d.cts","names":[],"sources":["../../src/host/execute.ts"],"mappings":";;;;;;;;;;;;;iBA0BiB;EACf,OAAO;;;;;EAKP,KAAK;EACL,SAAS;EACT,UAAU;EACV,SAAS;EACT,WAAW;EACX,YAAY;EACZ,WAAW;;iBAGI;EACf;EACA,QAAQ;;EAER;EACA,MAAM;;wBAec,eACpB,OAAO,iBACP,OAAO,kBACN,QAAQ"}
@@ -32,10 +32,6 @@ export interface JourneyRunInput {
32
32
  enteredAtMs: number;
33
33
  step: EngineStep;
34
34
  }
35
- /**
36
- * The IR is read outside a step: content addressing makes it immutable, so the read is
37
- * replay-safe.
38
- */
39
35
  export declare function executeJourney(input: JourneyRunInput, parts: JourneyRunParts): Promise<JourneyOutcome>;
40
36
  //#endregion
41
37
  //# sourceMappingURL=execute.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"execute.d.mts","names":[],"sources":["../../src/host/execute.ts"],"mappings":";;;;;;;;;;;;;iBA0BiB;EACf,OAAO;;;;;EAKP,KAAK;EACL,SAAS;EACT,UAAU;EACV,SAAS;EACT,WAAW;EACX,YAAY;EACZ,WAAW;;iBAGI;EACf;EACA,QAAQ;;EAER;EACA,MAAM;;;;;;wBAmBc,eACpB,OAAO,iBACP,OAAO,kBACN,QAAQ"}
1
+ {"version":3,"file":"execute.d.mts","names":[],"sources":["../../src/host/execute.ts"],"mappings":";;;;;;;;;;;;;iBA0BiB;EACf,OAAO;;;;;EAKP,KAAK;EACL,SAAS;EACT,UAAU;EACV,SAAS;EACT,WAAW;EACX,YAAY;EACZ,WAAW;;iBAGI;EACf;EACA,QAAQ;;EAER;EACA,MAAM;;wBAec,eACpB,OAAO,iBACP,OAAO,kBACN,QAAQ"}
@@ -12,18 +12,11 @@ function logging(sender) {
12
12
  return sender.send(message);
13
13
  } };
14
14
  }
15
- /**
16
- * The IR is read outside a step: content addressing makes it immutable, so the read is
17
- * replay-safe.
18
- */
19
15
  async function executeJourney(input, parts) {
20
16
  const { instanceId, params, step } = input;
21
17
  const { workflowId, contentHash, userId } = params;
22
18
  const { store, env, observer, contract } = parts;
23
- const stored = await store.getWorkflowIR(contentHash);
24
- if (stored === void 0) throw new Error(`WorkflowIR not found: ${workflowId}@${contentHash}`);
25
19
  const runtime = resolveRuntime({ current: parts.runtime }, "current");
26
- const ir = scaleDurations(stored, runtime.timeScale);
27
20
  const finished = (status, convertedAt) => observer?.finished?.({
28
21
  workflow: workflowId,
29
22
  instanceId,
@@ -49,6 +42,12 @@ async function executeJourney(input, parts) {
49
42
  } };
50
43
  let outcome;
51
44
  try {
45
+ const stored = await step.do("$workflow:ir", async () => {
46
+ const found = await store.getWorkflowIR(contentHash);
47
+ if (found === void 0) throw new Error(`WorkflowIR not found: ${workflowId}@${contentHash}`);
48
+ return found;
49
+ });
50
+ const ir = scaleDurations(stored, runtime.timeScale);
52
51
  outcome = await runJourney(ir, {
53
52
  userId,
54
53
  instanceId,
@@ -1 +1 @@
1
- {"version":3,"file":"execute.mjs","names":[],"sources":["../../src/host/execute.ts"],"sourcesContent":["import type { JourneyEnv, JourneyParams } from '../cloudflare/bindings';\nimport { type Contract, ContractValidationError, checkEvent } from '../contract';\nimport { runJourney } from '../engine/interpreter';\nimport type {\n ActionInvoker,\n EngineStep,\n EventSink,\n JourneyObserver,\n JourneyOutcome,\n MessageSender,\n MessageValidator,\n} from '../engine/ports';\nimport { scaleDurations } from '../engine/time-scale';\nimport { type JourneyRuntimeOptions, resolveRuntime } from '../runtime';\nimport { JourneyFactSource } from '../store/facts';\nimport type { JourneyStore } from '../store/index';\nimport { ingestEvent } from './router';\n\n/**\n * One journey instance run to its end, on whatever durable runtime supplies the `EngineStep`:\n * the IR loaded by the hash pinned at entry, the outlets wired, the interpreter run, and the\n * ledger finalized. Cloudflare's runner and the Lambda handler both come through here; only\n * their EngineStep differs.\n */\n\n/** Everything a run is wired to, assembled by the host. */\nexport interface JourneyRunParts {\n store: JourneyStore;\n /**\n * The env the emitted events are ingested with: its `JOURNEY` starts and wakes instances on\n * this runtime.\n */\n env: JourneyEnv;\n runtime: JourneyRuntimeOptions;\n messages: MessageSender;\n actions: ActionInvoker;\n contract?: Contract | undefined;\n validator?: MessageValidator | undefined;\n observer?: JourneyObserver | undefined;\n}\n\nexport interface JourneyRunInput {\n instanceId: string;\n params: JourneyParams;\n /** When the trigger happened: replay-stable, never Date.now() at run time. */\n enteredAtMs: number;\n step: EngineStep;\n}\n\n/** Development aid: echo each outbound message before handing it to the real sender. */\nfunction logging(sender: MessageSender): MessageSender {\n return {\n async send(message) {\n console.log(\n `[journey] ${message.channel} ${message.template} → ${message.recipient ?? message.userId} ${JSON.stringify(message.props)}`\n );\n return sender.send(message);\n },\n };\n}\n\n/**\n * The IR is read outside a step: content addressing makes it immutable, so the read is\n * replay-safe.\n */\nexport async function executeJourney(\n input: JourneyRunInput,\n parts: JourneyRunParts\n): Promise<JourneyOutcome> {\n const { instanceId, params, step } = input;\n const { workflowId, contentHash, userId } = params;\n const { store, env, observer, contract } = parts;\n\n const stored = await store.getWorkflowIR(contentHash);\n if (stored === undefined) {\n throw new Error(`WorkflowIR not found: ${workflowId}@${contentHash}`);\n }\n const runtime = resolveRuntime({ current: parts.runtime }, 'current');\n // Development runs the journey in fast-forward; the pinned hash still names the version.\n const ir = scaleDurations(stored, runtime.timeScale);\n\n const finished = (status: JourneyOutcome['status'] | 'failed', convertedAt?: number) =>\n observer?.finished?.({\n workflow: workflowId,\n instanceId,\n userId,\n status,\n at: Date.now(),\n ...(convertedAt === undefined ? {} : { convertedAt }),\n });\n\n const messages = runtime.logMessages ? logging(parts.messages) : parts.messages;\n\n // send_event feeds straight back into the router logic — an in-process call\n // forming the event edge between workflows. The step's identity travels as\n // the event id, so a retried emit records the occurrence once.\n // An emitted event passes the same contract check as one arriving over HTTP.\n const events: EventSink = {\n emit: async (name, payload, id) => {\n let accepted: Record<string, unknown> = payload;\n if (contract !== undefined) {\n const verdict = checkEvent(contract, name, payload);\n if (!verdict.ok) throw new ContractValidationError(name, verdict.issues);\n accepted = verdict.data;\n }\n await ingestEvent(env, { id, userId, event: name, payload: accepted }, store);\n },\n };\n\n let outcome: JourneyOutcome;\n try {\n outcome = await runJourney(ir, {\n userId,\n instanceId,\n enteredAtMs: input.enteredAtMs,\n step,\n facts: new JourneyFactSource(store, userId),\n messages,\n events,\n actions: parts.actions,\n validator: parts.validator,\n observer,\n });\n } catch (error) {\n /*\n * An error escaping run() ends the instance, and the ledger is the only\n * place anyone looks afterwards: leaving it at 'running' meant a dead\n * journey stayed indistinguishable from a waiting one, in the dashboard\n * and in the once-policy, forever.\n *\n * Subscriptions are deliberately left in place. They are cleaned up on\n * every ordinary ending, but here the wake rows are the only evidence of\n * what the instance was waiting for, and dropping them would also break\n * the instance if the platform does re-run it.\n *\n * A paused instance never gets here: a durable wait suspends the run\n * without throwing through it.\n */\n await step.do('finalize:failed', async () => {\n await store.setEntryStatus(instanceId, 'failed');\n await finished('failed');\n });\n throw error;\n }\n\n await step.do('finalize', async () => {\n await store.setEntryStatus(instanceId, outcome.status);\n await store.unsubscribe(instanceId);\n await finished(outcome.status, outcome.convertedAt);\n });\n\n return outcome;\n}\n"],"mappings":";;;;;;;;AAkDA,SAAS,QAAQ,QAAsC;CACrD,OAAO,EACL,MAAM,KAAK,SAAS;EAClB,QAAQ,IACN,aAAa,QAAQ,QAAQ,GAAG,QAAQ,SAAS,KAAK,QAAQ,aAAa,QAAQ,OAAO,GAAG,KAAK,UAAU,QAAQ,KAAK,GAC3H;EACA,OAAO,OAAO,KAAK,OAAO;CAC5B,EACF;AACF;;;;;AAMA,eAAsB,eACpB,OACA,OACyB;CACzB,MAAM,EAAE,YAAY,QAAQ,SAAS;CACrC,MAAM,EAAE,YAAY,aAAa,WAAW;CAC5C,MAAM,EAAE,OAAO,KAAK,UAAU,aAAa;CAE3C,MAAM,SAAS,MAAM,MAAM,cAAc,WAAW;CACpD,IAAI,WAAW,KAAA,GACb,MAAM,IAAI,MAAM,yBAAyB,WAAW,GAAG,aAAa;CAEtE,MAAM,UAAU,eAAe,EAAE,SAAS,MAAM,QAAQ,GAAG,SAAS;CAEpE,MAAM,KAAK,eAAe,QAAQ,QAAQ,SAAS;CAEnD,MAAM,YAAY,QAA6C,gBAC7D,UAAU,WAAW;EACnB,UAAU;EACV;EACA;EACA;EACA,IAAI,KAAK,IAAI;EACb,GAAI,gBAAgB,KAAA,IAAY,CAAC,IAAI,EAAE,YAAY;CACrD,CAAC;CAEH,MAAM,WAAW,QAAQ,cAAc,QAAQ,MAAM,QAAQ,IAAI,MAAM;CAMvE,MAAM,SAAoB,EACxB,MAAM,OAAO,MAAM,SAAS,OAAO;EACjC,IAAI,WAAoC;EACxC,IAAI,aAAa,KAAA,GAAW;GAC1B,MAAM,UAAU,WAAW,UAAU,MAAM,OAAO;GAClD,IAAI,CAAC,QAAQ,IAAI,MAAM,IAAI,wBAAwB,MAAM,QAAQ,MAAM;GACvE,WAAW,QAAQ;EACrB;EACA,MAAM,YAAY,KAAK;GAAE;GAAI;GAAQ,OAAO;GAAM,SAAS;EAAS,GAAG,KAAK;CAC9E,EACF;CAEA,IAAI;CACJ,IAAI;EACF,UAAU,MAAM,WAAW,IAAI;GAC7B;GACA;GACA,aAAa,MAAM;GACnB;GACA,OAAO,IAAI,kBAAkB,OAAO,MAAM;GAC1C;GACA;GACA,SAAS,MAAM;GACf,WAAW,MAAM;GACjB;EACF,CAAC;CACH,SAAS,OAAO;EAed,MAAM,KAAK,GAAG,mBAAmB,YAAY;GAC3C,MAAM,MAAM,eAAe,YAAY,QAAQ;GAC/C,MAAM,SAAS,QAAQ;EACzB,CAAC;EACD,MAAM;CACR;CAEA,MAAM,KAAK,GAAG,YAAY,YAAY;EACpC,MAAM,MAAM,eAAe,YAAY,QAAQ,MAAM;EACrD,MAAM,MAAM,YAAY,UAAU;EAClC,MAAM,SAAS,QAAQ,QAAQ,QAAQ,WAAW;CACpD,CAAC;CAED,OAAO;AACT"}
1
+ {"version":3,"file":"execute.mjs","names":[],"sources":["../../src/host/execute.ts"],"sourcesContent":["import type { JourneyEnv, JourneyParams } from '../cloudflare/bindings';\nimport { type Contract, ContractValidationError, checkEvent } from '../contract';\nimport { runJourney } from '../engine/interpreter';\nimport type {\n ActionInvoker,\n EngineStep,\n EventSink,\n JourneyObserver,\n JourneyOutcome,\n MessageSender,\n MessageValidator,\n} from '../engine/ports';\nimport { scaleDurations } from '../engine/time-scale';\nimport { type JourneyRuntimeOptions, resolveRuntime } from '../runtime';\nimport { JourneyFactSource } from '../store/facts';\nimport type { JourneyStore } from '../store/index';\nimport { ingestEvent } from './router';\n\n/**\n * One journey instance run to its end, on whatever durable runtime supplies the `EngineStep`:\n * the IR loaded by the hash pinned at entry, the outlets wired, the interpreter run, and the\n * ledger finalized. Cloudflare's runner and the Lambda handler both come through here; only\n * their EngineStep differs.\n */\n\n/** Everything a run is wired to, assembled by the host. */\nexport interface JourneyRunParts {\n store: JourneyStore;\n /**\n * The env the emitted events are ingested with: its `JOURNEY` starts and wakes instances on\n * this runtime.\n */\n env: JourneyEnv;\n runtime: JourneyRuntimeOptions;\n messages: MessageSender;\n actions: ActionInvoker;\n contract?: Contract | undefined;\n validator?: MessageValidator | undefined;\n observer?: JourneyObserver | undefined;\n}\n\nexport interface JourneyRunInput {\n instanceId: string;\n params: JourneyParams;\n /** When the trigger happened: replay-stable, never Date.now() at run time. */\n enteredAtMs: number;\n step: EngineStep;\n}\n\n/** Development aid: echo each outbound message before handing it to the real sender. */\nfunction logging(sender: MessageSender): MessageSender {\n return {\n async send(message) {\n console.log(\n `[journey] ${message.channel} ${message.template} → ${message.recipient ?? message.userId} ${JSON.stringify(message.props)}`\n );\n return sender.send(message);\n },\n };\n}\n\nexport async function executeJourney(\n input: JourneyRunInput,\n parts: JourneyRunParts\n): Promise<JourneyOutcome> {\n const { instanceId, params, step } = input;\n const { workflowId, contentHash, userId } = params;\n const { store, env, observer, contract } = parts;\n\n const runtime = resolveRuntime({ current: parts.runtime }, 'current');\n\n const finished = (status: JourneyOutcome['status'] | 'failed', convertedAt?: number) =>\n observer?.finished?.({\n workflow: workflowId,\n instanceId,\n userId,\n status,\n at: Date.now(),\n ...(convertedAt === undefined ? {} : { convertedAt }),\n });\n\n const messages = runtime.logMessages ? logging(parts.messages) : parts.messages;\n\n // send_event feeds straight back into the router logic — an in-process call\n // forming the event edge between workflows. The step's identity travels as\n // the event id, so a retried emit records the occurrence once.\n // An emitted event passes the same contract check as one arriving over HTTP.\n const events: EventSink = {\n emit: async (name, payload, id) => {\n let accepted: Record<string, unknown> = payload;\n if (contract !== undefined) {\n const verdict = checkEvent(contract, name, payload);\n if (!verdict.ok) throw new ContractValidationError(name, verdict.issues);\n accepted = verdict.data;\n }\n await ingestEvent(env, { id, userId, event: name, payload: accepted }, store);\n },\n };\n\n let outcome: JourneyOutcome;\n try {\n // In a step: a read that fails is retried like any other, and one that keeps failing ends\n // the instance through the failure path below rather than leaving its entry 'running'.\n // Content addressing makes the IR immutable, so the checkpointed copy is the one to replay\n const stored = await step.do('$workflow:ir', async () => {\n const found = await store.getWorkflowIR(contentHash);\n if (found === undefined)\n throw new Error(`WorkflowIR not found: ${workflowId}@${contentHash}`);\n return found;\n });\n // Development runs the journey in fast-forward; the pinned hash still names the version.\n const ir = scaleDurations(stored, runtime.timeScale);\n outcome = await runJourney(ir, {\n userId,\n instanceId,\n enteredAtMs: input.enteredAtMs,\n step,\n facts: new JourneyFactSource(store, userId),\n messages,\n events,\n actions: parts.actions,\n validator: parts.validator,\n observer,\n });\n } catch (error) {\n /*\n * An error escaping run() ends the instance, and the ledger is the only\n * place anyone looks afterwards: leaving it at 'running' meant a dead\n * journey stayed indistinguishable from a waiting one, in the dashboard\n * and in the once-policy, forever.\n *\n * Subscriptions are deliberately left in place. They are cleaned up on\n * every ordinary ending, but here the wake rows are the only evidence of\n * what the instance was waiting for, and dropping them would also break\n * the instance if the platform does re-run it.\n *\n * A paused instance never gets here: a durable wait suspends the run\n * without throwing through it.\n */\n await step.do('finalize:failed', async () => {\n await store.setEntryStatus(instanceId, 'failed');\n await finished('failed');\n });\n throw error;\n }\n\n await step.do('finalize', async () => {\n await store.setEntryStatus(instanceId, outcome.status);\n await store.unsubscribe(instanceId);\n await finished(outcome.status, outcome.convertedAt);\n });\n\n return outcome;\n}\n"],"mappings":";;;;;;;;AAkDA,SAAS,QAAQ,QAAsC;CACrD,OAAO,EACL,MAAM,KAAK,SAAS;EAClB,QAAQ,IACN,aAAa,QAAQ,QAAQ,GAAG,QAAQ,SAAS,KAAK,QAAQ,aAAa,QAAQ,OAAO,GAAG,KAAK,UAAU,QAAQ,KAAK,GAC3H;EACA,OAAO,OAAO,KAAK,OAAO;CAC5B,EACF;AACF;AAEA,eAAsB,eACpB,OACA,OACyB;CACzB,MAAM,EAAE,YAAY,QAAQ,SAAS;CACrC,MAAM,EAAE,YAAY,aAAa,WAAW;CAC5C,MAAM,EAAE,OAAO,KAAK,UAAU,aAAa;CAE3C,MAAM,UAAU,eAAe,EAAE,SAAS,MAAM,QAAQ,GAAG,SAAS;CAEpE,MAAM,YAAY,QAA6C,gBAC7D,UAAU,WAAW;EACnB,UAAU;EACV;EACA;EACA;EACA,IAAI,KAAK,IAAI;EACb,GAAI,gBAAgB,KAAA,IAAY,CAAC,IAAI,EAAE,YAAY;CACrD,CAAC;CAEH,MAAM,WAAW,QAAQ,cAAc,QAAQ,MAAM,QAAQ,IAAI,MAAM;CAMvE,MAAM,SAAoB,EACxB,MAAM,OAAO,MAAM,SAAS,OAAO;EACjC,IAAI,WAAoC;EACxC,IAAI,aAAa,KAAA,GAAW;GAC1B,MAAM,UAAU,WAAW,UAAU,MAAM,OAAO;GAClD,IAAI,CAAC,QAAQ,IAAI,MAAM,IAAI,wBAAwB,MAAM,QAAQ,MAAM;GACvE,WAAW,QAAQ;EACrB;EACA,MAAM,YAAY,KAAK;GAAE;GAAI;GAAQ,OAAO;GAAM,SAAS;EAAS,GAAG,KAAK;CAC9E,EACF;CAEA,IAAI;CACJ,IAAI;EAIF,MAAM,SAAS,MAAM,KAAK,GAAG,gBAAgB,YAAY;GACvD,MAAM,QAAQ,MAAM,MAAM,cAAc,WAAW;GACnD,IAAI,UAAU,KAAA,GACZ,MAAM,IAAI,MAAM,yBAAyB,WAAW,GAAG,aAAa;GACtE,OAAO;EACT,CAAC;EAED,MAAM,KAAK,eAAe,QAAQ,QAAQ,SAAS;EACnD,UAAU,MAAM,WAAW,IAAI;GAC7B;GACA;GACA,aAAa,MAAM;GACnB;GACA,OAAO,IAAI,kBAAkB,OAAO,MAAM;GAC1C;GACA;GACA,SAAS,MAAM;GACf,WAAW,MAAM;GACjB;EACF,CAAC;CACH,SAAS,OAAO;EAed,MAAM,KAAK,GAAG,mBAAmB,YAAY;GAC3C,MAAM,MAAM,eAAe,YAAY,QAAQ;GAC/C,MAAM,SAAS,QAAQ;EACzB,CAAC;EACD,MAAM;CACR;CAEA,MAAM,KAAK,GAAG,YAAY,YAAY;EACpC,MAAM,MAAM,eAAe,YAAY,QAAQ,MAAM;EACrD,MAAM,MAAM,YAAY,UAAU;EAClC,MAAM,SAAS,QAAQ,QAAQ,QAAQ,WAAW;CACpD,CAAC;CAED,OAAO;AACT"}
@@ -70,7 +70,7 @@ async function startTriggeredJourneys(env, store, input, ts) {
70
70
  const instanceId = await startJourney(env, store, route.workflow, route.hash, input.userId, ts, {
71
71
  event: input.event,
72
72
  payload: input.payload
73
- });
73
+ }, route.version);
74
74
  if (instanceId !== null) started.push(instanceId);
75
75
  }
76
76
  return started;
@@ -98,7 +98,8 @@ async function refreshSegments(env, store, userId, ts, routes, segments = [], de
98
98
  const list = bySegment.get(route.segment) ?? [];
99
99
  list.push({
100
100
  workflow: route.workflow,
101
- hash: route.hash
101
+ hash: route.hash,
102
+ ...route.version === void 0 ? {} : { version: route.version }
102
103
  });
103
104
  bySegment.set(route.segment, list);
104
105
  }
@@ -125,7 +126,7 @@ async function refreshSegments(env, store, userId, ts, routes, segments = [], de
125
126
  const instanceId = await startJourney(env, store, route.workflow, route.hash, userId, ts, {
126
127
  event: "$segment_entry",
127
128
  payload: { segment: segmentId }
128
- });
129
+ }, route.version);
129
130
  if (instanceId !== null) started.push(instanceId);
130
131
  await store.completeSegmentStart(segmentId, userId, route.workflow);
131
132
  } catch (error) {
@@ -180,7 +181,7 @@ async function sweepSegments(env, store, now = Date.now()) {
180
181
  * ingests cannot double-enter). A pair whose previous instance is recorded as
181
182
  * 'failed' is reclaimed rather than barred forever over one outage.
182
183
  */
183
- async function startJourney(env, store, workflow, hash, userId, ts, trigger) {
184
+ async function startJourney(env, store, workflow, hash, userId, ts, trigger, version) {
184
185
  const instanceId = buildInstanceId(workflow, hash, userId, ts);
185
186
  const params = {
186
187
  workflowId: workflow,
@@ -191,7 +192,8 @@ async function startJourney(env, store, workflow, hash, userId, ts, trigger) {
191
192
  const execution = await env.JOURNEY.plan?.({
192
193
  id: instanceId,
193
194
  params,
194
- ts
195
+ ts,
196
+ version
195
197
  });
196
198
  const outcome = await store.enterJourney({
197
199
  workflow,