@shware/workflow 9.0.2 → 9.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +74 -4
  2. package/dist/cloudflare/bindings.cjs.map +1 -1
  3. package/dist/cloudflare/bindings.d.cts +7 -7
  4. package/dist/cloudflare/bindings.d.mts +7 -7
  5. package/dist/cloudflare/bindings.mjs.map +1 -1
  6. package/dist/dsl/flow.cjs +2 -1
  7. package/dist/dsl/flow.cjs.map +1 -1
  8. package/dist/dsl/flow.d.cts.map +1 -1
  9. package/dist/dsl/flow.d.mts.map +1 -1
  10. package/dist/dsl/flow.mjs +2 -1
  11. package/dist/dsl/flow.mjs.map +1 -1
  12. package/dist/engine/interpreter.cjs +46 -10
  13. package/dist/engine/interpreter.cjs.map +1 -1
  14. package/dist/engine/interpreter.mjs +46 -10
  15. package/dist/engine/interpreter.mjs.map +1 -1
  16. package/dist/engine/ports.d.cts +31 -6
  17. package/dist/engine/ports.d.cts.map +1 -1
  18. package/dist/engine/ports.d.mts +31 -6
  19. package/dist/engine/ports.d.mts.map +1 -1
  20. package/dist/host/ingest.cjs +86 -3
  21. package/dist/host/ingest.cjs.map +1 -1
  22. package/dist/host/ingest.d.cts +30 -3
  23. package/dist/host/ingest.d.cts.map +1 -1
  24. package/dist/host/ingest.d.mts +30 -3
  25. package/dist/host/ingest.d.mts.map +1 -1
  26. package/dist/host/ingest.mjs +85 -4
  27. package/dist/host/ingest.mjs.map +1 -1
  28. package/dist/ir.cjs +2 -1
  29. package/dist/ir.cjs.map +1 -1
  30. package/dist/ir.d.cts +6 -0
  31. package/dist/ir.d.cts.map +1 -1
  32. package/dist/ir.d.mts +6 -0
  33. package/dist/ir.d.mts.map +1 -1
  34. package/dist/ir.mjs +2 -1
  35. package/dist/ir.mjs.map +1 -1
  36. package/dist/reporting/observer.cjs +2 -0
  37. package/dist/reporting/observer.cjs.map +1 -1
  38. package/dist/reporting/observer.mjs +2 -0
  39. package/dist/reporting/observer.mjs.map +1 -1
  40. package/dist/store/index.cjs.map +1 -1
  41. package/dist/store/index.d.cts +28 -0
  42. package/dist/store/index.d.cts.map +1 -1
  43. package/dist/store/index.d.mts +28 -0
  44. package/dist/store/index.d.mts.map +1 -1
  45. package/dist/store/index.mjs.map +1 -1
  46. package/dist/store/postgres-reporting.cjs +9 -0
  47. package/dist/store/postgres-reporting.cjs.map +1 -1
  48. package/dist/store/postgres-reporting.d.cts +2 -0
  49. package/dist/store/postgres-reporting.d.cts.map +1 -1
  50. package/dist/store/postgres-reporting.d.mts +2 -0
  51. package/dist/store/postgres-reporting.d.mts.map +1 -1
  52. package/dist/store/postgres-reporting.mjs +9 -0
  53. package/dist/store/postgres-reporting.mjs.map +1 -1
  54. package/dist/store/postgres.cjs +14 -0
  55. package/dist/store/postgres.cjs.map +1 -1
  56. package/dist/store/postgres.d.cts +3 -1
  57. package/dist/store/postgres.d.cts.map +1 -1
  58. package/dist/store/postgres.d.mts +3 -1
  59. package/dist/store/postgres.d.mts.map +1 -1
  60. package/dist/store/postgres.mjs +14 -0
  61. package/dist/store/postgres.mjs.map +1 -1
  62. package/dist/store/tables.cjs +1 -0
  63. package/dist/store/tables.cjs.map +1 -1
  64. package/dist/store/tables.d.cts.map +1 -1
  65. package/dist/store/tables.d.mts.map +1 -1
  66. package/dist/store/tables.mjs +1 -0
  67. package/dist/store/tables.mjs.map +1 -1
  68. package/dist/templates.cjs.map +1 -1
  69. package/dist/templates.d.cts +20 -0
  70. package/dist/templates.d.cts.map +1 -1
  71. package/dist/templates.d.mts +20 -0
  72. package/dist/templates.d.mts.map +1 -1
  73. package/dist/templates.mjs.map +1 -1
  74. package/dist/transports/cloudflare-email.cjs.map +1 -1
  75. package/dist/transports/cloudflare-email.mjs.map +1 -1
  76. package/package.json +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"flow.mjs","names":[],"sources":["../../src/dsl/flow.ts"],"sourcesContent":["import type { ChannelIR, ConditionIR, NodeIR, PropValueIR, SourceLocIR } from '../ir';\nimport { captureLoc } from '../provenance';\nimport {\n type AnyTemplate,\n CHANNEL_OF,\n type DiscordTemplate,\n type EmailTemplate,\n type PropsSchema,\n type PushTemplate,\n type SlackTemplate,\n isTemplate,\n} from '../templates';\nimport type { ActionRef } from './action';\nimport { type Duration, type TimeOfDay, type Weekday, durationIR, timeOfDayMinutes } from './base';\nimport { type Condition, condIR, positiveDurationIR } from './condition';\nimport type { EventRef, MessageArgs, PropInput, PropsInput } from './refs';\nimport type { PropsOf, TemplateRef } from './template';\n\n/**\n * The flow chain — FlowBuilder is the DSL's step surface (messages, delays,\n * flow control, data), and its implementation is the compiler front half: each\n * chained call executes no logic, it just collects one IR node (ids are\n * assigned later, in workflow.toIR()). Sub-flow callbacks run exactly once at\n * construction — builder in, builder out — so the output is always a static\n * tree.\n *\n * Every public method also captures its callsite (provenance): the loc lands\n * in the node's hash-excluded meta and is the anchor for future in-place\n * editing from the studio.\n */\n\ntype Scalar = string | number | boolean;\n\n/** An emitted field: a scalar (or a profile ref to one); a nested field cannot be sent. */\ntype EmitValue<T> = [NonNullable<T>] extends [Scalar] ? PropInput<T> : never;\n\n/** The payload `sendEvent` accepts for an event: its fields, scalar ones only. */\nexport type EmitPayload<P> = { [K in keyof P]: EmitValue<P[K]> };\n\n/** Payload may be omitted when the event declares no required field. */\nexport type EmitArgs<P> =\n Record<string, never> extends P ? [payload?: EmitPayload<P>] : [payload: EmitPayload<P>];\n\n/** Per-node push options. */\nexport interface PushOptions {\n /** Which devices get a copy: 'all' (the default) or only the most recently registered. */\n devices?: 'all' | 'latest';\n}\n\n/** `prefer()` options. */\nexport interface PreferOptions {\n /** The generated branch's name in IR (UI title / observability handle), as `branch(label, …)`. */\n label?: string;\n}\n\n/** The candidates `prefer()` takes: one push and one email template, in priority order. */\ntype PreferPair = readonly [PushTemplate, EmailTemplate] | readonly [EmailTemplate, PushTemplate];\n\ntype NormalizeProps<P> = { -readonly [K in keyof P]: P[K] };\ntype Equal<A, B> =\n (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;\n\n/** Both candidates render the one props object, so they must declare the same props type — not merely overlap. */\ntype SameProps<T extends PreferPair> =\n Equal<NormalizeProps<PropsOf<T[0]>>, NormalizeProps<PropsOf<T[1]>>> extends true\n ? unknown\n : { readonly __prefer_error__: 'Both templates must declare the same props type' };\n\n/** Distributes, so a field only one member of a union variable has still counts as extra. */\ntype KeysOfUnion<A> = A extends unknown ? keyof A : never;\n/** Rejects known extra fields, which `{}` (an empty props schema) would otherwise accept. */\ntype NoExtra<A, P> = Record<Exclude<KeysOfUnion<A>, keyof P>, never>;\n\n/** prefer() is typed strictly, but a JS caller (or an `any`) reaches it unchecked — fail at the call, not at send time. */\nfunction checkPreferArgs(candidates: unknown, props: unknown, options: unknown): void {\n if (!Array.isArray(candidates) || candidates.length !== 2) {\n throw new TypeError('prefer(): candidates must be exactly two templates — one push, one email');\n }\n const channels = (candidates as unknown[]).map((candidate) =>\n isTemplate(candidate) ? CHANNEL_OF[candidate.kind] : undefined\n );\n if (!channels.every((channel) => channel === 'push' || channel === 'email')) {\n throw new TypeError('prefer(): each candidate must be a pushTemplate or an emailTemplate');\n }\n if (channels[0] === channels[1]) {\n throw new TypeError('prefer(): candidates must be one push and one email template');\n }\n if (typeof props !== 'object' || props === null || Array.isArray(props)) {\n throw new TypeError(\n 'prefer(): props must be an object — pass {} when the templates declare none'\n );\n }\n if (options === undefined) return;\n if (typeof options !== 'object' || options === null || Array.isArray(options)) {\n throw new TypeError('prefer(): options must be an object');\n }\n for (const [name, value] of Object.entries(options)) {\n if (name !== 'label') {\n throw new TypeError(\n `prefer(): unknown option '${name}' — props go in the second argument, options take only 'label'`\n );\n }\n if (typeof value !== 'string') throw new TypeError(\"prefer(): 'label' must be a string\");\n }\n}\n\n/** A reusable flow fragment (what `flow(...)` returns), referenced directly by branch arms. */\nexport interface Flow {\n readonly __flow: true;\n}\n\ninterface FlowInternal extends Flow {\n readonly nodes: NodeIR[];\n readonly uses: readonly TemplateUse[];\n readonly actions: readonly ActionRef[];\n}\n\n/**\n * @internal A template definition a flow sends, kept beside the IR (which names it by id only):\n * compileConfig checks that the registered definition under that id is this very object, so the\n * schema that typed the call is the one that renders it. Never part of the IR or its hash.\n */\nexport interface TemplateUse {\n readonly template: AnyTemplate;\n readonly loc: SourceLocIR | undefined;\n}\n\n/** @internal The template definitions a workflow sends, sub-flows and fragments included. */\nexport function templateUses(builder: FlowBuilder): readonly TemplateUse[] {\n return builder instanceof FlowBuilderImpl ? builder.uses : [];\n}\n\n/**\n * @internal The action definitions a workflow runs, sub-flows and fragments included. The IR\n * names an action only; compileBundle checks that each ref is the very object listed in\n * `actions`, which is what keeps two definitions from sharing one name. Never part of the IR.\n */\nexport function actionUses(builder: FlowBuilder): readonly ActionRef[] {\n return builder instanceof FlowBuilderImpl ? builder.actions : [];\n}\n\n/** Sub-flow: a named fragment, or an inline callback (runs once at construction — builder in, builder out). */\nexport type SubFlow = Flow | ((w: FlowBuilder) => FlowBuilder);\n\n/** A branch arm: the [condition, sub-flow] tuple — an [if, then] ordering convention. */\nexport type BranchCase = readonly [condition: Condition, flow: SubFlow];\n\n/**\n * A branch argument: either a tuple arm, or a bare sub-flow meaning the default\n * arm. The bare form may appear once, and must come last (checked at runtime).\n */\nexport type BranchArm = BranchCase | SubFlow;\n\n/**\n * A cohort arm name has two jobs, and both constrain its spelling:\n * - it is interpolated into the node ids of the arm's flow ('{id}.{name}.{j}'),\n * and an id is a durable step name — a dot would fake a nesting level and can\n * collide with a real one, an empty name gives '{id}..{j}';\n * - it is an object key, and JavaScript lists integer-like keys first in\n * ascending numeric order however the author wrote them. The arms array is\n * documented (and interpreted) as authoring order, and the bucketing walk\n * accumulates weights along it, so an arm named '0' added to a live\n * experiment would prepend itself and re-bucket everyone already in it.\n */\nconst ARM_NAME = /^[A-Za-z_][A-Za-z0-9_-]*$/;\n\n/** A sub-flow's nodes; the template definitions it sends and the actions it runs are added to `uses` / `actions`. */\nfunction resolveSubFlow(sub: SubFlow, uses: TemplateUse[], actions: ActionRef[]): NodeIR[] {\n if (typeof sub === 'function') {\n const builder = new FlowBuilderImpl();\n const returned: unknown = sub(builder);\n // An async callback would add its nodes after construction is over — after ids were assigned\n if (typeof (returned as { then?: unknown } | undefined)?.then === 'function') {\n throw new TypeError(\n 'sub-flow callbacks must be synchronous: they run once, while the workflow is defined'\n );\n }\n uses.push(...builder.uses);\n actions.push(...builder.actions);\n return builder.nodes;\n }\n uses.push(...(sub as FlowInternal).uses);\n actions.push(...(sub as FlowInternal).actions);\n /*\n * A named fragment is a value, and the convention is to reuse it — two branch\n * arms may hold the same one. Handing out its own nodes aliases them into\n * both places, and assignIds writes ids *in place*: the second prefix\n * overwrites the first, so the two placements end up as one object under one\n * id — one durable step name, one message idempotency key, one bucket seed.\n * A copy per placement is what makes a fragment behave like a value.\n */\n return structuredClone((sub as FlowInternal).nodes);\n}\n\nexport interface FlowBuilder {\n /* ---- Messages: every channel is one message node; separate methods buy channel-level typing ---- */\n email<T extends EmailTemplate>(template: T, ...props: MessageArgs<PropsOf<T>>): this;\n sms<P extends object>(template: TemplateRef<'sms', P>, ...props: MessageArgs<P>): this;\n /**\n * Push to the user's devices — every device the host's directory lists, or\n * only the most recently registered with `{ devices: 'latest' }`. A user with\n * no device is skipped (`prefer([pushTpl, emailTpl], props)` falls back to email).\n */\n push<T extends PushTemplate>(\n template: T,\n ...args: [...props: MessageArgs<PropsOf<T>>, options?: PushOptions]\n ): this;\n inApp<P extends object>(template: TemplateRef<'in_app', P>, ...props: MessageArgs<P>): this;\n slack<T extends SlackTemplate<PropsSchema, never>>(\n template: T,\n ...props: MessageArgs<PropsOf<T>>\n ): this;\n discord<T extends DiscordTemplate<PropsSchema, never>>(\n template: T,\n ...props: MessageArgs<PropsOf<T>>\n ): this;\n survey<P extends object>(template: TemplateRef<'survey', P>, ...props: MessageArgs<P>): this;\n\n /**\n * Send one message on the first channel the user can be reached on: one push\n * and one email template, in priority order, rendered with the same props.\n * Sugar for the branch it compiles to — same IR, ids and hash as writing\n *\n * .branch(label, [reachable(first.channel), (b) => b.<first>(first, props)], (b) => b.<second>(second, props))\n *\n * so the choice is made once, when the step is reached, and a first choice\n * that fails to send retries as itself — it never falls through to the\n * second. Props are always passed (`{}` for templates that declare none),\n * and both templates must declare the same props type; for differing props\n * or other channels, write the branch.\n *\n * .prefer([push.tip, email.tip], { name: u.name }, { label: 'tip by channel' })\n */\n prefer<const T extends PreferPair, const A extends PropsInput<NoInfer<PropsOf<T[0]>>>>(\n candidates: T & SameProps<NoInfer<T>>,\n props: A & NoInfer<NoExtra<A, PropsOf<T[0]>>>,\n options?: PreferOptions\n ): this;\n\n /* --------------------------------- Delays --------------------------------- */\n /** Time Delay; passing { min, max } makes it a Randomized Delay. */\n delay(duration: Duration): this;\n delay(range: { min: Duration; max: Duration }): this;\n\n /** Time Window: hold until inside the window (e.g. weekdays 09:00–17:00). tz: 'user' uses the user's timezone. */\n timeWindow(opts: {\n days?: readonly Weekday[];\n between: readonly [TimeOfDay, TimeOfDay];\n tz?: 'user' | (string & {});\n }): this;\n\n /**\n * Wait Until: continue on the main line once the condition holds, or take\n * onTimeout when it expires (default 'continue'). On the engine side this is\n * \"await event racing a timer\" — a native primitive of durable runtimes.\n */\n waitUntil(\n condition: Condition,\n opts: { timeout: Duration; onTimeout?: 'continue' | 'exit' | SubFlow }\n ): this;\n\n /**\n * Splice a fragment in place: a named `flow(...)` or a callback, expanded\n * once while the workflow is defined — a composition step before compiling,\n * not a node (the IR has no trace of it, and ids are assigned over the whole\n * workflow as if the steps were written inline). Each use gets its own copy,\n * so one fragment can be used in several places and workflows.\n *\n * const officeHours = flow((w) => w.timeWindow({ days: ['mon', 'tue', 'wed', 'thu', 'fri'], between: ['09:00', '17:00'] }));\n * workflow({ id: 'onboarding', ... }).delay('1 day').use(officeHours).email(email.tip, { name: u.name })\n */\n use(fragment: SubFlow): this;\n\n /* ------------------------------ Flow Control ------------------------------ */\n /**\n * Conditional branch: ordered first-match (same semantics as Step Functions\n * Choice / Knock branch). This one primitive covers both the UI's True/False\n * Branch (two arms) and Multi-Split Branch (N arms). Arms are\n * [condition, sub-flow] tuples; a bare sub-flow in tail position is the\n * default arm. With no match and no default, execution simply continues.\n * Rejoin is structural: after the matching arm finishes, execution resumes\n * at the node following the branch — to opt out, call .exit() inside the arm.\n *\n * .branch([activeSubscriber, upgradeFlow], firstTimeFlow) // True/False\n * .branch([vip, vipFlow], [trial, trialFlow], defaultFlow) // Multi-Split\n * .branch([not(usedStaging), eduFlow]) // single arm: no match continues\n *\n * The optional first argument `label` becomes the node's name in IR (UI title\n * / observability handle). It is not a jump target — there is no goto here;\n * looping is a future re-entry-policy topic (see sendEvent). Arm labels in\n * the UI are derived from the condition.\n */\n branch(...arms: readonly BranchArm[]): this;\n branch(label: string, ...arms: readonly BranchArm[]): this;\n\n /**\n * Gate: exit unless the condition holds, otherwise continue. A first-class\n * node rather than sugar over branch — same semantics as Zapier's \"only\n * continue if\" / Iterable's Filter tile, and rendered on its own in the UI.\n * `reason` is the audit-log line — metadata, so rewording it is free\n * (excluded from contentHash, see hash.ts).\n */\n filter(condition: Condition, opts?: { reason?: string }): this;\n\n /**\n * Random Cohort Branch (A/B): weights must sum to 100 (checked at runtime).\n * `key` names the experiment: bucketing hashes `userId:key` instead of the\n * structural node id, so assignments survive node insertion/moves and the\n * experiment keeps one identity across workflow versions — set it for any\n * A/B whose results you intend to read.\n *\n * Arm names become node id segments and are read back in authoring order, so\n * they are restricted to letters, digits, '_' and '-', never starting with a\n * digit (checked at runtime).\n */\n cohort(arms: Record<string, { weight: number; flow?: SubFlow }>, opts?: { key?: string }): this;\n\n /**\n * Exit: end the whole workflow immediately (the UI's Exit node); `reason`\n * goes into the audit log and is metadata, so rewording it neither repins\n * in-flight journeys nor shows up in plan. Inside a branch arm this means\n * \"terminate, do not rejoin\" — it stops here instead of falling through,\n * while an arm without exit rejoins naturally.\n */\n exit(reason?: string): this;\n\n /* ---------------------------------- Data ---------------------------------- */\n /**\n * Emit a typed event (customer.io's Send Event). The event comes from an\n * e.xxx reference, so payload types flow in the same way trigger.event works.\n * This is how workflows compose: an event can trigger another workflow, and\n * the resulting call graph is statically analyzable. Payload values may\n * reference u.xxx. Only scalar fields can be sent (IR carries literals and\n * profile refs, not nested JSON): an event whose payload requires a nested\n * field cannot be emitted from a journey, and the types say so.\n *\n * Note on loops: a trailing self-triggering sendEvent will become the\n * official periodic-flow shape *once a re-entry policy exists* — today the\n * entry policy is once-per-user, so a self-trigger cannot re-enter and does\n * not loop.\n * TODO: the rest of the Data category — outbound webhook / profile update /\n * journey attributes.\n */\n sendEvent<P extends object>(event: EventRef<P>, ...payload: EmitArgs<P>): this;\n\n /**\n * Run a custom action (see dsl/action.ts for the model). The chain records\n * identity only — name and args — never the code: the implementation\n * ships inside the deployed Worker bundle and is resolved by name from the\n * runtime registry. Args are typed by the ActionRef and may reference u.xxx,\n * resolved at run time exactly like message props. Executed inside a durable\n * step: retried on throw, checkpointed on success.\n */\n run<A extends object>(action: ActionRef<A>, ...args: MessageArgs<A>): this;\n}\n\nconst DEFINED_CHANNELS: ReadonlySet<ChannelIR> = new Set(Object.values(CHANNEL_OF));\n\n/** A message's template: a definition of the channel (named by its id), or a reference by key. */\ntype MessageTemplate = AnyTemplate | TemplateRef;\n\nfunction templateKey(channel: ChannelIR, tpl: MessageTemplate): string {\n if (!DEFINED_CHANNELS.has(channel)) return (tpl as TemplateRef).key;\n // Typed, but a JS caller (or an `any`) reaches it unchecked: fail where the workflow is written\n if (!isTemplate(tpl) || CHANNEL_OF[tpl.kind] !== channel) {\n throw new TypeError(`${channel}(): expects a ${channel} template definition`);\n }\n return tpl.id;\n}\n\nfunction messageNode(\n channel: ChannelIR,\n tpl: MessageTemplate,\n props: object | undefined,\n devices?: 'latest'\n): NodeIR {\n return {\n id: '',\n type: 'message',\n channel,\n template: templateKey(channel, tpl),\n props: (props ?? {}) as Record<string, PropValueIR>,\n // Absent for 'all', so adding the option left every existing push's hash as it was\n ...(devices !== undefined ? { devices } : {}),\n // An email's or push's topic (null: transactional) is what the engine checks the opt-outs against\n ...(channel === 'email' || channel === 'push'\n ? { topic: (tpl as EmailTemplate | PushTemplate).topic ?? null }\n : {}),\n };\n}\n\n/* oxlint-disable typescript/unbound-method --\n * `captureLoc(this.xxx)` passes the method by identity as the stack-trace\n * boundary for Error.captureStackTrace; it is never invoked, so `this`\n * scoping cannot go wrong. */\n/** @internal extended by WorkflowBuilderImpl; not part of the public API */\nexport class FlowBuilderImpl implements FlowBuilder {\n /** Nodes as collected; ids are assigned in one pass inside toIR, so '' is a placeholder here. */\n readonly nodes: NodeIR[] = [];\n /** The template definitions sent, with where: compile-time only, never in the IR. */\n readonly uses: TemplateUse[] = [];\n /** The action definitions run: compile-time identity only, never in the IR. */\n readonly actions: ActionRef[] = [];\n\n /** Provenance: the callsite (captured in the public method) lands in meta.loc, outside the hash. */\n private pushNode(node: NodeIR, loc: SourceLocIR | undefined): this {\n this.nodes.push({ ...node, ...(loc !== undefined ? { meta: { loc } } : {}) });\n return this;\n }\n\n private message(\n channel: ChannelIR,\n tpl: MessageTemplate,\n props: object | undefined,\n loc: SourceLocIR | undefined,\n devices?: 'latest'\n ): this {\n this.pushNode(messageNode(channel, tpl, props, devices), loc);\n if (isTemplate(tpl)) this.uses.push({ template: tpl, loc });\n return this;\n }\n\n email<T extends EmailTemplate>(t: T, ...args: MessageArgs<PropsOf<T>>): this {\n return this.message('email', t, args[0], captureLoc(this.email));\n }\n sms<P extends object>(t: TemplateRef<'sms', P>, ...args: MessageArgs<P>): this {\n return this.message('sms', t, args[0], captureLoc(this.sms));\n }\n push<T extends PushTemplate>(\n t: T,\n ...args: [...props: MessageArgs<PropsOf<T>>, options?: PushOptions]\n ): this {\n const [props, options] = args as unknown as [object | undefined, PushOptions | undefined];\n const devices = options?.devices === 'latest' ? 'latest' : undefined;\n return this.message('push', t, props, captureLoc(this.push), devices);\n }\n inApp<P extends object>(t: TemplateRef<'in_app', P>, ...args: MessageArgs<P>): this {\n return this.message('in_app', t, args[0], captureLoc(this.inApp));\n }\n slack<T extends SlackTemplate<PropsSchema, never>>(t: T, ...args: MessageArgs<PropsOf<T>>): this {\n return this.message('slack', t, args[0], captureLoc(this.slack));\n }\n discord<T extends DiscordTemplate<PropsSchema, never>>(\n t: T,\n ...args: MessageArgs<PropsOf<T>>\n ): this {\n return this.message('discord', t, args[0], captureLoc(this.discord));\n }\n survey<P extends object>(t: TemplateRef<'survey', P>, ...args: MessageArgs<P>): this {\n return this.message('survey', t, args[0], captureLoc(this.survey));\n }\n\n prefer<const T extends PreferPair, const A extends PropsInput<NoInfer<PropsOf<T[0]>>>>(\n candidates: T & SameProps<NoInfer<T>>,\n props: A & NoInfer<NoExtra<A, PropsOf<T[0]>>>,\n options?: PreferOptions\n ): this {\n checkPreferArgs(candidates, props, options);\n // Every generated node points at the author's prefer() call, not at this method's body\n const loc = captureLoc(this.prefer);\n const meta = loc !== undefined ? { meta: { loc } } : {};\n const [first, second] = candidates as readonly (EmailTemplate | PushTemplate)[];\n const channel = (tpl: EmailTemplate | PushTemplate) => CHANNEL_OF[tpl.kind] as 'email' | 'push';\n // Both are sent from here as far as the registry check goes, the fallback included\n this.uses.push({ template: first, loc }, { template: second, loc });\n // A copy per message, as two separately written calls would hold\n const arm = (tpl: EmailTemplate | PushTemplate): NodeIR[] => [\n { ...messageNode(channel(tpl), tpl, { ...props }), ...meta },\n ];\n return this.pushNode(\n {\n id: '',\n type: 'branch',\n ...(options?.label !== undefined ? { label: options.label } : {}),\n cases: [\n { condition: { type: 'reachable', channel: channel(first), ...meta }, flow: arm(first) },\n ],\n otherwise: arm(second),\n },\n loc\n );\n }\n\n delay(duration: Duration): this;\n delay(range: { min: Duration; max: Duration }): this;\n delay(d: Duration | { min: Duration; max: Duration }): this {\n const loc = captureLoc(this.delay);\n if (typeof d === 'string') {\n return this.pushNode({ id: '', type: 'delay', duration: durationIR(d) }, loc);\n }\n const min = durationIR(d.min);\n const max = durationIR(d.max);\n /*\n * The interpreter draws `min + random(max - min)` with the span clamped at\n * zero, so a backwards range does not fail — it quietly collapses into a\n * fixed `min` sleep. Reject it here, where the author can still see it.\n */\n if (max.ms < min.ms) {\n throw new Error(\n `delay(): 'max' must not be shorter than 'min', got { min: '${d.min}', max: '${d.max}' }`\n );\n }\n return this.pushNode({ id: '', type: 'random_delay', min, max }, loc);\n }\n\n timeWindow(opts: {\n days?: readonly Weekday[];\n between: readonly [TimeOfDay, TimeOfDay];\n tz?: 'user' | (string & {});\n }): this {\n const loc = captureLoc(this.timeWindow);\n if (opts.days !== undefined) {\n // An empty list is a window that never opens: the node would hold the user forever\n if (opts.days.length === 0) {\n throw new Error(\"timeWindow(): 'days' must name at least one day\");\n }\n const seen = new Set<Weekday>();\n for (const day of opts.days) {\n if (seen.has(day)) throw new Error(`timeWindow(): 'days' lists '${day}' twice`);\n seen.add(day);\n }\n }\n const start = timeOfDayMinutes(opts.between[0]);\n const end = timeOfDayMinutes(opts.between[1]);\n if (end <= start) {\n throw new Error(\n `timeWindow(): 'between' must open before it closes within one day, got [${opts.between[0]}, ${opts.between[1]}] (overnight windows are not supported yet)`\n );\n }\n return this.pushNode(\n {\n id: '',\n type: 'time_window',\n days: [...(opts.days ?? (['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] as const))],\n between: [opts.between[0], opts.between[1]],\n tz: opts.tz ?? 'user',\n },\n loc\n );\n }\n\n waitUntil(\n condition: Condition,\n opts: { timeout: Duration; onTimeout?: 'continue' | 'exit' | SubFlow }\n ): this {\n const loc = captureLoc(this.waitUntil);\n const onTimeout = opts.onTimeout ?? 'continue';\n return this.pushNode(\n {\n id: '',\n type: 'wait_until',\n condition: condIR(condition),\n timeout: positiveDurationIR(opts.timeout, 'waitUntil(): timeout'),\n onTimeout:\n onTimeout === 'continue' || onTimeout === 'exit'\n ? onTimeout\n : resolveSubFlow(onTimeout, this.uses, this.actions),\n },\n loc\n );\n }\n\n use(fragment: SubFlow): this {\n // The fragment's nodes carry their own provenance (where the fragment was written)\n this.nodes.push(...resolveSubFlow(fragment, this.uses, this.actions));\n return this;\n }\n\n branch(...arms: readonly BranchArm[]): this;\n branch(label: string, ...arms: readonly BranchArm[]): this;\n branch(...args: readonly (string | BranchArm)[]): this {\n const loc = captureLoc(this.branch);\n const label = typeof args[0] === 'string' ? args[0] : undefined;\n const arms = (label === undefined ? args : args.slice(1)) as readonly BranchArm[];\n\n const cases: { condition: ConditionIR; flow: NodeIR[] }[] = [];\n let otherwise: NodeIR[] | undefined;\n arms.forEach((arm, i) => {\n if (Array.isArray(arm)) {\n if (otherwise !== undefined) {\n throw new Error('branch(): default arm (bare sub-flow) must be the last argument');\n }\n const [condition, sub] = arm as BranchCase;\n cases.push({\n condition: condIR(condition),\n flow: resolveSubFlow(sub, this.uses, this.actions),\n });\n } else {\n if (otherwise !== undefined) {\n throw new Error('branch(): only one default arm (bare sub-flow) is allowed');\n }\n if (i !== arms.length - 1) {\n throw new Error('branch(): default arm (bare sub-flow) must be the last argument');\n }\n otherwise = resolveSubFlow(arm as SubFlow, this.uses, this.actions);\n }\n });\n\n return this.pushNode(\n {\n id: '',\n type: 'branch',\n ...(label !== undefined ? { label } : {}),\n cases,\n ...(otherwise !== undefined ? { otherwise } : {}),\n },\n loc\n );\n }\n\n filter(condition: Condition, opts?: { reason?: string }): this {\n return this.pushNode(\n {\n id: '',\n type: 'filter',\n condition: condIR(condition),\n ...(opts?.reason !== undefined ? { reason: opts.reason } : {}),\n },\n captureLoc(this.filter)\n );\n }\n\n cohort(arms: Record<string, { weight: number; flow?: SubFlow }>, opts?: { key?: string }): this {\n const loc = captureLoc(this.cohort);\n const entries = Object.entries(arms);\n /*\n * Weights live on a 0.01% grid: the runtime buckets into 10,000 slots\n * (basis points), so anything finer would be silently quantized — reject\n * it here instead. Converting to integer basis points also makes the\n * sum check exact; no epsilon needed.\n */\n const basisPoints = entries.map(([name, a]) => {\n if (!ARM_NAME.test(name)) {\n throw new Error(\n `cohort(): arm name '${name}' must start with a letter or '_' and contain only letters, digits, '_' or '-' — arm names become node id segments, and integer-like names would silently reorder the arms`\n );\n }\n const bp = a.weight * 100;\n if (Math.abs(bp - Math.round(bp)) > 1e-6) {\n throw new Error(\n `cohort(): arm '${name}' weight ${a.weight} is finer than the 0.01% bucketing grid — use at most two decimal places`\n );\n }\n /*\n * The sum check alone accepts -50 / 150: the runtime walks the arms\n * accumulating basis points and takes the first whose cumulative total\n * clears the bucket, so a negative weight hands 100% of users to its\n * neighbour instead of splitting anything.\n */\n if (bp < 0) {\n throw new Error(`cohort(): arm '${name}' weight ${a.weight} must not be negative`);\n }\n return Math.round(bp);\n });\n const total = basisPoints.reduce((sum, bp) => sum + bp, 0);\n if (total !== 10_000) {\n throw new Error(`cohort(): weights must sum to 100, got ${total / 100}`);\n }\n return this.pushNode(\n {\n id: '',\n type: 'cohort',\n ...(opts?.key !== undefined ? { key: opts.key } : {}),\n arms: entries.map(([name, a]) => ({\n name,\n weight: a.weight,\n flow: a.flow ? resolveSubFlow(a.flow, this.uses, this.actions) : [],\n })),\n },\n loc\n );\n }\n\n exit(reason?: string): this {\n return this.pushNode(\n { id: '', type: 'exit', ...(reason !== undefined ? { reason } : {}) },\n captureLoc(this.exit)\n );\n }\n\n sendEvent<P extends object>(ev: EventRef<P>, ...payload: EmitArgs<P>): this {\n const values = (payload[0] ?? {}) as Record<string, unknown>;\n for (const [field, value] of Object.entries(values)) {\n const ref =\n typeof value === 'object' &&\n value !== null &&\n (value as { type?: unknown }).type === 'user_property';\n if (!ref && !['string', 'number', 'boolean'].includes(typeof value)) {\n throw new TypeError(\n `sendEvent('${ev.name}'): '${field}' is not a scalar or a u.xxx reference — an emitted payload carries scalar fields only`\n );\n }\n }\n return this.pushNode(\n {\n id: '',\n type: 'send_event',\n event: ev.name,\n payload: values as Record<string, PropValueIR>,\n },\n captureLoc(this.sendEvent)\n );\n }\n\n run<A extends object>(a: ActionRef<A>, ...args: MessageArgs<A>): this {\n this.actions.push(a);\n return this.pushNode(\n {\n id: '',\n type: 'action',\n action: a.name,\n args: (args[0] ?? {}) as Record<string, PropValueIR>,\n },\n captureLoc(this.run)\n );\n }\n}\n/* oxlint-enable typescript/unbound-method */\n\n/**\n * A reusable flow fragment: a free function. Once personalization goes through\n * u.xxx and events through e.xxx, the flow layer carries no schema generics.\n */\nexport function flow(build: (w: FlowBuilder) => FlowBuilder): Flow {\n const builder = new FlowBuilderImpl();\n build(builder);\n const impl: FlowInternal = {\n __flow: true,\n nodes: builder.nodes,\n uses: builder.uses,\n actions: builder.actions,\n };\n return impl;\n}\n"],"mappings":";;;;;;AA0EA,SAAS,gBAAgB,YAAqB,OAAgB,SAAwB;CACpF,IAAI,CAAC,MAAM,QAAQ,UAAU,KAAK,WAAW,WAAW,GACtD,MAAM,IAAI,UAAU,0EAA0E;CAEhG,MAAM,WAAY,WAAyB,KAAK,cAC9C,WAAW,SAAS,IAAI,WAAW,UAAU,QAAQ,KAAA,CACvD;CACA,IAAI,CAAC,SAAS,OAAO,YAAY,YAAY,UAAU,YAAY,OAAO,GACxE,MAAM,IAAI,UAAU,qEAAqE;CAE3F,IAAI,SAAS,OAAO,SAAS,IAC3B,MAAM,IAAI,UAAU,8DAA8D;CAEpF,IAAI,OAAO,UAAU,YAAY,UAAU,QAAQ,MAAM,QAAQ,KAAK,GACpE,MAAM,IAAI,UACR,6EACF;CAEF,IAAI,YAAY,KAAA,GAAW;CAC3B,IAAI,OAAO,YAAY,YAAY,YAAY,QAAQ,MAAM,QAAQ,OAAO,GAC1E,MAAM,IAAI,UAAU,qCAAqC;CAE3D,KAAK,MAAM,CAAC,MAAM,UAAU,OAAO,QAAQ,OAAO,GAAG;EACnD,IAAI,SAAS,SACX,MAAM,IAAI,UACR,6BAA6B,KAAK,+DACpC;EAEF,IAAI,OAAO,UAAU,UAAU,MAAM,IAAI,UAAU,oCAAoC;CACzF;AACF;;AAwBA,SAAgB,aAAa,SAA8C;CACzE,OAAO,mBAAmB,kBAAkB,QAAQ,OAAO,CAAC;AAC9D;;;;;;AAOA,SAAgB,WAAW,SAA4C;CACrE,OAAO,mBAAmB,kBAAkB,QAAQ,UAAU,CAAC;AACjE;;;;;;;;;;;;AAyBA,MAAM,WAAW;;AAGjB,SAAS,eAAe,KAAc,MAAqB,SAAgC;CACzF,IAAI,OAAO,QAAQ,YAAY;EAC7B,MAAM,UAAU,IAAI,gBAAgB;EAGpC,IAAI,OAFsB,IAAI,OAEX,CAAC,EAAqC,SAAS,YAChE,MAAM,IAAI,UACR,sFACF;EAEF,KAAK,KAAK,GAAG,QAAQ,IAAI;EACzB,QAAQ,KAAK,GAAG,QAAQ,OAAO;EAC/B,OAAO,QAAQ;CACjB;CACA,KAAK,KAAK,GAAI,IAAqB,IAAI;CACvC,QAAQ,KAAK,GAAI,IAAqB,OAAO;CAS7C,OAAO,gBAAiB,IAAqB,KAAK;AACpD;AAoKA,MAAM,mBAA2C,IAAI,IAAI,OAAO,OAAO,UAAU,CAAC;AAKlF,SAAS,YAAY,SAAoB,KAA8B;CACrE,IAAI,CAAC,iBAAiB,IAAI,OAAO,GAAG,OAAQ,IAAoB;CAEhE,IAAI,CAAC,WAAW,GAAG,KAAK,WAAW,IAAI,UAAU,SAC/C,MAAM,IAAI,UAAU,GAAG,QAAQ,gBAAgB,QAAQ,qBAAqB;CAE9E,OAAO,IAAI;AACb;AAEA,SAAS,YACP,SACA,KACA,OACA,SACQ;CACR,OAAO;EACL,IAAI;EACJ,MAAM;EACN;EACA,UAAU,YAAY,SAAS,GAAG;EAClC,OAAQ,SAAS,CAAC;EAElB,GAAI,YAAY,KAAA,IAAY,EAAE,QAAQ,IAAI,CAAC;EAE3C,GAAI,YAAY,WAAW,YAAY,SACnC,EAAE,OAAQ,IAAqC,SAAS,KAAK,IAC7D,CAAC;CACP;AACF;;AAOA,IAAa,kBAAb,MAAoD;;CAElD,QAA2B,CAAC;;CAE5B,OAA+B,CAAC;;CAEhC,UAAgC,CAAC;;CAGjC,SAAiB,MAAc,KAAoC;EACjE,KAAK,MAAM,KAAK;GAAE,GAAG;GAAM,GAAI,QAAQ,KAAA,IAAY,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC;EAAG,CAAC;EAC5E,OAAO;CACT;CAEA,QACE,SACA,KACA,OACA,KACA,SACM;EACN,KAAK,SAAS,YAAY,SAAS,KAAK,OAAO,OAAO,GAAG,GAAG;EAC5D,IAAI,WAAW,GAAG,GAAG,KAAK,KAAK,KAAK;GAAE,UAAU;GAAK;EAAI,CAAC;EAC1D,OAAO;CACT;CAEA,MAA+B,GAAM,GAAG,MAAqC;EAC3E,OAAO,KAAK,QAAQ,SAAS,GAAG,KAAK,IAAI,WAAW,KAAK,KAAK,CAAC;CACjE;CACA,IAAsB,GAA0B,GAAG,MAA4B;EAC7E,OAAO,KAAK,QAAQ,OAAO,GAAG,KAAK,IAAI,WAAW,KAAK,GAAG,CAAC;CAC7D;CACA,KACE,GACA,GAAG,MACG;EACN,MAAM,CAAC,OAAO,WAAW;EACzB,MAAM,UAAU,SAAS,YAAY,WAAW,WAAW,KAAA;EAC3D,OAAO,KAAK,QAAQ,QAAQ,GAAG,OAAO,WAAW,KAAK,IAAI,GAAG,OAAO;CACtE;CACA,MAAwB,GAA6B,GAAG,MAA4B;EAClF,OAAO,KAAK,QAAQ,UAAU,GAAG,KAAK,IAAI,WAAW,KAAK,KAAK,CAAC;CAClE;CACA,MAAmD,GAAM,GAAG,MAAqC;EAC/F,OAAO,KAAK,QAAQ,SAAS,GAAG,KAAK,IAAI,WAAW,KAAK,KAAK,CAAC;CACjE;CACA,QACE,GACA,GAAG,MACG;EACN,OAAO,KAAK,QAAQ,WAAW,GAAG,KAAK,IAAI,WAAW,KAAK,OAAO,CAAC;CACrE;CACA,OAAyB,GAA6B,GAAG,MAA4B;EACnF,OAAO,KAAK,QAAQ,UAAU,GAAG,KAAK,IAAI,WAAW,KAAK,MAAM,CAAC;CACnE;CAEA,OACE,YACA,OACA,SACM;EACN,gBAAgB,YAAY,OAAO,OAAO;EAE1C,MAAM,MAAM,WAAW,KAAK,MAAM;EAClC,MAAM,OAAO,QAAQ,KAAA,IAAY,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC;EACtD,MAAM,CAAC,OAAO,UAAU;EACxB,MAAM,WAAW,QAAsC,WAAW,IAAI;EAEtE,KAAK,KAAK,KAAK;GAAE,UAAU;GAAO;EAAI,GAAG;GAAE,UAAU;GAAQ;EAAI,CAAC;EAElE,MAAM,OAAO,QAAgD,CAC3D;GAAE,GAAG,YAAY,QAAQ,GAAG,GAAG,KAAK,EAAE,GAAG,MAAM,CAAC;GAAG,GAAG;EAAK,CAC7D;EACA,OAAO,KAAK,SACV;GACE,IAAI;GACJ,MAAM;GACN,GAAI,SAAS,UAAU,KAAA,IAAY,EAAE,OAAO,QAAQ,MAAM,IAAI,CAAC;GAC/D,OAAO,CACL;IAAE,WAAW;KAAE,MAAM;KAAa,SAAS,QAAQ,KAAK;KAAG,GAAG;IAAK;IAAG,MAAM,IAAI,KAAK;GAAE,CACzF;GACA,WAAW,IAAI,MAAM;EACvB,GACA,GACF;CACF;CAIA,MAAM,GAAsD;EAC1D,MAAM,MAAM,WAAW,KAAK,KAAK;EACjC,IAAI,OAAO,MAAM,UACf,OAAO,KAAK,SAAS;GAAE,IAAI;GAAI,MAAM;GAAS,UAAU,WAAW,CAAC;EAAE,GAAG,GAAG;EAE9E,MAAM,MAAM,WAAW,EAAE,GAAG;EAC5B,MAAM,MAAM,WAAW,EAAE,GAAG;EAM5B,IAAI,IAAI,KAAK,IAAI,IACf,MAAM,IAAI,MACR,8DAA8D,EAAE,IAAI,WAAW,EAAE,IAAI,IACvF;EAEF,OAAO,KAAK,SAAS;GAAE,IAAI;GAAI,MAAM;GAAgB;GAAK;EAAI,GAAG,GAAG;CACtE;CAEA,WAAW,MAIF;EACP,MAAM,MAAM,WAAW,KAAK,UAAU;EACtC,IAAI,KAAK,SAAS,KAAA,GAAW;GAE3B,IAAI,KAAK,KAAK,WAAW,GACvB,MAAM,IAAI,MAAM,iDAAiD;GAEnE,MAAM,uBAAO,IAAI,IAAa;GAC9B,KAAK,MAAM,OAAO,KAAK,MAAM;IAC3B,IAAI,KAAK,IAAI,GAAG,GAAG,MAAM,IAAI,MAAM,+BAA+B,IAAI,QAAQ;IAC9E,KAAK,IAAI,GAAG;GACd;EACF;EACA,MAAM,QAAQ,iBAAiB,KAAK,QAAQ,EAAE;EAE9C,IADY,iBAAiB,KAAK,QAAQ,EACpC,KAAK,OACT,MAAM,IAAI,MACR,2EAA2E,KAAK,QAAQ,GAAG,IAAI,KAAK,QAAQ,GAAG,4CACjH;EAEF,OAAO,KAAK,SACV;GACE,IAAI;GACJ,MAAM;GACN,MAAM,CAAC,GAAI,KAAK,QAAS;IAAC;IAAO;IAAO;IAAO;IAAO;IAAO;IAAO;GAAK,CAAY;GACrF,SAAS,CAAC,KAAK,QAAQ,IAAI,KAAK,QAAQ,EAAE;GAC1C,IAAI,KAAK,MAAM;EACjB,GACA,GACF;CACF;CAEA,UACE,WACA,MACM;EACN,MAAM,MAAM,WAAW,KAAK,SAAS;EACrC,MAAM,YAAY,KAAK,aAAa;EACpC,OAAO,KAAK,SACV;GACE,IAAI;GACJ,MAAM;GACN,WAAW,OAAO,SAAS;GAC3B,SAAS,mBAAmB,KAAK,SAAS,sBAAsB;GAChE,WACE,cAAc,cAAc,cAAc,SACtC,YACA,eAAe,WAAW,KAAK,MAAM,KAAK,OAAO;EACzD,GACA,GACF;CACF;CAEA,IAAI,UAAyB;EAE3B,KAAK,MAAM,KAAK,GAAG,eAAe,UAAU,KAAK,MAAM,KAAK,OAAO,CAAC;EACpE,OAAO;CACT;CAIA,OAAO,GAAG,MAA6C;EACrD,MAAM,MAAM,WAAW,KAAK,MAAM;EAClC,MAAM,QAAQ,OAAO,KAAK,OAAO,WAAW,KAAK,KAAK,KAAA;EACtD,MAAM,OAAQ,UAAU,KAAA,IAAY,OAAO,KAAK,MAAM,CAAC;EAEvD,MAAM,QAAsD,CAAC;EAC7D,IAAI;EACJ,KAAK,SAAS,KAAK,MAAM;GACvB,IAAI,MAAM,QAAQ,GAAG,GAAG;IACtB,IAAI,cAAc,KAAA,GAChB,MAAM,IAAI,MAAM,iEAAiE;IAEnF,MAAM,CAAC,WAAW,OAAO;IACzB,MAAM,KAAK;KACT,WAAW,OAAO,SAAS;KAC3B,MAAM,eAAe,KAAK,KAAK,MAAM,KAAK,OAAO;IACnD,CAAC;GACH,OAAO;IACL,IAAI,cAAc,KAAA,GAChB,MAAM,IAAI,MAAM,2DAA2D;IAE7E,IAAI,MAAM,KAAK,SAAS,GACtB,MAAM,IAAI,MAAM,iEAAiE;IAEnF,YAAY,eAAe,KAAgB,KAAK,MAAM,KAAK,OAAO;GACpE;EACF,CAAC;EAED,OAAO,KAAK,SACV;GACE,IAAI;GACJ,MAAM;GACN,GAAI,UAAU,KAAA,IAAY,EAAE,MAAM,IAAI,CAAC;GACvC;GACA,GAAI,cAAc,KAAA,IAAY,EAAE,UAAU,IAAI,CAAC;EACjD,GACA,GACF;CACF;CAEA,OAAO,WAAsB,MAAkC;EAC7D,OAAO,KAAK,SACV;GACE,IAAI;GACJ,MAAM;GACN,WAAW,OAAO,SAAS;GAC3B,GAAI,MAAM,WAAW,KAAA,IAAY,EAAE,QAAQ,KAAK,OAAO,IAAI,CAAC;EAC9D,GACA,WAAW,KAAK,MAAM,CACxB;CACF;CAEA,OAAO,MAA0D,MAA+B;EAC9F,MAAM,MAAM,WAAW,KAAK,MAAM;EAClC,MAAM,UAAU,OAAO,QAAQ,IAAI;EA8BnC,MAAM,QAvBc,QAAQ,KAAK,CAAC,MAAM,OAAO;GAC7C,IAAI,CAAC,SAAS,KAAK,IAAI,GACrB,MAAM,IAAI,MACR,uBAAuB,KAAK,2KAC9B;GAEF,MAAM,KAAK,EAAE,SAAS;GACtB,IAAI,KAAK,IAAI,KAAK,KAAK,MAAM,EAAE,CAAC,IAAI,MAClC,MAAM,IAAI,MACR,kBAAkB,KAAK,WAAW,EAAE,OAAO,yEAC7C;GAQF,IAAI,KAAK,GACP,MAAM,IAAI,MAAM,kBAAkB,KAAK,WAAW,EAAE,OAAO,sBAAsB;GAEnF,OAAO,KAAK,MAAM,EAAE;EACtB,CACwB,CAAC,CAAC,QAAQ,KAAK,OAAO,MAAM,IAAI,CAAC;EACzD,IAAI,UAAU,KACZ,MAAM,IAAI,MAAM,0CAA0C,QAAQ,KAAK;EAEzE,OAAO,KAAK,SACV;GACE,IAAI;GACJ,MAAM;GACN,GAAI,MAAM,QAAQ,KAAA,IAAY,EAAE,KAAK,KAAK,IAAI,IAAI,CAAC;GACnD,MAAM,QAAQ,KAAK,CAAC,MAAM,QAAQ;IAChC;IACA,QAAQ,EAAE;IACV,MAAM,EAAE,OAAO,eAAe,EAAE,MAAM,KAAK,MAAM,KAAK,OAAO,IAAI,CAAC;GACpE,EAAE;EACJ,GACA,GACF;CACF;CAEA,KAAK,QAAuB;EAC1B,OAAO,KAAK,SACV;GAAE,IAAI;GAAI,MAAM;GAAQ,GAAI,WAAW,KAAA,IAAY,EAAE,OAAO,IAAI,CAAC;EAAG,GACpE,WAAW,KAAK,IAAI,CACtB;CACF;CAEA,UAA4B,IAAiB,GAAG,SAA4B;EAC1E,MAAM,SAAU,QAAQ,MAAM,CAAC;EAC/B,KAAK,MAAM,CAAC,OAAO,UAAU,OAAO,QAAQ,MAAM,GAKhD,IAAI,EAHF,OAAO,UAAU,YACjB,UAAU,QACT,MAA6B,SAAS,oBAC7B,CAAC;GAAC;GAAU;GAAU;EAAS,CAAC,CAAC,SAAS,OAAO,KAAK,GAChE,MAAM,IAAI,UACR,cAAc,GAAG,KAAK,OAAO,MAAM,uFACrC;EAGJ,OAAO,KAAK,SACV;GACE,IAAI;GACJ,MAAM;GACN,OAAO,GAAG;GACV,SAAS;EACX,GACA,WAAW,KAAK,SAAS,CAC3B;CACF;CAEA,IAAsB,GAAiB,GAAG,MAA4B;EACpE,KAAK,QAAQ,KAAK,CAAC;EACnB,OAAO,KAAK,SACV;GACE,IAAI;GACJ,MAAM;GACN,QAAQ,EAAE;GACV,MAAO,KAAK,MAAM,CAAC;EACrB,GACA,WAAW,KAAK,GAAG,CACrB;CACF;AACF;;;;;AAOA,SAAgB,KAAK,OAA8C;CACjE,MAAM,UAAU,IAAI,gBAAgB;CACpC,MAAM,OAAO;CAOb,OAAO;EALL,QAAQ;EACR,OAAO,QAAQ;EACf,MAAM,QAAQ;EACd,SAAS,QAAQ;CAET;AACZ"}
