@shware/workflow 4.1.1 → 5.0.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 (118) hide show
  1. package/README.md +16 -38
  2. package/dist/aws/index.cjs +2 -3
  3. package/dist/aws/index.cjs.map +1 -1
  4. package/dist/aws/index.d.cts +1 -1
  5. package/dist/aws/index.d.mts +1 -1
  6. package/dist/aws/index.mjs +2 -3
  7. package/dist/aws/index.mjs.map +1 -1
  8. package/dist/aws/instances.cjs +1 -1
  9. package/dist/aws/instances.cjs.map +1 -1
  10. package/dist/aws/instances.d.cts +1 -1
  11. package/dist/aws/instances.d.mts +1 -1
  12. package/dist/aws/instances.mjs +1 -1
  13. package/dist/aws/instances.mjs.map +1 -1
  14. package/dist/cloudflare/bindings.cjs.map +1 -1
  15. package/dist/cloudflare/bindings.d.cts +0 -19
  16. package/dist/cloudflare/bindings.d.cts.map +1 -1
  17. package/dist/cloudflare/bindings.d.mts +0 -19
  18. package/dist/cloudflare/bindings.d.mts.map +1 -1
  19. package/dist/cloudflare/bindings.mjs.map +1 -1
  20. package/dist/cloudflare/index.cjs +0 -5
  21. package/dist/cloudflare/index.d.cts +4 -6
  22. package/dist/cloudflare/index.d.mts +4 -6
  23. package/dist/cloudflare/index.mjs +2 -4
  24. package/dist/cloudflare/runner.cjs +3 -4
  25. package/dist/cloudflare/runner.cjs.map +1 -1
  26. package/dist/cloudflare/runner.d.cts +3 -3
  27. package/dist/cloudflare/runner.d.cts.map +1 -1
  28. package/dist/cloudflare/runner.d.mts +3 -3
  29. package/dist/cloudflare/runner.d.mts.map +1 -1
  30. package/dist/cloudflare/runner.mjs +3 -4
  31. package/dist/cloudflare/runner.mjs.map +1 -1
  32. package/dist/config.cjs.map +1 -1
  33. package/dist/config.d.cts +1 -1
  34. package/dist/config.d.mts +1 -1
  35. package/dist/config.mjs.map +1 -1
  36. package/dist/engine/condition.cjs +2 -2
  37. package/dist/engine/condition.cjs.map +1 -1
  38. package/dist/engine/condition.d.cts +1 -1
  39. package/dist/engine/condition.d.mts +1 -1
  40. package/dist/engine/condition.mjs +2 -2
  41. package/dist/engine/condition.mjs.map +1 -1
  42. package/dist/engine/ports.d.cts +1 -1
  43. package/dist/engine/ports.d.mts +1 -1
  44. package/dist/host/channels.d.cts +1 -1
  45. package/dist/host/channels.d.mts +1 -1
  46. package/dist/host/execute.d.cts +1 -1
  47. package/dist/host/execute.d.mts +1 -1
  48. package/dist/host/http.cjs +3 -4
  49. package/dist/host/http.cjs.map +1 -1
  50. package/dist/host/http.d.cts +2 -2
  51. package/dist/host/http.d.cts.map +1 -1
  52. package/dist/host/http.d.mts +2 -2
  53. package/dist/host/http.d.mts.map +1 -1
  54. package/dist/host/http.mjs +3 -4
  55. package/dist/host/http.mjs.map +1 -1
  56. package/dist/host/ingest.cjs +11 -22
  57. package/dist/host/ingest.cjs.map +1 -1
  58. package/dist/host/ingest.d.cts +8 -13
  59. package/dist/host/ingest.d.cts.map +1 -1
  60. package/dist/host/ingest.d.mts +8 -13
  61. package/dist/host/ingest.d.mts.map +1 -1
  62. package/dist/host/ingest.mjs +12 -22
  63. package/dist/host/ingest.mjs.map +1 -1
  64. package/dist/host/journey-host.cjs +2 -4
  65. package/dist/host/journey-host.cjs.map +1 -1
  66. package/dist/host/journey-host.d.cts +6 -6
  67. package/dist/host/journey-host.d.cts.map +1 -1
  68. package/dist/host/journey-host.d.mts +6 -6
  69. package/dist/host/journey-host.d.mts.map +1 -1
  70. package/dist/host/journey-host.mjs +2 -4
  71. package/dist/host/journey-host.mjs.map +1 -1
  72. package/dist/host/platform.cjs.map +1 -1
  73. package/dist/host/platform.d.cts +1 -7
  74. package/dist/host/platform.d.cts.map +1 -1
  75. package/dist/host/platform.d.mts +1 -7
  76. package/dist/host/platform.d.mts.map +1 -1
  77. package/dist/host/platform.mjs.map +1 -1
  78. package/dist/store/facts.cjs +1 -1
  79. package/dist/store/facts.cjs.map +1 -1
  80. package/dist/store/facts.mjs +1 -1
  81. package/dist/store/facts.mjs.map +1 -1
  82. package/dist/store/index.cjs +0 -2
  83. package/dist/store/index.cjs.map +1 -1
  84. package/dist/store/index.d.cts +21 -26
  85. package/dist/store/index.d.cts.map +1 -1
  86. package/dist/store/index.d.mts +21 -26
  87. package/dist/store/index.d.mts.map +1 -1
  88. package/dist/store/index.mjs +1 -2
  89. package/dist/store/index.mjs.map +1 -1
  90. package/dist/store/postgres.cjs +2 -3
  91. package/dist/store/postgres.cjs.map +1 -1
  92. package/dist/store/postgres.d.cts +2 -3
  93. package/dist/store/postgres.d.cts.map +1 -1
  94. package/dist/store/postgres.d.mts +2 -3
  95. package/dist/store/postgres.d.mts.map +1 -1
  96. package/dist/store/postgres.mjs +2 -3
  97. package/dist/store/postgres.mjs.map +1 -1
  98. package/dist/transports/cloudflare-email.d.cts +1 -1
  99. package/dist/transports/cloudflare-email.d.mts +1 -1
  100. package/package.json +1 -3
  101. package/dist/cloudflare/d1-env.cjs +0 -17
  102. package/dist/cloudflare/d1-env.cjs.map +0 -1
  103. package/dist/cloudflare/d1-env.d.cts +0 -12
  104. package/dist/cloudflare/d1-env.d.cts.map +0 -1
  105. package/dist/cloudflare/d1-env.d.mts +0 -12
  106. package/dist/cloudflare/d1-env.d.mts.map +0 -1
  107. package/dist/cloudflare/d1-env.mjs +0 -16
  108. package/dist/cloudflare/d1-env.mjs.map +0 -1
  109. package/dist/cloudflare/schema.sql +0 -148
  110. package/dist/store/d1.cjs +0 -245
  111. package/dist/store/d1.cjs.map +0 -1
  112. package/dist/store/d1.d.cts +0 -73
  113. package/dist/store/d1.d.cts.map +0 -1
  114. package/dist/store/d1.d.mts +0 -73
  115. package/dist/store/d1.d.mts.map +0 -1
  116. package/dist/store/d1.mjs +0 -244
  117. package/dist/store/d1.mjs.map +0 -1
  118. package/src/cloudflare/schema.sql +0 -148