1
+ {"version":3,"file":"flow.mjs","names":[],"sources":["../../src/dsl/flow.ts"],"sourcesContent":["import type { ChannelIR, ConditionIR, NodeIR, PropValueIR, SourceLocIR } from '../ir';\nimport { captureLoc } from '../provenance';\nimport {\n type AnyTemplate,\n CHANNEL_OF,\n type DiscordTemplate,\n type EmailTemplate,\n type PropsSchema,\n type PushTemplate,\n type SlackTemplate,\n isTemplate,\n} from '../templates';\nimport type { ActionRef } from './action';\nimport { type Duration, type TimeOfDay, type Weekday, durationIR, timeOfDayMinutes } from './base';\nimport { type Condition, condIR, positiveDurationIR } from './condition';\nimport type { EventRef, MessageArgs, PropInput, PropsInput } from './refs';\nimport type { PropsOf, TemplateRef } from './template';\n\n/**\n * The flow chain — FlowBuilder is the DSL's step surface (messages, delays,\n * flow control, data), and its implementation is the compiler front half: each\n * chained call executes no logic, it just collects one IR node (ids are\n * assigned later, in workflow.toIR()). Sub-flow callbacks run exactly once at\n * construction — builder in, builder out — so the output is always a static\n * tree.\n *\n * Every public method also captures its callsite (provenance): the loc lands\n * in the node's hash-excluded meta and is the anchor for future in-place\n * editing from the studio.\n */\n\ntype Scalar = string | number | boolean;\n\n/** An emitted field: a scalar (or a profile ref to one); a nested field cannot be sent. */\ntype EmitValue<T> = [NonNullable<T>] extends [Scalar] ? PropInput<T> : never;\n\n/** The payload `sendEvent` accepts for an event: its fields, scalar ones only. */\nexport type EmitPayload<P> = { [K in keyof P]: EmitValue<P[K]> };\n\n/** Payload may be omitted when the event declares no required field. */\nexport type EmitArgs<P> =\n Record<string, never> extends P ? [payload?: EmitPayload<P>] : [payload: EmitPayload<P>];\n\n/** Per-node push options. */\nexport interface PushOptions {\n /** Which devices get a copy: 'all' (the default) or only the most recently registered. */\n devices?: 'all' | 'latest';\n}\n\n/** `prefer()` options. */\nexport interface PreferOptions {\n /** The generated branch's name in IR (UI title / observability handle), as `branch(label, …)`. */\n label?: string;\n}\n\n/** The candidates `prefer()` takes: one push and one email template, in priority order. */\ntype PreferPair = readonly [PushTemplate, EmailTemplate] | readonly [EmailTemplate, PushTemplate];\n\ntype NormalizeProps<P> = { -readonly [K in keyof P]: P[K] };\ntype Equal<A, B> =\n (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;\n\n/** Both candidates render the one props object, so they must declare the same props type — not merely overlap. */\ntype SameProps<T extends PreferPair> =\n Equal<NormalizeProps<PropsOf<T[0]>>, NormalizeProps<PropsOf<T[1]>>> extends true\n ? unknown\n : { readonly __prefer_error__: 'Both templates must declare the same props type' };\n\n/** Distributes, so a field only one member of a union variable has still counts as extra. */\ntype KeysOfUnion<A> = A extends unknown ? keyof A : never;\n/** Rejects known extra fields, which `{}` (an empty props schema) would otherwise accept. */\ntype NoExtra<A, P> = Record<Exclude<KeysOfUnion<A>, keyof P>, never>;\n\n/** prefer() is typed strictly, but a JS caller (or an `any`) reaches it unchecked — fail at the call, not at send time. */\nfunction checkPreferArgs(candidates: unknown, props: unknown, options: unknown): void {\n if (!Array.isArray(candidates) || candidates.length !== 2) {\n throw new TypeError('prefer(): candidates must be exactly two templates — one push, one email');\n }\n const channels = (candidates as unknown[]).map((candidate) =>\n isTemplate(candidate) ? CHANNEL_OF[candidate.kind] : undefined\n );\n if (!channels.every((channel) => channel === 'push' || channel === 'email')) {\n throw new TypeError('prefer(): each candidate must be a pushTemplate or an emailTemplate');\n }\n if (channels[0] === channels[1]) {\n throw new TypeError('prefer(): candidates must be one push and one email template');\n }\n if (typeof props !== 'object' || props === null || Array.isArray(props)) {\n throw new TypeError(\n 'prefer(): props must be an object — pass {} when the templates declare none'\n );\n }\n if (options === undefined) return;\n if (typeof options !== 'object' || options === null || Array.isArray(options)) {\n throw new TypeError('prefer(): options must be an object');\n }\n for (const [name, value] of Object.entries(options)) {\n if (name !== 'label') {\n throw new TypeError(\n `prefer(): unknown option '${name}' — props go in the second argument, options take only 'label'`\n );\n }\n if (typeof value !== 'string') throw new TypeError(\"prefer(): 'label' must be a string\");\n }\n}\n\n/** A reusable flow fragment (what `flow(...)` returns), referenced directly by branch arms. */\nexport interface Flow {\n readonly __flow: true;\n}\n\ninterface FlowInternal extends Flow {\n readonly nodes: NodeIR[];\n readonly uses: readonly TemplateUse[];\n readonly actions: readonly ActionRef[];\n}\n\n/**\n * @internal A template definition a flow sends, kept beside the IR (which names it by id only):\n * compileConfig checks that the registered definition under that id is this very object, so the\n * schema that typed the call is the one that renders it. Never part of the IR or its hash.\n */\nexport interface TemplateUse {\n readonly template: AnyTemplate;\n readonly loc: SourceLocIR | undefined;\n}\n\n/** @internal The template definitions a workflow sends, sub-flows and fragments included. */\nexport function templateUses(builder: FlowBuilder): readonly TemplateUse[] {\n return builder instanceof FlowBuilderImpl ? builder.uses : [];\n}\n\n/**\n * @internal The action definitions a workflow runs, sub-flows and fragments included. The IR\n * names an action only; compileBundle checks that each ref is the very object listed in\n * `actions`, which is what keeps two definitions from sharing one name. Never part of the IR.\n */\nexport function actionUses(builder: FlowBuilder): readonly ActionRef[] {\n return builder instanceof FlowBuilderImpl ? builder.actions : [];\n}\n\n/** Sub-flow: a named fragment, or an inline callback (runs once at construction — builder in, builder out). */\nexport type SubFlow = Flow | ((w: FlowBuilder) => FlowBuilder);\n\n/** A branch arm: the [condition, sub-flow] tuple — an [if, then] ordering convention. */\nexport type BranchCase = readonly [condition: Condition, flow: SubFlow];\n\n/**\n * A branch argument: either a tuple arm, or a bare sub-flow meaning the default\n * arm. The bare form may appear once, and must come last (checked at runtime).\n */\nexport type BranchArm = BranchCase | SubFlow;\n\n/**\n * A cohort arm name has two jobs, and both constrain its spelling:\n * - it is interpolated into the node ids of the arm's flow ('{id}.{name}.{j}'),\n * and an id is a durable step name — a dot would fake a nesting level and can\n * collide with a real one, an empty name gives '{id}..{j}';\n * - it is an object key, and JavaScript lists integer-like keys first in\n * ascending numeric order however the author wrote them. The arms array is\n * documented (and interpreted) as authoring order, and the bucketing walk\n * accumulates weights along it, so an arm named '0' added to a live\n * experiment would prepend itself and re-bucket everyone already in it.\n */\nconst ARM_NAME = /^[A-Za-z_][A-Za-z0-9_-]*$/;\n\n/** A sub-flow's nodes; the template definitions it sends and the actions it runs are added to `uses` / `actions`. */\nfunction resolveSubFlow(sub: SubFlow, uses: TemplateUse[], actions: ActionRef[]): NodeIR[] {\n if (typeof sub === 'function') {\n const builder = new FlowBuilderImpl();\n const returned: unknown = sub(builder);\n // An async callback would add its nodes after construction is over — after ids were assigned\n if (typeof (returned as { then?: unknown } | undefined)?.then === 'function') {\n throw new TypeError(\n 'sub-flow callbacks must be synchronous: they run once, while the workflow is defined'\n );\n }\n uses.push(...builder.uses);\n actions.push(...builder.actions);\n return builder.nodes;\n }\n uses.push(...(sub as FlowInternal).uses);\n actions.push(...(sub as FlowInternal).actions);\n /*\n * A named fragment is a value, and the convention is to reuse it — two branch\n * arms may hold the same one. Handing out its own nodes aliases them into\n * both places, and assignIds writes ids *in place*: the second prefix\n * overwrites the first, so the two placements end up as one object under one\n * id — one durable step name, one message idempotency key, one bucket seed.\n * A copy per placement is what makes a fragment behave like a value.\n */\n return structuredClone((sub as FlowInternal).nodes);\n}\n\nexport interface FlowBuilder {\n /* ---- Messages: every channel is one message node; separate methods buy channel-level typing ---- */\n email<T extends EmailTemplate>(template: T, ...props: MessageArgs<PropsOf<T>>): this;\n sms<P extends object>(template: TemplateRef<'sms', P>, ...props: MessageArgs<P>): this;\n /**\n * Push to the user's devices — every device the host's directory lists, or\n * only the most recently registered with `{ devices: 'latest' }`. A user with\n * no device is skipped (`prefer([pushTpl, emailTpl], props)` falls back to email).\n */\n push<T extends PushTemplate>(\n template: T,\n ...args: [...props: MessageArgs<PropsOf<T>>, options?: PushOptions]\n ): this;\n inApp<P extends object>(template: TemplateRef<'in_app', P>, ...props: MessageArgs<P>): this;\n slack<T extends SlackTemplate<PropsSchema, never>>(\n template: T,\n ...props: MessageArgs<PropsOf<T>>\n ): this;\n discord<T extends DiscordTemplate<PropsSchema, never>>(\n template: T,\n ...props: MessageArgs<PropsOf<T>>\n ): this;\n survey<P extends object>(template: TemplateRef<'survey', P>, ...props: MessageArgs<P>): this;\n\n /**\n * Send one message on the first channel the user can be reached on: one push\n * and one email template, in priority order, rendered with the same props.\n * Sugar for the branch it compiles to — same IR, ids and hash as writing\n *\n * .branch(label, [reachable(first.channel), (b) => b.<first>(first, props)], (b) => b.<second>(second, props))\n *\n * so the choice is made once, when the step is reached, and a first choice\n * that fails to send retries as itself — it never falls through to the\n * second. Props are always passed (`{}` for templates that declare none),\n * and both templates must declare the same props type; for differing props\n * or other channels, write the branch.\n *\n * .prefer([push.tip, email.tip], { name: u.name }, { label: 'tip by channel' })\n */\n prefer<const T extends PreferPair, const A extends PropsInput<NoInfer<PropsOf<T[0]>>>>(\n candidates: T & SameProps<NoInfer<T>>,\n props: A & NoInfer<NoExtra<A, PropsOf<T[0]>>>,\n options?: PreferOptions\n ): this;\n\n /* --------------------------------- Delays --------------------------------- */\n /** Time Delay; passing { min, max } makes it a Randomized Delay. */\n delay(duration: Duration): this;\n delay(range: { min: Duration; max: Duration }): this;\n\n /** Time Window: hold until inside the window (e.g. weekdays 09:00–17:00). tz: 'user' uses the user's timezone. */\n timeWindow(opts: {\n days?: readonly Weekday[];\n between: readonly [TimeOfDay, TimeOfDay];\n tz?: 'user' | (string & {});\n }): this;\n\n /**\n * Wait Until: continue on the main line once the condition holds, or take\n * onTimeout when it expires (default 'continue'). On the engine side this is\n * \"await event racing a timer\" — a native primitive of durable runtimes.\n */\n waitUntil(\n condition: Condition,\n opts: { timeout: Duration; onTimeout?: 'continue' | 'exit' | SubFlow }\n ): this;\n\n /**\n * Splice a fragment in place: a named `flow(...)` or a callback, expanded\n * once while the workflow is defined — a composition step before compiling,\n * not a node (the IR has no trace of it, and ids are assigned over the whole\n * workflow as if the steps were written inline). Each use gets its own copy,\n * so one fragment can be used in several places and workflows.\n *\n * const officeHours = flow((w) => w.timeWindow({ days: ['mon', 'tue', 'wed', 'thu', 'fri'], between: ['09:00', '17:00'] }));\n * workflow({ id: 'onboarding', ... }).delay('1 day').use(officeHours).email(email.tip, { name: u.name })\n */\n use(fragment: SubFlow): this;\n\n /* ------------------------------ Flow Control ------------------------------ */\n /**\n * Conditional branch: ordered first-match (same semantics as Step Functions\n * Choice / Knock branch). This one primitive covers both the UI's True/False\n * Branch (two arms) and Multi-Split Branch (N arms). Arms are\n * [condition, sub-flow] tuples; a bare sub-flow in tail position is the\n * default arm. With no match and no default, execution simply continues.\n * Rejoin is structural: after the matching arm finishes, execution resumes\n * at the node following the branch — to opt out, call .exit() inside the arm.\n *\n * .branch([activeSubscriber, upgradeFlow], firstTimeFlow) // True/False\n * .branch([vip, vipFlow], [trial, trialFlow], defaultFlow) // Multi-Split\n * .branch([not(usedStaging), eduFlow]) // single arm: no match continues\n *\n * The optional first argument `label` becomes the node's name in IR (UI title\n * / observability handle). It is not a jump target — there is no goto here;\n * looping is a future re-entry-policy topic (see sendEvent). Arm labels in\n * the UI are derived from the condition.\n */\n branch(...arms: readonly BranchArm[]): this;\n branch(label: string, ...arms: readonly BranchArm[]): this;\n\n /**\n * Gate: exit unless the condition holds, otherwise continue. A first-class\n * node rather than sugar over branch — same semantics as Zapier's \"only\n * continue if\" / Iterable's Filter tile, and rendered on its own in the UI.\n * `reason` is the audit-log line — metadata, so rewording it is free\n * (excluded from contentHash, see hash.ts).\n */\n filter(condition: Condition, opts?: { reason?: string }): this;\n\n /**\n * Random Cohort Branch (A/B): weights must sum to 100 (checked at runtime).\n * `key` names the experiment: bucketing hashes `userId:key` instead of the\n * structural node id, so assignments survive node insertion/moves and the\n * experiment keeps one identity across workflow versions — set it for any\n * A/B whose results you intend to read.\n *\n * Arm names become node id segments and are read back in authoring order, so\n * they are restricted to letters, digits, '_' and '-', never starting with a\n * digit (checked at runtime).\n */\n cohort(arms: Record<string, { weight: number; flow?: SubFlow }>, opts?: { key?: string }): this;\n\n /**\n * Exit: end the whole workflow immediately (the UI's Exit node); `reason`\n * goes into the audit log and is metadata, so rewording it neither repins\n * in-flight journeys nor shows up in plan. Inside a branch arm this means\n * \"terminate, do not rejoin\" — it stops here instead of falling through,\n * while an arm without exit rejoins naturally.\n */\n exit(reason?: string): this;\n\n /* ---------------------------------- Data ---------------------------------- */\n /**\n * Emit a typed event (customer.io's Send Event). The event comes from an\n * e.xxx reference, so payload types flow in the same way trigger.event works.\n * This is how workflows compose: an event can trigger another workflow, and\n * the resulting call graph is statically analyzable. Payload values may\n * reference u.xxx. Only scalar fields can be sent (IR carries literals and\n * profile refs, not nested JSON): an event whose payload requires a nested\n * field cannot be emitted from a journey, and the types say so.\n *\n * Note on loops: a trailing self-triggering sendEvent will become the\n * official periodic-flow shape *once a re-entry policy exists* — today the\n * entry policy is once-per-user, so a self-trigger cannot re-enter and does\n * not loop.\n * TODO: the rest of the Data category — outbound webhook / profile update /\n * journey attributes.\n */\n sendEvent<P extends object>(event: EventRef<P>, ...payload: EmitArgs<P>): this;\n\n /**\n * Run a custom action (see dsl/action.ts for the model). The chain records\n * identity only — name and args — never the code: the implementation\n * ships inside the deployed Worker bundle and is resolved by name from the\n * runtime registry. Args are typed by the ActionRef and may reference u.xxx,\n * resolved at run time exactly like message props. Executed inside a durable\n * step: retried on throw, checkpointed on success.\n */\n run<A extends object>(action: ActionRef<A>, ...args: MessageArgs<A>): this;\n}\n\nconst DEFINED_CHANNELS: ReadonlySet<ChannelIR> = new Set(Object.values(CHANNEL_OF));\n\n/** A message's template: a definition of the channel (named by its id), or a reference by key. */\ntype MessageTemplate = AnyTemplate | TemplateRef;\n\nfunction templateKey(channel: ChannelIR, tpl: MessageTemplate): string {\n if (!DEFINED_CHANNELS.has(channel)) return (tpl as TemplateRef).key;\n // Typed, but a JS caller (or an `any`) reaches it unchecked: fail where the workflow is written\n if (!isTemplate(tpl) || CHANNEL_OF[tpl.kind] !== channel) {\n throw new TypeError(`${channel}(): expects a ${channel} template definition`);\n }\n return tpl.id;\n}\n\nfunction messageNode(\n channel: ChannelIR,\n tpl: MessageTemplate,\n props: object | undefined,\n devices?: 'latest'\n): NodeIR {\n return {\n id: '',\n type: 'message',\n channel,\n template: templateKey(channel, tpl),\n props: (props ?? {}) as Record<string, PropValueIR>,\n // Absent for 'all', so adding the option left every existing push's hash as it was\n ...(devices !== undefined ? { devices } : {}),\n // An email's or push's topic (null: transactional) is what the engine checks the opt-outs against\n ...(channel === 'email' || channel === 'push'\n ? { topic: (tpl as EmailTemplate | PushTemplate).topic ?? null }\n : {}),\n // Only the opt-in is written: 'skip' is the default, and absent keeps the node's hash\n ...('redelivery' in tpl && tpl.redelivery === 'resend'\n ? { redelivery: 'resend' as const }\n : {}),\n };\n}\n\n/* oxlint-disable typescript/unbound-method --\n * `captureLoc(this.xxx)` passes the method by identity as the stack-trace\n * boundary for Error.captureStackTrace; it is never invoked, so `this`\n * scoping cannot go wrong. */\n/** @internal extended by WorkflowBuilderImpl; not part of the public API */\nexport class FlowBuilderImpl implements FlowBuilder {\n /** Nodes as collected; ids are assigned in one pass inside toIR, so '' is a placeholder here. */\n readonly nodes: NodeIR[] = [];\n /** The template definitions sent, with where: compile-time only, never in the IR. */\n readonly uses: TemplateUse[] = [];\n /** The action definitions run: compile-time identity only, never in the IR. */\n readonly actions: ActionRef[] = [];\n\n /** Provenance: the callsite (captured in the public method) lands in meta.loc, outside the hash. */\n private pushNode(node: NodeIR, loc: SourceLocIR | undefined): this {\n this.nodes.push({ ...node, ...(loc !== undefined ? { meta: { loc } } : {}) });\n return this;\n }\n\n private message(\n channel: ChannelIR,\n tpl: MessageTemplate,\n props: object | undefined,\n loc: SourceLocIR | undefined,\n devices?: 'latest'\n ): this {\n this.pushNode(messageNode(channel, tpl, props, devices), loc);\n if (isTemplate(tpl)) this.uses.push({ template: tpl, loc });\n return this;\n }\n\n email<T extends EmailTemplate>(t: T, ...args: MessageArgs<PropsOf<T>>): this {\n return this.message('email', t, args[0], captureLoc(this.email));\n }\n sms<P extends object>(t: TemplateRef<'sms', P>, ...args: MessageArgs<P>): this {\n return this.message('sms', t, args[0], captureLoc(this.sms));\n }\n push<T extends PushTemplate>(\n t: T,\n ...args: [...props: MessageArgs<PropsOf<T>>, options?: PushOptions]\n ): this {\n const [props, options] = args as unknown as [object | undefined, PushOptions | undefined];\n const devices = options?.devices === 'latest' ? 'latest' : undefined;\n return this.message('push', t, props, captureLoc(this.push), devices);\n }\n inApp<P extends object>(t: TemplateRef<'in_app', P>, ...args: MessageArgs<P>): this {\n return this.message('in_app', t, args[0], captureLoc(this.inApp));\n }\n slack<T extends SlackTemplate<PropsSchema, never>>(t: T, ...args: MessageArgs<PropsOf<T>>): this {\n return this.message('slack', t, args[0], captureLoc(this.slack));\n }\n discord<T extends DiscordTemplate<PropsSchema, never>>(\n t: T,\n ...args: MessageArgs<PropsOf<T>>\n ): this {\n return this.message('discord', t, args[0], captureLoc(this.discord));\n }\n survey<P extends object>(t: TemplateRef<'survey', P>, ...args: MessageArgs<P>): this {\n return this.message('survey', t, args[0], captureLoc(this.survey));\n }\n\n prefer<const T extends PreferPair, const A extends PropsInput<NoInfer<PropsOf<T[0]>>>>(\n candidates: T & SameProps<NoInfer<T>>,\n props: A & NoInfer<NoExtra<A, PropsOf<T[0]>>>,\n options?: PreferOptions\n ): this {\n checkPreferArgs(candidates, props, options);\n // Every generated node points at the author's prefer() call, not at this method's body\n const loc = captureLoc(this.prefer);\n const meta = loc !== undefined ? { meta: { loc } } : {};\n const [first, second] = candidates as readonly (EmailTemplate | PushTemplate)[];\n const channel = (tpl: EmailTemplate | PushTemplate) => CHANNEL_OF[tpl.kind] as 'email' | 'push';\n // Both are sent from here as far as the registry check goes, the fallback included\n this.uses.push({ template: first, loc }, { template: second, loc });\n // A copy per message, as two separately written calls would hold\n const arm = (tpl: EmailTemplate | PushTemplate): NodeIR[] => [\n { ...messageNode(channel(tpl), tpl, { ...props }), ...meta },\n ];\n return this.pushNode(\n {\n id: '',\n type: 'branch',\n ...(options?.label !== undefined ? { label: options.label } : {}),\n cases: [\n { condition: { type: 'reachable', channel: channel(first), ...meta }, flow: arm(first) },\n ],\n otherwise: arm(second),\n },\n loc\n );\n }\n\n delay(duration: Duration): this;\n delay(range: { min: Duration; max: Duration }): this;\n delay(d: Duration | { min: Duration; max: Duration }): this {\n const loc = captureLoc(this.delay);\n if (typeof d === 'string') {\n return this.pushNode({ id: '', type: 'delay', duration: durationIR(d) }, loc);\n }\n const min = durationIR(d.min);\n const max = durationIR(d.max);\n /*\n * The interpreter draws `min + random(max - min)` with the span clamped at\n * zero, so a backwards range does not fail — it quietly collapses into a\n * fixed `min` sleep. Reject it here, where the author can still see it.\n */\n if (max.ms < min.ms) {\n throw new Error(\n `delay(): 'max' must not be shorter than 'min', got { min: '${d.min}', max: '${d.max}' }`\n );\n }\n return this.pushNode({ id: '', type: 'random_delay', min, max }, loc);\n }\n\n timeWindow(opts: {\n days?: readonly Weekday[];\n between: readonly [TimeOfDay, TimeOfDay];\n tz?: 'user' | (string & {});\n }): this {\n const loc = captureLoc(this.timeWindow);\n if (opts.days !== undefined) {\n // An empty list is a window that never opens: the node would hold the user forever\n if (opts.days.length === 0) {\n throw new Error(\"timeWindow(): 'days' must name at least one day\");\n }\n const seen = new Set<Weekday>();\n for (const day of opts.days) {\n if (seen.has(day)) throw new Error(`timeWindow(): 'days' lists '${day}' twice`);\n seen.add(day);\n }\n }\n const start = timeOfDayMinutes(opts.between[0]);\n const end = timeOfDayMinutes(opts.between[1]);\n if (end <= start) {\n throw new Error(\n `timeWindow(): 'between' must open before it closes within one day, got [${opts.between[0]}, ${opts.between[1]}] (overnight windows are not supported yet)`\n );\n }\n return this.pushNode(\n {\n id: '',\n type: 'time_window',\n days: [...(opts.days ?? (['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] as const))],\n between: [opts.between[0], opts.between[1]],\n tz: opts.tz ?? 'user',\n },\n loc\n );\n }\n\n waitUntil(\n condition: Condition,\n opts: { timeout: Duration; onTimeout?: 'continue' | 'exit' | SubFlow }\n ): this {\n const loc = captureLoc(this.waitUntil);\n const onTimeout = opts.onTimeout ?? 'continue';\n return this.pushNode(\n {\n id: '',\n type: 'wait_until',\n condition: condIR(condition),\n timeout: positiveDurationIR(opts.timeout, 'waitUntil(): timeout'),\n onTimeout:\n onTimeout === 'continue' || onTimeout === 'exit'\n ? onTimeout\n : resolveSubFlow(onTimeout, this.uses, this.actions),\n },\n loc\n );\n }\n\n use(fragment: SubFlow): this {\n // The fragment's nodes carry their own provenance (where the fragment was written)\n this.nodes.push(...resolveSubFlow(fragment, this.uses, this.actions));\n return this;\n }\n\n branch(...arms: readonly BranchArm[]): this;\n branch(label: string, ...arms: readonly BranchArm[]): this;\n branch(...args: readonly (string | BranchArm)[]): this {\n const loc = captureLoc(this.branch);\n const label = typeof args[0] === 'string' ? args[0] : undefined;\n const arms = (label === undefined ? args : args.slice(1)) as readonly BranchArm[];\n\n const cases: { condition: ConditionIR; flow: NodeIR[] }[] = [];\n let otherwise: NodeIR[] | undefined;\n arms.forEach((arm, i) => {\n if (Array.isArray(arm)) {\n if (otherwise !== undefined) {\n throw new Error('branch(): default arm (bare sub-flow) must be the last argument');\n }\n const [condition, sub] = arm as BranchCase;\n cases.push({\n condition: condIR(condition),\n flow: resolveSubFlow(sub, this.uses, this.actions),\n });\n } else {\n if (otherwise !== undefined) {\n throw new Error('branch(): only one default arm (bare sub-flow) is allowed');\n }\n if (i !== arms.length - 1) {\n throw new Error('branch(): default arm (bare sub-flow) must be the last argument');\n }\n otherwise = resolveSubFlow(arm as SubFlow, this.uses, this.actions);\n }\n });\n\n return this.pushNode(\n {\n id: '',\n type: 'branch',\n ...(label !== undefined ? { label } : {}),\n cases,\n ...(otherwise !== undefined ? { otherwise } : {}),\n },\n loc\n );\n }\n\n filter(condition: Condition, opts?: { reason?: string }): this {\n return this.pushNode(\n {\n id: '',\n type: 'filter',\n condition: condIR(condition),\n ...(opts?.reason !== undefined ? { reason: opts.reason } : {}),\n },\n captureLoc(this.filter)\n );\n }\n\n cohort(arms: Record<string, { weight: number; flow?: SubFlow }>, opts?: { key?: string }): this {\n const loc = captureLoc(this.cohort);\n const entries = Object.entries(arms);\n /*\n * Weights live on a 0.01% grid: the runtime buckets into 10,000 slots\n * (basis points), so anything finer would be silently quantized — reject\n * it here instead. Converting to integer basis points also makes the\n * sum check exact; no epsilon needed.\n */\n const basisPoints = entries.map(([name, a]) => {\n if (!ARM_NAME.test(name)) {\n throw new Error(\n `cohort(): arm name '${name}' must start with a letter or '_' and contain only letters, digits, '_' or '-' — arm names become node id segments, and integer-like names would silently reorder the arms`\n );\n }\n const bp = a.weight * 100;\n if (Math.abs(bp - Math.round(bp)) > 1e-6) {\n throw new Error(\n `cohort(): arm '${name}' weight ${a.weight} is finer than the 0.01% bucketing grid — use at most two decimal places`\n );\n }\n /*\n * The sum check alone accepts -50 / 150: the runtime walks the arms\n * accumulating basis points and takes the first whose cumulative total\n * clears the bucket, so a negative weight hands 100% of users to its\n * neighbour instead of splitting anything.\n */\n if (bp < 0) {\n throw new Error(`cohort(): arm '${name}' weight ${a.weight} must not be negative`);\n }\n return Math.round(bp);\n });\n const total = basisPoints.reduce((sum, bp) => sum + bp, 0);\n if (total !== 10_000) {\n throw new Error(`cohort(): weights must sum to 100, got ${total / 100}`);\n }\n return this.pushNode(\n {\n id: '',\n type: 'cohort',\n ...(opts?.key !== undefined ? { key: opts.key } : {}),\n arms: entries.map(([name, a]) => ({\n name,\n weight: a.weight,\n flow: a.flow ? resolveSubFlow(a.flow, this.uses, this.actions) : [],\n })),\n },\n loc\n );\n }\n\n exit(reason?: string): this {\n return this.pushNode(\n { id: '', type: 'exit', ...(reason !== undefined ? { reason } : {}) },\n captureLoc(this.exit)\n );\n }\n\n sendEvent<P extends object>(ev: EventRef<P>, ...payload: EmitArgs<P>): this {\n const values = (payload[0] ?? {}) as Record<string, unknown>;\n for (const [field, value] of Object.entries(values)) {\n const ref =\n typeof value === 'object' &&\n value !== null &&\n (value as { type?: unknown }).type === 'user_property';\n if (!ref && !['string', 'number', 'boolean'].includes(typeof value)) {\n throw new TypeError(\n `sendEvent('${ev.name}'): '${field}' is not a scalar or a u.xxx reference — an emitted payload carries scalar fields only`\n );\n }\n }\n return this.pushNode(\n {\n id: '',\n type: 'send_event',\n event: ev.name,\n payload: values as Record<string, PropValueIR>,\n },\n captureLoc(this.sendEvent)\n );\n }\n\n run<A extends object>(a: ActionRef<A>, ...args: MessageArgs<A>): this {\n this.actions.push(a);\n return this.pushNode(\n {\n id: '',\n type: 'action',\n action: a.name,\n args: (args[0] ?? {}) as Record<string, PropValueIR>,\n },\n captureLoc(this.run)\n );\n }\n}\n/* oxlint-enable typescript/unbound-method */\n\n/**\n * A reusable flow fragment: a free function. Once personalization goes through\n * u.xxx and events through e.xxx, the flow layer carries no schema generics.\n */\nexport function flow(build: (w: FlowBuilder) => FlowBuilder): Flow {\n const builder = new FlowBuilderImpl();\n build(builder);\n const impl: FlowInternal = {\n __flow: true,\n nodes: builder.nodes,\n uses: builder.uses,\n actions: builder.actions,\n };\n return impl;\n}\n"],"mappings":"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"}
@@ -134,11 +134,26 @@ async function reached(node, ir, ctx) {
134
134
  });
135
135
  }
136
136
  /**
137
- * One copy out through the host's sender, with the observer on both sides: it may attach
138
- * tracking before, and hears how the transport took the copy after. A send that throws is
139
- * not reported — the step retries it, and reports when it settles. A report that fails after
140
- * the send is logged, never thrown: the step would retry and the transport — a webhook, a web
141
- * push, with no idempotency key of its own — would deliver the copy again.
137
+ * One copy out through the host's sender, with the observer around it: settled? → prepared
138
+ * (the row, the tracking) → claimed (handed to the transport, committed before the send) →
139
+ * send → sent.
140
+ *
141
+ * Delivery is at most once. A send that throws is a definite failure: the claim is released, the
142
+ * step retries, and the copy is reported when it settles. A copy that was claimed but never
143
+ * settled is the uncertain case — the earlier run died between the transport taking it and the
144
+ * report (or the step's checkpoint) — and a replay does not send it again; it logs and moves on.
145
+ * The templates that must arrive opt out of that per template (`redelivery: 'resend'`), and are
146
+ * sent again as the step retries. A report that fails after the send is logged, never thrown:
147
+ * the step would retry, and the copy — now claimed — would be skipped.
148
+ *
149
+ * Why at most once (decided 2026-11-10, after reviewing the alternatives): exactly-once is out of
150
+ * reach — Cloudflare Email Service (the `send_email` binding and the REST endpoint alike) and SES
151
+ * accept no idempotency key or client message id, FCM does not de-duplicate, only APNs and Web
152
+ * Push collapse a repeat on the device. Between the two remaining options, a duplicate marketing
153
+ * email costs spam complaints, unsubscribes and sender reputation, while a lost nudge is invisible
154
+ * to the user and a journey has later steps; this is how Customer.io and Braze behave (the
155
+ * delivery record is the source of truth, no resend on uncertainty). Do not "fix" this back to
156
+ * at-least-once: set `redelivery: 'resend'` on the templates that need it instead.
142
157
  */