@@ -1 +1 @@
1
- {"version":3,"file":"ingest.mjs","names":[],"sources":["../../src/host/ingest.ts"],"sourcesContent":["import { d1Store } from '../cloudflare/d1-env';\nimport { murmur3 } from '../engine/bucket';\nimport {\n DEVICES_UPDATED_EVENT,\n evaluateCondition,\n matchesWhere,\n profileUpdatedEvent,\n} from '../engine/condition';\nimport { semanticHash } from '../hash';\nimport { BundleIR, type ConditionIR, type TriggerIR } from '../ir';\nimport { JourneyFactSource } from '../store/facts';\nimport {\n type BundleVersion,\n type JourneyStore,\n type ProfileProps,\n SchemaMismatchError,\n type SegmentTriggerRoute,\n UserErasedError,\n} from '../store/index';\nimport {\n InstanceNotFoundError,\n type JourneyEnv,\n type JourneyParams,\n WAKE_EVENT_TYPE,\n type WorkflowInstanceLike,\n} from './platform';\n\n/**\n * Ingest: what the host does with what comes in, HTTP aside (that is `http.ts`). An incoming\n * event does four things: it is stored, it wakes waiting instances, it is matched against event\n * triggers, and it re-evaluates segment-trigger membership. An identify merges a profile, which\n * wakes the waits on the properties it patched and re-evaluates segment triggers too. Plus\n * devices, erasure, the segment sweep and bundle deploys.\n *\n * Storage goes through a JourneyStore; the `store` argument defaults to the D1 store built from\n * the env's binding, so a Worker that only has that calls these functions as they are.\n */\n\n/**\n * The fast answer for a user with an erasure tombstone (being erased, or erased for good): every\n * ingest-side entry point starts here and does no work for them. The gate itself is the store's\n * conditional write, which throws the same error when the tombstone lands after this check.\n */\nasync function refuseIfErased(store: JourneyStore, userId: string): Promise<void> {\n if ((await store.erasureState(userId)) !== 'none') throw new UserErasedError(userId);\n}\n\nexport interface IngestInput {\n userId: string;\n event: string;\n payload?: Record<string, unknown>;\n ts?: number;\n /**\n * This occurrence's identity, and with it the idempotency of sending it.\n *\n * Only the sender can know whether two deliveries describe one occurrence or\n * two, so the engine does not decide: an id it has already stored makes the\n * second delivery a no-op. That matters because ingest is reachable from two\n * retrying callers — an HTTP client retrying a 500, and the interpreter's\n * send_event step being retried after it already committed its row — and a\n * second copy of an event silently changes every count- and window-based\n * condition that reads the log.\n *\n * Callers that retry should send an id they can reproduce. Omitting it gets\n * a fresh one, which records the event as its own occurrence.\n */\n id?: string;\n}\n\nexport interface IngestResult {\n /** The occurrence's identity: the caller's `id`, or the one minted for it. */\n id: string;\n /** False when that id was already in the log: nothing was written this time. */\n stored: boolean;\n woke: number;\n started: string[];\n}\n\nexport async function ingestEvent(\n env: JourneyEnv,\n input: IngestInput,\n store: JourneyStore = d1Store(env)\n): Promise<IngestResult> {\n if (input.event.startsWith('$')) {\n throw new Error(`event name '${input.event}' is reserved ('$' prefix is internal)`);\n }\n // Also reached from a running instance's send_event: an erased user's instances are stopped\n // before the erase, so one that still gets here is refused like any other writer\n await refuseIfErased(store, input.userId);\n const ts = input.ts ?? Date.now();\n const payload = input.payload ?? {};\n // No id means the sender is not claiming this is a repeat of anything, which\n // a fresh one states truthfully — unlike a key derived from the contents,\n // which would silently collapse two identical events a second apart.\n const id = input.id ?? crypto.randomUUID();\n\n const stored = await store.insertEvent({\n id,\n userId: input.userId,\n event: input.event,\n ts,\n payload,\n });\n\n /*\n * The rest runs even for a duplicate. A retry arrives precisely because the\n * first attempt did not finish — it committed the row and then failed — so\n * returning early would strand the journeys the retry exists to start. Both\n * halves are independently idempotent: waking an instance that already moved\n * on is a no-op, and the entry ledger refuses a second entry.\n */\n const woke = await wakeSubscribers(env, store, input.userId, [input.event]);\n const started = await startTriggeredJourneys(env, store, { ...input, payload }, ts);\n started.push(...(await refreshSegmentTriggers(env, store, input.userId, ts)));\n return { id, stored, woke, started };\n}\n\n/**\n * Wake every instance of the user waiting on any of `events`, once each: an instance subscribed\n * to several of them (a wait on two profile properties, patched together) gets one wake-up, and\n * the payload names the first event it matched.\n */\nexport async function wakeSubscribers(\n env: JourneyEnv,\n store: JourneyStore,\n userId: string,\n events: readonly string[]\n): Promise<number> {\n const handles = new Map<string, string>(); // handle → the event that matched it first\n for (const event of events) {\n for (const handle of await store.findWakeHandles(userId, event)) {\n if (!handles.has(handle)) handles.set(handle, event);\n }\n }\n let woke = 0;\n for (const [handle, event] of handles) {\n let instance: WorkflowInstanceLike;\n try {\n instance = await env.JOURNEY.get(handle);\n } catch (error) {\n // Unknown instance (errored out without cleanup): drop the dangling subscription. Any\n // other failure (a database blip) keeps it: deleting would silence the wait for good\n if (error instanceof InstanceNotFoundError) await store.unsubscribe(handle);\n else console.warn(`[workflow] looking up ${handle} to wake it failed`, error);\n continue;\n }\n try {\n await instance.sendEvent({ type: WAKE_EVENT_TYPE, payload: { event } });\n woke++;\n } catch {\n // Transient send failure (or an instance finishing right now): keep the\n // subscription — a later event retries it, and the wait's own timeout\n // bounds the damage. Deleting here would silence all future wake-ups.\n }\n }\n return woke;\n}\n\nasync function startTriggeredJourneys(\n env: JourneyEnv,\n store: JourneyStore,\n input: IngestInput & { payload: Record<string, unknown> },\n ts: number\n): Promise<string[]> {\n const started: string[] = [];\n for (const route of await store.findTriggers(input.event)) {\n // where: the payload gate — evaluated against the incoming event first\n if (route.where !== null && !matchesWhere(input.payload, route.where)) continue;\n\n // filter: the profile gate\n if (route.filter !== null) {\n const facts = new JourneyFactSource(store, input.userId);\n if (!(await evaluateCondition(route.filter, facts, ts))) continue;\n }\n\n const instanceId = await startJourney(\n env,\n store,\n route.workflow,\n route.hash,\n input.userId,\n ts,\n {\n event: input.event,\n payload: input.payload,\n },\n route.version\n );\n if (instanceId !== null) started.push(instanceId);\n }\n return started;\n}\n\n/**\n * Segment-entry triggers, maintained lazily: on each ingest/identify for a\n * user, re-evaluate every trigger-routed segment for that user and act on the\n * membership transition (enter → start journeys, leave → drop membership so a\n * later re-entry is observable). Purely time-driven drift (an inactivity\n * segment turning true by clock alone) has no event to ride on: the scheduled\n * sweep catches it (`sweepSegments`).\n */\nexport async function refreshSegmentTriggers(\n env: JourneyEnv,\n store: JourneyStore,\n userId: string,\n ts: number\n): Promise<string[]> {\n return refreshSegments(env, store, userId, ts, await store.listSegmentTriggers());\n}\n\n/**\n * Re-evaluate segments for one user — the routed ones only, or with `segments` every one named —\n * and act on each membership transition: entering records the membership and starts the\n * segment's routed workflows, leaving drops it. A segment trigger therefore starts journeys for\n * users who enter after it is deployed; a user already a member is not entered again.\n */\nasync function refreshSegments(\n env: JourneyEnv,\n store: JourneyStore,\n userId: string,\n ts: number,\n routes: readonly SegmentTriggerRoute[],\n segments: readonly string[] = [],\n // A sweep loads each definition once for all its users, not once per user\n definitions = new Map<string, ConditionIR | undefined>()\n): Promise<string[]> {\n const bySegment = new Map<string, { workflow: string; hash: string; version?: string }[]>(\n segments.map((name) => [name, []])\n );\n for (const route of routes) {\n const list = bySegment.get(route.segment) ?? [];\n list.push({\n workflow: route.workflow,\n hash: route.hash,\n ...(route.version === undefined ? {} : { version: route.version }),\n });\n bySegment.set(route.segment, list);\n }\n if (bySegment.size === 0) return [];\n\n const facts = new JourneyFactSource(store, userId);\n const started: string[] = [];\n const failures: unknown[] = [];\n for (const [segmentId, segmentRoutes] of bySegment) {\n if (!definitions.has(segmentId)) {\n definitions.set(segmentId, await store.getSegmentCondition(segmentId));\n }\n const definition = definitions.get(segmentId);\n if (definition === undefined) continue; // route without a definition: broken deploy, skip\n\n const matches = await evaluateCondition(definition, facts, ts);\n const member = await store.isSegmentMember(segmentId, userId);\n\n if (matches && !member) {\n // Entry transition: the membership and the starts it owes, recorded together\n await store.addSegmentMember(\n segmentId,\n userId,\n ts,\n segmentRoutes.map((route) => route.workflow)\n );\n } else if (!matches && member) {\n await store.removeSegmentMember(segmentId, userId);\n continue;\n }\n if (!matches) continue;\n\n // Every start the entry still owes — a fresh entry's, or one an earlier evaluation failed on.\n // Each is crossed off once its instance exists, so one workflow failing neither blocks the\n // others nor gets lost: the next evaluation (the sweep, the user's next event) retries it.\n const pending = new Set(await store.pendingSegmentStarts(segmentId, userId));\n for (const route of segmentRoutes) {\n if (!pending.has(route.workflow)) continue;\n try {\n const instanceId = await startJourney(\n env,\n store,\n route.workflow,\n route.hash,\n userId,\n ts,\n { event: '$segment_entry', payload: { segment: segmentId } },\n route.version\n );\n if (instanceId !== null) started.push(instanceId);\n await store.completeSegmentStart(segmentId, userId, route.workflow);\n } catch (error) {\n failures.push(error);\n }\n }\n }\n // Surface a failure to the caller (an ingest answers 500) once every start had its chance\n if (failures.length > 0) throw failures[0];\n return started;\n}\n\nexport interface SweepResult {\n users: number;\n /** Journeys the segments' entries started. */\n started: string[];\n /** Each deployed segment's size after the sweep (empty when the store keeps no reporting). */\n sizes: { segment: string; size: number }[];\n}\n\n/** How many users one sweep page evaluates: a unit of resumable work (AWS checkpoints each). */\nexport const SWEEP_PAGE_SIZE = 500;\n\n/**\n * What the sweep does before it looks at any user: the runtime's own upkeep (AWS: unconfirmed\n * starts, executions that ended unseen) and the erasures left unfinished (an instance would not\n * stop). A failure of either is logged, never fatal: the pages still run. Users whose erasure is\n * still pending afterwards are skipped by every page (`sweepSegmentsPaged`).\n */\nexport async function sweepMaintenance(env: JourneyEnv, store: JourneyStore): Promise<void> {\n try {\n await env.JOURNEY.maintain?.();\n } catch (error) {\n console.error('[workflow] runtime maintenance failed', error);\n }\n // Erasures left pending (an admitted instance would not stop, or is still draining): retried\n // until every one of them is confirmed stopped and the data is gone\n for (const userId of await store.pendingErasures()) {\n try {\n await eraseUser(env, userId, store, Date.now());\n } catch (error) {\n // Still pending: the next sweep tries again, and the rest of this one goes on\n console.error(`[workflow] erasure of ${userId} failed`, error);\n }\n }\n}\n\nexport interface SweepPage {\n /** Users evaluated on this page (those being erased are listed but skipped). */\n users: number;\n /** Journeys the segments' entries started on this page. */\n started: string[];\n /** The last user id listed, to resume past (`after`); undefined when the page was empty. */\n lastUserId?: string | undefined;\n /** Whether the page was full, so another may follow. */\n more: boolean;\n}\n\n/**\n * One page of the sweep: the users after `after` (ascending, at most `limit`), every deployed\n * segment evaluated for each. The pages are what makes the sweep resumable — a host whose run\n * can be cut short (a Lambda timeout) checkpoints each one and replays the checkpointed pages\n * instead of starting over; `sweepSegments` is the plain loop over them.\n */\nexport async function sweepSegmentsPaged(\n env: JourneyEnv,\n store: JourneyStore,\n page: { after?: string | undefined; limit?: number | undefined; now?: number | undefined }\n): Promise<SweepPage> {\n const now = page.now ?? Date.now();\n const limit = page.limit ?? SWEEP_PAGE_SIZE;\n const segments = (await store.listSegments()).map((segment) => segment.id);\n const routes = await store.listSegmentTriggers();\n // Read per page, not once up front: an erasure requested since the last page is honored too\n const erasing = new Set(await store.pendingErasures());\n const listed = await store.listUserIds({ after: page.after, limit });\n const lastUserId = listed.at(-1);\n // A store that ignores `after` would hand back the same page forever: refuse to loop on it.\n // Equality only — the order is the store's collation, which JavaScript's `<` does not know\n if (page.after !== undefined && lastUserId === page.after) {\n throw new Error(`listUserIds did not advance past ${page.after}: the store ignores 'after'`);\n }\n const users = listed.filter((userId) => !erasing.has(userId));\n const started: string[] = [];\n const definitions = new Map<string, ConditionIR | undefined>();\n for (const userId of users) {\n try {\n started.push(\n ...(await refreshSegments(env, store, userId, now, routes, segments, definitions))\n );\n } catch (error) {\n // One user's failed start is kept for the next sweep; it must not stop everyone else's\n console.error(`[workflow] segment refresh for ${userId} failed`, error);\n }\n }\n return { users: users.length, started, lastUserId, more: listed.length >= limit };\n}\n\n/**\n * The scheduled sweep: every deployed segment evaluated for every known user — the evaluator a\n * journey's conditions run on, so a member is exactly who a journey would see in it. Membership\n * follows (joiners keep the time they joined, leavers are removed), segment triggers start the\n * journeys of whoever entered by the clock alone, and the store records each segment's size.\n * It walks users page by page (`sweepSegmentsPaged`): fine for thousands, a large audience wants\n * SQL-side evaluation.\n */\nexport async function sweepSegments(\n env: JourneyEnv,\n store: JourneyStore,\n now = Date.now()\n): Promise<SweepResult> {\n await sweepMaintenance(env, store);\n let users = 0;\n const started: string[] = [];\n let after: string | undefined;\n for (;;) {\n const page = await sweepSegmentsPaged(env, store, { after, limit: SWEEP_PAGE_SIZE, now });\n users += page.users;\n started.push(...page.started);\n if (!page.more || page.lastUserId === undefined) break;\n after = page.lastUserId;\n }\n const sizes = (await store.reporting?.snapshotSegments(now)) ?? [];\n return { users, started, sizes };\n}\n\n/**\n * Create one journey instance behind the entry ledger. Returns null when the\n * once-policy blocks the entry (the store's attempt is atomic, so concurrent\n * ingests cannot double-enter). A pair whose previous instance is recorded as\n * 'failed' is reclaimed rather than barred forever over one outage.\n */\nasync function startJourney(\n env: JourneyEnv,\n store: JourneyStore,\n workflow: string,\n hash: string,\n userId: string,\n ts: number,\n trigger: { event: string; payload: Record<string, unknown> },\n /** The code version whose bundle routed this start, read with the hash (see TriggerRoute). */\n version?: string\n): Promise<string | null> {\n const instanceId = buildInstanceId(workflow, hash, userId, ts);\n const params: JourneyParams = { workflowId: workflow, contentHash: hash, userId, trigger };\n const execution = await env.JOURNEY.plan?.({ id: instanceId, params, ts, version });\n\n const outcome = await store.enterJourney({ workflow, userId, instanceId, hash, ts, execution });\n if (outcome === null) {\n // Already entered and still alive — unless its start never got through: this retry is the\n // first chance to finish it\n await env.JOURNEY.resume?.({ workflow, userId });\n return null;\n }\n\n try {\n await env.JOURNEY.create({ id: instanceId, params });\n } catch (error) {\n // A recorded start stays: the runtime may have taken it, and a retry with the same name\n // and payload finds that execution instead of starting a second one\n if (execution !== undefined) throw error;\n if (outcome === 'reclaimed') {\n // Put the corpse back rather than deleting it: the entry is still a\n // failure, and dropping the row would erase that from the ledger.\n await store.setEntryStatus(instanceId, 'failed');\n } else {\n // Roll the ledger back so a retried ingest can enter — otherwise the\n // once-policy would permanently record an entry that never ran.\n await store.removeEntry(instanceId);\n }\n throw error;\n }\n return instanceId;\n}\n\nconst utf8 = new TextEncoder();\n\n/**\n * Instance id: addressable (the host gets it directly to wake it), ≤100\n * chars, legal charset. The murmur3 fingerprint keeps ids distinct when the\n * sanitized/truncated userId alone would collide.\n */\nfunction buildInstanceId(workflow: string, hash: string, userId: string, ts: number): string {\n const uid = userId.replace(/[^a-zA-Z0-9_-]/g, '-').slice(0, 24);\n const fingerprint = murmur3(utf8.encode(userId)).toString(36);\n return `${workflow.slice(0, 32)}-${hash.slice(0, 8)}-${uid}-${fingerprint}-${ts.toString(36)}`;\n}\n\n/* --------------------------------- identify --------------------------------- */\n\nexport interface IdentifyResult {\n ok: true;\n props: ProfileProps;\n woke: number;\n started: string[];\n}\n\n/**\n * Merge-patch profile properties (a null removes one), then wake property waits\n * and re-evaluate segment triggers. The merge is one statement in the store, so\n * two identifies landing together cannot lose each other's fields.\n */\nexport async function identifyUser(\n env: JourneyEnv,\n userId: string,\n props: ProfileProps,\n store: JourneyStore = d1Store(env)\n): Promise<IdentifyResult> {\n await refuseIfErased(store, userId);\n const ts = Date.now();\n const merged = await store.mergeProfile(userId, props);\n // A property wait subscribes to its own property's channel (see relevantEvents): only the\n // waits on what this patch touched wake up\n const woke = await wakeSubscribers(\n env,\n store,\n userId,\n Object.keys(props).map(profileUpdatedEvent)\n );\n // A profile change can move the user into (or out of) a trigger-routed segment\n const started = await refreshSegmentTriggers(env, store, userId, ts);\n return { ok: true, props: merged, woke, started };\n}\n\nexport interface DeviceRegistration {\n userId: string;\n token: string;\n platform: string;\n ts?: number;\n}\n\n/**\n * A push token registered for the user (or moved to them from another account: a token belongs\n * to one user at a time). Wakes the waits that read devices (`reachable('push')` inside\n * `waitUntil`), for the new owner and the previous one.\n */\nexport async function registerDevice(\n env: JourneyEnv,\n device: DeviceRegistration,\n store: JourneyStore = d1Store(env)\n): Promise<{ ok: true; woke: number }> {\n await refuseIfErased(store, device.userId);\n const previous = await store.removeDevice(device.token);\n await store.upsertDevice({ ...device, ts: device.ts ?? Date.now() });\n let woke = await wakeSubscribers(env, store, device.userId, [DEVICES_UPDATED_EVENT]);\n if (previous !== undefined && previous !== device.userId) {\n woke += await wakeSubscribers(env, store, previous, [DEVICES_UPDATED_EVENT]);\n }\n return { ok: true, woke };\n}\n\n/** Workflow instance states that will never run another step. */\nconst STOPPED_STATUSES = new Set(['complete', 'errored', 'terminated']);\n\n/**\n * Stop one admitted instance for good; true once it is known not to run again: terminated, or\n * already in a terminal state (terminate refuses those). Any other failure is not taken for\n * \"already gone\": it answers false, and the erasure waits for a retry.\n *\n * An admitted instance the platform no longer knows counts as stopped too — but only an\n * admitted one. Admission is the runner's first act, so an admitted instance did reach the\n * runtime: not found now means it finished and was evicted. An entry that is not admitted is\n * never passed here: for it, not found may just as well mean its `create` is still in flight.\n */\nasync function stopInstance(env: JourneyEnv, instanceId: string): Promise<boolean> {\n let instance: WorkflowInstanceLike;\n try {\n instance = await env.JOURNEY.get(instanceId);\n } catch (error) {\n if (error instanceof InstanceNotFoundError) return true;\n console.warn(`[workflow] looking up ${instanceId} to stop it failed`, error);\n return false;\n }\n if (instance.terminate === undefined) return false;\n try {\n await instance.terminate();\n return true;\n } catch (error) {\n const status = await instance.status?.().catch(() => undefined);\n if (status !== undefined && STOPPED_STATUSES.has(status.status)) return true;\n console.warn(`[workflow] terminating ${instanceId} failed`, error);\n return false;\n }\n}\n\n/**\n * Carry out a recorded erasure: stop every running instance of the user, and only once all are\n * known not to run erase their data. The tombstone is already committed, so no new entry,\n * event, profile or device of theirs lands meanwhile.\n *\n * An entry's runner admits itself before its first effect (`admitInstance`, mutually exclusive\n * with the tombstone's commit), which splits the running instances in two:\n *\n * - not admitted: nothing of it has run, and nothing will — a `create` still in flight, or a\n * runner invoked later, meets the tombstone at admission and exits. No stop is needed, and\n * none would be reliable: the platform may not know the instance yet.\n * - admitted: it did reach the runtime and must be stopped (`stopInstance`), or it would write\n * rows back after the erase. One that will not stop leaves the erasure pending.\n *\n * Erasing nothing until then keeps the entries that name the instances still to stop, so a\n * retry can finish the job. The tombstone outlives the erase (`erased_at`), which is what makes\n * a repeat idempotent and a late write harmless. There is no straggler to come back for: every\n * write about a user runs in the user's locked transaction, the same lock the tombstone takes,\n * so a write either committed before the tombstone — and `forgetUser` deletes it here — or\n * waited for the lock and is refused by the tombstone it then sees.\n */\nasync function eraseUser(\n env: JourneyEnv,\n userId: string,\n store: JourneyStore,\n now: number\n): Promise<{ erased: boolean; pending: string[] }> {\n const pending: string[] = [];\n for (const { instanceId, admitted } of await store.runningInstances(userId)) {\n if (!admitted) continue;\n if (!(await stopInstance(env, instanceId))) pending.push(instanceId);\n }\n if (pending.length > 0) return { erased: false, pending };\n await store.forgetUser(userId, now);\n return { erased: true, pending };\n}\n\n/**\n * Forget a user (a deleted account): the tombstone is committed first, then the erasure carried\n * out. `erased: true` means every admitted runner of theirs was confirmed stopped, the entries\n * never admitted can never run, and the data is deleted — for good, with no time window and no\n * second pass: the tombstone commits under the user's lock, which every write about the user\n * also takes, so nothing of theirs can land after it. An admitted instance that will not stop\n * leaves it pending — nothing erased yet — and the scheduled sweep retries it; `erased: false`\n * says so. A user already erased answers `erased: true` again, with nothing to do.\n */\nexport async function forgetUser(\n env: JourneyEnv,\n userId: string,\n store: JourneyStore = d1Store(env),\n now = Date.now()\n): Promise<{ ok: true; erased: boolean; pending: string[] }> {\n await store.requestErasure(userId, now);\n return { ok: true, ...(await eraseUser(env, userId, store, now)) };\n}\n\n/**\n * The user signed out on the device: the token stays registered, bound to nobody, so the next\n * sign-in only has to bind it again. Wakes the former owner's device waits (`reachable('push')`\n * may no longer hold).\n */\nexport async function unbindDevice(\n env: JourneyEnv,\n token: string,\n store: JourneyStore = d1Store(env)\n): Promise<{ ok: true; woke: number }> {\n const owner = await store.unbindDevice(token);\n const woke =\n owner === undefined ? 0 : await wakeSubscribers(env, store, owner, [DEVICES_UPDATED_EVENT]);\n return { ok: true, woke };\n}\n\n/** A push token forgotten for good (the push service reports it gone); wakes its owner's device waits. */\nexport async function removeDevice(\n env: JourneyEnv,\n token: string,\n store: JourneyStore = d1Store(env)\n): Promise<{ ok: true; woke: number }> {\n const owner = await store.removeDevice(token);\n const woke =\n owner === undefined ? 0 : await wakeSubscribers(env, store, owner, [DEVICES_UPDATED_EVENT]);\n return { ok: true, woke };\n}\n\n/* ---------------------------------- deploy ---------------------------------- */\n\nexport interface DeployResult {\n workflows: string[];\n /**\n * Workflows whose trigger type has no runtime routing yet — they deploy (the\n * IR is stored and versioned) but will never start until the routing lands.\n * Currently: date (TODO — needs cron + Queues, instance creation is\n * rate-limited and a date trigger fans out to the whole audience at once)\n * and webhook (TODO — needs endpoint allocation).\n */\n unrouted: { workflow: string; trigger: TriggerIR['type'] }[];\n /**\n * Whether the routes now point at this bundle: always for a forced deploy, false for a\n * versioned one when a version as new or newer is already live (nothing was swapped).\n */\n applied: boolean;\n}\n\n/** A versioned deploy on a store that cannot tell which version is live (D1). */\nexport class UnversionedStoreError extends Error {\n override readonly name = 'UnversionedStoreError';\n constructor() {\n super('this store does not record which version is live: deploy with POST /bundle/deploy');\n }\n}\n\n/** A bundle whose declared hashes do not describe its contents — rejected before anything is written. */\nexport class BundleIntegrityError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'BundleIntegrityError';\n }\n}\n\n/**\n * Recompute every declared contentHash and refuse the bundle if one disagrees.\n *\n * The contentHash is the address an in-flight journey reads its IR from, so\n * a hash that does not describe its own content is a way to change what a\n * pinned instance executes: submit an edited flow under the old hash and the\n * next replay picks it up. The compiler always computes the hash it ships, so\n * a mismatch means a hand-rolled payload or a stale client — neither should be\n * able to overwrite a version that journeys are running.\n *\n * Each hash is recomputed exactly the way its compiler produced it: a workflow\n * hashes its whole IR (contentHash strips itself), a segment hashes only its\n * condition. Mirroring them is load-bearing — recomputing a segment over the\n * whole record would reject every honest deploy.\n */\nfunction verifyBundleHashes(parsed: BundleIR): void {\n const mismatched: string[] = [];\n for (const workflow of parsed.workflows) {\n if (semanticHash(workflow) !== workflow.contentHash) {\n mismatched.push(`workflow '${workflow.id}'`);\n }\n }\n for (const segment of parsed.segments) {\n if (semanticHash(segment.condition) !== segment.contentHash) {\n mismatched.push(`segment '${segment.id}'`);\n }\n }\n if (mismatched.length > 0) {\n throw new BundleIntegrityError(\n `contentHash does not match content for ${mismatched.join(', ')} — recompile the bundle instead of editing it by hand`\n );\n }\n}\n\n/**\n * Bundle deploy, terraform-apply style: workflow bodies first (content-addressed, so a failed\n * deploy leaves only harmless orphans), then the routing swap. With a `version` the swap happens\n * only over an older live version (`replaceRoutesIfNewer`) — how journeyWorker applies the bundle\n * compiled into it; without one it is forced, whatever is live.\n */\nexport async function deployBundle(\n env: JourneyEnv,\n bundle: unknown,\n store: JourneyStore = d1Store(env),\n version?: BundleVersion\n): Promise<DeployResult> {\n if (version !== undefined && store.replaceRoutesIfNewer === undefined) {\n throw new UnversionedStoreError();\n }\n const parsed = BundleIR.parse(bundle);\n // Before any write: a bad hash must not reach the store, where it would address content it does not describe\n verifyBundleHashes(parsed);\n // Nor a bundle the database cannot hold: the host migrates, the engine only checks\n const problems = (await store.checkSchema?.()) ?? [];\n if (problems.length > 0) throw new SchemaMismatchError(problems);\n\n for (const workflow of parsed.workflows) await store.putWorkflowIR(workflow);\n let applied = true;\n if (version === undefined) await store.replaceRoutes(parsed);\n else applied = (await store.replaceRoutesIfNewer?.(parsed, version)) ?? false;\n\n const unrouted = parsed.workflows\n .filter((workflow) => workflow.trigger.type === 'date' || workflow.trigger.type === 'webhook')\n .map((workflow) => ({ workflow: workflow.id, trigger: workflow.trigger.type }));\n\n return {\n workflows: parsed.workflows.map((w) => `${w.id}@${w.contentHash.slice(0, 8)}`),\n unrouted,\n applied,\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"ingest.mjs","names":[],"sources":["../../src/host/ingest.ts"],"sourcesContent":["import { murmur3 } from '../engine/bucket';\nimport {\n DEVICES_UPDATED_EVENT,\n evaluateCondition,\n matchesWhere,\n profileUpdatedEvent,\n} from '../engine/condition';\nimport { semanticHash } from '../hash';\nimport { BundleIR, type ConditionIR, type TriggerIR } from '../ir';\nimport { JourneyFactSource } from '../store/facts';\nimport {\n type BundleVersion,\n type JourneyStore,\n type ProfileProps,\n SchemaMismatchError,\n type SegmentTriggerRoute,\n UserErasedError,\n} from '../store/index';\nimport {\n InstanceNotFoundError,\n type JourneyEnv,\n type JourneyParams,\n WAKE_EVENT_TYPE,\n type WorkflowInstanceLike,\n} from './platform';\n\n/**\n * Ingest: what the host does with what comes in, HTTP aside (that is `http.ts`). An incoming\n * event does four things: it is stored, it wakes waiting instances, it is matched against event\n * triggers, and it re-evaluates segment-trigger membership. An identify merges a profile, which\n * wakes the waits on the properties it patched and re-evaluates segment triggers too. Plus\n * devices, erasure, the segment sweep and bundle deploys.\n *\n * Storage goes through a JourneyStore; every entry point takes the one the host opened.\n */\n\n/**\n * The fast answer for a user with an erasure tombstone (being erased, or erased for good): every\n * ingest-side entry point starts here and does no work for them. The gate itself is the store's\n * conditional write, which throws the same error when the tombstone lands after this check.\n */\nasync function refuseIfErased(store: JourneyStore, userId: string): Promise<void> {\n if ((await store.erasureState(userId)) !== 'none') throw new UserErasedError(userId);\n}\n\nexport interface IngestInput {\n userId: string;\n event: string;\n payload?: Record<string, unknown>;\n ts?: number;\n /**\n * This occurrence's identity, and with it the idempotency of sending it.\n *\n * Only the sender can know whether two deliveries describe one occurrence or\n * two, so the engine does not decide: an id it has already stored makes the\n * second delivery a no-op. That matters because ingest is reachable from two\n * retrying callers — an HTTP client retrying a 500, and the interpreter's\n * send_event step being retried after it already committed its row — and a\n * second copy of an event silently changes every count- and window-based\n * condition that reads the log.\n *\n * Callers that retry should send an id they can reproduce. Omitting it gets\n * a fresh one, which records the event as its own occurrence.\n */\n id?: string;\n}\n\nexport interface IngestResult {\n /** The occurrence's identity: the caller's `id`, or the one minted for it. */\n id: string;\n /** False when that id was already in the log: nothing was written this time. */\n stored: boolean;\n woke: number;\n started: string[];\n}\n\nexport async function ingestEvent(\n env: JourneyEnv,\n input: IngestInput,\n store: JourneyStore\n): Promise<IngestResult> {\n if (input.event.startsWith('$')) {\n throw new Error(`event name '${input.event}' is reserved ('$' prefix is internal)`);\n }\n // Also reached from a running instance's send_event: an erased user's instances are stopped\n // before the erase, so one that still gets here is refused like any other writer\n await refuseIfErased(store, input.userId);\n const ts = input.ts ?? Date.now();\n const payload = input.payload ?? {};\n // No id means the sender is not claiming this is a repeat of anything, which\n // a fresh one states truthfully — unlike a key derived from the contents,\n // which would silently collapse two identical events a second apart.\n const id = input.id ?? crypto.randomUUID();\n\n const stored = await store.insertEvent({\n id,\n userId: input.userId,\n event: input.event,\n ts,\n payload,\n });\n\n /*\n * The rest runs even for a duplicate. A retry arrives precisely because the\n * first attempt did not finish — it committed the row and then failed — so\n * returning early would strand the journeys the retry exists to start. Both\n * halves are independently idempotent: waking an instance that already moved\n * on is a no-op, and the entry ledger refuses a second entry.\n */\n const woke = await wakeSubscribers(env, store, input.userId, [input.event]);\n const started = await startTriggeredJourneys(env, store, { ...input, payload }, ts);\n started.push(...(await refreshSegmentTriggers(env, store, input.userId, ts)));\n return { id, stored, woke, started };\n}\n\n/**\n * Wake every instance of the user waiting on any of `events`, once each: an instance subscribed\n * to several of them (a wait on two profile properties, patched together) gets one wake-up, and\n * the payload names the first event it matched.\n */\nexport async function wakeSubscribers(\n env: JourneyEnv,\n store: JourneyStore,\n userId: string,\n events: readonly string[]\n): Promise<number> {\n const handles = new Map<string, string>(); // handle → the event that matched it first\n for (const event of events) {\n for (const handle of await store.findWakeHandles(userId, event)) {\n if (!handles.has(handle)) handles.set(handle, event);\n }\n }\n let woke = 0;\n for (const [handle, event] of handles) {\n let instance: WorkflowInstanceLike;\n try {\n instance = await env.JOURNEY.get(handle);\n } catch (error) {\n // Unknown instance (errored out without cleanup): drop the dangling subscription. Any\n // other failure (a database blip) keeps it: deleting would silence the wait for good\n if (error instanceof InstanceNotFoundError) await store.unsubscribe(handle);\n else console.warn(`[workflow] looking up ${handle} to wake it failed`, error);\n continue;\n }\n try {\n await instance.sendEvent({ type: WAKE_EVENT_TYPE, payload: { event } });\n woke++;\n } catch {\n // Transient send failure (or an instance finishing right now): keep the\n // subscription — a later event retries it, and the wait's own timeout\n // bounds the damage. Deleting here would silence all future wake-ups.\n }\n }\n return woke;\n}\n\nasync function startTriggeredJourneys(\n env: JourneyEnv,\n store: JourneyStore,\n input: IngestInput & { payload: Record<string, unknown> },\n ts: number\n): Promise<string[]> {\n const started: string[] = [];\n for (const route of await store.findTriggers(input.event)) {\n // where: the payload gate — evaluated against the incoming event first\n if (route.where !== null && !matchesWhere(input.payload, route.where)) continue;\n\n // filter: the profile gate\n if (route.filter !== null) {\n const facts = new JourneyFactSource(store, input.userId);\n if (!(await evaluateCondition(route.filter, facts, ts))) continue;\n }\n\n const instanceId = await startJourney(\n env,\n store,\n route.workflow,\n route.hash,\n input.userId,\n ts,\n {\n event: input.event,\n payload: input.payload,\n },\n route.version\n );\n if (instanceId !== null) started.push(instanceId);\n }\n return started;\n}\n\n/**\n * Segment-entry triggers, maintained lazily: on each ingest/identify for a\n * user, re-evaluate every trigger-routed segment for that user and act on the\n * membership transition (enter → start journeys, leave → drop membership so a\n * later re-entry is observable). Purely time-driven drift (an inactivity\n * segment turning true by clock alone) has no event to ride on: the scheduled\n * sweep catches it (`sweepSegments`).\n */\nexport async function refreshSegmentTriggers(\n env: JourneyEnv,\n store: JourneyStore,\n userId: string,\n ts: number\n): Promise<string[]> {\n return refreshSegments(env, store, userId, ts, await store.listSegmentTriggers());\n}\n\n/**\n * Re-evaluate segments for one user — the routed ones only, or with `segments` every one named —\n * and act on each membership transition: entering records the membership and starts the\n * segment's routed workflows, leaving drops it. A segment trigger therefore starts journeys for\n * users who enter after it is deployed; a user already a member is not entered again.\n */\nasync function refreshSegments(\n env: JourneyEnv,\n store: JourneyStore,\n userId: string,\n ts: number,\n routes: readonly SegmentTriggerRoute[],\n segments: readonly string[] = [],\n // A sweep loads each definition once for all its users, not once per user\n definitions = new Map<string, ConditionIR | undefined>()\n): Promise<string[]> {\n const bySegment = new Map<string, { workflow: string; hash: string; version?: string }[]>(\n segments.map((name) => [name, []])\n );\n for (const route of routes) {\n const list = bySegment.get(route.segment) ?? [];\n list.push({\n workflow: route.workflow,\n hash: route.hash,\n ...(route.version === undefined ? {} : { version: route.version }),\n });\n bySegment.set(route.segment, list);\n }\n if (bySegment.size === 0) return [];\n\n const facts = new JourneyFactSource(store, userId);\n const started: string[] = [];\n const failures: unknown[] = [];\n for (const [segmentId, segmentRoutes] of bySegment) {\n if (!definitions.has(segmentId)) {\n definitions.set(segmentId, await store.getSegmentCondition(segmentId));\n }\n const definition = definitions.get(segmentId);\n if (definition === undefined) continue; // route without a definition: broken deploy, skip\n\n const matches = await evaluateCondition(definition, facts, ts);\n const member = await store.isSegmentMember(segmentId, userId);\n\n if (matches && !member) {\n // Entry transition: the membership and the starts it owes, recorded together\n await store.addSegmentMember(\n segmentId,\n userId,\n ts,\n segmentRoutes.map((route) => route.workflow)\n );\n } else if (!matches && member) {\n await store.removeSegmentMember(segmentId, userId);\n continue;\n }\n if (!matches) continue;\n\n // Every start the entry still owes — a fresh entry's, or one an earlier evaluation failed on.\n // Each is crossed off once its instance exists, so one workflow failing neither blocks the\n // others nor gets lost: the next evaluation (the sweep, the user's next event) retries it.\n const pending = new Set(await store.pendingSegmentStarts(segmentId, userId));\n for (const route of segmentRoutes) {\n if (!pending.has(route.workflow)) continue;\n try {\n const instanceId = await startJourney(\n env,\n store,\n route.workflow,\n route.hash,\n userId,\n ts,\n { event: '$segment_entry', payload: { segment: segmentId } },\n route.version\n );\n if (instanceId !== null) started.push(instanceId);\n await store.completeSegmentStart(segmentId, userId, route.workflow);\n } catch (error) {\n failures.push(error);\n }\n }\n }\n // Surface a failure to the caller (an ingest answers 500) once every start had its chance\n if (failures.length > 0) throw failures[0];\n return started;\n}\n\nexport interface SweepResult {\n users: number;\n /** Journeys the segments' entries started. */\n started: string[];\n /** Each deployed segment's size after the sweep (empty when the store keeps no reporting). */\n sizes: { segment: string; size: number }[];\n}\n\n/** How many users one sweep page evaluates: a unit of resumable work (AWS checkpoints each). */\nexport const SWEEP_PAGE_SIZE = 500;\n\n/**\n * What the sweep does before it looks at any user: the runtime's own upkeep (AWS: unconfirmed\n * starts, executions that ended unseen) and the erasures left unfinished (an instance would not\n * stop). A failure of either is logged, never fatal: the pages still run. Users whose erasure is\n * still pending afterwards are skipped by every page (`sweepSegmentsPaged`).\n */\nexport async function sweepMaintenance(env: JourneyEnv, store: JourneyStore): Promise<void> {\n try {\n await env.JOURNEY.maintain?.();\n } catch (error) {\n console.error('[workflow] runtime maintenance failed', error);\n }\n // Erasures left pending (an admitted instance would not stop, or is still draining): retried\n // until every one of them is confirmed stopped and the data is gone\n for (const userId of await store.pendingErasures()) {\n try {\n await eraseUser(env, userId, store, Date.now());\n } catch (error) {\n // Still pending: the next sweep tries again, and the rest of this one goes on\n console.error(`[workflow] erasure of ${userId} failed`, error);\n }\n }\n}\n\nexport interface SweepPage {\n /** Users evaluated on this page (those being erased are listed but skipped). */\n users: number;\n /** Journeys the segments' entries started on this page. */\n started: string[];\n /** The last user id listed, to resume past (`after`); undefined when the page was empty. */\n lastUserId?: string | undefined;\n /** Whether the page was full, so another may follow. */\n more: boolean;\n}\n\n/**\n * One page of the sweep: the users after `after` (ascending, at most `limit`), every deployed\n * segment evaluated for each. The pages are what makes the sweep resumable — a host whose run\n * can be cut short (a Lambda timeout) checkpoints each one and replays the checkpointed pages\n * instead of starting over; `sweepSegments` is the plain loop over them.\n */\nexport async function sweepSegmentsPaged(\n env: JourneyEnv,\n store: JourneyStore,\n page: { after?: string | undefined; limit?: number | undefined; now?: number | undefined }\n): Promise<SweepPage> {\n const now = page.now ?? Date.now();\n const limit = page.limit ?? SWEEP_PAGE_SIZE;\n const segments = (await store.listSegments()).map((segment) => segment.id);\n const routes = await store.listSegmentTriggers();\n // Read per page, not once up front: an erasure requested since the last page is honored too\n const erasing = new Set(await store.pendingErasures());\n const listed = await store.listUserIds({ after: page.after, limit });\n const lastUserId = listed.at(-1);\n // A store that ignores `after` would hand back the same page forever: refuse to loop on it.\n // Equality only — the order is the store's collation, which JavaScript's `<` does not know\n if (page.after !== undefined && lastUserId === page.after) {\n throw new Error(`listUserIds did not advance past ${page.after}: the store ignores 'after'`);\n }\n const users = listed.filter((userId) => !erasing.has(userId));\n const started: string[] = [];\n const definitions = new Map<string, ConditionIR | undefined>();\n for (const userId of users) {\n try {\n started.push(\n ...(await refreshSegments(env, store, userId, now, routes, segments, definitions))\n );\n } catch (error) {\n // One user's failed start is kept for the next sweep; it must not stop everyone else's\n console.error(`[workflow] segment refresh for ${userId} failed`, error);\n }\n }\n return { users: users.length, started, lastUserId, more: listed.length >= limit };\n}\n\n/**\n * The scheduled sweep: every deployed segment evaluated for every known user — the evaluator a\n * journey's conditions run on, so a member is exactly who a journey would see in it. Membership\n * follows (joiners keep the time they joined, leavers are removed), segment triggers start the\n * journeys of whoever entered by the clock alone, and the store records each segment's size.\n * It walks users page by page (`sweepSegmentsPaged`): fine for thousands, a large audience wants\n * SQL-side evaluation.\n */\nexport async function sweepSegments(\n env: JourneyEnv,\n store: JourneyStore,\n now = Date.now()\n): Promise<SweepResult> {\n await sweepMaintenance(env, store);\n let users = 0;\n const started: string[] = [];\n let after: string | undefined;\n for (;;) {\n const page = await sweepSegmentsPaged(env, store, { after, limit: SWEEP_PAGE_SIZE, now });\n users += page.users;\n started.push(...page.started);\n if (!page.more || page.lastUserId === undefined) break;\n after = page.lastUserId;\n }\n const sizes = await store.reporting.snapshotSegments(now);\n return { users, started, sizes };\n}\n\n/**\n * Create one journey instance behind the entry ledger. Returns null when the\n * once-policy blocks the entry (the store's attempt is atomic, so concurrent\n * ingests cannot double-enter). A pair whose previous instance is recorded as\n * 'failed' is reclaimed rather than barred forever over one outage.\n */\nasync function startJourney(\n env: JourneyEnv,\n store: JourneyStore,\n workflow: string,\n hash: string,\n userId: string,\n ts: number,\n trigger: { event: string; payload: Record<string, unknown> },\n /** The code version whose bundle routed this start, read with the hash (see TriggerRoute). */\n version?: string\n): Promise<string | null> {\n const instanceId = buildInstanceId(workflow, hash, userId, ts);\n const params: JourneyParams = { workflowId: workflow, contentHash: hash, userId, trigger };\n const execution = await env.JOURNEY.plan?.({ id: instanceId, params, ts, version });\n\n const outcome = await store.enterJourney({ workflow, userId, instanceId, hash, ts, execution });\n if (outcome === null) {\n // Already entered and still alive — unless its start never got through: this retry is the\n // first chance to finish it\n await env.JOURNEY.resume?.({ workflow, userId });\n return null;\n }\n\n try {\n await env.JOURNEY.create({ id: instanceId, params });\n } catch (error) {\n // A recorded start stays: the runtime may have taken it, and a retry with the same name\n // and payload finds that execution instead of starting a second one\n if (execution !== undefined) throw error;\n if (outcome === 'reclaimed') {\n // Put the corpse back rather than deleting it: the entry is still a\n // failure, and dropping the row would erase that from the ledger.\n await store.setEntryStatus(instanceId, 'failed');\n } else {\n // Roll the ledger back so a retried ingest can enter — otherwise the\n // once-policy would permanently record an entry that never ran.\n await store.removeEntry(instanceId);\n }\n throw error;\n }\n return instanceId;\n}\n\nconst utf8 = new TextEncoder();\n\n/**\n * Instance id: addressable (the host gets it directly to wake it), ≤100\n * chars, legal charset. The murmur3 fingerprint keeps ids distinct when the\n * sanitized/truncated userId alone would collide.\n */\nfunction buildInstanceId(workflow: string, hash: string, userId: string, ts: number): string {\n const uid = userId.replace(/[^a-zA-Z0-9_-]/g, '-').slice(0, 24);\n const fingerprint = murmur3(utf8.encode(userId)).toString(36);\n return `${workflow.slice(0, 32)}-${hash.slice(0, 8)}-${uid}-${fingerprint}-${ts.toString(36)}`;\n}\n\n/* --------------------------------- identify --------------------------------- */\n\nexport interface IdentifyResult {\n ok: true;\n props: ProfileProps;\n woke: number;\n started: string[];\n}\n\n/**\n * Merge-patch profile properties (a null removes one), then wake property waits\n * and re-evaluate segment triggers. The merge is one statement in the store, so\n * two identifies landing together cannot lose each other's fields.\n */\nexport async function identifyUser(\n env: JourneyEnv,\n userId: string,\n props: ProfileProps,\n store: JourneyStore\n): Promise<IdentifyResult> {\n await refuseIfErased(store, userId);\n const ts = Date.now();\n const merged = await store.mergeProfile(userId, props);\n // A property wait subscribes to its own property's channel (see relevantEvents): only the\n // waits on what this patch touched wake up\n const woke = await wakeSubscribers(\n env,\n store,\n userId,\n Object.keys(props).map(profileUpdatedEvent)\n );\n // A profile change can move the user into (or out of) a trigger-routed segment\n const started = await refreshSegmentTriggers(env, store, userId, ts);\n return { ok: true, props: merged, woke, started };\n}\n\nexport interface DeviceRegistration {\n userId: string;\n token: string;\n platform: string;\n ts?: number;\n}\n\n/**\n * A push token registered for the user (or moved to them from another account: a token belongs\n * to one user at a time). Wakes the waits that read devices (`reachable('push')` inside\n * `waitUntil`), for the new owner and the previous one.\n */\nexport async function registerDevice(\n env: JourneyEnv,\n device: DeviceRegistration,\n store: JourneyStore\n): Promise<{ ok: true; woke: number }> {\n await refuseIfErased(store, device.userId);\n const previous = await store.removeDevice(device.token);\n await store.upsertDevice({ ...device, ts: device.ts ?? Date.now() });\n let woke = await wakeSubscribers(env, store, device.userId, [DEVICES_UPDATED_EVENT]);\n if (previous !== undefined && previous !== device.userId) {\n woke += await wakeSubscribers(env, store, previous, [DEVICES_UPDATED_EVENT]);\n }\n return { ok: true, woke };\n}\n\n/** Workflow instance states that will never run another step. */\nconst STOPPED_STATUSES = new Set(['complete', 'errored', 'terminated']);\n\n/**\n * Stop one admitted instance for good; true once it is known not to run again: terminated, or\n * already in a terminal state (terminate refuses those). Any other failure is not taken for\n * \"already gone\": it answers false, and the erasure waits for a retry.\n *\n * An admitted instance the platform no longer knows counts as stopped too — but only an\n * admitted one. Admission is the runner's first act, so an admitted instance did reach the\n * runtime: not found now means it finished and was evicted. An entry that is not admitted is\n * never passed here: for it, not found may just as well mean its `create` is still in flight.\n */\nasync function stopInstance(env: JourneyEnv, instanceId: string): Promise<boolean> {\n let instance: WorkflowInstanceLike;\n try {\n instance = await env.JOURNEY.get(instanceId);\n } catch (error) {\n if (error instanceof InstanceNotFoundError) return true;\n console.warn(`[workflow] looking up ${instanceId} to stop it failed`, error);\n return false;\n }\n if (instance.terminate === undefined) return false;\n try {\n await instance.terminate();\n return true;\n } catch (error) {\n const status = await instance.status?.().catch(() => undefined);\n if (status !== undefined && STOPPED_STATUSES.has(status.status)) return true;\n console.warn(`[workflow] terminating ${instanceId} failed`, error);\n return false;\n }\n}\n\n/**\n * Carry out a recorded erasure: stop every running instance of the user, and only once all are\n * known not to run erase their data. The tombstone is already committed, so no new entry,\n * event, profile or device of theirs lands meanwhile.\n *\n * An entry's runner admits itself before its first effect (`admitInstance`, mutually exclusive\n * with the tombstone's commit), which splits the running instances in two:\n *\n * - not admitted: nothing of it has run, and nothing will — a `create` still in flight, or a\n * runner invoked later, meets the tombstone at admission and exits. No stop is needed, and\n * none would be reliable: the platform may not know the instance yet.\n * - admitted: it did reach the runtime and must be stopped (`stopInstance`), or it would write\n * rows back after the erase. One that will not stop leaves the erasure pending.\n *\n * Erasing nothing until then keeps the entries that name the instances still to stop, so a\n * retry can finish the job. The tombstone outlives the erase (`erased_at`), which is what makes\n * a repeat idempotent and a late write harmless. There is no straggler to come back for: every\n * write about a user runs in the user's locked transaction, the same lock the tombstone takes,\n * so a write either committed before the tombstone — and `forgetUser` deletes it here — or\n * waited for the lock and is refused by the tombstone it then sees.\n */\nasync function eraseUser(\n env: JourneyEnv,\n userId: string,\n store: JourneyStore,\n now: number\n): Promise<{ erased: boolean; pending: string[] }> {\n const pending: string[] = [];\n for (const { instanceId, admitted } of await store.runningInstances(userId)) {\n if (!admitted) continue;\n if (!(await stopInstance(env, instanceId))) pending.push(instanceId);\n }\n if (pending.length > 0) return { erased: false, pending };\n await store.forgetUser(userId, now);\n return { erased: true, pending };\n}\n\n/**\n * Forget a user (a deleted account): the tombstone is committed first, then the erasure carried\n * out. `erased: true` means every admitted runner of theirs was confirmed stopped, the entries\n * never admitted can never run, and the data is deleted — for good, with no time window and no\n * second pass: the tombstone commits under the user's lock, which every write about the user\n * also takes, so nothing of theirs can land after it. An admitted instance that will not stop\n * leaves it pending — nothing erased yet — and the scheduled sweep retries it; `erased: false`\n * says so. A user already erased answers `erased: true` again, with nothing to do.\n */\nexport async function forgetUser(\n env: JourneyEnv,\n userId: string,\n store: JourneyStore,\n now = Date.now()\n): Promise<{ ok: true; erased: boolean; pending: string[] }> {\n await store.requestErasure(userId, now);\n return { ok: true, ...(await eraseUser(env, userId, store, now)) };\n}\n\n/**\n * The user signed out on the device: the token stays registered, bound to nobody, so the next\n * sign-in only has to bind it again. Wakes the former owner's device waits (`reachable('push')`\n * may no longer hold).\n */\nexport async function unbindDevice(\n env: JourneyEnv,\n token: string,\n store: JourneyStore\n): Promise<{ ok: true; woke: number }> {\n const owner = await store.unbindDevice(token);\n const woke =\n owner === undefined ? 0 : await wakeSubscribers(env, store, owner, [DEVICES_UPDATED_EVENT]);\n return { ok: true, woke };\n}\n\n/** A push token forgotten for good (the push service reports it gone); wakes its owner's device waits. */\nexport async function removeDevice(\n env: JourneyEnv,\n token: string,\n store: JourneyStore\n): Promise<{ ok: true; woke: number }> {\n const owner = await store.removeDevice(token);\n const woke =\n owner === undefined ? 0 : await wakeSubscribers(env, store, owner, [DEVICES_UPDATED_EVENT]);\n return { ok: true, woke };\n}\n\n/* ---------------------------------- deploy ---------------------------------- */\n\nexport interface DeployResult {\n workflows: string[];\n /**\n * Workflows whose trigger type has no runtime routing yet — they deploy (the\n * IR is stored and versioned) but will never start until the routing lands.\n * Currently: date (TODO — needs cron + Queues, instance creation is\n * rate-limited and a date trigger fans out to the whole audience at once)\n * and webhook (TODO — needs endpoint allocation).\n */\n unrouted: { workflow: string; trigger: TriggerIR['type'] }[];\n /**\n * Whether the routes now point at this bundle: always for a forced deploy, false for a\n * versioned one when a version as new or newer is already live (nothing was swapped).\n */\n applied: boolean;\n}\n\n/** A bundle whose declared hashes do not describe its contents — rejected before anything is written. */\nexport class BundleIntegrityError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'BundleIntegrityError';\n }\n}\n\n/**\n * Recompute every declared contentHash and refuse the bundle if one disagrees.\n *\n * The contentHash is the address an in-flight journey reads its IR from, so\n * a hash that does not describe its own content is a way to change what a\n * pinned instance executes: submit an edited flow under the old hash and the\n * next replay picks it up. The compiler always computes the hash it ships, so\n * a mismatch means a hand-rolled payload or a stale client — neither should be\n * able to overwrite a version that journeys are running.\n *\n * Each hash is recomputed exactly the way its compiler produced it: a workflow\n * hashes its whole IR (contentHash strips itself), a segment hashes only its\n * condition. Mirroring them is load-bearing — recomputing a segment over the\n * whole record would reject every honest deploy.\n */\nfunction verifyBundleHashes(parsed: BundleIR): void {\n const mismatched: string[] = [];\n for (const workflow of parsed.workflows) {\n if (semanticHash(workflow) !== workflow.contentHash) {\n mismatched.push(`workflow '${workflow.id}'`);\n }\n }\n for (const segment of parsed.segments) {\n if (semanticHash(segment.condition) !== segment.contentHash) {\n mismatched.push(`segment '${segment.id}'`);\n }\n }\n if (mismatched.length > 0) {\n throw new BundleIntegrityError(\n `contentHash does not match content for ${mismatched.join(', ')} — recompile the bundle instead of editing it by hand`\n );\n }\n}\n\n/**\n * Bundle deploy, terraform-apply style: workflow bodies first (content-addressed, so a failed\n * deploy leaves only harmless orphans), then the routing swap. With a `version` the swap happens\n * only over an older live version (`replaceRoutesIfNewer`) — how journeyWorker applies the bundle\n * compiled into it; without one it is forced, whatever is live.\n */\nexport async function deployBundle(\n env: JourneyEnv,\n bundle: unknown,\n store: JourneyStore,\n version?: BundleVersion\n): Promise<DeployResult> {\n const parsed = BundleIR.parse(bundle);\n // Before any write: a bad hash must not reach the store, where it would address content it does not describe\n verifyBundleHashes(parsed);\n // Nor a bundle the database cannot hold: the host migrates, the engine only checks\n const problems = await store.checkSchema();\n if (problems.length > 0) throw new SchemaMismatchError(problems);\n\n for (const workflow of parsed.workflows) await store.putWorkflowIR(workflow);\n let applied = true;\n if (version === undefined) await store.replaceRoutes(parsed);\n else applied = await store.replaceRoutesIfNewer(parsed, version);\n\n const unrouted = parsed.workflows\n .filter((workflow) => workflow.trigger.type === 'date' || workflow.trigger.type === 'webhook')\n .map((workflow) => ({ workflow: workflow.id, trigger: workflow.trigger.type }));\n\n return {\n workflows: parsed.workflows.map((w) => `${w.id}@${w.contentHash.slice(0, 8)}`),\n unrouted,\n applied,\n };\n}\n"],"mappings":"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"}
@@ -9,7 +9,6 @@ const require_react_email = require("../react-email.cjs");
9
9
  const require_engine_recipient = require("../engine/recipient.cjs");
10
10
  const require_engine_condition = require("../engine/condition.cjs");
11
11
  const require_engine_time_scale = require("../engine/time-scale.cjs");
12
- const require_cloudflare_d1_env = require("../cloudflare/d1-env.cjs");
13
12
  const require_host_ingest = require("./ingest.cjs");
14
13
  const require_engine_actions = require("../engine/actions.cjs");
15
14
  const require_reporting_observer = require("../reporting/observer.cjs");
@@ -71,7 +70,7 @@ function createJourneyHost(options, platform = {}) {
71
70
  const emails = config.templates?.email;
72
71
  const database = { schema: config.database?.schema ?? "workflow" };
73
72
  const lease = (env) => {
74
- const opened = options.store === void 0 ? require_cloudflare_d1_env.d1Store(env) : options.store(env, database);
73
+ const opened = options.store(env, database);
75
74
  const noop = async () => {};
76
75
  const factor = require_host_channels.runtimeFromConfig(config, env).timeScale;
77
76
  return "store" in opened ? {
@@ -94,7 +93,7 @@ function createJourneyHost(options, platform = {}) {
94
93
  };
95
94
  const observer = (env, store) => {
96
95
  if (options.observer !== void 0) return options.observer(env, store);
97
- return store.reporting === void 0 ? void 0 : require_reporting_observer.reportingObserver(store.reporting, env.WORKFLOW_ORIGIN);
96
+ return require_reporting_observer.reportingObserver(store.reporting, env.WORKFLOW_ORIGIN);
98
97
  };
99
98
  const runParts = (env, store, params) => ({
100
99
  store,
@@ -170,7 +169,6 @@ function createJourneyHost(options, platform = {}) {
170
169
  applying = (async () => {
171
170
  const { store, close } = lease(env);
172
171
  try {
173
- if (store.liveBundleVersion === void 0) return;
174
172
  const live = await store.liveBundleVersion();
175
173
  if (live !== void 0 && (live.id === version.id || live.createdAt >= version.createdAt)) return;
176
174
  if ((await require_host_ingest.deployBundle(env, bundle(), store, version)).applied) console.log(`[workflow] version ${version.id} is live`);
@@ -1 +1 @@
1
- {"version":3,"file":"journey-host.cjs","names":["checkEvent","ContractValidationError","indexTemplates","compileConfig","z","d1Store","runtimeFromConfig","scaleSegmentWindows","createProviderCache","channelsFromConfig","reportingObserver","RegistryActionInvoker","registryValidator","registryRenderer","ingestEvent","profileUpdatedEvent","RECIPIENT_PROPERTY","wakeSubscribers","serve","sweepSegments","deployBundle"],"sources":["../../src/host/journey-host.ts"],"sourcesContent":["import * as z from 'zod/mini';\nimport type * as core from 'zod/v4/core';\nimport { d1Store } from '../cloudflare/d1-env';\nimport { type JourneyConfig, compileConfig } from '../config';\nimport { type Contract, ContractValidationError, checkEvent } from '../contract';\nimport { assertEmailConfig } from '../email';\nimport type { EmailTransport } from '../email-sender';\nimport { RegistryActionInvoker } from '../engine/actions';\nimport { profileUpdatedEvent } from '../engine/condition';\nimport type { JourneyObserver, MessageSender } from '../engine/ports';\nimport { RECIPIENT_PROPERTY } from '../engine/recipient';\nimport { scaleSegmentWindows } from '../engine/time-scale';\nimport type { BundleIR, NodeIR } from '../ir';\nimport { registryRenderer } from '../react-email';\nimport { reportingObserver } from '../reporting/observer';\nimport type { TrackingOptions } from '../reporting/tracking';\nimport type { JourneyRuntimeOptions } from '../runtime';\nimport type { BundleVersion, JourneyStore, StoreLease } from '../store/index';\nimport { DEFAULT_SCHEMA } from '../store/tables';\nimport { indexTemplates, registryValidator } from '../templates';\nimport type { Authorize } from './auth';\nimport { channelsFromConfig, createProviderCache, runtimeFromConfig } from './channels';\nimport type { JourneyRunParts } from './execute';\nimport { serve } from './http';\nimport {\n type SweepResult,\n deployBundle,\n ingestEvent,\n sweepSegments,\n wakeSubscribers,\n} from './ingest';\nimport type { JourneyEnv, JourneyParams } from './platform';\n\n/**\n * What a journey host is, apart from the runtime it runs on: the validated environment, the\n * store lease, the outlets a run is wired to, the HTTP API (`http.ts`, with the host's parts\n * plugged in) and the bundle that goes live with the code. `journeyWorker` (Cloudflare) and\n * `journeyLambda` (AWS) are this plus how their platform starts, wakes and stops instances.\n */\n\nexport interface JourneyHostOptions<Env extends JourneyEnv> {\n /**\n * The project's `workflow.config.ts` (`defineConfig`): what the host deploys (`GET /bundle`,\n * `POST /bundle/deploy` serve `compileConfig`), the template registries the senders, the\n * validator and `/preview` render from, the contract every `/events` and `/identify` is\n * checked against, the email defaults, actions, and the runtime / APNs / FCM settings.\n * Secrets come from the env under the names `JourneyEnv` documents.\n */\n config: JourneyConfig;\n /**\n * The data plane, opened per request and per workflow run. Return a `StoreLease` when the\n * client must be released afterwards (a Postgres connection); defaults to D1 from the env's\n * binding. `database` is the config's (`schema` defaulting to `workflow`), for Postgres:\n *\n * store: (env, database) => postgresJsStore(env.DATABASE_URL, database)\n */\n store?: (env: Env, database: { schema: string }) => JourneyStore | StoreLease;\n /**\n * Where journey emails go: a transport built from the validated env — `sesEmailTransport(...)`,\n * `cloudflareEmailTransport(...)`, or your own. Which one is independent of where the workflow\n * runs. Without it the email channel fails its sends, saying so.\n */\n email?: (env: Env) => EmailTransport;\n /**\n * Reporting hooks: node arrivals, every copy sent (with the tracking it should carry — click\n * links and an open pixel for email, payload data for a push), and how each instance ended.\n * By default a store that keeps reporting (Postgres) records them itself, and the host serves\n * the email tracking routes (`/t/...`, on `WORKFLOW_ORIGIN`); override to report elsewhere.\n */\n observer?: (env: Env, store: JourneyStore) => JourneyObserver;\n /** Message outlet override (tests, a custom gateway): replaces every channel, `email` included. */\n messages?: (env: Env, store: JourneyStore) => MessageSender;\n /** Per-instance runtime options override (a tester in fast-forward); by default `config.runtime` for `ENVIRONMENT`. */\n runtime?: (env: Env, params: JourneyParams) => JourneyRuntimeOptions;\n /**\n * The host's environment, as a zod schema over its vars and secrets (bindings pass through\n * untouched): checked the first time the host runs, so a missing or malformed variable fails\n * there — every problem listed — not at the send that needs it. Webhook templates read their\n * URL from the parsed values: `webhook: (env: Env) => env.WEBHOOK_DISCORD_ALERTS`.\n */\n env?: core.$ZodType<object>;\n /** 'enforce' (default) answers an event or patch the contract refuses with a 400; 'report' only logs it. */\n contractMode?: 'enforce' | 'report';\n /**\n * Who may call the API routes, in whatever form the host chooses: an HMAC over the body, a\n * JWT, a header its proxy adds. Receives a copy of the request and the env; true lets it\n * through, false answers 401, a throw answers 500. The tracking routes and `/health` stay\n * public. Without it, the env's `API_TOKEN` as a bearer (`bearerMatches`), which production\n * then requires.\n *\n * authorize: async (request, env) =>\n * timingSafeEqual(request.headers.get('x-signature') ?? '', await hmac(env.SECRET, await request.text()))\n */\n authorize?: Authorize<Env>;\n}\n\n/** The host's environment failed its schema (`env` option). */\nexport class EnvValidationError extends Error {\n override readonly name = 'EnvValidationError';\n constructor(readonly issues: readonly string[]) {\n super(`the host's environment is invalid:\\n ${issues.join('\\n ')}`);\n }\n}\n\nexport interface JourneyHost<Env extends JourneyEnv> {\n config: JourneyConfig;\n /** The env, parsed by the schema once per env object (throws EnvValidationError). */\n checkEnv(env: Env): Env;\n /** A store for one request or run, its segment windows scaled with the runtime. */\n lease(env: Env): Required<StoreLease>;\n /** The bundle compiled into this code. */\n bundle(): BundleIR;\n /** The outlets one run is wired to (the store is the run's). */\n runParts(env: Env, store: JourneyStore, params: JourneyParams): JourneyRunParts;\n /** The HTTP API over one store: the ingest API plus tracking, the sweep, the bundle and previews. */\n handle(request: Request, env: Env, store: JourneyStore): Promise<Response>;\n /** The scheduled sweep (`sweepSegments`) on a store of its own. */\n sweep(env: Env): Promise<SweepResult>;\n /**\n * Make the bundle compiled into `version` live, once per process, only over an older live\n * version (the store records it). Undefined when there is nothing to do; a failure is logged\n * and retried a minute later.\n */\n applyOwnBundle(env: Env, version: BundleVersion | undefined): Promise<void> | undefined;\n}\n\n/** What the platform adapter itself contributes. */\nexport interface JourneyPlatform {\n /** An HTTP/2 fetch for APNs, where the runtime's own fetch is HTTP/1.1 (Node). */\n apnsFetch?: typeof fetch;\n}\n\nfunction sendsEmail(nodes: readonly NodeIR[]): boolean {\n return nodes.some((node) => {\n switch (node.type) {\n case 'message':\n return node.channel === 'email';\n case 'wait_until':\n return Array.isArray(node.onTimeout) && sendsEmail(node.onTimeout);\n case 'branch':\n return (\n node.cases.some((branch) => sendsEmail(branch.flow)) || sendsEmail(node.otherwise ?? [])\n );\n case 'cohort':\n return node.arms.some((arm) => sendsEmail(arm.flow));\n default:\n return false;\n }\n });\n}\n\nconst describeIssue = (issue: { path: PropertyKey[]; message: string }): string =>\n `${issue.path.join('.') || '(root)'}: ${issue.message}`;\n\n/** An event the host emits itself, checked against the contract like any ingest. */\nfunction checkedPayload(\n contract: Contract | undefined,\n event: string,\n payload: Record<string, unknown>\n): Record<string, unknown> {\n if (contract === undefined) return payload;\n const verdict = checkEvent(contract, event, payload);\n if (!verdict.ok) throw new ContractValidationError(event, verdict.issues);\n return verdict.data;\n}\n\nexport function createJourneyHost<Env extends JourneyEnv>(\n options: JourneyHostOptions<Env>,\n platform: JourneyPlatform = {}\n): JourneyHost<Env> {\n const { config } = options;\n const emailIds = indexTemplates('email', config.templates?.email);\n // A config still on `emails` (its sender would be silently dropped) fails here, not at a send\n assertEmailConfig(config, emailIds.values());\n let compiled: BundleIR | undefined;\n const bundle = () => (compiled ??= compileConfig(config));\n // A journey that emails with nowhere to send fails here too, not at its first email\n const emailing = bundle().workflows.filter((workflow) => sendsEmail(workflow.flow));\n if (options.email === undefined && options.messages === undefined && emailing.length > 0) {\n throw new TypeError(\n `workflows ${emailing.map((w) => `'${w.id}'`).join(', ')} send email, but the host has no ` +\n '`email` transport: pass `email: (env) => cloudflareEmailTransport(...)` or `sesEmailTransport(...)`'\n );\n }\n // Parsed once per env object: the runtime hands the same one to every request of an isolate\n const checked = new WeakMap<object, Env>();\n const checkEnv = (env: Env): Env => {\n if (options.env === undefined) return env;\n const cached = checked.get(env);\n if (cached !== undefined) return cached;\n const result = z.safeParse(options.env, env);\n // Names and reasons only: the values are secrets\n if (!result.success) throw new EnvValidationError(result.error.issues.map(describeIssue));\n const merged: Env = { ...env, ...result.data };\n checked.set(env, merged);\n return merged;\n };\n const emails = config.templates?.email;\n const database = { schema: config.database?.schema ?? DEFAULT_SCHEMA };\n // In fast-forward, segment windows scale with the journeys reading them (time-scale.ts)\n const lease = (env: Env): Required<StoreLease> => {\n const opened = options.store === undefined ? d1Store(env) : options.store(env, database);\n const noop = async () => {};\n const factor = runtimeFromConfig(config, env).timeScale;\n return 'store' in opened\n ? { store: scaleSegmentWindows(opened.store, factor), close: opened.close ?? noop }\n : { store: scaleSegmentWindows(opened, factor), close: noop };\n };\n\n // The provider clients (APNs JWT, FCM access token) live with the host, not with a run: a\n // run's env is a fresh object on every Lambda invocation, so nothing keyed on it would be reused\n const providers = createProviderCache();\n const messages = (env: Env, store: JourneyStore): MessageSender => {\n if (options.messages !== undefined) return options.messages(env, store);\n return channelsFromConfig(config, env, {\n store,\n email: options.email?.(env),\n apnsFetch: platform.apnsFetch,\n providers,\n });\n };\n\n const observer = (env: Env, store: JourneyStore): JourneyObserver | undefined => {\n if (options.observer !== undefined) return options.observer(env, store);\n return store.reporting === undefined\n ? undefined\n : reportingObserver(store.reporting, env.WORKFLOW_ORIGIN);\n };\n\n const runParts = (env: Env, store: JourneyStore, params: JourneyParams): JourneyRunParts => ({\n store,\n env,\n runtime:\n options.runtime === undefined ? runtimeFromConfig(config, env) : options.runtime(env, params),\n messages: messages(env, store),\n // An action ref is a registered action structurally: the same object carries name, code and hash\n actions: new RegistryActionInvoker(config.actions ?? []),\n contract: config.contract,\n validator: registryValidator(config.templates ?? {}),\n observer: observer(env, store),\n });\n\n const preview = emails === undefined ? undefined : registryRenderer(emails, config.email);\n\n /**\n * The recipients' routes (`/t/*`): a push tapped open is recorded as the config's\n * `webPush.openedEvent` — keyed by the copy, so a second tap of the same notification is the\n * same event — and an unsubscribe wakes the waits reading email reachability (`reachable\n * ('email')` subscribes to the email property's channel), so an `exitWhen:\n * not(reachable('email'))` takes effect without waiting for a timeout.\n */\n const tracking = (env: Env, store: JourneyStore): TrackingOptions => {\n const opened = config.webPush?.openedEvent;\n return {\n product: config.title,\n quickMs: config.email?.tracking?.quickClickMs,\n pushOpened:\n opened === undefined\n ? undefined\n : async ({ messageId, userId, at }) => {\n const payload = checkedPayload(config.contract, opened, { message_id: messageId });\n const input = {\n id: `${opened}:${messageId}`,\n userId,\n event: opened,\n payload,\n ts: at,\n };\n await ingestEvent(env, input, store);\n },\n onUnsubscribed: async ({ userId }) => {\n const email = profileUpdatedEvent(RECIPIENT_PROPERTY.email ?? 'email');\n await wakeSubscribers(env, store, userId, [email]);\n },\n };\n };\n\n const handle = (request: Request, env: Env, store: JourneyStore): Promise<Response> =>\n serve(request, {\n env,\n store,\n validation: { contract: config.contract, mode: options.contractMode },\n authorize: options.authorize as Authorize<JourneyEnv> | undefined,\n host: {\n bundle,\n preview:\n preview === undefined ? undefined : { has: (id) => emailIds.has(id), render: preview },\n tracking: tracking(env, store),\n sweep: () => sweepSegments(env, store),\n },\n });\n\n const sweep = async (env: Env) => {\n const { store, close } = lease(env);\n try {\n return await sweepSegments(env, store);\n } finally {\n await close();\n }\n };\n\n // The bundle compiled into this version goes live on its own, once per process: the first\n // request or scheduled run reads which version's bundle is live (one row) and applies this one\n // only over an older version, so one still serving during a rollout can never put its routes\n // back. A failure (say the migrations have not run) is logged and retried a minute later. Only\n // the request that starts the check gets its promise back: a request context cannot wait on\n // another's I/O.\n let applying: Promise<void> | undefined;\n let retryAt = 0;\n const applyOwnBundle = (env: Env, version: BundleVersion | undefined) => {\n if (version === undefined || version.id === '' || Number.isNaN(version.createdAt)) {\n return undefined;\n }\n if (applying !== undefined || Date.now() < retryAt) return undefined;\n applying = (async () => {\n const { store, close } = lease(env);\n try {\n if (store.liveBundleVersion === undefined) return; // D1: POST /bundle/deploy\n const live = await store.liveBundleVersion();\n if (live !== undefined && (live.id === version.id || live.createdAt >= version.createdAt))\n return;\n const result = await deployBundle(env, bundle(), store, version);\n if (result.applied) console.log(`[workflow] version ${version.id} is live`);\n } finally {\n await close();\n }\n })().catch((error: unknown) => {\n applying = undefined;\n retryAt = Date.now() + 60_000;\n console.error(`[workflow] could not make version ${version.id} live`, error);\n });\n return applying;\n };\n\n return { config, checkEnv, lease, bundle, runParts, handle, sweep, applyOwnBundle };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"journey-host.cjs","names":["checkEvent","ContractValidationError","indexTemplates","compileConfig","z","runtimeFromConfig","scaleSegmentWindows","createProviderCache","channelsFromConfig","reportingObserver","RegistryActionInvoker","registryValidator","registryRenderer","ingestEvent","profileUpdatedEvent","RECIPIENT_PROPERTY","wakeSubscribers","serve","sweepSegments","deployBundle"],"sources":["../../src/host/journey-host.ts"],"sourcesContent":["import * as z from 'zod/mini';\nimport type * as core from 'zod/v4/core';\nimport { type JourneyConfig, compileConfig } from '../config';\nimport { type Contract, ContractValidationError, checkEvent } from '../contract';\nimport { assertEmailConfig } from '../email';\nimport type { EmailTransport } from '../email-sender';\nimport { RegistryActionInvoker } from '../engine/actions';\nimport { profileUpdatedEvent } from '../engine/condition';\nimport type { JourneyObserver, MessageSender } from '../engine/ports';\nimport { RECIPIENT_PROPERTY } from '../engine/recipient';\nimport { scaleSegmentWindows } from '../engine/time-scale';\nimport type { BundleIR, NodeIR } from '../ir';\nimport { registryRenderer } from '../react-email';\nimport { reportingObserver } from '../reporting/observer';\nimport type { TrackingOptions } from '../reporting/tracking';\nimport type { JourneyRuntimeOptions } from '../runtime';\nimport type { BundleVersion, JourneyStore, StoreLease } from '../store/index';\nimport { DEFAULT_SCHEMA } from '../store/tables';\nimport { indexTemplates, registryValidator } from '../templates';\nimport type { Authorize } from './auth';\nimport { channelsFromConfig, createProviderCache, runtimeFromConfig } from './channels';\nimport type { JourneyRunParts } from './execute';\nimport { serve } from './http';\nimport {\n type SweepResult,\n deployBundle,\n ingestEvent,\n sweepSegments,\n wakeSubscribers,\n} from './ingest';\nimport type { JourneyEnv, JourneyParams } from './platform';\n\n/**\n * What a journey host is, apart from the runtime it runs on: the validated environment, the\n * store lease, the outlets a run is wired to, the HTTP API (`http.ts`, with the host's parts\n * plugged in) and the bundle that goes live with the code. `journeyWorker` (Cloudflare) and\n * `journeyLambda` (AWS) are this plus how their platform starts, wakes and stops instances.\n */\n\nexport interface JourneyHostOptions<Env extends JourneyEnv> {\n /**\n * The project's `workflow.config.ts` (`defineConfig`): what the host deploys (`GET /bundle`,\n * `POST /bundle/deploy` serve `compileConfig`), the template registries the senders, the\n * validator and `/preview` render from, the contract every `/events` and `/identify` is\n * checked against, the email defaults, actions, and the runtime / APNs / FCM settings.\n * Secrets come from the env under the names `JourneyEnv` documents.\n */\n config: JourneyConfig;\n /**\n * The data plane, opened per request and per workflow run. Return a `StoreLease` when the\n * client must be released afterwards (a Postgres connection). `database` is the config's\n * (`schema` defaulting to `workflow`):\n *\n * store: (env, database) => postgresJsStore(env.DATABASE_URL, database)\n */\n store: (env: Env, database: { schema: string }) => JourneyStore | StoreLease;\n /**\n * Where journey emails go: a transport built from the validated env — `sesEmailTransport(...)`,\n * `cloudflareEmailTransport(...)`, or your own. Which one is independent of where the workflow\n * runs. Without it the email channel fails its sends, saying so.\n */\n email?: (env: Env) => EmailTransport;\n /**\n * Reporting hooks: node arrivals, every copy sent (with the tracking it should carry — click\n * links and an open pixel for email, payload data for a push), and how each instance ended.\n * By default the store records them itself, and the host serves the email tracking routes\n * (`/t/...`, on `WORKFLOW_ORIGIN`); override to report elsewhere.\n */\n observer?: (env: Env, store: JourneyStore) => JourneyObserver;\n /** Message outlet override (tests, a custom gateway): replaces every channel, `email` included. */\n messages?: (env: Env, store: JourneyStore) => MessageSender;\n /** Per-instance runtime options override (a tester in fast-forward); by default `config.runtime` for `ENVIRONMENT`. */\n runtime?: (env: Env, params: JourneyParams) => JourneyRuntimeOptions;\n /**\n * The host's environment, as a zod schema over its vars and secrets (bindings pass through\n * untouched): checked the first time the host runs, so a missing or malformed variable fails\n * there — every problem listed — not at the send that needs it. Webhook templates read their\n * URL from the parsed values: `webhook: (env: Env) => env.WEBHOOK_DISCORD_ALERTS`.\n */\n env?: core.$ZodType<object>;\n /** 'enforce' (default) answers an event or patch the contract refuses with a 400; 'report' only logs it. */\n contractMode?: 'enforce' | 'report';\n /**\n * Who may call the API routes, in whatever form the host chooses: an HMAC over the body, a\n * JWT, a header its proxy adds. Receives a copy of the request and the env; true lets it\n * through, false answers 401, a throw answers 500. The tracking routes and `/health` stay\n * public. Without it, the env's `API_TOKEN` as a bearer (`bearerMatches`), which production\n * then requires.\n *\n * authorize: async (request, env) =>\n * timingSafeEqual(request.headers.get('x-signature') ?? '', await hmac(env.SECRET, await request.text()))\n */\n authorize?: Authorize<Env>;\n}\n\n/** The host's environment failed its schema (`env` option). */\nexport class EnvValidationError extends Error {\n override readonly name = 'EnvValidationError';\n constructor(readonly issues: readonly string[]) {\n super(`the host's environment is invalid:\\n ${issues.join('\\n ')}`);\n }\n}\n\nexport interface JourneyHost<Env extends JourneyEnv> {\n config: JourneyConfig;\n /** The env, parsed by the schema once per env object (throws EnvValidationError). */\n checkEnv(env: Env): Env;\n /** A store for one request or run, its segment windows scaled with the runtime. */\n lease(env: Env): Required<StoreLease>;\n /** The bundle compiled into this code. */\n bundle(): BundleIR;\n /** The outlets one run is wired to (the store is the run's). */\n runParts(env: Env, store: JourneyStore, params: JourneyParams): JourneyRunParts;\n /** The HTTP API over one store: the ingest API plus tracking, the sweep, the bundle and previews. */\n handle(request: Request, env: Env, store: JourneyStore): Promise<Response>;\n /** The scheduled sweep (`sweepSegments`) on a store of its own. */\n sweep(env: Env): Promise<SweepResult>;\n /**\n * Make the bundle compiled into `version` live, once per process, only over an older live\n * version (the store records it). Undefined when there is nothing to do; a failure is logged\n * and retried a minute later.\n */\n applyOwnBundle(env: Env, version: BundleVersion | undefined): Promise<void> | undefined;\n}\n\n/** What the platform adapter itself contributes. */\nexport interface JourneyPlatform {\n /** An HTTP/2 fetch for APNs, where the runtime's own fetch is HTTP/1.1 (Node). */\n apnsFetch?: typeof fetch;\n}\n\nfunction sendsEmail(nodes: readonly NodeIR[]): boolean {\n return nodes.some((node) => {\n switch (node.type) {\n case 'message':\n return node.channel === 'email';\n case 'wait_until':\n return Array.isArray(node.onTimeout) && sendsEmail(node.onTimeout);\n case 'branch':\n return (\n node.cases.some((branch) => sendsEmail(branch.flow)) || sendsEmail(node.otherwise ?? [])\n );\n case 'cohort':\n return node.arms.some((arm) => sendsEmail(arm.flow));\n default:\n return false;\n }\n });\n}\n\nconst describeIssue = (issue: { path: PropertyKey[]; message: string }): string =>\n `${issue.path.join('.') || '(root)'}: ${issue.message}`;\n\n/** An event the host emits itself, checked against the contract like any ingest. */\nfunction checkedPayload(\n contract: Contract | undefined,\n event: string,\n payload: Record<string, unknown>\n): Record<string, unknown> {\n if (contract === undefined) return payload;\n const verdict = checkEvent(contract, event, payload);\n if (!verdict.ok) throw new ContractValidationError(event, verdict.issues);\n return verdict.data;\n}\n\nexport function createJourneyHost<Env extends JourneyEnv>(\n options: JourneyHostOptions<Env>,\n platform: JourneyPlatform = {}\n): JourneyHost<Env> {\n const { config } = options;\n const emailIds = indexTemplates('email', config.templates?.email);\n // A config still on `emails` (its sender would be silently dropped) fails here, not at a send\n assertEmailConfig(config, emailIds.values());\n let compiled: BundleIR | undefined;\n const bundle = () => (compiled ??= compileConfig(config));\n // A journey that emails with nowhere to send fails here too, not at its first email\n const emailing = bundle().workflows.filter((workflow) => sendsEmail(workflow.flow));\n if (options.email === undefined && options.messages === undefined && emailing.length > 0) {\n throw new TypeError(\n `workflows ${emailing.map((w) => `'${w.id}'`).join(', ')} send email, but the host has no ` +\n '`email` transport: pass `email: (env) => cloudflareEmailTransport(...)` or `sesEmailTransport(...)`'\n );\n }\n // Parsed once per env object: the runtime hands the same one to every request of an isolate\n const checked = new WeakMap<object, Env>();\n const checkEnv = (env: Env): Env => {\n if (options.env === undefined) return env;\n const cached = checked.get(env);\n if (cached !== undefined) return cached;\n const result = z.safeParse(options.env, env);\n // Names and reasons only: the values are secrets\n if (!result.success) throw new EnvValidationError(result.error.issues.map(describeIssue));\n const merged: Env = { ...env, ...result.data };\n checked.set(env, merged);\n return merged;\n };\n const emails = config.templates?.email;\n const database = { schema: config.database?.schema ?? DEFAULT_SCHEMA };\n // In fast-forward, segment windows scale with the journeys reading them (time-scale.ts)\n const lease = (env: Env): Required<StoreLease> => {\n const opened = options.store(env, database);\n const noop = async () => {};\n const factor = runtimeFromConfig(config, env).timeScale;\n return 'store' in opened\n ? { store: scaleSegmentWindows(opened.store, factor), close: opened.close ?? noop }\n : { store: scaleSegmentWindows(opened, factor), close: noop };\n };\n\n // The provider clients (APNs JWT, FCM access token) live with the host, not with a run: a\n // run's env is a fresh object on every Lambda invocation, so nothing keyed on it would be reused\n const providers = createProviderCache();\n const messages = (env: Env, store: JourneyStore): MessageSender => {\n if (options.messages !== undefined) return options.messages(env, store);\n return channelsFromConfig(config, env, {\n store,\n email: options.email?.(env),\n apnsFetch: platform.apnsFetch,\n providers,\n });\n };\n\n const observer = (env: Env, store: JourneyStore): JourneyObserver => {\n if (options.observer !== undefined) return options.observer(env, store);\n return reportingObserver(store.reporting, env.WORKFLOW_ORIGIN);\n };\n\n const runParts = (env: Env, store: JourneyStore, params: JourneyParams): JourneyRunParts => ({\n store,\n env,\n runtime:\n options.runtime === undefined ? runtimeFromConfig(config, env) : options.runtime(env, params),\n messages: messages(env, store),\n // An action ref is a registered action structurally: the same object carries name, code and hash\n actions: new RegistryActionInvoker(config.actions ?? []),\n contract: config.contract,\n validator: registryValidator(config.templates ?? {}),\n observer: observer(env, store),\n });\n\n const preview = emails === undefined ? undefined : registryRenderer(emails, config.email);\n\n /**\n * The recipients' routes (`/t/*`): a push tapped open is recorded as the config's\n * `webPush.openedEvent` — keyed by the copy, so a second tap of the same notification is the\n * same event — and an unsubscribe wakes the waits reading email reachability (`reachable\n * ('email')` subscribes to the email property's channel), so an `exitWhen:\n * not(reachable('email'))` takes effect without waiting for a timeout.\n */\n const tracking = (env: Env, store: JourneyStore): TrackingOptions => {\n const opened = config.webPush?.openedEvent;\n return {\n product: config.title,\n quickMs: config.email?.tracking?.quickClickMs,\n pushOpened:\n opened === undefined\n ? undefined\n : async ({ messageId, userId, at }) => {\n const payload = checkedPayload(config.contract, opened, { message_id: messageId });\n const input = {\n id: `${opened}:${messageId}`,\n userId,\n event: opened,\n payload,\n ts: at,\n };\n await ingestEvent(env, input, store);\n },\n onUnsubscribed: async ({ userId }) => {\n const email = profileUpdatedEvent(RECIPIENT_PROPERTY.email ?? 'email');\n await wakeSubscribers(env, store, userId, [email]);\n },\n };\n };\n\n const handle = (request: Request, env: Env, store: JourneyStore): Promise<Response> =>\n serve(request, {\n env,\n store,\n validation: { contract: config.contract, mode: options.contractMode },\n authorize: options.authorize as Authorize<JourneyEnv> | undefined,\n host: {\n bundle,\n preview:\n preview === undefined ? undefined : { has: (id) => emailIds.has(id), render: preview },\n tracking: tracking(env, store),\n sweep: () => sweepSegments(env, store),\n },\n });\n\n const sweep = async (env: Env) => {\n const { store, close } = lease(env);\n try {\n return await sweepSegments(env, store);\n } finally {\n await close();\n }\n };\n\n // The bundle compiled into this version goes live on its own, once per process: the first\n // request or scheduled run reads which version's bundle is live (one row) and applies this one\n // only over an older version, so one still serving during a rollout can never put its routes\n // back. A failure (say the migrations have not run) is logged and retried a minute later. Only\n // the request that starts the check gets its promise back: a request context cannot wait on\n // another's I/O.\n let applying: Promise<void> | undefined;\n let retryAt = 0;\n const applyOwnBundle = (env: Env, version: BundleVersion | undefined) => {\n if (version === undefined || version.id === '' || Number.isNaN(version.createdAt)) {\n return undefined;\n }\n if (applying !== undefined || Date.now() < retryAt) return undefined;\n applying = (async () => {\n const { store, close } = lease(env);\n try {\n const live = await store.liveBundleVersion();\n if (live !== undefined && (live.id === version.id || live.createdAt >= version.createdAt))\n return;\n const result = await deployBundle(env, bundle(), store, version);\n if (result.applied) console.log(`[workflow] version ${version.id} is live`);\n } finally {\n await close();\n }\n })().catch((error: unknown) => {\n applying = undefined;\n retryAt = Date.now() + 60_000;\n console.error(`[workflow] could not make version ${version.id} live`, error);\n });\n return applying;\n };\n\n return { config, checkEnv, lease, bundle, runParts, handle, sweep, applyOwnBundle };\n}\n"],"mappings":"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"}
@@ -1,11 +1,11 @@
1
1
  import { BundleIR } from "../ir.cjs";
2
2
  import { JourneyObserver, MessageSender } from "../engine/ports.cjs";
3
- import { JourneyEnv, JourneyParams } from "./platform.cjs";
4
3
  import { BundleVersion, JourneyStore, StoreLease } from "../store/index.cjs";
5
4
  import { JourneyRuntimeOptions } from "../runtime.cjs";
6
5
  import { JourneyConfig } from "../config.cjs";
7
6
  import { EmailTransport } from "../email-sender.cjs";
8
7
  import { Authorize } from "./auth.cjs";
8
+ import { JourneyEnv, JourneyParams } from "./platform.cjs";
9
9
  import { JourneyRunParts } from "./execute.cjs";
10
10
  import { SweepResult } from "./ingest.cjs";
11
11
  import * as core from "zod/v4/core";
@@ -27,12 +27,12 @@ export interface JourneyHostOptions<Env extends JourneyEnv> {
27
27
  config: JourneyConfig;
28
28
  /**
29
29
  * The data plane, opened per request and per workflow run. Return a `StoreLease` when the
30
- * client must be released afterwards (a Postgres connection); defaults to D1 from the env's
31
- * binding. `database` is the config's (`schema` defaulting to `workflow`), for Postgres:
30
+ * client must be released afterwards (a Postgres connection). `database` is the config's
31
+ * (`schema` defaulting to `workflow`):
32
32
  *
33
33
  * store: (env, database) => postgresJsStore(env.DATABASE_URL, database)
34
34
  */
35
- store?: (env: Env, database: {
35
+ store: (env: Env, database: {
36
36
  schema: string;
37
37
  }) => JourneyStore | StoreLease;
38
38
  /**
@@ -44,8 +44,8 @@ export interface JourneyHostOptions<Env extends JourneyEnv> {
44
44
  /**
45
45
  * Reporting hooks: node arrivals, every copy sent (with the tracking it should carry — click
46
46
  * links and an open pixel for email, payload data for a push), and how each instance ended.
47
- * By default a store that keeps reporting (Postgres) records them itself, and the host serves
48
- * the email tracking routes (`/t/...`, on `WORKFLOW_ORIGIN`); override to report elsewhere.
47
+ * By default the store records them itself, and the host serves the email tracking routes
48
+ * (`/t/...`, on `WORKFLOW_ORIGIN`); override to report elsewhere.
49
49
  */
50
50
  observer?: (env: Env, store: JourneyStore) => JourneyObserver;
51
51
  /** Message outlet override (tests, a custom gateway): replaces every channel, `email` included. */
@@ -1 +1 @@
1
- {"version":3,"file":"journey-host.d.cts","names":[],"sources":["../../src/host/journey-host.ts"],"mappings":";;;;;;;;;;;;;;;;;;iBAwCiB,mBAAmB,YAAY;;;;;;;;EAQ9C,QAAQ;;;;;;;;EAQR,SAAS,KAAK,KAAK;IAAY;QAAqB,eAAe;;;;;;EAMnE,SAAS,KAAK,QAAQ;;;;;;;EAOtB,YAAY,KAAK,KAAK,OAAO,iBAAiB;;EAE9C,YAAY,KAAK,KAAK,OAAO,iBAAiB;;EAE9C,WAAW,KAAK,KAAK,QAAQ,kBAAkB;;;;;;;EAO/C,MAAM,KAAK;;EAEX;;;;;;;;;;;EAWA,YAAY,UAAU;;;qBAIX,2BAA2B;WAEjB;WADH;EAClB,YAAqB;;iBAKN,YAAY,YAAY;EACvC,QAAQ;;EAER,SAAS,KAAK,MAAM;;EAEpB,MAAM,KAAK,MAAM,SAAS;;EAE1B,UAAU;;EAEV,SAAS,KAAK,KAAK,OAAO,cAAc,QAAQ,gBAAgB;;EAEhE,OAAO,SAAS,SAAS,KAAK,KAAK,OAAO,eAAe,QAAQ;;EAEjE,MAAM,KAAK,MAAM,QAAQ;;;;;;EAMzB,eAAe,KAAK,KAAK,SAAS,4BAA4B;;;iBAI/C;;EAEf,mBAAmB;;wBAqCL,kBAAkB,YAAY,YAC5C,SAAS,mBAAmB,MAC5B,WAAU,kBACT,YAAY"}
1
+ {"version":3,"file":"journey-host.d.cts","names":[],"sources":["../../src/host/journey-host.ts"],"mappings":";;;;;;;;;;;;;;;;;;iBAuCiB,mBAAmB,YAAY;;;;;;;;EAQ9C,QAAQ;;;;;;;;EAQR,QAAQ,KAAK,KAAK;IAAY;QAAqB,eAAe;;;;;;EAMlE,SAAS,KAAK,QAAQ;;;;;;;EAOtB,YAAY,KAAK,KAAK,OAAO,iBAAiB;;EAE9C,YAAY,KAAK,KAAK,OAAO,iBAAiB;;EAE9C,WAAW,KAAK,KAAK,QAAQ,kBAAkB;;;;;;;EAO/C,MAAM,KAAK;;EAEX;;;;;;;;;;;EAWA,YAAY,UAAU;;;qBAIX,2BAA2B;WAEjB;WADH;EAClB,YAAqB;;iBAKN,YAAY,YAAY;EACvC,QAAQ;;EAER,SAAS,KAAK,MAAM;;EAEpB,MAAM,KAAK,MAAM,SAAS;;EAE1B,UAAU;;EAEV,SAAS,KAAK,KAAK,OAAO,cAAc,QAAQ,gBAAgB;;EAEhE,OAAO,SAAS,SAAS,KAAK,KAAK,OAAO,eAAe,QAAQ;;EAEjE,MAAM,KAAK,MAAM,QAAQ;;;;;;EAMzB,eAAe,KAAK,KAAK,SAAS,4BAA4B;;;iBAI/C;;EAEf,mBAAmB;;wBAqCL,kBAAkB,YAAY,YAC5C,SAAS,mBAAmB,MAC5B,WAAU,kBACT,YAAY"}
@@ -1,11 +1,11 @@
1
1
  import { BundleIR } from "../ir.mjs";
2
2
  import { JourneyObserver, MessageSender } from "../engine/ports.mjs";
3
- import { JourneyEnv, JourneyParams } from "./platform.mjs";
4
3
  import { BundleVersion, JourneyStore, StoreLease } from "../store/index.mjs";
5
4
  import { JourneyRuntimeOptions } from "../runtime.mjs";
6
5
  import { JourneyConfig } from "../config.mjs";
7
6
  import { EmailTransport } from "../email-sender.mjs";
8
7
  import { Authorize } from "./auth.mjs";
8
+ import { JourneyEnv, JourneyParams } from "./platform.mjs";
9
9
  import { JourneyRunParts } from "./execute.mjs";
10
10
  import { SweepResult } from "./ingest.mjs";
11
11
  import * as core from "zod/v4/core";
@@ -27,12 +27,12 @@ export interface JourneyHostOptions<Env extends JourneyEnv> {
27
27
  config: JourneyConfig;
28
28
  /**
29
29
  * The data plane, opened per request and per workflow run. Return a `StoreLease` when the
30
- * client must be released afterwards (a Postgres connection); defaults to D1 from the env's
31
- * binding. `database` is the config's (`schema` defaulting to `workflow`), for Postgres:
30
+ * client must be released afterwards (a Postgres connection). `database` is the config's
31
+ * (`schema` defaulting to `workflow`):
32
32
  *
33
33
  * store: (env, database) => postgresJsStore(env.DATABASE_URL, database)
34
34
  */
35
- store?: (env: Env, database: {
35
+ store: (env: Env, database: {
36
36
  schema: string;
37
37
  }) => JourneyStore | StoreLease;
38
38
  /**
@@ -44,8 +44,8 @@ export interface JourneyHostOptions<Env extends JourneyEnv> {
44
44
  /**
45
45
  * Reporting hooks: node arrivals, every copy sent (with the tracking it should carry — click
46
46
  * links and an open pixel for email, payload data for a push), and how each instance ended.
47
- * By default a store that keeps reporting (Postgres) records them itself, and the host serves
48
- * the email tracking routes (`/t/...`, on `WORKFLOW_ORIGIN`); override to report elsewhere.
47
+ * By default the store records them itself, and the host serves the email tracking routes
48
+ * (`/t/...`, on `WORKFLOW_ORIGIN`); override to report elsewhere.
49
49
  */
50
50
  observer?: (env: Env, store: JourneyStore) => JourneyObserver;
51
51
  /** Message outlet override (tests, a custom gateway): replaces every channel, `email` included. */
@@ -1 +1 @@
1
- {"version":3,"file":"journey-host.d.mts","names":[],"sources":["../../src/host/journey-host.ts"],"mappings":";;;;;;;;;;;;;;;;;;iBAwCiB,mBAAmB,YAAY;;;;;;;;EAQ9C,QAAQ;;;;;;;;EAQR,SAAS,KAAK,KAAK;IAAY;QAAqB,eAAe;;;;;;EAMnE,SAAS,KAAK,QAAQ;;;;;;;EAOtB,YAAY,KAAK,KAAK,OAAO,iBAAiB;;EAE9C,YAAY,KAAK,KAAK,OAAO,iBAAiB;;EAE9C,WAAW,KAAK,KAAK,QAAQ,kBAAkB;;;;;;;EAO/C,MAAM,KAAK;;EAEX;;;;;;;;;;;EAWA,YAAY,UAAU;;;qBAIX,2BAA2B;WAEjB;WADH;EAClB,YAAqB;;iBAKN,YAAY,YAAY;EACvC,QAAQ;;EAER,SAAS,KAAK,MAAM;;EAEpB,MAAM,KAAK,MAAM,SAAS;;EAE1B,UAAU;;EAEV,SAAS,KAAK,KAAK,OAAO,cAAc,QAAQ,gBAAgB;;EAEhE,OAAO,SAAS,SAAS,KAAK,KAAK,OAAO,eAAe,QAAQ;;EAEjE,MAAM,KAAK,MAAM,QAAQ;;;;;;EAMzB,eAAe,KAAK,KAAK,SAAS,4BAA4B;;;iBAI/C;;EAEf,mBAAmB;;wBAqCL,kBAAkB,YAAY,YAC5C,SAAS,mBAAmB,MAC5B,WAAU,kBACT,YAAY"}
1
+ {"version":3,"file":"journey-host.d.mts","names":[],"sources":["../../src/host/journey-host.ts"],"mappings":";;;;;;;;;;;;;;;;;;iBAuCiB,mBAAmB,YAAY;;;;;;;;EAQ9C,QAAQ;;;;;;;;EAQR,QAAQ,KAAK,KAAK;IAAY;QAAqB,eAAe;;;;;;EAMlE,SAAS,KAAK,QAAQ;;;;;;;EAOtB,YAAY,KAAK,KAAK,OAAO,iBAAiB;;EAE9C,YAAY,KAAK,KAAK,OAAO,iBAAiB;;EAE9C,WAAW,KAAK,KAAK,QAAQ,kBAAkB;;;;;;;EAO/C,MAAM,KAAK;;EAEX;;;;;;;;;;;EAWA,YAAY,UAAU;;;qBAIX,2BAA2B;WAEjB;WADH;EAClB,YAAqB;;iBAKN,YAAY,YAAY;EACvC,QAAQ;;EAER,SAAS,KAAK,MAAM;;EAEpB,MAAM,KAAK,MAAM,SAAS;;EAE1B,UAAU;;EAEV,SAAS,KAAK,KAAK,OAAO,cAAc,QAAQ,gBAAgB;;EAEhE,OAAO,SAAS,SAAS,KAAK,KAAK,OAAO,eAAe,QAAQ;;EAEjE,MAAM,KAAK,MAAM,QAAQ;;;;;;EAMzB,eAAe,KAAK,KAAK,SAAS,4BAA4B;;;iBAI/C;;EAEf,mBAAmB;;wBAqCL,kBAAkB,YAAY,YAC5C,SAAS,mBAAmB,MAC5B,WAAU,kBACT,YAAY"}
@@ -7,7 +7,6 @@ import { registryRenderer } from "../react-email.mjs";
7
7
  import { RECIPIENT_PROPERTY } from "../engine/recipient.mjs";
8
8
  import { profileUpdatedEvent } from "../engine/condition.mjs";
9
9
  import { scaleSegmentWindows } from "../engine/time-scale.mjs";
10
- import { d1Store } from "../cloudflare/d1-env.mjs";
11
10
  import { deployBundle, ingestEvent, sweepSegments, wakeSubscribers } from "./ingest.mjs";
12
11
  import { RegistryActionInvoker } from "../engine/actions.mjs";
13
12
  import { reportingObserver } from "../reporting/observer.mjs";
@@ -68,7 +67,7 @@ function createJourneyHost(options, platform = {}) {
68
67
  const emails = config.templates?.email;
69
68
  const database = { schema: config.database?.schema ?? "workflow" };
70
69
  const lease = (env) => {
71
- const opened = options.store === void 0 ? d1Store(env) : options.store(env, database);
70
+ const opened = options.store(env, database);
72
71
  const noop = async () => {};
73
72
  const factor = runtimeFromConfig(config, env).timeScale;
74
73
  return "store" in opened ? {
@@ -91,7 +90,7 @@ function createJourneyHost(options, platform = {}) {
91
90
  };
92
91
  const observer = (env, store) => {
93
92
  if (options.observer !== void 0) return options.observer(env, store);
94
- return store.reporting === void 0 ? void 0 : reportingObserver(store.reporting, env.WORKFLOW_ORIGIN);
93
+ return reportingObserver(store.reporting, env.WORKFLOW_ORIGIN);
95
94
  };
96
95
  const runParts = (env, store, params) => ({
97
96
  store,
@@ -167,7 +166,6 @@ function createJourneyHost(options, platform = {}) {
167
166
  applying = (async () => {
168
167
  const { store, close } = lease(env);
169
168
  try {
170
- if (store.liveBundleVersion === void 0) return;
171
169
  const live = await store.liveBundleVersion();
172
170
  if (live !== void 0 && (live.id === version.id || live.createdAt >= version.createdAt)) return;
173
171
  if ((await deployBundle(env, bundle(), store, version)).applied) console.log(`[workflow] version ${version.id} is live`);
@@ -1 +1 @@
1
- {"version":3,"file":"journey-host.mjs","names":[],"sources":["../../src/host/journey-host.ts"],"sourcesContent":["import * as z from 'zod/mini';\nimport type * as core from 'zod/v4/core';\nimport { d1Store } from '../cloudflare/d1-env';\nimport { type JourneyConfig, compileConfig } from '../config';\nimport { type Contract, ContractValidationError, checkEvent } from '../contract';\nimport { assertEmailConfig } from '../email';\nimport type { EmailTransport } from '../email-sender';\nimport { RegistryActionInvoker } from '../engine/actions';\nimport { profileUpdatedEvent } from '../engine/condition';\nimport type { JourneyObserver, MessageSender } from '../engine/ports';\nimport { RECIPIENT_PROPERTY } from '../engine/recipient';\nimport { scaleSegmentWindows } from '../engine/time-scale';\nimport type { BundleIR, NodeIR } from '../ir';\nimport { registryRenderer } from '../react-email';\nimport { reportingObserver } from '../reporting/observer';\nimport type { TrackingOptions } from '../reporting/tracking';\nimport type { JourneyRuntimeOptions } from '../runtime';\nimport type { BundleVersion, JourneyStore, StoreLease } from '../store/index';\nimport { DEFAULT_SCHEMA } from '../store/tables';\nimport { indexTemplates, registryValidator } from '../templates';\nimport type { Authorize } from './auth';\nimport { channelsFromConfig, createProviderCache, runtimeFromConfig } from './channels';\nimport type { JourneyRunParts } from './execute';\nimport { serve } from './http';\nimport {\n type SweepResult,\n deployBundle,\n ingestEvent,\n sweepSegments,\n wakeSubscribers,\n} from './ingest';\nimport type { JourneyEnv, JourneyParams } from './platform';\n\n/**\n * What a journey host is, apart from the runtime it runs on: the validated environment, the\n * store lease, the outlets a run is wired to, the HTTP API (`http.ts`, with the host's parts\n * plugged in) and the bundle that goes live with the code. `journeyWorker` (Cloudflare) and\n * `journeyLambda` (AWS) are this plus how their platform starts, wakes and stops instances.\n */\n\nexport interface JourneyHostOptions<Env extends JourneyEnv> {\n /**\n * The project's `workflow.config.ts` (`defineConfig`): what the host deploys (`GET /bundle`,\n * `POST /bundle/deploy` serve `compileConfig`), the template registries the senders, the\n * validator and `/preview` render from, the contract every `/events` and `/identify` is\n * checked against, the email defaults, actions, and the runtime / APNs / FCM settings.\n * Secrets come from the env under the names `JourneyEnv` documents.\n */\n config: JourneyConfig;\n /**\n * The data plane, opened per request and per workflow run. Return a `StoreLease` when the\n * client must be released afterwards (a Postgres connection); defaults to D1 from the env's\n * binding. `database` is the config's (`schema` defaulting to `workflow`), for Postgres:\n *\n * store: (env, database) => postgresJsStore(env.DATABASE_URL, database)\n */\n store?: (env: Env, database: { schema: string }) => JourneyStore | StoreLease;\n /**\n * Where journey emails go: a transport built from the validated env — `sesEmailTransport(...)`,\n * `cloudflareEmailTransport(...)`, or your own. Which one is independent of where the workflow\n * runs. Without it the email channel fails its sends, saying so.\n */\n email?: (env: Env) => EmailTransport;\n /**\n * Reporting hooks: node arrivals, every copy sent (with the tracking it should carry — click\n * links and an open pixel for email, payload data for a push), and how each instance ended.\n * By default a store that keeps reporting (Postgres) records them itself, and the host serves\n * the email tracking routes (`/t/...`, on `WORKFLOW_ORIGIN`); override to report elsewhere.\n */\n observer?: (env: Env, store: JourneyStore) => JourneyObserver;\n /** Message outlet override (tests, a custom gateway): replaces every channel, `email` included. */\n messages?: (env: Env, store: JourneyStore) => MessageSender;\n /** Per-instance runtime options override (a tester in fast-forward); by default `config.runtime` for `ENVIRONMENT`. */\n runtime?: (env: Env, params: JourneyParams) => JourneyRuntimeOptions;\n /**\n * The host's environment, as a zod schema over its vars and secrets (bindings pass through\n * untouched): checked the first time the host runs, so a missing or malformed variable fails\n * there — every problem listed — not at the send that needs it. Webhook templates read their\n * URL from the parsed values: `webhook: (env: Env) => env.WEBHOOK_DISCORD_ALERTS`.\n */\n env?: core.$ZodType<object>;\n /** 'enforce' (default) answers an event or patch the contract refuses with a 400; 'report' only logs it. */\n contractMode?: 'enforce' | 'report';\n /**\n * Who may call the API routes, in whatever form the host chooses: an HMAC over the body, a\n * JWT, a header its proxy adds. Receives a copy of the request and the env; true lets it\n * through, false answers 401, a throw answers 500. The tracking routes and `/health` stay\n * public. Without it, the env's `API_TOKEN` as a bearer (`bearerMatches`), which production\n * then requires.\n *\n * authorize: async (request, env) =>\n * timingSafeEqual(request.headers.get('x-signature') ?? '', await hmac(env.SECRET, await request.text()))\n */\n authorize?: Authorize<Env>;\n}\n\n/** The host's environment failed its schema (`env` option). */\nexport class EnvValidationError extends Error {\n override readonly name = 'EnvValidationError';\n constructor(readonly issues: readonly string[]) {\n super(`the host's environment is invalid:\\n ${issues.join('\\n ')}`);\n }\n}\n\nexport interface JourneyHost<Env extends JourneyEnv> {\n config: JourneyConfig;\n /** The env, parsed by the schema once per env object (throws EnvValidationError). */\n checkEnv(env: Env): Env;\n /** A store for one request or run, its segment windows scaled with the runtime. */\n lease(env: Env): Required<StoreLease>;\n /** The bundle compiled into this code. */\n bundle(): BundleIR;\n /** The outlets one run is wired to (the store is the run's). */\n runParts(env: Env, store: JourneyStore, params: JourneyParams): JourneyRunParts;\n /** The HTTP API over one store: the ingest API plus tracking, the sweep, the bundle and previews. */\n handle(request: Request, env: Env, store: JourneyStore): Promise<Response>;\n /** The scheduled sweep (`sweepSegments`) on a store of its own. */\n sweep(env: Env): Promise<SweepResult>;\n /**\n * Make the bundle compiled into `version` live, once per process, only over an older live\n * version (the store records it). Undefined when there is nothing to do; a failure is logged\n * and retried a minute later.\n */\n applyOwnBundle(env: Env, version: BundleVersion | undefined): Promise<void> | undefined;\n}\n\n/** What the platform adapter itself contributes. */\nexport interface JourneyPlatform {\n /** An HTTP/2 fetch for APNs, where the runtime's own fetch is HTTP/1.1 (Node). */\n apnsFetch?: typeof fetch;\n}\n\nfunction sendsEmail(nodes: readonly NodeIR[]): boolean {\n return nodes.some((node) => {\n switch (node.type) {\n case 'message':\n return node.channel === 'email';\n case 'wait_until':\n return Array.isArray(node.onTimeout) && sendsEmail(node.onTimeout);\n case 'branch':\n return (\n node.cases.some((branch) => sendsEmail(branch.flow)) || sendsEmail(node.otherwise ?? [])\n );\n case 'cohort':\n return node.arms.some((arm) => sendsEmail(arm.flow));\n default:\n return false;\n }\n });\n}\n\nconst describeIssue = (issue: { path: PropertyKey[]; message: string }): string =>\n `${issue.path.join('.') || '(root)'}: ${issue.message}`;\n\n/** An event the host emits itself, checked against the contract like any ingest. */\nfunction checkedPayload(\n contract: Contract | undefined,\n event: string,\n payload: Record<string, unknown>\n): Record<string, unknown> {\n if (contract === undefined) return payload;\n const verdict = checkEvent(contract, event, payload);\n if (!verdict.ok) throw new ContractValidationError(event, verdict.issues);\n return verdict.data;\n}\n\nexport function createJourneyHost<Env extends JourneyEnv>(\n options: JourneyHostOptions<Env>,\n platform: JourneyPlatform = {}\n): JourneyHost<Env> {\n const { config } = options;\n const emailIds = indexTemplates('email', config.templates?.email);\n // A config still on `emails` (its sender would be silently dropped) fails here, not at a send\n assertEmailConfig(config, emailIds.values());\n let compiled: BundleIR | undefined;\n const bundle = () => (compiled ??= compileConfig(config));\n // A journey that emails with nowhere to send fails here too, not at its first email\n const emailing = bundle().workflows.filter((workflow) => sendsEmail(workflow.flow));\n if (options.email === undefined && options.messages === undefined && emailing.length > 0) {\n throw new TypeError(\n `workflows ${emailing.map((w) => `'${w.id}'`).join(', ')} send email, but the host has no ` +\n '`email` transport: pass `email: (env) => cloudflareEmailTransport(...)` or `sesEmailTransport(...)`'\n );\n }\n // Parsed once per env object: the runtime hands the same one to every request of an isolate\n const checked = new WeakMap<object, Env>();\n const checkEnv = (env: Env): Env => {\n if (options.env === undefined) return env;\n const cached = checked.get(env);\n if (cached !== undefined) return cached;\n const result = z.safeParse(options.env, env);\n // Names and reasons only: the values are secrets\n if (!result.success) throw new EnvValidationError(result.error.issues.map(describeIssue));\n const merged: Env = { ...env, ...result.data };\n checked.set(env, merged);\n return merged;\n };\n const emails = config.templates?.email;\n const database = { schema: config.database?.schema ?? DEFAULT_SCHEMA };\n // In fast-forward, segment windows scale with the journeys reading them (time-scale.ts)\n const lease = (env: Env): Required<StoreLease> => {\n const opened = options.store === undefined ? d1Store(env) : options.store(env, database);\n const noop = async () => {};\n const factor = runtimeFromConfig(config, env).timeScale;\n return 'store' in opened\n ? { store: scaleSegmentWindows(opened.store, factor), close: opened.close ?? noop }\n : { store: scaleSegmentWindows(opened, factor), close: noop };\n };\n\n // The provider clients (APNs JWT, FCM access token) live with the host, not with a run: a\n // run's env is a fresh object on every Lambda invocation, so nothing keyed on it would be reused\n const providers = createProviderCache();\n const messages = (env: Env, store: JourneyStore): MessageSender => {\n if (options.messages !== undefined) return options.messages(env, store);\n return channelsFromConfig(config, env, {\n store,\n email: options.email?.(env),\n apnsFetch: platform.apnsFetch,\n providers,\n });\n };\n\n const observer = (env: Env, store: JourneyStore): JourneyObserver | undefined => {\n if (options.observer !== undefined) return options.observer(env, store);\n return store.reporting === undefined\n ? undefined\n : reportingObserver(store.reporting, env.WORKFLOW_ORIGIN);\n };\n\n const runParts = (env: Env, store: JourneyStore, params: JourneyParams): JourneyRunParts => ({\n store,\n env,\n runtime:\n options.runtime === undefined ? runtimeFromConfig(config, env) : options.runtime(env, params),\n messages: messages(env, store),\n // An action ref is a registered action structurally: the same object carries name, code and hash\n actions: new RegistryActionInvoker(config.actions ?? []),\n contract: config.contract,\n validator: registryValidator(config.templates ?? {}),\n observer: observer(env, store),\n });\n\n const preview = emails === undefined ? undefined : registryRenderer(emails, config.email);\n\n /**\n * The recipients' routes (`/t/*`): a push tapped open is recorded as the config's\n * `webPush.openedEvent` — keyed by the copy, so a second tap of the same notification is the\n * same event — and an unsubscribe wakes the waits reading email reachability (`reachable\n * ('email')` subscribes to the email property's channel), so an `exitWhen:\n * not(reachable('email'))` takes effect without waiting for a timeout.\n */\n const tracking = (env: Env, store: JourneyStore): TrackingOptions => {\n const opened = config.webPush?.openedEvent;\n return {\n product: config.title,\n quickMs: config.email?.tracking?.quickClickMs,\n pushOpened:\n opened === undefined\n ? undefined\n : async ({ messageId, userId, at }) => {\n const payload = checkedPayload(config.contract, opened, { message_id: messageId });\n const input = {\n id: `${opened}:${messageId}`,\n userId,\n event: opened,\n payload,\n ts: at,\n };\n await ingestEvent(env, input, store);\n },\n onUnsubscribed: async ({ userId }) => {\n const email = profileUpdatedEvent(RECIPIENT_PROPERTY.email ?? 'email');\n await wakeSubscribers(env, store, userId, [email]);\n },\n };\n };\n\n const handle = (request: Request, env: Env, store: JourneyStore): Promise<Response> =>\n serve(request, {\n env,\n store,\n validation: { contract: config.contract, mode: options.contractMode },\n authorize: options.authorize as Authorize<JourneyEnv> | undefined,\n host: {\n bundle,\n preview:\n preview === undefined ? undefined : { has: (id) => emailIds.has(id), render: preview },\n tracking: tracking(env, store),\n sweep: () => sweepSegments(env, store),\n },\n });\n\n const sweep = async (env: Env) => {\n const { store, close } = lease(env);\n try {\n return await sweepSegments(env, store);\n } finally {\n await close();\n }\n };\n\n // The bundle compiled into this version goes live on its own, once per process: the first\n // request or scheduled run reads which version's bundle is live (one row) and applies this one\n // only over an older version, so one still serving during a rollout can never put its routes\n // back. A failure (say the migrations have not run) is logged and retried a minute later. Only\n // the request that starts the check gets its promise back: a request context cannot wait on\n // another's I/O.\n let applying: Promise<void> | undefined;\n let retryAt = 0;\n const applyOwnBundle = (env: Env, version: BundleVersion | undefined) => {\n if (version === undefined || version.id === '' || Number.isNaN(version.createdAt)) {\n return undefined;\n }\n if (applying !== undefined || Date.now() < retryAt) return undefined;\n applying = (async () => {\n const { store, close } = lease(env);\n try {\n if (store.liveBundleVersion === undefined) return; // D1: POST /bundle/deploy\n const live = await store.liveBundleVersion();\n if (live !== undefined && (live.id === version.id || live.createdAt >= version.createdAt))\n return;\n const result = await deployBundle(env, bundle(), store, version);\n if (result.applied) console.log(`[workflow] version ${version.id} is live`);\n } finally {\n await close();\n }\n })().catch((error: unknown) => {\n applying = undefined;\n retryAt = Date.now() + 60_000;\n console.error(`[workflow] could not make version ${version.id} live`, error);\n });\n return applying;\n };\n\n return { config, checkEnv, lease, bundle, runParts, handle, sweep, applyOwnBundle };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAiGA,IAAa,qBAAb,cAAwC,MAAM;CAEvB;CADrB,OAAyB;CACzB,YAAY,QAAoC;EAC9C,MAAM,yCAAyC,OAAO,KAAK,MAAM,GAAG;EADjD,KAAA,SAAA;CAErB;AACF;AA8BA,SAAS,WAAW,OAAmC;CACrD,OAAO,MAAM,MAAM,SAAS;EAC1B,QAAQ,KAAK,MAAb;GACE,KAAK,WACH,OAAO,KAAK,YAAY;GAC1B,KAAK,cACH,OAAO,MAAM,QAAQ,KAAK,SAAS,KAAK,WAAW,KAAK,SAAS;GACnE,KAAK,UACH,OACE,KAAK,MAAM,MAAM,WAAW,WAAW,OAAO,IAAI,CAAC,KAAK,WAAW,KAAK,aAAa,CAAC,CAAC;GAE3F,KAAK,UACH,OAAO,KAAK,KAAK,MAAM,QAAQ,WAAW,IAAI,IAAI,CAAC;GACrD,SACE,OAAO;EACX;CACF,CAAC;AACH;AAEA,MAAM,iBAAiB,UACrB,GAAG,MAAM,KAAK,KAAK,GAAG,KAAK,SAAS,IAAI,MAAM;;AAGhD,SAAS,eACP,UACA,OACA,SACyB;CACzB,IAAI,aAAa,KAAA,GAAW,OAAO;CACnC,MAAM,UAAU,WAAW,UAAU,OAAO,OAAO;CACnD,IAAI,CAAC,QAAQ,IAAI,MAAM,IAAI,wBAAwB,OAAO,QAAQ,MAAM;CACxE,OAAO,QAAQ;AACjB;AAEA,SAAgB,kBACd,SACA,WAA4B,CAAC,GACX;CAClB,MAAM,EAAE,WAAW;CACnB,MAAM,WAAW,eAAe,SAAS,OAAO,WAAW,KAAK;CAEhE,kBAAkB,QAAQ,SAAS,OAAO,CAAC;CAC3C,IAAI;CACJ,MAAM,eAAgB,aAAa,cAAc,MAAM;CAEvD,MAAM,WAAW,OAAO,CAAC,CAAC,UAAU,QAAQ,aAAa,WAAW,SAAS,IAAI,CAAC;CAClF,IAAI,QAAQ,UAAU,KAAA,KAAa,QAAQ,aAAa,KAAA,KAAa,SAAS,SAAS,GACrF,MAAM,IAAI,UACR,aAAa,SAAS,KAAK,MAAM,IAAI,EAAE,GAAG,EAAE,CAAC,CAAC,KAAK,IAAI,EAAE,2IAE3D;CAGF,MAAM,0BAAU,IAAI,QAAqB;CACzC,MAAM,YAAY,QAAkB;EAClC,IAAI,QAAQ,QAAQ,KAAA,GAAW,OAAO;EACtC,MAAM,SAAS,QAAQ,IAAI,GAAG;EAC9B,IAAI,WAAW,KAAA,GAAW,OAAO;EACjC,MAAM,SAAS,EAAE,UAAU,QAAQ,KAAK,GAAG;EAE3C,IAAI,CAAC,OAAO,SAAS,MAAM,IAAI,mBAAmB,OAAO,MAAM,OAAO,IAAI,aAAa,CAAC;EACxF,MAAM,SAAc;GAAE,GAAG;GAAK,GAAG,OAAO;EAAK;EAC7C,QAAQ,IAAI,KAAK,MAAM;EACvB,OAAO;CACT;CACA,MAAM,SAAS,OAAO,WAAW;CACjC,MAAM,WAAW,EAAE,QAAQ,OAAO,UAAU,UAAA,WAAyB;CAErE,MAAM,SAAS,QAAmC;EAChD,MAAM,SAAS,QAAQ,UAAU,KAAA,IAAY,QAAQ,GAAG,IAAI,QAAQ,MAAM,KAAK,QAAQ;EACvF,MAAM,OAAO,YAAY,CAAC;EAC1B,MAAM,SAAS,kBAAkB,QAAQ,GAAG,CAAC,CAAC;EAC9C,OAAO,WAAW,SACd;GAAE,OAAO,oBAAoB,OAAO,OAAO,MAAM;GAAG,OAAO,OAAO,SAAS;EAAK,IAChF;GAAE,OAAO,oBAAoB,QAAQ,MAAM;GAAG,OAAO;EAAK;CAChE;CAIA,MAAM,YAAY,oBAAoB;CACtC,MAAM,YAAY,KAAU,UAAuC;EACjE,IAAI,QAAQ,aAAa,KAAA,GAAW,OAAO,QAAQ,SAAS,KAAK,KAAK;EACtE,OAAO,mBAAmB,QAAQ,KAAK;GACrC;GACA,OAAO,QAAQ,QAAQ,GAAG;GAC1B,WAAW,SAAS;GACpB;EACF,CAAC;CACH;CAEA,MAAM,YAAY,KAAU,UAAqD;EAC/E,IAAI,QAAQ,aAAa,KAAA,GAAW,OAAO,QAAQ,SAAS,KAAK,KAAK;EACtE,OAAO,MAAM,cAAc,KAAA,IACvB,KAAA,IACA,kBAAkB,MAAM,WAAW,IAAI,eAAe;CAC5D;CAEA,MAAM,YAAY,KAAU,OAAqB,YAA4C;EAC3F;EACA;EACA,SACE,QAAQ,YAAY,KAAA,IAAY,kBAAkB,QAAQ,GAAG,IAAI,QAAQ,QAAQ,KAAK,MAAM;EAC9F,UAAU,SAAS,KAAK,KAAK;EAE7B,SAAS,IAAI,sBAAsB,OAAO,WAAW,CAAC,CAAC;EACvD,UAAU,OAAO;EACjB,WAAW,kBAAkB,OAAO,aAAa,CAAC,CAAC;EACnD,UAAU,SAAS,KAAK,KAAK;CAC/B;CAEA,MAAM,UAAU,WAAW,KAAA,IAAY,KAAA,IAAY,iBAAiB,QAAQ,OAAO,KAAK;;;;;;;;CASxF,MAAM,YAAY,KAAU,UAAyC;EACnE,MAAM,SAAS,OAAO,SAAS;EAC/B,OAAO;GACL,SAAS,OAAO;GAChB,SAAS,OAAO,OAAO,UAAU;GACjC,YACE,WAAW,KAAA,IACP,KAAA,IACA,OAAO,EAAE,WAAW,QAAQ,SAAS;IACnC,MAAM,UAAU,eAAe,OAAO,UAAU,QAAQ,EAAE,YAAY,UAAU,CAAC;IACjF,MAAM,QAAQ;KACZ,IAAI,GAAG,OAAO,GAAG;KACjB;KACA,OAAO;KACP;KACA,IAAI;IACN;IACA,MAAM,YAAY,KAAK,OAAO,KAAK;GACrC;GACN,gBAAgB,OAAO,EAAE,aAAa;IACpC,MAAM,QAAQ,oBAAoB,mBAAmB,SAAS,OAAO;IACrE,MAAM,gBAAgB,KAAK,OAAO,QAAQ,CAAC,KAAK,CAAC;GACnD;EACF;CACF;CAEA,MAAM,UAAU,SAAkB,KAAU,UAC1C,MAAM,SAAS;EACb;EACA;EACA,YAAY;GAAE,UAAU,OAAO;GAAU,MAAM,QAAQ;EAAa;EACpE,WAAW,QAAQ;EACnB,MAAM;GACJ;GACA,SACE,YAAY,KAAA,IAAY,KAAA,IAAY;IAAE,MAAM,OAAO,SAAS,IAAI,EAAE;IAAG,QAAQ;GAAQ;GACvF,UAAU,SAAS,KAAK,KAAK;GAC7B,aAAa,cAAc,KAAK,KAAK;EACvC;CACF,CAAC;CAEH,MAAM,QAAQ,OAAO,QAAa;EAChC,MAAM,EAAE,OAAO,UAAU,MAAM,GAAG;EAClC,IAAI;GACF,OAAO,MAAM,cAAc,KAAK,KAAK;EACvC,UAAU;GACR,MAAM,MAAM;EACd;CACF;CAQA,IAAI;CACJ,IAAI,UAAU;CACd,MAAM,kBAAkB,KAAU,YAAuC;EACvE,IAAI,YAAY,KAAA,KAAa,QAAQ,OAAO,MAAM,OAAO,MAAM,QAAQ,SAAS,GAC9E;EAEF,IAAI,aAAa,KAAA,KAAa,KAAK,IAAI,IAAI,SAAS,OAAO,KAAA;EAC3D,YAAY,YAAY;GACtB,MAAM,EAAE,OAAO,UAAU,MAAM,GAAG;GAClC,IAAI;IACF,IAAI,MAAM,sBAAsB,KAAA,GAAW;IAC3C,MAAM,OAAO,MAAM,MAAM,kBAAkB;IAC3C,IAAI,SAAS,KAAA,MAAc,KAAK,OAAO,QAAQ,MAAM,KAAK,aAAa,QAAQ,YAC7E;IAEF,KAAI,MADiB,aAAa,KAAK,OAAO,GAAG,OAAO,OAAO,EAAA,CACpD,SAAS,QAAQ,IAAI,sBAAsB,QAAQ,GAAG,SAAS;GAC5E,UAAU;IACR,MAAM,MAAM;GACd;EACF,EAAA,CAAG,CAAC,CAAC,OAAO,UAAmB;GAC7B,WAAW,KAAA;GACX,UAAU,KAAK,IAAI,IAAI;GACvB,QAAQ,MAAM,qCAAqC,QAAQ,GAAG,QAAQ,KAAK;EAC7E,CAAC;EACD,OAAO;CACT;CAEA,OAAO;EAAE;EAAQ;EAAU;EAAO;EAAQ;EAAU;EAAQ;EAAO;CAAe;AACpF"}
1
+ {"version":3,"file":"journey-host.mjs","names":[],"sources":["../../src/host/journey-host.ts"],"sourcesContent":["import * as z from 'zod/mini';\nimport type * as core from 'zod/v4/core';\nimport { type JourneyConfig, compileConfig } from '../config';\nimport { type Contract, ContractValidationError, checkEvent } from '../contract';\nimport { assertEmailConfig } from '../email';\nimport type { EmailTransport } from '../email-sender';\nimport { RegistryActionInvoker } from '../engine/actions';\nimport { profileUpdatedEvent } from '../engine/condition';\nimport type { JourneyObserver, MessageSender } from '../engine/ports';\nimport { RECIPIENT_PROPERTY } from '../engine/recipient';\nimport { scaleSegmentWindows } from '../engine/time-scale';\nimport type { BundleIR, NodeIR } from '../ir';\nimport { registryRenderer } from '../react-email';\nimport { reportingObserver } from '../reporting/observer';\nimport type { TrackingOptions } from '../reporting/tracking';\nimport type { JourneyRuntimeOptions } from '../runtime';\nimport type { BundleVersion, JourneyStore, StoreLease } from '../store/index';\nimport { DEFAULT_SCHEMA } from '../store/tables';\nimport { indexTemplates, registryValidator } from '../templates';\nimport type { Authorize } from './auth';\nimport { channelsFromConfig, createProviderCache, runtimeFromConfig } from './channels';\nimport type { JourneyRunParts } from './execute';\nimport { serve } from './http';\nimport {\n type SweepResult,\n deployBundle,\n ingestEvent,\n sweepSegments,\n wakeSubscribers,\n} from './ingest';\nimport type { JourneyEnv, JourneyParams } from './platform';\n\n/**\n * What a journey host is, apart from the runtime it runs on: the validated environment, the\n * store lease, the outlets a run is wired to, the HTTP API (`http.ts`, with the host's parts\n * plugged in) and the bundle that goes live with the code. `journeyWorker` (Cloudflare) and\n * `journeyLambda` (AWS) are this plus how their platform starts, wakes and stops instances.\n */\n\nexport interface JourneyHostOptions<Env extends JourneyEnv> {\n /**\n * The project's `workflow.config.ts` (`defineConfig`): what the host deploys (`GET /bundle`,\n * `POST /bundle/deploy` serve `compileConfig`), the template registries the senders, the\n * validator and `/preview` render from, the contract every `/events` and `/identify` is\n * checked against, the email defaults, actions, and the runtime / APNs / FCM settings.\n * Secrets come from the env under the names `JourneyEnv` documents.\n */\n config: JourneyConfig;\n /**\n * The data plane, opened per request and per workflow run. Return a `StoreLease` when the\n * client must be released afterwards (a Postgres connection). `database` is the config's\n * (`schema` defaulting to `workflow`):\n *\n * store: (env, database) => postgresJsStore(env.DATABASE_URL, database)\n */\n store: (env: Env, database: { schema: string }) => JourneyStore | StoreLease;\n /**\n * Where journey emails go: a transport built from the validated env — `sesEmailTransport(...)`,\n * `cloudflareEmailTransport(...)`, or your own. Which one is independent of where the workflow\n * runs. Without it the email channel fails its sends, saying so.\n */\n email?: (env: Env) => EmailTransport;\n /**\n * Reporting hooks: node arrivals, every copy sent (with the tracking it should carry — click\n * links and an open pixel for email, payload data for a push), and how each instance ended.\n * By default the store records them itself, and the host serves the email tracking routes\n * (`/t/...`, on `WORKFLOW_ORIGIN`); override to report elsewhere.\n */\n observer?: (env: Env, store: JourneyStore) => JourneyObserver;\n /** Message outlet override (tests, a custom gateway): replaces every channel, `email` included. */\n messages?: (env: Env, store: JourneyStore) => MessageSender;\n /** Per-instance runtime options override (a tester in fast-forward); by default `config.runtime` for `ENVIRONMENT`. */\n runtime?: (env: Env, params: JourneyParams) => JourneyRuntimeOptions;\n /**\n * The host's environment, as a zod schema over its vars and secrets (bindings pass through\n * untouched): checked the first time the host runs, so a missing or malformed variable fails\n * there — every problem listed — not at the send that needs it. Webhook templates read their\n * URL from the parsed values: `webhook: (env: Env) => env.WEBHOOK_DISCORD_ALERTS`.\n */\n env?: core.$ZodType<object>;\n /** 'enforce' (default) answers an event or patch the contract refuses with a 400; 'report' only logs it. */\n contractMode?: 'enforce' | 'report';\n /**\n * Who may call the API routes, in whatever form the host chooses: an HMAC over the body, a\n * JWT, a header its proxy adds. Receives a copy of the request and the env; true lets it\n * through, false answers 401, a throw answers 500. The tracking routes and `/health` stay\n * public. Without it, the env's `API_TOKEN` as a bearer (`bearerMatches`), which production\n * then requires.\n *\n * authorize: async (request, env) =>\n * timingSafeEqual(request.headers.get('x-signature') ?? '', await hmac(env.SECRET, await request.text()))\n */\n authorize?: Authorize<Env>;\n}\n\n/** The host's environment failed its schema (`env` option). */\nexport class EnvValidationError extends Error {\n override readonly name = 'EnvValidationError';\n constructor(readonly issues: readonly string[]) {\n super(`the host's environment is invalid:\\n ${issues.join('\\n ')}`);\n }\n}\n\nexport interface JourneyHost<Env extends JourneyEnv> {\n config: JourneyConfig;\n /** The env, parsed by the schema once per env object (throws EnvValidationError). */\n checkEnv(env: Env): Env;\n /** A store for one request or run, its segment windows scaled with the runtime. */\n lease(env: Env): Required<StoreLease>;\n /** The bundle compiled into this code. */\n bundle(): BundleIR;\n /** The outlets one run is wired to (the store is the run's). */\n runParts(env: Env, store: JourneyStore, params: JourneyParams): JourneyRunParts;\n /** The HTTP API over one store: the ingest API plus tracking, the sweep, the bundle and previews. */\n handle(request: Request, env: Env, store: JourneyStore): Promise<Response>;\n /** The scheduled sweep (`sweepSegments`) on a store of its own. */\n sweep(env: Env): Promise<SweepResult>;\n /**\n * Make the bundle compiled into `version` live, once per process, only over an older live\n * version (the store records it). Undefined when there is nothing to do; a failure is logged\n * and retried a minute later.\n */\n applyOwnBundle(env: Env, version: BundleVersion | undefined): Promise<void> | undefined;\n}\n\n/** What the platform adapter itself contributes. */\nexport interface JourneyPlatform {\n /** An HTTP/2 fetch for APNs, where the runtime's own fetch is HTTP/1.1 (Node). */\n apnsFetch?: typeof fetch;\n}\n\nfunction sendsEmail(nodes: readonly NodeIR[]): boolean {\n return nodes.some((node) => {\n switch (node.type) {\n case 'message':\n return node.channel === 'email';\n case 'wait_until':\n return Array.isArray(node.onTimeout) && sendsEmail(node.onTimeout);\n case 'branch':\n return (\n node.cases.some((branch) => sendsEmail(branch.flow)) || sendsEmail(node.otherwise ?? [])\n );\n case 'cohort':\n return node.arms.some((arm) => sendsEmail(arm.flow));\n default:\n return false;\n }\n });\n}\n\nconst describeIssue = (issue: { path: PropertyKey[]; message: string }): string =>\n `${issue.path.join('.') || '(root)'}: ${issue.message}`;\n\n/** An event the host emits itself, checked against the contract like any ingest. */\nfunction checkedPayload(\n contract: Contract | undefined,\n event: string,\n payload: Record<string, unknown>\n): Record<string, unknown> {\n if (contract === undefined) return payload;\n const verdict = checkEvent(contract, event, payload);\n if (!verdict.ok) throw new ContractValidationError(event, verdict.issues);\n return verdict.data;\n}\n\nexport function createJourneyHost<Env extends JourneyEnv>(\n options: JourneyHostOptions<Env>,\n platform: JourneyPlatform = {}\n): JourneyHost<Env> {\n const { config } = options;\n const emailIds = indexTemplates('email', config.templates?.email);\n // A config still on `emails` (its sender would be silently dropped) fails here, not at a send\n assertEmailConfig(config, emailIds.values());\n let compiled: BundleIR | undefined;\n const bundle = () => (compiled ??= compileConfig(config));\n // A journey that emails with nowhere to send fails here too, not at its first email\n const emailing = bundle().workflows.filter((workflow) => sendsEmail(workflow.flow));\n if (options.email === undefined && options.messages === undefined && emailing.length > 0) {\n throw new TypeError(\n `workflows ${emailing.map((w) => `'${w.id}'`).join(', ')} send email, but the host has no ` +\n '`email` transport: pass `email: (env) => cloudflareEmailTransport(...)` or `sesEmailTransport(...)`'\n );\n }\n // Parsed once per env object: the runtime hands the same one to every request of an isolate\n const checked = new WeakMap<object, Env>();\n const checkEnv = (env: Env): Env => {\n if (options.env === undefined) return env;\n const cached = checked.get(env);\n if (cached !== undefined) return cached;\n const result = z.safeParse(options.env, env);\n // Names and reasons only: the values are secrets\n if (!result.success) throw new EnvValidationError(result.error.issues.map(describeIssue));\n const merged: Env = { ...env, ...result.data };\n checked.set(env, merged);\n return merged;\n };\n const emails = config.templates?.email;\n const database = { schema: config.database?.schema ?? DEFAULT_SCHEMA };\n // In fast-forward, segment windows scale with the journeys reading them (time-scale.ts)\n const lease = (env: Env): Required<StoreLease> => {\n const opened = options.store(env, database);\n const noop = async () => {};\n const factor = runtimeFromConfig(config, env).timeScale;\n return 'store' in opened\n ? { store: scaleSegmentWindows(opened.store, factor), close: opened.close ?? noop }\n : { store: scaleSegmentWindows(opened, factor), close: noop };\n };\n\n // The provider clients (APNs JWT, FCM access token) live with the host, not with a run: a\n // run's env is a fresh object on every Lambda invocation, so nothing keyed on it would be reused\n const providers = createProviderCache();\n const messages = (env: Env, store: JourneyStore): MessageSender => {\n if (options.messages !== undefined) return options.messages(env, store);\n return channelsFromConfig(config, env, {\n store,\n email: options.email?.(env),\n apnsFetch: platform.apnsFetch,\n providers,\n });\n };\n\n const observer = (env: Env, store: JourneyStore): JourneyObserver => {\n if (options.observer !== undefined) return options.observer(env, store);\n return reportingObserver(store.reporting, env.WORKFLOW_ORIGIN);\n };\n\n const runParts = (env: Env, store: JourneyStore, params: JourneyParams): JourneyRunParts => ({\n store,\n env,\n runtime:\n options.runtime === undefined ? runtimeFromConfig(config, env) : options.runtime(env, params),\n messages: messages(env, store),\n // An action ref is a registered action structurally: the same object carries name, code and hash\n actions: new RegistryActionInvoker(config.actions ?? []),\n contract: config.contract,\n validator: registryValidator(config.templates ?? {}),\n observer: observer(env, store),\n });\n\n const preview = emails === undefined ? undefined : registryRenderer(emails, config.email);\n\n /**\n * The recipients' routes (`/t/*`): a push tapped open is recorded as the config's\n * `webPush.openedEvent` — keyed by the copy, so a second tap of the same notification is the\n * same event — and an unsubscribe wakes the waits reading email reachability (`reachable\n * ('email')` subscribes to the email property's channel), so an `exitWhen:\n * not(reachable('email'))` takes effect without waiting for a timeout.\n */\n const tracking = (env: Env, store: JourneyStore): TrackingOptions => {\n const opened = config.webPush?.openedEvent;\n return {\n product: config.title,\n quickMs: config.email?.tracking?.quickClickMs,\n pushOpened:\n opened === undefined\n ? undefined\n : async ({ messageId, userId, at }) => {\n const payload = checkedPayload(config.contract, opened, { message_id: messageId });\n const input = {\n id: `${opened}:${messageId}`,\n userId,\n event: opened,\n payload,\n ts: at,\n };\n await ingestEvent(env, input, store);\n },\n onUnsubscribed: async ({ userId }) => {\n const email = profileUpdatedEvent(RECIPIENT_PROPERTY.email ?? 'email');\n await wakeSubscribers(env, store, userId, [email]);\n },\n };\n };\n\n const handle = (request: Request, env: Env, store: JourneyStore): Promise<Response> =>\n serve(request, {\n env,\n store,\n validation: { contract: config.contract, mode: options.contractMode },\n authorize: options.authorize as Authorize<JourneyEnv> | undefined,\n host: {\n bundle,\n preview:\n preview === undefined ? undefined : { has: (id) => emailIds.has(id), render: preview },\n tracking: tracking(env, store),\n sweep: () => sweepSegments(env, store),\n },\n });\n\n const sweep = async (env: Env) => {\n const { store, close } = lease(env);\n try {\n return await sweepSegments(env, store);\n } finally {\n await close();\n }\n };\n\n // The bundle compiled into this version goes live on its own, once per process: the first\n // request or scheduled run reads which version's bundle is live (one row) and applies this one\n // only over an older version, so one still serving during a rollout can never put its routes\n // back. A failure (say the migrations have not run) is logged and retried a minute later. Only\n // the request that starts the check gets its promise back: a request context cannot wait on\n // another's I/O.\n let applying: Promise<void> | undefined;\n let retryAt = 0;\n const applyOwnBundle = (env: Env, version: BundleVersion | undefined) => {\n if (version === undefined || version.id === '' || Number.isNaN(version.createdAt)) {\n return undefined;\n }\n if (applying !== undefined || Date.now() < retryAt) return undefined;\n applying = (async () => {\n const { store, close } = lease(env);\n try {\n const live = await store.liveBundleVersion();\n if (live !== undefined && (live.id === version.id || live.createdAt >= version.createdAt))\n return;\n const result = await deployBundle(env, bundle(), store, version);\n if (result.applied) console.log(`[workflow] version ${version.id} is live`);\n } finally {\n await close();\n }\n })().catch((error: unknown) => {\n applying = undefined;\n retryAt = Date.now() + 60_000;\n console.error(`[workflow] could not make version ${version.id} live`, error);\n });\n return applying;\n };\n\n return { config, checkEnv, lease, bundle, runParts, handle, sweep, applyOwnBundle };\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"platform.cjs","names":[],"sources":["../../src/host/platform.ts"],"sourcesContent":["import type { D1DatabaseLike, EmailBindingLike } from '../cloudflare/bindings';\nimport type { ExecutionStart } from '../store/index';\n\n/**\n * The runtime port: what the host needs from the platform it runs on, whichever one that is.\n * Cloudflare's Workflow binding satisfies it (`cloudflareJourneyBinding`), the AWS adapter's\n * `AwsInstances` implements it over durable executions.\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/** How the host starts, wakes and stops instances. */\nexport interface WorkflowBindingLike {\n create(options: { id: string; params: unknown }): Promise<WorkflowInstanceLike>;\n /** The instance, or an `InstanceNotFoundError` when the runtime does not know the id. */\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 * The runtime does not know the instance: the only lookup failure that lets the host drop the\n * instance's wakes, or count it as stopped. Every `WorkflowBindingLike.get` answers it this way.\n */\nexport class InstanceNotFoundError extends Error {\n override readonly name = 'InstanceNotFoundError';\n constructor(\n readonly instanceId: string,\n options?: ErrorOptions\n ) {\n super(`no such instance: ${instanceId}`, options);\n }\n}\n\n/** The event type the host uses to wake waiting instances (sendEvent's `type`). */\nexport const WAKE_EVENT_TYPE = 'wake';\n\n/** Journey instance parameters, passed by the host 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/** Every binding, variable and secret the journey host reads (names match the wrangler config). */\nexport interface JourneyEnv {\n /**\n * The default D1 store's database (events / profiles / segments / triggers / entries /\n * subscriptions / workflow IR). Optional: a host on another JourneyStore (Postgres, say)\n * does not declare it.\n */\n DB?: D1DatabaseLike;\n /** The runtime port: creating instances, waking and stopping 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 * The host'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"],"mappings":";;;;;;AAiDA,IAAa,wBAAb,cAA2C,MAAM;CAGpC;CAFX,OAAyB;CACzB,YACE,YACA,SACA;EACA,MAAM,qBAAqB,cAAc,OAAO;EAHvC,KAAA,aAAA;CAIX;AACF;;AAGA,MAAa,kBAAkB"}
1
+ {"version":3,"file":"platform.cjs","names":[],"sources":["../../src/host/platform.ts"],"sourcesContent":["import type { EmailBindingLike } from '../cloudflare/bindings';\nimport type { ExecutionStart } from '../store/index';\n\n/**\n * The runtime port: what the host needs from the platform it runs on, whichever one that is.\n * Cloudflare's Workflow binding satisfies it (`cloudflareJourneyBinding`), the AWS adapter's\n * `AwsInstances` implements it over durable executions.\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/** How the host starts, wakes and stops instances. */\nexport interface WorkflowBindingLike {\n create(options: { id: string; params: unknown }): Promise<WorkflowInstanceLike>;\n /** The instance, or an `InstanceNotFoundError` when the runtime does not know the id. */\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 * The runtime does not know the instance: the only lookup failure that lets the host drop the\n * instance's wakes, or count it as stopped. Every `WorkflowBindingLike.get` answers it this way.\n */\nexport class InstanceNotFoundError extends Error {\n override readonly name = 'InstanceNotFoundError';\n constructor(\n readonly instanceId: string,\n options?: ErrorOptions\n ) {\n super(`no such instance: ${instanceId}`, options);\n }\n}\n\n/** The event type the host uses to wake waiting instances (sendEvent's `type`). */\nexport const WAKE_EVENT_TYPE = 'wake';\n\n/** Journey instance parameters, passed by the host 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/** Every binding, variable and secret the journey host reads (names match the wrangler config). */\nexport interface JourneyEnv {\n /** The runtime port: creating instances, waking and stopping 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 * The host'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"],"mappings":";;;;;;AAiDA,IAAa,wBAAb,cAA2C,MAAM;CAGpC;CAFX,OAAyB;CACzB,YACE,YACA,SACA;EACA,MAAM,qBAAqB,cAAc,OAAO;EAHvC,KAAA,aAAA;CAIX;AACF;;AAGA,MAAa,kBAAkB"}