143
158
  async function deliver(message, ctx) {
144
159
  const observer = ctx.observer;
@@ -148,7 +163,21 @@ async function deliver(message, ctx) {
148
163
  ...message,
149
164
  tracking
150
165
  };
151
- const result = await ctx.messages.send(copy);
166
+ if (!(await observer?.messageClaimed?.(copy) ?? true) && copy.redelivery !== "resend") {
167
+ console.warn(`[workflow] ${copy.idempotencyKey} was handed to the transport by an earlier run and never settled: not sent again (redelivery: 'skip')`);
168
+ return;
169
+ }
170
+ let result;
171
+ try {
172
+ result = await ctx.messages.send(copy);
173
+ } catch (error) {
174
+ try {
175
+ await observer?.messageReleased?.(copy);
176
+ } catch (releaseError) {
177
+ console.error(`[workflow] releasing ${copy.idempotencyKey} after a failed send failed`, releaseError);
178
+ }
179
+ throw error;
180
+ }
152
181
  try {
153
182
  await observer?.messageSent?.(copy, result ?? { delivered: true });
154
183
  } catch (error) {
@@ -217,11 +246,15 @@ async function suppressed(node, ctx) {
217
246
  function topicOf(node) {
218
247
  return node.channel === "email" || node.channel === "push" ? { topic: node.topic ?? null } : {};
219
248
  }
249
+ /** The template's `redelivery`, only when it opts into resending: absent is the default, 'skip'. */
250
+ function redeliveryOf(node) {
251
+ return node.redelivery === "resend" ? { redelivery: "resend" } : {};
252
+ }
220
253
  /**
221
254
  * A push goes to devices, not to a profile address: one copy per device the
222
255
  * host's directory lists (or only the most recent, `devices: 'latest'`). Each
223
256
  * copy's idempotency key carries its token, so a retried step that already
224
- * reached some devices re-sends to the rest and the senders drop the repeats —
257
+ * reached some devices re-sends to the rest and skips the ones it reached —
225
258
  * keyed by token rather than position, because the list can change between
226
259
  * attempts. A user with no device is skipped, not failed: being unreachable is
227
260
  * a state of the user, and `reachable('push')` is how a journey branches on it.
@@ -229,7 +262,8 @@ function topicOf(node) {
229
262
  /**
230
263
  * A push: one step settles what goes out and to which devices, then each device gets a step of
231
264
  * its own — a failure on one device retries that device alone, and the copies already delivered
232
- * are not sent again (APNs and FCM carry the idempotency key but do not de-duplicate on it).
265
+ * or claimed are not sent again (see `deliver`; APNs collapses a repeat on the device, FCM does
266
+ * not de-duplicate at all).
233
267
  */
234
268
  async function sendPush(node, ir, ctx) {
235
269
  const plan = await ctx.step.do(`${node.id}:send`, async () => {
@@ -258,7 +292,8 @@ async function sendPush(node, ir, ctx) {
258
292
  recipient: device.token,
259
293
  device,
260
294
  idempotencyKey: `${ctx.instanceId}:${node.id}:${device.token}`,
261
- ...topicOf(node)
295
+ ...topicOf(node),
296
+ ...redeliveryOf(node)
262
297
  }, ctx);
263
298
  });
264
299
  }
@@ -284,7 +319,8 @@ async function runNode(node, ir, ctx) {
284
319
  userId: await ownerOf(ctx),
285
320
  recipient: recipient === void 0 ? void 0 : String(recipient),
286
321
  idempotencyKey: `${ctx.instanceId}:${node.id}`,
287
- ...topicOf(node)
322
+ ...topicOf(node),
323
+ ...redeliveryOf(node)
288
324
  }, ctx);
289
325
  });
290
326
  return FALL_THROUGH;
@@ -1 +1 @@
1
- {"version":3,"file":"interpreter.cjs","names":["evaluateCondition","RECIPIENT_PROPERTY","nextWindowStart","hashToBucket","relevantEvents"],"sources":["../../src/engine/interpreter.ts"],"sourcesContent":["import type { NodeIR, PropValueIR, ScalarIR, WorkflowIR } from '../ir';\nimport { hashToBucket } from './bucket';\nimport { evaluateCondition, relevantEvents } from './condition';\nimport type {\n FactSource,\n JourneyContext,\n JourneyOutcome,\n OutboundMessage,\n PushDevice,\n} from './ports';\nimport { RECIPIENT_PROPERTY } from './recipient';\nimport { nextWindowStart } from './time-window';\n\n/**\n * IR interpreter: the execution core for one user's journey instance.\n *\n * Pure logic — it imports nothing platform-specific; every external effect\n * arrives through the JourneyContext ports, and durable semantics\n * (checkpoint/replay) are the EngineStep adapter's job.\n *\n * Step naming: an IR node id *is* the step name (derived from the structural\n * path, so it is deterministic and unique). When one node needs several steps,\n * a suffix is appended (`${id}:guard`, `${id}:check:${n}`).\n *\n * Guard semantics: goal / exitWhen are checked at journey entry and after\n * every suspension point (delay / random_delay / time_window / wait_until) —\n * the moments where wall-clock time passed and the user's state may have\n * moved. Between two synchronous nodes no meaningful time passes, so checking\n * there would only burn step budget (each checkpoint costs a state write).\n */\n\n/**\n * Cap on wait_until re-evaluations, so a wake-up storm cannot exhaust the platform's step\n * budget. Each loop iteration costs a handful of steps, so 64 checks stay well inside the\n * platform step budgets while absorbing far more wake churn than 16 did. Past the cap the wait\n * is not given up: it sleeps to its deadline and checks once more there (see waitLoop) — the\n * answer is still right at the deadline, it just stops being event-driven in between.\n */\nconst MAX_WAIT_CHECKS = 64;\n\n/** The user property holding an IANA timezone id, read when a time_window says tz: 'user'. Same convention-table status as RECIPIENT_PROPERTY. */\nconst TIMEZONE_PROPERTY = 'timezone';\n\ntype FlowSignal =\n | { kind: 'fall_through' }\n | { kind: 'exit'; reason?: string }\n | { kind: 'goal' }\n | { kind: 'exit_when' };\n\nconst FALL_THROUGH: FlowSignal = { kind: 'fall_through' };\n\n/**\n * Whether and when the instance converted — a fact of its own, apart from how it ends: with\n * `exitOnMatch: false` a conversion lets the flow go on, and the instance still ends `completed`\n * (or `exited`, `exit_when`) carrying it. Set only from checkpointed step results, so a replay\n * rebuilds the same state.\n */\ninterface GoalState {\n convertedAt?: number;\n}\n\n/** Who a message or report is about now: a journey started by a visitor moves when they sign in. */\nfunction ownerOf(ctx: JourneyContext): Promise<string> {\n return ctx.currentUserId?.() ?? Promise.resolve(ctx.userId);\n}\n\nconst goalStates = new WeakMap<JourneyContext, GoalState>();\n\nfunction goalState(ctx: JourneyContext): GoalState {\n let state = goalStates.get(ctx);\n if (state === undefined) {\n state = {};\n goalStates.set(ctx, state);\n }\n return state;\n}\n\n/** When the goal's attribution window closes, measured from entry; none without `within`. */\nfunction goalDeadline(ir: WorkflowIR, ctx: JourneyContext): number | undefined {\n return ir.goal?.within === undefined ? undefined : ctx.enteredAtMs + ir.goal.within.ms;\n}\n\n/**\n * Whether the goal holds for events inside its window: after entry, and not after the window\n * closed — an instance resumed past the deadline must not count a purchase made after it. The\n * conversion time is when the check saw it, capped at the deadline.\n */\nasync function goalReached(\n ir: WorkflowIR,\n ctx: JourneyContext,\n now: number\n): Promise<{ at: number } | null> {\n const goal = ir.goal;\n if (goal === undefined) return null;\n const deadline = goalDeadline(ir, ctx);\n const met = await evaluateCondition(goal.condition, ctx.facts, now, {\n anchorMs: ctx.enteredAtMs,\n ...(deadline === undefined ? {} : { untilMs: deadline }),\n });\n return met ? { at: deadline === undefined ? now : Math.min(now, deadline) } : null;\n}\n\nexport async function runJourney(ir: WorkflowIR, ctx: JourneyContext): Promise<JourneyOutcome> {\n await started(ir, ctx);\n const entry = await checkGuards('entry', ir, ctx);\n let signal = entry ?? (await runFlow(ir.flow, ir, ctx));\n // The flow is done but the conversion window is not: keep watching it (see awaitTail)\n if (signal.kind === 'fall_through') signal = (await awaitTail(ir, ctx)) ?? FALL_THROUGH;\n const { convertedAt } = goalState(ctx);\n const converted = convertedAt === undefined ? {} : { convertedAt };\n switch (signal.kind) {\n case 'fall_through':\n return { status: 'completed', ...converted };\n case 'exit':\n return signal.reason !== undefined\n ? { status: 'exited', reason: signal.reason, ...converted }\n : { status: 'exited', ...converted };\n case 'goal':\n return { status: 'goal', ...converted };\n case 'exit_when':\n return { status: 'exit_when', ...converted };\n }\n}\n\nasync function runFlow(nodes: NodeIR[], ir: WorkflowIR, ctx: JourneyContext): Promise<FlowSignal> {\n for (const node of nodes) {\n await reached(node, ir, ctx);\n const signal = await runNode(node, ir, ctx);\n if (signal.kind !== 'fall_through') return signal;\n }\n return FALL_THROUGH;\n}\n\n/**\n * Tell the observer the instance entered — before the entry guards, which can end it before the\n * first node: an instance is counted from its entry, not from its first node.\n */\nasync function started(ir: WorkflowIR, ctx: JourneyContext): Promise<void> {\n const observer = ctx.observer;\n if (observer?.started === undefined) return;\n await ctx.step.do('entry:started', async () => {\n await observer.started?.({\n workflow: ir.id,\n instanceId: ctx.instanceId,\n userId: await ownerOf(ctx),\n at: ctx.enteredAtMs,\n });\n });\n}\n\n/**\n * Tell the observer the instance is at `node`. A step of its own, so a replay does not report\n * the nodes it passes on the way back again, and the arrival time stays the first one.\n */\nasync function reached(node: NodeIR, ir: WorkflowIR, ctx: JourneyContext): Promise<void> {\n const observer = ctx.observer;\n if (observer?.nodeReached === undefined) return;\n await ctx.step.do(`${node.id}:reached`, async () => {\n await observer.nodeReached?.({\n workflow: ir.id,\n instanceId: ctx.instanceId,\n userId: await ownerOf(ctx),\n nodeId: node.id,\n at: Date.now(),\n });\n });\n}\n\n/**\n * One copy out through the host's sender, with the observer on both sides: it may attach\n * tracking before, and hears how the transport took the copy after. A send that throws is\n * not reported — the step retries it, and reports when it settles. A report that fails after\n * the send is logged, never thrown: the step would retry and the transport — a webhook, a web\n * push, with no idempotency key of its own — would deliver the copy again.\n */\nasync function deliver(message: OutboundMessage, ctx: JourneyContext): Promise<void> {\n const observer = ctx.observer;\n // A replayed step whose copy an earlier run already settled: the transport took it once\n if (await observer?.messageSettled?.(message)) return;\n const tracking = await observer?.messagePrepared?.(message);\n const copy = tracking === undefined ? message : { ...message, tracking };\n const result = await ctx.messages.send(copy);\n try {\n await observer?.messageSent?.(copy, result ?? { delivered: true });\n } catch (error) {\n console.error(`[workflow] reporting ${copy.idempotencyKey} as sent failed`, error);\n }\n}\n\n/**\n * Goal / exitWhen guards (see the header for when they run).\n *\n * The goal condition is anchored at workflow entry: a `performed` inside it\n * only counts events after the user entered, so pre-existing history cannot\n * register as a conversion. exitWhen is deliberately unanchored — \"user\n * unsubscribed\" should exit no matter when it happened.\n */\nasync function checkGuards(\n stepId: string,\n ir: WorkflowIR,\n ctx: JourneyContext\n): Promise<FlowSignal | null> {\n const goal = ir.goal;\n const exitWhen = ir.exitWhen;\n if (goal === undefined && exitWhen === undefined) return null;\n\n const state = goalState(ctx);\n const result = await ctx.step.do(`${stepId}:guard`, async () => {\n const now = Date.now();\n // Already converted (exitOnMatch: false): the conversion stands, no need to look again\n const goalHit =\n goal !== undefined && state.convertedAt === undefined\n ? await goalReached(ir, ctx, now)\n : null;\n const exitMet = exitWhen !== undefined && (await evaluateCondition(exitWhen, ctx.facts, now));\n return { goalAt: goalHit?.at ?? null, exitMet };\n });\n\n if (result.goalAt !== null) {\n state.convertedAt ??= result.goalAt;\n if (goal?.exitOnMatch ?? true) return { kind: 'goal' };\n }\n if (result.exitMet) return { kind: 'exit_when' };\n return null;\n}\n\n/**\n * The host's template check, with the instance and node added to the message:\n * a refused message must point at the journey that produced it. The error\n * keeps its class (and `name`), so adapters can stop retrying it.\n */\nfunction validate(\n node: Extract<NodeIR, { type: 'message' }>,\n props: Record<string, ScalarIR | undefined>,\n ctx: JourneyContext\n): Record<string, ScalarIR | undefined> {\n if (ctx.validator === undefined) return props;\n try {\n return ctx.validator.validate({ channel: node.channel, template: node.template, props });\n } catch (error) {\n if (error instanceof Error) {\n error.message = `[${ctx.instanceId} ${node.id}] ${error.message}`;\n }\n throw error;\n }\n}\n\n/**\n * Whether the user opted out of what this step sends. An email or push step checks its topic —\n * an opt-out of the topic on that channel or of the whole channel skips it — and a transactional\n * message (`topic: null`) is never skipped: a receipt or a code goes out whatever the user opted\n * out of. A node compiled before topics existed (no `topic`) checks the channel-wide opt-out\n * only, as it always did. Other channels check the channel.\n */\nasync function suppressed(\n node: Extract<NodeIR, { type: 'message' }>,\n ctx: JourneyContext\n): Promise<boolean> {\n if (node.topic === null) return false;\n return (await ctx.facts.isSuppressed?.(node.channel, node.topic)) === true;\n}\n\n/** The copy's topic, for the channels that have one: what the observer records and the senders may read. */\nfunction topicOf(node: Extract<NodeIR, { type: 'message' }>): { topic?: string | null } {\n return node.channel === 'email' || node.channel === 'push' ? { topic: node.topic ?? null } : {};\n}\n\ntype PushPlan = { props: Record<string, ScalarIR | undefined>; devices: PushDevice[] };\n\n/**\n * A push goes to devices, not to a profile address: one copy per device the\n * host's directory lists (or only the most recent, `devices: 'latest'`). Each\n * copy's idempotency key carries its token, so a retried step that already\n * reached some devices re-sends to the rest and the senders drop the repeats —\n * keyed by token rather than position, because the list can change between\n * attempts. A user with no device is skipped, not failed: being unreachable is\n * a state of the user, and `reachable('push')` is how a journey branches on it.\n */\n/**\n * A push: one step settles what goes out and to which devices, then each device gets a step of\n * its own — a failure on one device retries that device alone, and the copies already delivered\n * are not sent again (APNs and FCM carry the idempotency key but do not de-duplicate on it).\n */\nasync function sendPush(\n node: Extract<NodeIR, { type: 'message' }>,\n ir: WorkflowIR,\n ctx: JourneyContext\n): Promise<void> {\n const plan = await ctx.step.do<PushPlan | null>(`${node.id}:send`, async () => {\n const props = validate(node, await resolveValues(node.props, ctx.facts), ctx);\n // Once per step, before any device: the topic's opt-out or the channel's (never a transactional push)\n if (await suppressed(node, ctx)) return null;\n const listed = await ctx.facts.listDevices();\n const devices = node.devices === 'latest' ? listed.slice(0, 1) : listed;\n return { props, devices };\n });\n // null: the topic or the channel is opted out\n if (plan === null) return;\n if (plan.devices.length === 0) {\n console.log(`[journey] push ${node.template} skipped: no device for user ${ctx.userId}`);\n return;\n }\n for (const [index, device] of plan.devices.entries()) {\n await ctx.step.do(`${node.id}:send:${index}`, async () => {\n await deliver(\n {\n workflow: ir.id,\n instanceId: ctx.instanceId,\n nodeId: node.id,\n channel: 'push',\n template: node.template,\n props: plan.props,\n userId: await ownerOf(ctx),\n recipient: device.token,\n device,\n idempotencyKey: `${ctx.instanceId}:${node.id}:${device.token}`,\n ...topicOf(node),\n },\n ctx\n );\n });\n }\n}\n\nasync function runNode(node: NodeIR, ir: WorkflowIR, ctx: JourneyContext): Promise<FlowSignal> {\n switch (node.type) {\n case 'message': {\n if (node.channel === 'push') {\n await sendPush(node, ir, ctx);\n return FALL_THROUGH;\n }\n await ctx.step.do(`${node.id}:send`, async () => {\n const props = validate(node, await resolveValues(node.props, ctx.facts), ctx);\n // An opted-out channel (an email unsubscribe) is skipped, like a push with no device\n if (await suppressed(node, ctx)) return;\n const recipientPath = RECIPIENT_PROPERTY[node.channel];\n const recipient =\n recipientPath === undefined ? undefined : await ctx.facts.getProperty(recipientPath);\n await deliver(\n {\n workflow: ir.id,\n instanceId: ctx.instanceId,\n nodeId: node.id,\n channel: node.channel,\n template: node.template,\n props,\n userId: await ownerOf(ctx),\n recipient: recipient === undefined ? undefined : String(recipient),\n idempotencyKey: `${ctx.instanceId}:${node.id}`,\n ...topicOf(node),\n },\n ctx\n );\n });\n return FALL_THROUGH;\n }\n\n case 'delay': {\n await ctx.step.sleep(node.id, node.duration.ms);\n return (await checkGuards(node.id, ir, ctx)) ?? FALL_THROUGH;\n }\n\n case 'random_delay': {\n // The random value is drawn and persisted inside the step, so replay does not re-roll it\n const ms = await ctx.step.do(`${node.id}:roll`, async () => {\n const span = node.max.ms - node.min.ms;\n return node.min.ms + Math.floor(Math.random() * Math.max(span, 0));\n });\n await ctx.step.sleep(`${node.id}:sleep`, ms);\n return (await checkGuards(node.id, ir, ctx)) ?? FALL_THROUGH;\n }\n\n case 'time_window': {\n const target = await ctx.step.do(`${node.id}:target`, async () => {\n const tz =\n node.tz === 'user'\n ? String((await ctx.facts.getProperty(TIMEZONE_PROPERTY)) ?? 'UTC')\n : node.tz;\n return nextWindowStart(Date.now(), node.days, node.between, tz);\n });\n if (target === null) return FALL_THROUGH; // already inside the window: no suspension, no guard\n // The remaining time is checkpointed: a replay must not recompute it from a later clock\n // (the target has passed by then, and a negative sleep is an error on some runtimes)\n const ms = await ctx.step.do(`${node.id}:remaining`, async () => target - Date.now());\n if (ms > 0) await ctx.step.sleep(`${node.id}:sleep`, ms);\n return (await checkGuards(node.id, ir, ctx)) ?? FALL_THROUGH;\n }\n\n case 'wait_until': {\n const met = await waitUntil(node, ctx);\n // Time passed while waiting — guard before acting on the outcome (goal wins over the wait result)\n const guard = await checkGuards(node.id, ir, ctx);\n if (guard) return guard;\n if (met) return FALL_THROUGH;\n const onTimeout = node.onTimeout;\n if (onTimeout === 'continue') return FALL_THROUGH;\n if (onTimeout === 'exit') return { kind: 'exit', reason: `${node.id}:timeout` };\n return runFlow(onTimeout, ir, ctx);\n }\n\n case 'branch': {\n const matched = await ctx.step.do(`${node.id}:eval`, async () => {\n const now = Date.now();\n for (const [i, branchCase] of node.cases.entries()) {\n if (await evaluateCondition(branchCase.condition, ctx.facts, now)) {\n return i;\n }\n }\n return -1; // no match → default arm, or simply continue\n });\n const flow = matched >= 0 ? node.cases[matched]?.flow : node.otherwise;\n if (flow === undefined) return FALL_THROUGH;\n const signal = await runFlow(flow, ir, ctx);\n // Structural rejoin: once an arm falls through, execution resumes after the branch\n return signal;\n }\n\n case 'filter': {\n const pass = await ctx.step.do(`${node.id}:eval`, async () =>\n evaluateCondition(node.condition, ctx.facts, Date.now())\n );\n return pass ? FALL_THROUGH : { kind: 'exit', reason: node.reason ?? `${node.id}:filtered` };\n }\n\n case 'cohort': {\n // Deterministic bucketing, so replay needs no checkpoint. An explicit\n // experiment key is global on purpose: two workflows naming the same\n // experiment bucket a user identically. The fallback is namespaced by the\n // workflow, because structural ids ('2', '1.c0.0') repeat across\n // workflows and a bare one would put a user in the same arm of every\n // unrelated split at that position.\n const bucket = hashToBucket(`${ctx.userId}:${node.key ?? `${ir.id}:${node.id}`}`);\n // Integer basis points on both sides (the DSL guarantees weights sit on\n // the 0.01% grid), so no float ever meets the comparison.\n let cumulative = 0;\n for (const arm of node.arms) {\n cumulative += Math.round(arm.weight * 100);\n if (bucket < cumulative) return runFlow(arm.flow, ir, ctx);\n }\n return FALL_THROUGH;\n }\n\n case 'exit':\n return node.reason !== undefined ? { kind: 'exit', reason: node.reason } : { kind: 'exit' };\n\n case 'send_event': {\n await ctx.step.do(`${node.id}:emit`, async () => {\n const payload = await resolveValues(node.payload, ctx.facts);\n try {\n await ctx.events.emit(node.event, payload, `${ctx.instanceId}:${node.id}`);\n } catch (error) {\n // A refused event must point at the journey that emitted it\n if (error instanceof Error)\n error.message = `[${ctx.instanceId} ${node.id}] ${error.message}`;\n throw error;\n }\n });\n return FALL_THROUGH;\n }\n\n case 'action': {\n // Custom code runs inside step.do: retried on throw, checkpointed on success\n const invoker = ctx.actions;\n if (invoker === undefined) {\n throw new Error(\n `action '${node.action}': this runtime has no ActionInvoker configured — register the action on the adapter (see engine/actions.ts)`\n );\n }\n await ctx.step.do(`${node.id}:run`, async () => {\n const args = await resolveValues(node.args, ctx.facts);\n await invoker.invoke({\n action: node.action,\n args,\n userId: await ownerOf(ctx),\n idempotencyKey: `${ctx.instanceId}:${node.id}`,\n });\n });\n return FALL_THROUGH;\n }\n }\n}\n\nasync function waitUntil(\n node: Extract<NodeIR, { type: 'wait_until' }>,\n ctx: JourneyContext\n): Promise<boolean> {\n const deadline = await ctx.step.do(\n `${node.id}:deadline`,\n async () => Date.now() + node.timeout.ms\n );\n const events = await ctx.step.do(`${node.id}:events`, async () =>\n relevantEvents(node.condition, ctx.facts)\n );\n // Anchor at the moment the wait began (deadline is checkpointed, so this is\n // replay-stable): `performed` inside the condition only counts events that\n // happen during the wait — pre-existing history must not satisfy it.\n const anchorMs = deadline - node.timeout.ms;\n const met = await waitLoop(node.id, ctx, deadline, events, async (now) =>\n (await evaluateCondition(node.condition, ctx.facts, now, { anchorMs })) ? true : null\n );\n await ctx.step.unsubscribe(`${node.id}:unsubscribe`);\n return met === true;\n}\n\n/**\n * The conversion window outlives the flow: a journey whose steps are done while its goal's\n * `within` is still open keeps watching until the window closes — the purchase that follows the\n * last offer email is that email's conversion, and ending the instance at the email would report\n * it as a plain completion. `exitWhen` still applies while it waits. Nothing to wait for without\n * a window, or once the instance has converted.\n */\nasync function awaitTail(ir: WorkflowIR, ctx: JourneyContext): Promise<FlowSignal | null> {\n const deadline = goalDeadline(ir, ctx);\n const goal = ir.goal;\n if (goal === undefined || deadline === undefined) return null;\n const state = goalState(ctx);\n if (state.convertedAt !== undefined) return null;\n const open = await ctx.step.do('goal:open', async () => deadline > Date.now());\n if (!open) return null;\n const exitWhen = ir.exitWhen;\n const events = await ctx.step.do('goal:events', async () => [\n ...new Set([\n ...(await relevantEvents(goal.condition, ctx.facts)),\n ...(exitWhen === undefined ? [] : await relevantEvents(exitWhen, ctx.facts)),\n ]),\n ]);\n const hit = await waitLoop('goal', ctx, deadline, events, async (now) => {\n const reached = await goalReached(ir, ctx, now);\n if (reached !== null) return { kind: 'goal' as const, at: reached.at };\n if (exitWhen !== undefined && (await evaluateCondition(exitWhen, ctx.facts, now))) {\n return { kind: 'exit_when' as const, at: now };\n }\n return null;\n });\n await ctx.step.unsubscribe('goal:unsubscribe');\n if (hit === null) return null;\n if (hit.kind === 'goal') {\n state.convertedAt ??= hit.at;\n return { kind: 'goal' };\n }\n return { kind: 'exit_when' };\n}\n\n/**\n * Wait until `check` finds something or the deadline passes: subscribe, check, sleep until a\n * relevant event wakes the instance or time runs out. `check` runs inside a step, so its result\n * must be serializable; null means \"not yet\". Null from here means the deadline passed with\n * nothing found — never a failure, and never a wait given up on early.\n */\nasync function waitLoop<R>(\n id: string,\n ctx: JourneyContext,\n deadline: number,\n events: string[],\n check: (now: number) => Promise<R | null>\n): Promise<R | null> {\n for (let attempt = 0; attempt < MAX_WAIT_CHECKS; attempt++) {\n // Subscribe before the check, every attempt: one subscribe arms at least\n // one wake-up (AWS callbacks are consumed on delivery, so each attempt\n // needs a fresh arm; Cloudflare rows make the repeat call idempotent), and\n // arming first closes the race where the event lands between the check and\n // the registration.\n await ctx.step.subscribe(`${id}:subscribe:${attempt}`, events, { deadlineMs: deadline });\n\n const met = await ctx.step.do(`${id}:check:${attempt}`, async () => check(Date.now()));\n if (met !== null) return met;\n\n // Checkpointed: replay re-runs this control flow with wall-clock time far\n // ahead of the original run, so the branch must come from a persisted value.\n const remaining = await ctx.step.do(\n `${id}:remaining:${attempt}`,\n async () => deadline - Date.now()\n );\n // The condition was checked just above — expiring here needs no extra final check\n if (remaining <= 0) return null;\n\n const wake = await ctx.step.waitForWake(`${id}:wait:${attempt}`, {\n timeoutMs: remaining,\n deadlineMs: deadline,\n });\n // Final check after timeout: in a tight race the event may land exactly as the timer fires\n if (wake === 'timeout') return finalCheck(id, ctx, check);\n // Woken → back to the top of the loop to re-evaluate (at-least-once wake-up semantics)\n }\n // Check budget exhausted (see MAX_WAIT_CHECKS). Not a timeout: the wait keeps its deadline,\n // only the wake-ups are given up — sleep to the deadline, then check once more there. The\n // subscription rows stay until the caller's unsubscribe; a wake landing meanwhile is dropped\n // by the runtime (nothing is parked on it), which is the point.\n console.warn(\n `[journey] ${ctx.instanceId} ${id}: ${MAX_WAIT_CHECKS} wake-ups without the condition holding — sleeping to the deadline`\n );\n const budgetRemaining = await ctx.step.do(\n `${id}:budget:remaining`,\n async () => deadline - Date.now()\n );\n if (budgetRemaining > 0) await ctx.step.sleep(`${id}:budget:sleep`, budgetRemaining);\n return finalCheck(id, ctx, check);\n}\n\n/** The check once the deadline is reached — one step for all the ways of getting there. */\nasync function finalCheck<R>(\n id: string,\n ctx: JourneyContext,\n check: (now: number) => Promise<R | null>\n): Promise<R | null> {\n return ctx.step.do(`${id}:final`, async () => check(Date.now()));\n}\n\nasync function resolveValues(\n values: Record<string, PropValueIR>,\n facts: FactSource\n): Promise<Record<string, ScalarIR | undefined>> {\n const resolved: Record<string, ScalarIR | undefined> = {};\n for (const [key, value] of Object.entries(values)) {\n resolved[key] = typeof value === 'object' ? await facts.getProperty(value.path) : value;\n }\n return resolved;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAsCA,MAAM,kBAAkB;;AAGxB,MAAM,oBAAoB;AAQ1B,MAAM,eAA2B,EAAE,MAAM,eAAe;;AAaxD,SAAS,QAAQ,KAAsC;CACrD,OAAO,IAAI,gBAAgB,KAAK,QAAQ,QAAQ,IAAI,MAAM;AAC5D;AAEA,MAAM,6BAAa,IAAI,QAAmC;AAE1D,SAAS,UAAU,KAAgC;CACjD,IAAI,QAAQ,WAAW,IAAI,GAAG;CAC9B,IAAI,UAAU,KAAA,GAAW;EACvB,QAAQ,CAAC;EACT,WAAW,IAAI,KAAK,KAAK;CAC3B;CACA,OAAO;AACT;;AAGA,SAAS,aAAa,IAAgB,KAAyC;CAC7E,OAAO,GAAG,MAAM,WAAW,KAAA,IAAY,KAAA,IAAY,IAAI,cAAc,GAAG,KAAK,OAAO;AACtF;;;;;;AAOA,eAAe,YACb,IACA,KACA,KACgC;CAChC,MAAM,OAAO,GAAG;CAChB,IAAI,SAAS,KAAA,GAAW,OAAO;CAC/B,MAAM,WAAW,aAAa,IAAI,GAAG;CAKrC,OAAO,MAJWA,yBAAAA,kBAAkB,KAAK,WAAW,IAAI,OAAO,KAAK;EAClE,UAAU,IAAI;EACd,GAAI,aAAa,KAAA,IAAY,CAAC,IAAI,EAAE,SAAS,SAAS;CACxD,CAAC,IACY,EAAE,IAAI,aAAa,KAAA,IAAY,MAAM,KAAK,IAAI,KAAK,QAAQ,EAAE,IAAI;AAChF;AAEA,eAAsB,WAAW,IAAgB,KAA8C;CAC7F,MAAM,QAAQ,IAAI,GAAG;CAErB,IAAI,SAAS,MADO,YAAY,SAAS,IAAI,GAAG,KACzB,MAAM,QAAQ,GAAG,MAAM,IAAI,GAAG;CAErD,IAAI,OAAO,SAAS,gBAAgB,SAAU,MAAM,UAAU,IAAI,GAAG,KAAM;CAC3E,MAAM,EAAE,gBAAgB,UAAU,GAAG;CACrC,MAAM,YAAY,gBAAgB,KAAA,IAAY,CAAC,IAAI,EAAE,YAAY;CACjE,QAAQ,OAAO,MAAf;EACE,KAAK,gBACH,OAAO;GAAE,QAAQ;GAAa,GAAG;EAAU;EAC7C,KAAK,QACH,OAAO,OAAO,WAAW,KAAA,IACrB;GAAE,QAAQ;GAAU,QAAQ,OAAO;GAAQ,GAAG;EAAU,IACxD;GAAE,QAAQ;GAAU,GAAG;EAAU;EACvC,KAAK,QACH,OAAO;GAAE,QAAQ;GAAQ,GAAG;EAAU;EACxC,KAAK,aACH,OAAO;GAAE,QAAQ;GAAa,GAAG;EAAU;CAC/C;AACF;AAEA,eAAe,QAAQ,OAAiB,IAAgB,KAA0C;CAChG,KAAK,MAAM,QAAQ,OAAO;EACxB,MAAM,QAAQ,MAAM,IAAI,GAAG;EAC3B,MAAM,SAAS,MAAM,QAAQ,MAAM,IAAI,GAAG;EAC1C,IAAI,OAAO,SAAS,gBAAgB,OAAO;CAC7C;CACA,OAAO;AACT;;;;;AAMA,eAAe,QAAQ,IAAgB,KAAoC;CACzE,MAAM,WAAW,IAAI;CACrB,IAAI,UAAU,YAAY,KAAA,GAAW;CACrC,MAAM,IAAI,KAAK,GAAG,iBAAiB,YAAY;EAC7C,MAAM,SAAS,UAAU;GACvB,UAAU,GAAG;GACb,YAAY,IAAI;GAChB,QAAQ,MAAM,QAAQ,GAAG;GACzB,IAAI,IAAI;EACV,CAAC;CACH,CAAC;AACH;;;;;AAMA,eAAe,QAAQ,MAAc,IAAgB,KAAoC;CACvF,MAAM,WAAW,IAAI;CACrB,IAAI,UAAU,gBAAgB,KAAA,GAAW;CACzC,MAAM,IAAI,KAAK,GAAG,GAAG,KAAK,GAAG,WAAW,YAAY;EAClD,MAAM,SAAS,cAAc;GAC3B,UAAU,GAAG;GACb,YAAY,IAAI;GAChB,QAAQ,MAAM,QAAQ,GAAG;GACzB,QAAQ,KAAK;GACb,IAAI,KAAK,IAAI;EACf,CAAC;CACH,CAAC;AACH;;;;;;;;AASA,eAAe,QAAQ,SAA0B,KAAoC;CACnF,MAAM,WAAW,IAAI;CAErB,IAAI,MAAM,UAAU,iBAAiB,OAAO,GAAG;CAC/C,MAAM,WAAW,MAAM,UAAU,kBAAkB,OAAO;CAC1D,MAAM,OAAO,aAAa,KAAA,IAAY,UAAU;EAAE,GAAG;EAAS;CAAS;CACvE,MAAM,SAAS,MAAM,IAAI,SAAS,KAAK,IAAI;CAC3C,IAAI;EACF,MAAM,UAAU,cAAc,MAAM,UAAU,EAAE,WAAW,KAAK,CAAC;CACnE,SAAS,OAAO;EACd,QAAQ,MAAM,wBAAwB,KAAK,eAAe,kBAAkB,KAAK;CACnF;AACF;;;;;;;;;AAUA,eAAe,YACb,QACA,IACA,KAC4B;CAC5B,MAAM,OAAO,GAAG;CAChB,MAAM,WAAW,GAAG;CACpB,IAAI,SAAS,KAAA,KAAa,aAAa,KAAA,GAAW,OAAO;CAEzD,MAAM,QAAQ,UAAU,GAAG;CAC3B,MAAM,SAAS,MAAM,IAAI,KAAK,GAAG,GAAG,OAAO,SAAS,YAAY;EAC9D,MAAM,MAAM,KAAK,IAAI;EAErB,MAAM,UACJ,SAAS,KAAA,KAAa,MAAM,gBAAgB,KAAA,IACxC,MAAM,YAAY,IAAI,KAAK,GAAG,IAC9B;EACN,MAAM,UAAU,aAAa,KAAA,KAAc,MAAMA,yBAAAA,kBAAkB,UAAU,IAAI,OAAO,GAAG;EAC3F,OAAO;GAAE,QAAQ,SAAS,MAAM;GAAM;EAAQ;CAChD,CAAC;CAED,IAAI,OAAO,WAAW,MAAM;EAC1B,MAAM,gBAAgB,OAAO;EAC7B,IAAI,MAAM,eAAe,MAAM,OAAO,EAAE,MAAM,OAAO;CACvD;CACA,IAAI,OAAO,SAAS,OAAO,EAAE,MAAM,YAAY;CAC/C,OAAO;AACT;;;;;;AAOA,SAAS,SACP,MACA,OACA,KACsC;CACtC,IAAI,IAAI,cAAc,KAAA,GAAW,OAAO;CACxC,IAAI;EACF,OAAO,IAAI,UAAU,SAAS;GAAE,SAAS,KAAK;GAAS,UAAU,KAAK;GAAU;EAAM,CAAC;CACzF,SAAS,OAAO;EACd,IAAI,iBAAiB,OACnB,MAAM,UAAU,IAAI,IAAI,WAAW,GAAG,KAAK,GAAG,IAAI,MAAM;EAE1D,MAAM;CACR;AACF;;;;;;;;AASA,eAAe,WACb,MACA,KACkB;CAClB,IAAI,KAAK,UAAU,MAAM,OAAO;CAChC,OAAQ,MAAM,IAAI,MAAM,eAAe,KAAK,SAAS,KAAK,KAAK,MAAO;AACxE;;AAGA,SAAS,QAAQ,MAAuE;CACtF,OAAO,KAAK,YAAY,WAAW,KAAK,YAAY,SAAS,EAAE,OAAO,KAAK,SAAS,KAAK,IAAI,CAAC;AAChG;;;;;;;;;;;;;;;AAkBA,eAAe,SACb,MACA,IACA,KACe;CACf,MAAM,OAAO,MAAM,IAAI,KAAK,GAAoB,GAAG,KAAK,GAAG,QAAQ,YAAY;EAC7E,MAAM,QAAQ,SAAS,MAAM,MAAM,cAAc,KAAK,OAAO,IAAI,KAAK,GAAG,GAAG;EAE5E,IAAI,MAAM,WAAW,MAAM,GAAG,GAAG,OAAO;EACxC,MAAM,SAAS,MAAM,IAAI,MAAM,YAAY;EAE3C,OAAO;GAAE;GAAO,SADA,KAAK,YAAY,WAAW,OAAO,MAAM,GAAG,CAAC,IAAI;EACzC;CAC1B,CAAC;CAED,IAAI,SAAS,MAAM;CACnB,IAAI,KAAK,QAAQ,WAAW,GAAG;EAC7B,QAAQ,IAAI,kBAAkB,KAAK,SAAS,+BAA+B,IAAI,QAAQ;EACvF;CACF;CACA,KAAK,MAAM,CAAC,OAAO,WAAW,KAAK,QAAQ,QAAQ,GACjD,MAAM,IAAI,KAAK,GAAG,GAAG,KAAK,GAAG,QAAQ,SAAS,YAAY;EACxD,MAAM,QACJ;GACE,UAAU,GAAG;GACb,YAAY,IAAI;GAChB,QAAQ,KAAK;GACb,SAAS;GACT,UAAU,KAAK;GACf,OAAO,KAAK;GACZ,QAAQ,MAAM,QAAQ,GAAG;GACzB,WAAW,OAAO;GAClB;GACA,gBAAgB,GAAG,IAAI,WAAW,GAAG,KAAK,GAAG,GAAG,OAAO;GACvD,GAAG,QAAQ,IAAI;EACjB,GACA,GACF;CACF,CAAC;AAEL;AAEA,eAAe,QAAQ,MAAc,IAAgB,KAA0C;CAC7F,QAAQ,KAAK,MAAb;EACE,KAAK;GACH,IAAI,KAAK,YAAY,QAAQ;IAC3B,MAAM,SAAS,MAAM,IAAI,GAAG;IAC5B,OAAO;GACT;GACA,MAAM,IAAI,KAAK,GAAG,GAAG,KAAK,GAAG,QAAQ,YAAY;IAC/C,MAAM,QAAQ,SAAS,MAAM,MAAM,cAAc,KAAK,OAAO,IAAI,KAAK,GAAG,GAAG;IAE5E,IAAI,MAAM,WAAW,MAAM,GAAG,GAAG;IACjC,MAAM,gBAAgBC,yBAAAA,mBAAmB,KAAK;IAC9C,MAAM,YACJ,kBAAkB,KAAA,IAAY,KAAA,IAAY,MAAM,IAAI,MAAM,YAAY,aAAa;IACrF,MAAM,QACJ;KACE,UAAU,GAAG;KACb,YAAY,IAAI;KAChB,QAAQ,KAAK;KACb,SAAS,KAAK;KACd,UAAU,KAAK;KACf;KACA,QAAQ,MAAM,QAAQ,GAAG;KACzB,WAAW,cAAc,KAAA,IAAY,KAAA,IAAY,OAAO,SAAS;KACjE,gBAAgB,GAAG,IAAI,WAAW,GAAG,KAAK;KAC1C,GAAG,QAAQ,IAAI;IACjB,GACA,GACF;GACF,CAAC;GACD,OAAO;EAGT,KAAK;GACH,MAAM,IAAI,KAAK,MAAM,KAAK,IAAI,KAAK,SAAS,EAAE;GAC9C,OAAQ,MAAM,YAAY,KAAK,IAAI,IAAI,GAAG,KAAM;EAGlD,KAAK,gBAAgB;GAEnB,MAAM,KAAK,MAAM,IAAI,KAAK,GAAG,GAAG,KAAK,GAAG,QAAQ,YAAY;IAC1D,MAAM,OAAO,KAAK,IAAI,KAAK,KAAK,IAAI;IACpC,OAAO,KAAK,IAAI,KAAK,KAAK,MAAM,KAAK,OAAO,IAAI,KAAK,IAAI,MAAM,CAAC,CAAC;GACnE,CAAC;GACD,MAAM,IAAI,KAAK,MAAM,GAAG,KAAK,GAAG,SAAS,EAAE;GAC3C,OAAQ,MAAM,YAAY,KAAK,IAAI,IAAI,GAAG,KAAM;EAClD;EAEA,KAAK,eAAe;GAClB,MAAM,SAAS,MAAM,IAAI,KAAK,GAAG,GAAG,KAAK,GAAG,UAAU,YAAY;IAChE,MAAM,KACJ,KAAK,OAAO,SACR,OAAQ,MAAM,IAAI,MAAM,YAAY,iBAAiB,KAAM,KAAK,IAChE,KAAK;IACX,OAAOC,2BAAAA,gBAAgB,KAAK,IAAI,GAAG,KAAK,MAAM,KAAK,SAAS,EAAE;GAChE,CAAC;GACD,IAAI,WAAW,MAAM,OAAO;GAG5B,MAAM,KAAK,MAAM,IAAI,KAAK,GAAG,GAAG,KAAK,GAAG,aAAa,YAAY,SAAS,KAAK,IAAI,CAAC;GACpF,IAAI,KAAK,GAAG,MAAM,IAAI,KAAK,MAAM,GAAG,KAAK,GAAG,SAAS,EAAE;GACvD,OAAQ,MAAM,YAAY,KAAK,IAAI,IAAI,GAAG,KAAM;EAClD;EAEA,KAAK,cAAc;GACjB,MAAM,MAAM,MAAM,UAAU,MAAM,GAAG;GAErC,MAAM,QAAQ,MAAM,YAAY,KAAK,IAAI,IAAI,GAAG;GAChD,IAAI,OAAO,OAAO;GAClB,IAAI,KAAK,OAAO;GAChB,MAAM,YAAY,KAAK;GACvB,IAAI,cAAc,YAAY,OAAO;GACrC,IAAI,cAAc,QAAQ,OAAO;IAAE,MAAM;IAAQ,QAAQ,GAAG,KAAK,GAAG;GAAU;GAC9E,OAAO,QAAQ,WAAW,IAAI,GAAG;EACnC;EAEA,KAAK,UAAU;GACb,MAAM,UAAU,MAAM,IAAI,KAAK,GAAG,GAAG,KAAK,GAAG,QAAQ,YAAY;IAC/D,MAAM,MAAM,KAAK,IAAI;IACrB,KAAK,MAAM,CAAC,GAAG,eAAe,KAAK,MAAM,QAAQ,GAC/C,IAAI,MAAMF,yBAAAA,kBAAkB,WAAW,WAAW,IAAI,OAAO,GAAG,GAC9D,OAAO;IAGX,OAAO;GACT,CAAC;GACD,MAAM,OAAO,WAAW,IAAI,KAAK,MAAM,QAAQ,EAAE,OAAO,KAAK;GAC7D,IAAI,SAAS,KAAA,GAAW,OAAO;GAG/B,OAAO,MAFc,QAAQ,MAAM,IAAI,GAAG;EAG5C;EAEA,KAAK,UAIH,OAAO,MAHY,IAAI,KAAK,GAAG,GAAG,KAAK,GAAG,QAAQ,YAChDA,yBAAAA,kBAAkB,KAAK,WAAW,IAAI,OAAO,KAAK,IAAI,CAAC,CACzD,IACc,eAAe;GAAE,MAAM;GAAQ,QAAQ,KAAK,UAAU,GAAG,KAAK,GAAG;EAAW;EAG5F,KAAK,UAAU;GAOb,MAAM,SAASG,sBAAAA,aAAa,GAAG,IAAI,OAAO,GAAG,KAAK,OAAO,GAAG,GAAG,GAAG,GAAG,KAAK,MAAM;GAGhF,IAAI,aAAa;GACjB,KAAK,MAAM,OAAO,KAAK,MAAM;IAC3B,cAAc,KAAK,MAAM,IAAI,SAAS,GAAG;IACzC,IAAI,SAAS,YAAY,OAAO,QAAQ,IAAI,MAAM,IAAI,GAAG;GAC3D;GACA,OAAO;EACT;EAEA,KAAK,QACH,OAAO,KAAK,WAAW,KAAA,IAAY;GAAE,MAAM;GAAQ,QAAQ,KAAK;EAAO,IAAI,EAAE,MAAM,OAAO;EAE5F,KAAK;GACH,MAAM,IAAI,KAAK,GAAG,GAAG,KAAK,GAAG,QAAQ,YAAY;IAC/C,MAAM,UAAU,MAAM,cAAc,KAAK,SAAS,IAAI,KAAK;IAC3D,IAAI;KACF,MAAM,IAAI,OAAO,KAAK,KAAK,OAAO,SAAS,GAAG,IAAI,WAAW,GAAG,KAAK,IAAI;IAC3E,SAAS,OAAO;KAEd,IAAI,iBAAiB,OACnB,MAAM,UAAU,IAAI,IAAI,WAAW,GAAG,KAAK,GAAG,IAAI,MAAM;KAC1D,MAAM;IACR;GACF,CAAC;GACD,OAAO;EAGT,KAAK,UAAU;GAEb,MAAM,UAAU,IAAI;GACpB,IAAI,YAAY,KAAA,GACd,MAAM,IAAI,MACR,WAAW,KAAK,OAAO,6GACzB;GAEF,MAAM,IAAI,KAAK,GAAG,GAAG,KAAK,GAAG,OAAO,YAAY;IAC9C,MAAM,OAAO,MAAM,cAAc,KAAK,MAAM,IAAI,KAAK;IACrD,MAAM,QAAQ,OAAO;KACnB,QAAQ,KAAK;KACb;KACA,QAAQ,MAAM,QAAQ,GAAG;KACzB,gBAAgB,GAAG,IAAI,WAAW,GAAG,KAAK;IAC5C,CAAC;GACH,CAAC;GACD,OAAO;EACT;CACF;AACF;AAEA,eAAe,UACb,MACA,KACkB;CAClB,MAAM,WAAW,MAAM,IAAI,KAAK,GAC9B,GAAG,KAAK,GAAG,YACX,YAAY,KAAK,IAAI,IAAI,KAAK,QAAQ,EACxC;CACA,MAAM,SAAS,MAAM,IAAI,KAAK,GAAG,GAAG,KAAK,GAAG,UAAU,YACpDC,yBAAAA,eAAe,KAAK,WAAW,IAAI,KAAK,CAC1C;CAIA,MAAM,WAAW,WAAW,KAAK,QAAQ;CACzC,MAAM,MAAM,MAAM,SAAS,KAAK,IAAI,KAAK,UAAU,QAAQ,OAAO,QAC/D,MAAMJ,yBAAAA,kBAAkB,KAAK,WAAW,IAAI,OAAO,KAAK,EAAE,SAAS,CAAC,IAAK,OAAO,IACnF;CACA,MAAM,IAAI,KAAK,YAAY,GAAG,KAAK,GAAG,aAAa;CACnD,OAAO,QAAQ;AACjB;;;;;;;;AASA,eAAe,UAAU,IAAgB,KAAiD;CACxF,MAAM,WAAW,aAAa,IAAI,GAAG;CACrC,MAAM,OAAO,GAAG;CAChB,IAAI,SAAS,KAAA,KAAa,aAAa,KAAA,GAAW,OAAO;CACzD,MAAM,QAAQ,UAAU,GAAG;CAC3B,IAAI,MAAM,gBAAgB,KAAA,GAAW,OAAO;CAE5C,IAAI,CAAC,MADc,IAAI,KAAK,GAAG,aAAa,YAAY,WAAW,KAAK,IAAI,CAAC,GAClE,OAAO;CAClB,MAAM,WAAW,GAAG;CAOpB,MAAM,MAAM,MAAM,SAAS,QAAQ,KAAK,UAAU,MAN7B,IAAI,KAAK,GAAG,eAAe,YAAY,CAC1D,mBAAG,IAAI,IAAI,CACT,GAAI,MAAMI,yBAAAA,eAAe,KAAK,WAAW,IAAI,KAAK,GAClD,GAAI,aAAa,KAAA,IAAY,CAAC,IAAI,MAAMA,yBAAAA,eAAe,UAAU,IAAI,KAAK,CAC5E,CAAC,CACH,CAAC,GACyD,OAAO,QAAQ;EACvE,MAAM,UAAU,MAAM,YAAY,IAAI,KAAK,GAAG;EAC9C,IAAI,YAAY,MAAM,OAAO;GAAE,MAAM;GAAiB,IAAI,QAAQ;EAAG;EACrE,IAAI,aAAa,KAAA,KAAc,MAAMJ,yBAAAA,kBAAkB,UAAU,IAAI,OAAO,GAAG,GAC7E,OAAO;GAAE,MAAM;GAAsB,IAAI;EAAI;EAE/C,OAAO;CACT,CAAC;CACD,MAAM,IAAI,KAAK,YAAY,kBAAkB;CAC7C,IAAI,QAAQ,MAAM,OAAO;CACzB,IAAI,IAAI,SAAS,QAAQ;EACvB,MAAM,gBAAgB,IAAI;EAC1B,OAAO,EAAE,MAAM,OAAO;CACxB;CACA,OAAO,EAAE,MAAM,YAAY;AAC7B;;;;;;;AAQA,eAAe,SACb,IACA,KACA,UACA,QACA,OACmB;CACnB,KAAK,IAAI,UAAU,GAAG,UAAU,iBAAiB,WAAW;EAM1D,MAAM,IAAI,KAAK,UAAU,GAAG,GAAG,aAAa,WAAW,QAAQ,EAAE,YAAY,SAAS,CAAC;EAEvF,MAAM,MAAM,MAAM,IAAI,KAAK,GAAG,GAAG,GAAG,SAAS,WAAW,YAAY,MAAM,KAAK,IAAI,CAAC,CAAC;EACrF,IAAI,QAAQ,MAAM,OAAO;EAIzB,MAAM,YAAY,MAAM,IAAI,KAAK,GAC/B,GAAG,GAAG,aAAa,WACnB,YAAY,WAAW,KAAK,IAAI,CAClC;EAEA,IAAI,aAAa,GAAG,OAAO;EAO3B,IAAI,MALe,IAAI,KAAK,YAAY,GAAG,GAAG,QAAQ,WAAW;GAC/D,WAAW;GACX,YAAY;EACd,CAAC,MAEY,WAAW,OAAO,WAAW,IAAI,KAAK,KAAK;CAE1D;CAKA,QAAQ,KACN,aAAa,IAAI,WAAW,GAAG,GAAG,IAAI,gBAAgB,mEACxD;CACA,MAAM,kBAAkB,MAAM,IAAI,KAAK,GACrC,GAAG,GAAG,oBACN,YAAY,WAAW,KAAK,IAAI,CAClC;CACA,IAAI,kBAAkB,GAAG,MAAM,IAAI,KAAK,MAAM,GAAG,GAAG,gBAAgB,eAAe;CACnF,OAAO,WAAW,IAAI,KAAK,KAAK;AAClC;;AAGA,eAAe,WACb,IACA,KACA,OACmB;CACnB,OAAO,IAAI,KAAK,GAAG,GAAG,GAAG,SAAS,YAAY,MAAM,KAAK,IAAI,CAAC,CAAC;AACjE;AAEA,eAAe,cACb,QACA,OAC+C;CAC/C,MAAM,WAAiD,CAAC;CACxD,KAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,MAAM,GAC9C,SAAS,OAAO,OAAO,UAAU,WAAW,MAAM,MAAM,YAAY,MAAM,IAAI,IAAI;CAEpF,OAAO;AACT"}
1
+ {"version":3,"file":"interpreter.cjs","names":["evaluateCondition","RECIPIENT_PROPERTY","nextWindowStart","hashToBucket","relevantEvents"],"sources":["../../src/engine/interpreter.ts"],"sourcesContent":["import type { NodeIR, PropValueIR, ScalarIR, WorkflowIR } from '../ir';\nimport { hashToBucket } from './bucket';\nimport { evaluateCondition, relevantEvents } from './condition';\nimport type {\n FactSource,\n JourneyContext,\n JourneyOutcome,\n OutboundMessage,\n PushDevice,\n} from './ports';\nimport { RECIPIENT_PROPERTY } from './recipient';\nimport { nextWindowStart } from './time-window';\n\n/**\n * IR interpreter: the execution core for one user's journey instance.\n *\n * Pure logic — it imports nothing platform-specific; every external effect\n * arrives through the JourneyContext ports, and durable semantics\n * (checkpoint/replay) are the EngineStep adapter's job.\n *\n * Step naming: an IR node id *is* the step name (derived from the structural\n * path, so it is deterministic and unique). When one node needs several steps,\n * a suffix is appended (`${id}:guard`, `${id}:check:${n}`).\n *\n * Guard semantics: goal / exitWhen are checked at journey entry and after\n * every suspension point (delay / random_delay / time_window / wait_until) —\n * the moments where wall-clock time passed and the user's state may have\n * moved. Between two synchronous nodes no meaningful time passes, so checking\n * there would only burn step budget (each checkpoint costs a state write).\n */\n\n/**\n * Cap on wait_until re-evaluations, so a wake-up storm cannot exhaust the platform's step\n * budget. Each loop iteration costs a handful of steps, so 64 checks stay well inside the\n * platform step budgets while absorbing far more wake churn than 16 did. Past the cap the wait\n * is not given up: it sleeps to its deadline and checks once more there (see waitLoop) — the\n * answer is still right at the deadline, it just stops being event-driven in between.\n */\nconst MAX_WAIT_CHECKS = 64;\n\n/** The user property holding an IANA timezone id, read when a time_window says tz: 'user'. Same convention-table status as RECIPIENT_PROPERTY. */\nconst TIMEZONE_PROPERTY = 'timezone';\n\ntype FlowSignal =\n | { kind: 'fall_through' }\n | { kind: 'exit'; reason?: string }\n | { kind: 'goal' }\n | { kind: 'exit_when' };\n\nconst FALL_THROUGH: FlowSignal = { kind: 'fall_through' };\n\n/**\n * Whether and when the instance converted — a fact of its own, apart from how it ends: with\n * `exitOnMatch: false` a conversion lets the flow go on, and the instance still ends `completed`\n * (or `exited`, `exit_when`) carrying it. Set only from checkpointed step results, so a replay\n * rebuilds the same state.\n */\ninterface GoalState {\n convertedAt?: number;\n}\n\n/** Who a message or report is about now: a journey started by a visitor moves when they sign in. */\nfunction ownerOf(ctx: JourneyContext): Promise<string> {\n return ctx.currentUserId?.() ?? Promise.resolve(ctx.userId);\n}\n\nconst goalStates = new WeakMap<JourneyContext, GoalState>();\n\nfunction goalState(ctx: JourneyContext): GoalState {\n let state = goalStates.get(ctx);\n if (state === undefined) {\n state = {};\n goalStates.set(ctx, state);\n }\n return state;\n}\n\n/** When the goal's attribution window closes, measured from entry; none without `within`. */\nfunction goalDeadline(ir: WorkflowIR, ctx: JourneyContext): number | undefined {\n return ir.goal?.within === undefined ? undefined : ctx.enteredAtMs + ir.goal.within.ms;\n}\n\n/**\n * Whether the goal holds for events inside its window: after entry, and not after the window\n * closed — an instance resumed past the deadline must not count a purchase made after it. The\n * conversion time is when the check saw it, capped at the deadline.\n */\nasync function goalReached(\n ir: WorkflowIR,\n ctx: JourneyContext,\n now: number\n): Promise<{ at: number } | null> {\n const goal = ir.goal;\n if (goal === undefined) return null;\n const deadline = goalDeadline(ir, ctx);\n const met = await evaluateCondition(goal.condition, ctx.facts, now, {\n anchorMs: ctx.enteredAtMs,\n ...(deadline === undefined ? {} : { untilMs: deadline }),\n });\n return met ? { at: deadline === undefined ? now : Math.min(now, deadline) } : null;\n}\n\nexport async function runJourney(ir: WorkflowIR, ctx: JourneyContext): Promise<JourneyOutcome> {\n await started(ir, ctx);\n const entry = await checkGuards('entry', ir, ctx);\n let signal = entry ?? (await runFlow(ir.flow, ir, ctx));\n // The flow is done but the conversion window is not: keep watching it (see awaitTail)\n if (signal.kind === 'fall_through') signal = (await awaitTail(ir, ctx)) ?? FALL_THROUGH;\n const { convertedAt } = goalState(ctx);\n const converted = convertedAt === undefined ? {} : { convertedAt };\n switch (signal.kind) {\n case 'fall_through':\n return { status: 'completed', ...converted };\n case 'exit':\n return signal.reason !== undefined\n ? { status: 'exited', reason: signal.reason, ...converted }\n : { status: 'exited', ...converted };\n case 'goal':\n return { status: 'goal', ...converted };\n case 'exit_when':\n return { status: 'exit_when', ...converted };\n }\n}\n\nasync function runFlow(nodes: NodeIR[], ir: WorkflowIR, ctx: JourneyContext): Promise<FlowSignal> {\n for (const node of nodes) {\n await reached(node, ir, ctx);\n const signal = await runNode(node, ir, ctx);\n if (signal.kind !== 'fall_through') return signal;\n }\n return FALL_THROUGH;\n}\n\n/**\n * Tell the observer the instance entered — before the entry guards, which can end it before the\n * first node: an instance is counted from its entry, not from its first node.\n */\nasync function started(ir: WorkflowIR, ctx: JourneyContext): Promise<void> {\n const observer = ctx.observer;\n if (observer?.started === undefined) return;\n await ctx.step.do('entry:started', async () => {\n await observer.started?.({\n workflow: ir.id,\n instanceId: ctx.instanceId,\n userId: await ownerOf(ctx),\n at: ctx.enteredAtMs,\n });\n });\n}\n\n/**\n * Tell the observer the instance is at `node`. A step of its own, so a replay does not report\n * the nodes it passes on the way back again, and the arrival time stays the first one.\n */\nasync function reached(node: NodeIR, ir: WorkflowIR, ctx: JourneyContext): Promise<void> {\n const observer = ctx.observer;\n if (observer?.nodeReached === undefined) return;\n await ctx.step.do(`${node.id}:reached`, async () => {\n await observer.nodeReached?.({\n workflow: ir.id,\n instanceId: ctx.instanceId,\n userId: await ownerOf(ctx),\n nodeId: node.id,\n at: Date.now(),\n });\n });\n}\n\n/**\n * One copy out through the host's sender, with the observer around it: settled? → prepared\n * (the row, the tracking) → claimed (handed to the transport, committed before the send) →\n * send → sent.\n *\n * Delivery is at most once. A send that throws is a definite failure: the claim is released, the\n * step retries, and the copy is reported when it settles. A copy that was claimed but never\n * settled is the uncertain case — the earlier run died between the transport taking it and the\n * report (or the step's checkpoint) — and a replay does not send it again; it logs and moves on.\n * The templates that must arrive opt out of that per template (`redelivery: 'resend'`), and are\n * sent again as the step retries. A report that fails after the send is logged, never thrown:\n * the step would retry, and the copy — now claimed — would be skipped.\n *\n * Why at most once (decided 2026-11-10, after reviewing the alternatives): exactly-once is out of\n * reach — Cloudflare Email Service (the `send_email` binding and the REST endpoint alike) and SES\n * accept no idempotency key or client message id, FCM does not de-duplicate, only APNs and Web\n * Push collapse a repeat on the device. Between the two remaining options, a duplicate marketing\n * email costs spam complaints, unsubscribes and sender reputation, while a lost nudge is invisible\n * to the user and a journey has later steps; this is how Customer.io and Braze behave (the\n * delivery record is the source of truth, no resend on uncertainty). Do not \"fix\" this back to\n * at-least-once: set `redelivery: 'resend'` on the templates that need it instead.\n */\nasync function deliver(message: OutboundMessage, ctx: JourneyContext): Promise<void> {\n const observer = ctx.observer;\n // A replayed step whose copy an earlier run already settled: the transport took it once\n if (await observer?.messageSettled?.(message)) return;\n const tracking = await observer?.messagePrepared?.(message);\n const copy = tracking === undefined ? message : { ...message, tracking };\n const claimed = (await observer?.messageClaimed?.(copy)) ?? true;\n if (!claimed && copy.redelivery !== 'resend') {\n console.warn(\n `[workflow] ${copy.idempotencyKey} was handed to the transport by an earlier run and never settled: not sent again (redelivery: 'skip')`\n );\n return;\n }\n let result: Awaited<ReturnType<typeof ctx.messages.send>>;\n try {\n result = await ctx.messages.send(copy);\n } catch (error) {\n // A thrown send is a definite failure: the copy did not go out, so the retry must be free to send it\n try {\n await observer?.messageReleased?.(copy);\n } catch (releaseError) {\n console.error(\n `[workflow] releasing ${copy.idempotencyKey} after a failed send failed`,\n releaseError\n );\n }\n throw error;\n }\n try {\n await observer?.messageSent?.(copy, result ?? { delivered: true });\n } catch (error) {\n console.error(`[workflow] reporting ${copy.idempotencyKey} as sent failed`, error);\n }\n}\n\n/**\n * Goal / exitWhen guards (see the header for when they run).\n *\n * The goal condition is anchored at workflow entry: a `performed` inside it\n * only counts events after the user entered, so pre-existing history cannot\n * register as a conversion. exitWhen is deliberately unanchored — \"user\n * unsubscribed\" should exit no matter when it happened.\n */\nasync function checkGuards(\n stepId: string,\n ir: WorkflowIR,\n ctx: JourneyContext\n): Promise<FlowSignal | null> {\n const goal = ir.goal;\n const exitWhen = ir.exitWhen;\n if (goal === undefined && exitWhen === undefined) return null;\n\n const state = goalState(ctx);\n const result = await ctx.step.do(`${stepId}:guard`, async () => {\n const now = Date.now();\n // Already converted (exitOnMatch: false): the conversion stands, no need to look again\n const goalHit =\n goal !== undefined && state.convertedAt === undefined\n ? await goalReached(ir, ctx, now)\n : null;\n const exitMet = exitWhen !== undefined && (await evaluateCondition(exitWhen, ctx.facts, now));\n return { goalAt: goalHit?.at ?? null, exitMet };\n });\n\n if (result.goalAt !== null) {\n state.convertedAt ??= result.goalAt;\n if (goal?.exitOnMatch ?? true) return { kind: 'goal' };\n }\n if (result.exitMet) return { kind: 'exit_when' };\n return null;\n}\n\n/**\n * The host's template check, with the instance and node added to the message:\n * a refused message must point at the journey that produced it. The error\n * keeps its class (and `name`), so adapters can stop retrying it.\n */\nfunction validate(\n node: Extract<NodeIR, { type: 'message' }>,\n props: Record<string, ScalarIR | undefined>,\n ctx: JourneyContext\n): Record<string, ScalarIR | undefined> {\n if (ctx.validator === undefined) return props;\n try {\n return ctx.validator.validate({ channel: node.channel, template: node.template, props });\n } catch (error) {\n if (error instanceof Error) {\n error.message = `[${ctx.instanceId} ${node.id}] ${error.message}`;\n }\n throw error;\n }\n}\n\n/**\n * Whether the user opted out of what this step sends. An email or push step checks its topic —\n * an opt-out of the topic on that channel or of the whole channel skips it — and a transactional\n * message (`topic: null`) is never skipped: a receipt or a code goes out whatever the user opted\n * out of. A node compiled before topics existed (no `topic`) checks the channel-wide opt-out\n * only, as it always did. Other channels check the channel.\n */\nasync function suppressed(\n node: Extract<NodeIR, { type: 'message' }>,\n ctx: JourneyContext\n): Promise<boolean> {\n if (node.topic === null) return false;\n return (await ctx.facts.isSuppressed?.(node.channel, node.topic)) === true;\n}\n\n/** The copy's topic, for the channels that have one: what the observer records and the senders may read. */\nfunction topicOf(node: Extract<NodeIR, { type: 'message' }>): { topic?: string | null } {\n return node.channel === 'email' || node.channel === 'push' ? { topic: node.topic ?? null } : {};\n}\n\n/** The template's `redelivery`, only when it opts into resending: absent is the default, 'skip'. */\nfunction redeliveryOf(node: Extract<NodeIR, { type: 'message' }>): { redelivery?: 'resend' } {\n return node.redelivery === 'resend' ? { redelivery: 'resend' } : {};\n}\n\ntype PushPlan = { props: Record<string, ScalarIR | undefined>; devices: PushDevice[] };\n\n/**\n * A push goes to devices, not to a profile address: one copy per device the\n * host's directory lists (or only the most recent, `devices: 'latest'`). Each\n * copy's idempotency key carries its token, so a retried step that already\n * reached some devices re-sends to the rest and skips the ones it reached —\n * keyed by token rather than position, because the list can change between\n * attempts. A user with no device is skipped, not failed: being unreachable is\n * a state of the user, and `reachable('push')` is how a journey branches on it.\n */\n/**\n * A push: one step settles what goes out and to which devices, then each device gets a step of\n * its own — a failure on one device retries that device alone, and the copies already delivered\n * or claimed are not sent again (see `deliver`; APNs collapses a repeat on the device, FCM does\n * not de-duplicate at all).\n */\nasync function sendPush(\n node: Extract<NodeIR, { type: 'message' }>,\n ir: WorkflowIR,\n ctx: JourneyContext\n): Promise<void> {\n const plan = await ctx.step.do<PushPlan | null>(`${node.id}:send`, async () => {\n const props = validate(node, await resolveValues(node.props, ctx.facts), ctx);\n // Once per step, before any device: the topic's opt-out or the channel's (never a transactional push)\n if (await suppressed(node, ctx)) return null;\n const listed = await ctx.facts.listDevices();\n const devices = node.devices === 'latest' ? listed.slice(0, 1) : listed;\n return { props, devices };\n });\n // null: the topic or the channel is opted out\n if (plan === null) return;\n if (plan.devices.length === 0) {\n console.log(`[journey] push ${node.template} skipped: no device for user ${ctx.userId}`);\n return;\n }\n for (const [index, device] of plan.devices.entries()) {\n await ctx.step.do(`${node.id}:send:${index}`, async () => {\n await deliver(\n {\n workflow: ir.id,\n instanceId: ctx.instanceId,\n nodeId: node.id,\n channel: 'push',\n template: node.template,\n props: plan.props,\n userId: await ownerOf(ctx),\n recipient: device.token,\n device,\n idempotencyKey: `${ctx.instanceId}:${node.id}:${device.token}`,\n ...topicOf(node),\n ...redeliveryOf(node),\n },\n ctx\n );\n });\n }\n}\n\nasync function runNode(node: NodeIR, ir: WorkflowIR, ctx: JourneyContext): Promise<FlowSignal> {\n switch (node.type) {\n case 'message': {\n if (node.channel === 'push') {\n await sendPush(node, ir, ctx);\n return FALL_THROUGH;\n }\n await ctx.step.do(`${node.id}:send`, async () => {\n const props = validate(node, await resolveValues(node.props, ctx.facts), ctx);\n // An opted-out channel (an email unsubscribe) is skipped, like a push with no device\n if (await suppressed(node, ctx)) return;\n const recipientPath = RECIPIENT_PROPERTY[node.channel];\n const recipient =\n recipientPath === undefined ? undefined : await ctx.facts.getProperty(recipientPath);\n await deliver(\n {\n workflow: ir.id,\n instanceId: ctx.instanceId,\n nodeId: node.id,\n channel: node.channel,\n template: node.template,\n props,\n userId: await ownerOf(ctx),\n recipient: recipient === undefined ? undefined : String(recipient),\n idempotencyKey: `${ctx.instanceId}:${node.id}`,\n ...topicOf(node),\n ...redeliveryOf(node),\n },\n ctx\n );\n });\n return FALL_THROUGH;\n }\n\n case 'delay': {\n await ctx.step.sleep(node.id, node.duration.ms);\n return (await checkGuards(node.id, ir, ctx)) ?? FALL_THROUGH;\n }\n\n case 'random_delay': {\n // The random value is drawn and persisted inside the step, so replay does not re-roll it\n const ms = await ctx.step.do(`${node.id}:roll`, async () => {\n const span = node.max.ms - node.min.ms;\n return node.min.ms + Math.floor(Math.random() * Math.max(span, 0));\n });\n await ctx.step.sleep(`${node.id}:sleep`, ms);\n return (await checkGuards(node.id, ir, ctx)) ?? FALL_THROUGH;\n }\n\n case 'time_window': {\n const target = await ctx.step.do(`${node.id}:target`, async () => {\n const tz =\n node.tz === 'user'\n ? String((await ctx.facts.getProperty(TIMEZONE_PROPERTY)) ?? 'UTC')\n : node.tz;\n return nextWindowStart(Date.now(), node.days, node.between, tz);\n });\n if (target === null) return FALL_THROUGH; // already inside the window: no suspension, no guard\n // The remaining time is checkpointed: a replay must not recompute it from a later clock\n // (the target has passed by then, and a negative sleep is an error on some runtimes)\n const ms = await ctx.step.do(`${node.id}:remaining`, async () => target - Date.now());\n if (ms > 0) await ctx.step.sleep(`${node.id}:sleep`, ms);\n return (await checkGuards(node.id, ir, ctx)) ?? FALL_THROUGH;\n }\n\n case 'wait_until': {\n const met = await waitUntil(node, ctx);\n // Time passed while waiting — guard before acting on the outcome (goal wins over the wait result)\n const guard = await checkGuards(node.id, ir, ctx);\n if (guard) return guard;\n if (met) return FALL_THROUGH;\n const onTimeout = node.onTimeout;\n if (onTimeout === 'continue') return FALL_THROUGH;\n if (onTimeout === 'exit') return { kind: 'exit', reason: `${node.id}:timeout` };\n return runFlow(onTimeout, ir, ctx);\n }\n\n case 'branch': {\n const matched = await ctx.step.do(`${node.id}:eval`, async () => {\n const now = Date.now();\n for (const [i, branchCase] of node.cases.entries()) {\n if (await evaluateCondition(branchCase.condition, ctx.facts, now)) {\n return i;\n }\n }\n return -1; // no match → default arm, or simply continue\n });\n const flow = matched >= 0 ? node.cases[matched]?.flow : node.otherwise;\n if (flow === undefined) return FALL_THROUGH;\n const signal = await runFlow(flow, ir, ctx);\n // Structural rejoin: once an arm falls through, execution resumes after the branch\n return signal;\n }\n\n case 'filter': {\n const pass = await ctx.step.do(`${node.id}:eval`, async () =>\n evaluateCondition(node.condition, ctx.facts, Date.now())\n );\n return pass ? FALL_THROUGH : { kind: 'exit', reason: node.reason ?? `${node.id}:filtered` };\n }\n\n case 'cohort': {\n // Deterministic bucketing, so replay needs no checkpoint. An explicit\n // experiment key is global on purpose: two workflows naming the same\n // experiment bucket a user identically. The fallback is namespaced by the\n // workflow, because structural ids ('2', '1.c0.0') repeat across\n // workflows and a bare one would put a user in the same arm of every\n // unrelated split at that position.\n const bucket = hashToBucket(`${ctx.userId}:${node.key ?? `${ir.id}:${node.id}`}`);\n // Integer basis points on both sides (the DSL guarantees weights sit on\n // the 0.01% grid), so no float ever meets the comparison.\n let cumulative = 0;\n for (const arm of node.arms) {\n cumulative += Math.round(arm.weight * 100);\n if (bucket < cumulative) return runFlow(arm.flow, ir, ctx);\n }\n return FALL_THROUGH;\n }\n\n case 'exit':\n return node.reason !== undefined ? { kind: 'exit', reason: node.reason } : { kind: 'exit' };\n\n case 'send_event': {\n await ctx.step.do(`${node.id}:emit`, async () => {\n const payload = await resolveValues(node.payload, ctx.facts);\n try {\n await ctx.events.emit(node.event, payload, `${ctx.instanceId}:${node.id}`);\n } catch (error) {\n // A refused event must point at the journey that emitted it\n if (error instanceof Error)\n error.message = `[${ctx.instanceId} ${node.id}] ${error.message}`;\n throw error;\n }\n });\n return FALL_THROUGH;\n }\n\n case 'action': {\n // Custom code runs inside step.do: retried on throw, checkpointed on success\n const invoker = ctx.actions;\n if (invoker === undefined) {\n throw new Error(\n `action '${node.action}': this runtime has no ActionInvoker configured — register the action on the adapter (see engine/actions.ts)`\n );\n }\n await ctx.step.do(`${node.id}:run`, async () => {\n const args = await resolveValues(node.args, ctx.facts);\n await invoker.invoke({\n action: node.action,\n args,\n userId: await ownerOf(ctx),\n idempotencyKey: `${ctx.instanceId}:${node.id}`,\n });\n });\n return FALL_THROUGH;\n }\n }\n}\n\nasync function waitUntil(\n node: Extract<NodeIR, { type: 'wait_until' }>,\n ctx: JourneyContext\n): Promise<boolean> {\n const deadline = await ctx.step.do(\n `${node.id}:deadline`,\n async () => Date.now() + node.timeout.ms\n );\n const events = await ctx.step.do(`${node.id}:events`, async () =>\n relevantEvents(node.condition, ctx.facts)\n );\n // Anchor at the moment the wait began (deadline is checkpointed, so this is\n // replay-stable): `performed` inside the condition only counts events that\n // happen during the wait — pre-existing history must not satisfy it.\n const anchorMs = deadline - node.timeout.ms;\n const met = await waitLoop(node.id, ctx, deadline, events, async (now) =>\n (await evaluateCondition(node.condition, ctx.facts, now, { anchorMs })) ? true : null\n );\n await ctx.step.unsubscribe(`${node.id}:unsubscribe`);\n return met === true;\n}\n\n/**\n * The conversion window outlives the flow: a journey whose steps are done while its goal's\n * `within` is still open keeps watching until the window closes — the purchase that follows the\n * last offer email is that email's conversion, and ending the instance at the email would report\n * it as a plain completion. `exitWhen` still applies while it waits. Nothing to wait for without\n * a window, or once the instance has converted.\n */\nasync function awaitTail(ir: WorkflowIR, ctx: JourneyContext): Promise<FlowSignal | null> {\n const deadline = goalDeadline(ir, ctx);\n const goal = ir.goal;\n if (goal === undefined || deadline === undefined) return null;\n const state = goalState(ctx);\n if (state.convertedAt !== undefined) return null;\n const open = await ctx.step.do('goal:open', async () => deadline > Date.now());\n if (!open) return null;\n const exitWhen = ir.exitWhen;\n const events = await ctx.step.do('goal:events', async () => [\n ...new Set([\n ...(await relevantEvents(goal.condition, ctx.facts)),\n ...(exitWhen === undefined ? [] : await relevantEvents(exitWhen, ctx.facts)),\n ]),\n ]);\n const hit = await waitLoop('goal', ctx, deadline, events, async (now) => {\n const reached = await goalReached(ir, ctx, now);\n if (reached !== null) return { kind: 'goal' as const, at: reached.at };\n if (exitWhen !== undefined && (await evaluateCondition(exitWhen, ctx.facts, now))) {\n return { kind: 'exit_when' as const, at: now };\n }\n return null;\n });\n await ctx.step.unsubscribe('goal:unsubscribe');\n if (hit === null) return null;\n if (hit.kind === 'goal') {\n state.convertedAt ??= hit.at;\n return { kind: 'goal' };\n }\n return { kind: 'exit_when' };\n}\n\n/**\n * Wait until `check` finds something or the deadline passes: subscribe, check, sleep until a\n * relevant event wakes the instance or time runs out. `check` runs inside a step, so its result\n * must be serializable; null means \"not yet\". Null from here means the deadline passed with\n * nothing found — never a failure, and never a wait given up on early.\n */\nasync function waitLoop<R>(\n id: string,\n ctx: JourneyContext,\n deadline: number,\n events: string[],\n check: (now: number) => Promise<R | null>\n): Promise<R | null> {\n for (let attempt = 0; attempt < MAX_WAIT_CHECKS; attempt++) {\n // Subscribe before the check, every attempt: one subscribe arms at least\n // one wake-up (AWS callbacks are consumed on delivery, so each attempt\n // needs a fresh arm; Cloudflare rows make the repeat call idempotent), and\n // arming first closes the race where the event lands between the check and\n // the registration.\n await ctx.step.subscribe(`${id}:subscribe:${attempt}`, events, { deadlineMs: deadline });\n\n const met = await ctx.step.do(`${id}:check:${attempt}`, async () => check(Date.now()));\n if (met !== null) return met;\n\n // Checkpointed: replay re-runs this control flow with wall-clock time far\n // ahead of the original run, so the branch must come from a persisted value.\n const remaining = await ctx.step.do(\n `${id}:remaining:${attempt}`,\n async () => deadline - Date.now()\n );\n // The condition was checked just above — expiring here needs no extra final check\n if (remaining <= 0) return null;\n\n const wake = await ctx.step.waitForWake(`${id}:wait:${attempt}`, {\n timeoutMs: remaining,\n deadlineMs: deadline,\n });\n // Final check after timeout: in a tight race the event may land exactly as the timer fires\n if (wake === 'timeout') return finalCheck(id, ctx, check);\n // Woken → back to the top of the loop to re-evaluate (at-least-once wake-up semantics)\n }\n // Check budget exhausted (see MAX_WAIT_CHECKS). Not a timeout: the wait keeps its deadline,\n // only the wake-ups are given up — sleep to the deadline, then check once more there. The\n // subscription rows stay until the caller's unsubscribe; a wake landing meanwhile is dropped\n // by the runtime (nothing is parked on it), which is the point.\n console.warn(\n `[journey] ${ctx.instanceId} ${id}: ${MAX_WAIT_CHECKS} wake-ups without the condition holding — sleeping to the deadline`\n );\n const budgetRemaining = await ctx.step.do(\n `${id}:budget:remaining`,\n async () => deadline - Date.now()\n );\n if (budgetRemaining > 0) await ctx.step.sleep(`${id}:budget:sleep`, budgetRemaining);\n return finalCheck(id, ctx, check);\n}\n\n/** The check once the deadline is reached — one step for all the ways of getting there. */\nasync function finalCheck<R>(\n id: string,\n ctx: JourneyContext,\n check: (now: number) => Promise<R | null>\n): Promise<R | null> {\n return ctx.step.do(`${id}:final`, async () => check(Date.now()));\n}\n\nasync function resolveValues(\n values: Record<string, PropValueIR>,\n facts: FactSource\n): Promise<Record<string, ScalarIR | undefined>> {\n const resolved: Record<string, ScalarIR | undefined> = {};\n for (const [key, value] of Object.entries(values)) {\n resolved[key] = typeof value === 'object' ? await facts.getProperty(value.path) : value;\n }\n return resolved;\n}\n"],"mappings":"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"}
@@ -133,11 +133,26 @@ async function reached(node, ir, ctx) {
133
133
  });
134
134
  }
135
135
  /**
136
- * One copy out through the host's sender, with the observer on both sides: it may attach
137
- * tracking before, and hears how the transport took the copy after. A send that throws is
138
- * not reported — the step retries it, and reports when it settles. A report that fails after
139
- * the send is logged, never thrown: the step would retry and the transport — a webhook, a web
140
- * push, with no idempotency key of its own — would deliver the copy again.
136
+ * One copy out through the host's sender, with the observer around it: settled? → prepared
137
+ * (the row, the tracking) → claimed (handed to the transport, committed before the send) →
138
+ * send → sent.
139
+ *
140
+ * Delivery is at most once. A send that throws is a definite failure: the claim is released, the
141
+ * step retries, and the copy is reported when it settles. A copy that was claimed but never
142
+ * settled is the uncertain case — the earlier run died between the transport taking it and the
143
+ * report (or the step's checkpoint) — and a replay does not send it again; it logs and moves on.
144
+ * The templates that must arrive opt out of that per template (`redelivery: 'resend'`), and are
145
+ * sent again as the step retries. A report that fails after the send is logged, never thrown:
146
+ * the step would retry, and the copy — now claimed — would be skipped.
147
+ *
148
+ * Why at most once (decided 2026-11-10, after reviewing the alternatives): exactly-once is out of
149
+ * reach — Cloudflare Email Service (the `send_email` binding and the REST endpoint alike) and SES
150
+ * accept no idempotency key or client message id, FCM does not de-duplicate, only APNs and Web
151
+ * Push collapse a repeat on the device. Between the two remaining options, a duplicate marketing
152
+ * email costs spam complaints, unsubscribes and sender reputation, while a lost nudge is invisible
153
+ * to the user and a journey has later steps; this is how Customer.io and Braze behave (the
154
+ * delivery record is the source of truth, no resend on uncertainty). Do not "fix" this back to
155
+ * at-least-once: set `redelivery: 'resend'` on the templates that need it instead.
141
156
  */
142
157
  async function deliver(message, ctx) {
143
158
  const observer = ctx.observer;
@@ -147,7 +162,21 @@ async function deliver(message, ctx) {
147
162
  ...message,
148
163
  tracking
149
164
  };
150
- const result = await ctx.messages.send(copy);
165
+ if (!(await observer?.messageClaimed?.(copy) ?? true) && copy.redelivery !== "resend") {
166
+ console.warn(`[workflow] ${copy.idempotencyKey} was handed to the transport by an earlier run and never settled: not sent again (redelivery: 'skip')`);
167
+ return;
168
+ }
169
+ let result;
170
+ try {
171
+ result = await ctx.messages.send(copy);
172
+ } catch (error) {
173
+ try {
174
+ await observer?.messageReleased?.(copy);
175
+ } catch (releaseError) {
176
+ console.error(`[workflow] releasing ${copy.idempotencyKey} after a failed send failed`, releaseError);
177
+ }
178
+ throw error;
179
+ }
151
180
  try {
152
181
  await observer?.messageSent?.(copy, result ?? { delivered: true });
153
182
  } catch (error) {
@@ -216,11 +245,15 @@ async function suppressed(node, ctx) {
216
245
  function topicOf(node) {
217
246
  return node.channel === "email" || node.channel === "push" ? { topic: node.topic ?? null } : {};
218
247
  }
248
+ /** The template's `redelivery`, only when it opts into resending: absent is the default, 'skip'. */
249
+ function redeliveryOf(node) {
250
+ return node.redelivery === "resend" ? { redelivery: "resend" } : {};
251
+ }
219
252
  /**
220
253
  * A push goes to devices, not to a profile address: one copy per device the
221
254
  * host's directory lists (or only the most recent, `devices: 'latest'`). Each
222
255
  * copy's idempotency key carries its token, so a retried step that already
223
- * reached some devices re-sends to the rest and the senders drop the repeats —
256
+ * reached some devices re-sends to the rest and skips the ones it reached —
224
257
  * keyed by token rather than position, because the list can change between
225
258
  * attempts. A user with no device is skipped, not failed: being unreachable is
226
259
  * a state of the user, and `reachable('push')` is how a journey branches on it.
@@ -228,7 +261,8 @@ function topicOf(node) {
228
261
  /**
229
262
  * A push: one step settles what goes out and to which devices, then each device gets a step of
230
263
  * its own — a failure on one device retries that device alone, and the copies already delivered
231
- * are not sent again (APNs and FCM carry the idempotency key but do not de-duplicate on it).
264
+ * or claimed are not sent again (see `deliver`; APNs collapses a repeat on the device, FCM does
265
+ * not de-duplicate at all).
232
266
  */
233
267
  async function sendPush(node, ir, ctx) {
234
268
  const plan = await ctx.step.do(`${node.id}:send`, async () => {
@@ -257,7 +291,8 @@ async function sendPush(node, ir, ctx) {
257
291
  recipient: device.token,
258
292
  device,
259
293
  idempotencyKey: `${ctx.instanceId}:${node.id}:${device.token}`,
260
- ...topicOf(node)
294
+ ...topicOf(node),
295
+ ...redeliveryOf(node)
261
296
  }, ctx);
262
297
  });
263
298
  }
@@ -283,7 +318,8 @@ async function runNode(node, ir, ctx) {
283
318
  userId: await ownerOf(ctx),
284
319
  recipient: recipient === void 0 ? void 0 : String(recipient),
285
320
  idempotencyKey: `${ctx.instanceId}:${node.id}`,
286
- ...topicOf(node)
321
+ ...topicOf(node),
322
+ ...redeliveryOf(node)
287
323
  }, ctx);
288
324
  });
289
325
  return FALL_THROUGH;