@shware/workflow 0.3.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +48 -38
  2. package/dist/client.d.cts +1 -0
  3. package/dist/client.d.mts +1 -0
  4. package/dist/cloudflare/channels.cjs +12 -14
  5. package/dist/cloudflare/channels.cjs.map +1 -1
  6. package/dist/cloudflare/channels.d.cts.map +1 -1
  7. package/dist/cloudflare/channels.d.mts.map +1 -1
  8. package/dist/cloudflare/channels.mjs +13 -15
  9. package/dist/cloudflare/channels.mjs.map +1 -1
  10. package/dist/config.cjs +61 -36
  11. package/dist/config.cjs.map +1 -1
  12. package/dist/config.d.cts +19 -15
  13. package/dist/config.d.cts.map +1 -1
  14. package/dist/config.d.mts +19 -15
  15. package/dist/config.d.mts.map +1 -1
  16. package/dist/config.mjs +61 -36
  17. package/dist/config.mjs.map +1 -1
  18. package/dist/contract.d.cts +1 -0
  19. package/dist/contract.d.mts +1 -0
  20. package/dist/dsl/action.cjs +1 -1
  21. package/dist/dsl/action.cjs.map +1 -1
  22. package/dist/dsl/action.d.cts +1 -1
  23. package/dist/dsl/action.d.mts +1 -1
  24. package/dist/dsl/action.mjs +1 -1
  25. package/dist/dsl/action.mjs.map +1 -1
  26. package/dist/dsl/bundle.cjs +3 -2
  27. package/dist/dsl/bundle.cjs.map +1 -1
  28. package/dist/dsl/bundle.d.cts +3 -2
  29. package/dist/dsl/bundle.d.cts.map +1 -1
  30. package/dist/dsl/bundle.d.mts +3 -2
  31. package/dist/dsl/bundle.d.mts.map +1 -1
  32. package/dist/dsl/bundle.mjs +3 -2
  33. package/dist/dsl/bundle.mjs.map +1 -1
  34. package/dist/dsl/condition.cjs +1 -1
  35. package/dist/dsl/condition.cjs.map +1 -1
  36. package/dist/dsl/condition.d.cts +2 -1
  37. package/dist/dsl/condition.d.mts +2 -1
  38. package/dist/dsl/condition.mjs +1 -1
  39. package/dist/dsl/condition.mjs.map +1 -1
  40. package/dist/dsl/flow.cjs +45 -17
  41. package/dist/dsl/flow.cjs.map +1 -1
  42. package/dist/dsl/flow.d.cts +31 -17
  43. package/dist/dsl/flow.d.cts.map +1 -1
  44. package/dist/dsl/flow.d.mts +31 -17
  45. package/dist/dsl/flow.d.mts.map +1 -1
  46. package/dist/dsl/flow.mjs +45 -18
  47. package/dist/dsl/flow.mjs.map +1 -1
  48. package/dist/dsl/index.cjs +0 -1
  49. package/dist/dsl/index.d.cts +2 -2
  50. package/dist/dsl/index.d.mts +2 -2
  51. package/dist/dsl/index.mjs +2 -2
  52. package/dist/dsl/template.cjs +2 -47
  53. package/dist/dsl/template.cjs.map +1 -1
  54. package/dist/dsl/template.d.cts +4 -53
  55. package/dist/dsl/template.d.cts.map +1 -1
  56. package/dist/dsl/template.d.mts +4 -53
  57. package/dist/dsl/template.d.mts.map +1 -1
  58. package/dist/dsl/template.mjs +3 -47
  59. package/dist/dsl/template.mjs.map +1 -1
  60. package/dist/dsl/workflow.cjs +1 -1
  61. package/dist/dsl/workflow.cjs.map +1 -1
  62. package/dist/dsl/workflow.d.cts +1 -1
  63. package/dist/dsl/workflow.d.mts +1 -1
  64. package/dist/dsl/workflow.mjs +1 -1
  65. package/dist/dsl/workflow.mjs.map +1 -1
  66. package/dist/index.cjs +5 -2
  67. package/dist/index.d.cts +3 -3
  68. package/dist/index.d.mts +3 -3
  69. package/dist/index.mjs +3 -3
  70. package/dist/ir.d.cts +1 -0
  71. package/dist/ir.d.mts +1 -0
  72. package/dist/provenance.d.cts +1 -0
  73. package/dist/provenance.d.mts +1 -0
  74. package/dist/push.cjs +3 -5
  75. package/dist/push.cjs.map +1 -1
  76. package/dist/push.d.cts +2 -2
  77. package/dist/push.d.cts.map +1 -1
  78. package/dist/push.d.mts +2 -2
  79. package/dist/push.d.mts.map +1 -1
  80. package/dist/push.mjs +4 -6
  81. package/dist/push.mjs.map +1 -1
  82. package/dist/react-email.cjs +5 -5
  83. package/dist/react-email.cjs.map +1 -1
  84. package/dist/react-email.d.cts +1 -1
  85. package/dist/react-email.d.cts.map +1 -1
  86. package/dist/react-email.d.mts +1 -1
  87. package/dist/react-email.d.mts.map +1 -1
  88. package/dist/react-email.mjs +6 -6
  89. package/dist/react-email.mjs.map +1 -1
  90. package/dist/templates.cjs +84 -29
  91. package/dist/templates.cjs.map +1 -1
  92. package/dist/templates.d.cts +50 -11
  93. package/dist/templates.d.cts.map +1 -1
  94. package/dist/templates.d.mts +50 -11
  95. package/dist/templates.d.mts.map +1 -1
  96. package/dist/templates.mjs +81 -30
  97. package/dist/templates.mjs.map +1 -1
  98. package/dist/webpush.d.cts +1 -0
  99. package/dist/webpush.d.mts +1 -0
  100. package/package.json +13 -12
@@ -1 +1 @@
1
- {"version":3,"file":"config.cjs","names":["compileBundle","template","z"],"sources":["../src/config.ts"],"sourcesContent":["import * as z from 'zod/mini';\nimport type * as core from 'zod/v4/core';\nimport type { Contract } from './contract';\nimport {\n type ActionRef,\n type SegmentRef,\n type TemplateRegistry,\n type WorkflowBuilder,\n compileBundle,\n template,\n} from './dsl';\nimport type { BundleIR, ChannelIR, NodeIR, WorkflowIR } from './ir';\nimport type { JourneyMode, JourneyRuntimeConfig } from './runtime';\nimport type { EmailTemplate, PropsSchema, PushTemplate } from './templates';\n\n/**\n * `workflow.config.ts` — the project's journey configuration, typed, flat and\n * committed with the code: what it deploys (workflows, segments, actions),\n * the template registries behind them, the contract every inbound event is\n * checked against, the email envelope, and how the engine runs per\n * environment (time scale, APNs, FCM). The compiler (`compileConfig`), the\n * Worker (`journeyWorker({ config })`) and the Workflow Studio all read this\n * one object, so everything is registered exactly once:\n *\n * export default defineConfig({\n * title: 'Workflow Studio · Acme',\n * contract,\n * workflows: [onboarding, notificationTest],\n * templates: registry, // { email, push }; `t = templates(registry)` for the workflows\n * emails: { from: { email: 'noreply@acme.test', name: 'Acme' } },\n * runtime: { development: { timeScale: 600, logMessages: true } },\n * apns: { topic: 'io.acme.app', keyId: 'ABC123DEFG', teamId: 'TEAM123456' },\n * });\n *\n * Secrets are the one thing that stay out: a `.p8`, a service account, the\n * ingest bearer. The host reads them from its environment under fixed names\n * (see `JourneyEnv`), so the config says *which* key without carrying it. The\n * config is a source object (schemas, components, functions); only its\n * compiled bundle and preview-safe template data may cross to a browser.\n */\n\n/** An address as the transports take it: bare, or with a display name. */\nexport type EmailAddress = string | { email: string; name?: string };\n\n/** `Name <email>`, or the bare address. */\nexport function formatEmailAddress(address: EmailAddress): string {\n if (typeof address === 'string') return address;\n return address.name === undefined ? address.email : `${address.name} <${address.email}>`;\n}\n\n/** The envelope every journey email goes out with (and the studio shows). */\nexport interface EmailConfig {\n from: EmailAddress;\n replyTo?: EmailAddress;\n /**\n * The unsubscribe link the templates' footer carries. Tracked like every link, but following\n * it shows a confirmation page, and only the confirmation (a POST no link scanner sends) is\n * recorded as the message's unsubscribe.\n */\n unsubscribe?: string;\n /** How the Worker tells a person's opens and clicks from a machine's (see the reporting classifier). */\n tracking?: {\n /**\n * An open or click this many milliseconds after delivery is a weak machine signal — machine\n * only together with another (a second link within seconds); 1000 by default. A person\n * tapping a mail notification can click within a few seconds, so keep it short.\n */\n quickClickMs?: number;\n };\n}\n\n/** Token-based APNs, the `.p8` auth key; the key itself is `APNS_PRIVATE_KEY_BASE64` in the environment. */\nexport interface ApnsConfig {\n /** The app's bundle id (`apns-topic`). */\n topic: string;\n /** The key's 10-character id. */\n keyId: string;\n /** The Apple Developer team id. */\n teamId: string;\n /**\n * Which APNs host each mode's device tokens belong to: `sandbox` for builds\n * signed with `aps-environment: development` (Xcode, the simulator),\n * `production` for App Store and TestFlight builds. Unlisted modes default\n * to `production` for the production mode and `sandbox` for every other.\n */\n environment?: Partial<Record<JourneyMode, 'sandbox' | 'production'>>;\n}\n\n/**\n * Standard Web Push for browsers (VAPID); the key pair is `VAPID_PUBLIC_KEY` /\n * `VAPID_PRIVATE_KEY` in the environment, and the site subscribes with the same public key.\n */\nexport interface WebPushConfig {\n /** Who push services may contact about this sender: `mailto:` or an `https:` URL. */\n subject: string;\n /**\n * The contract event a notification tap the browser reports (`opened_url`) is also recorded\n * as, with `{ message_id }`: the app reports its own taps as an event, and a service worker has\n * no session to do so — without this, browser opens reach the reports but not the conditions\n * (a `performed(e.push_opened)` segment). One event per copy, however often it is tapped.\n */\n openedEvent?: string;\n}\n\n/** FCM HTTP v1 for Android; the service account is `GOOGLE_APPLICATION_CREDENTIALS_BASE64` in the environment. */\nexport interface FcmConfig {\n /** Android notification channel the pushes land in (`android.notification.channel_id`). */\n androidChannelId?: string;\n}\n\n/**\n * The engine's tables live in a Postgres schema of their own, so their short names (`event`,\n * `profile`, `message`, …) never meet the app's. `workflow-ui generate` writes them as one drizzle\n * file on this schema; the host migrates it with its own tooling.\n */\nexport interface DatabaseConfig {\n /** The Postgres schema; `workflow` by default. */\n schema?: string;\n}\n\nexport interface JourneyConfigDefinition<\n E extends Record<string, EmailTemplate> = Record<string, EmailTemplate>,\n P extends Record<string, PushTemplate> = Record<string, PushTemplate>,\n> {\n /** Studio title (the browser tab names the project). */\n title?: string;\n /** The shared `defineContract(...)` the Worker validates ingest against. */\n contract?: Contract;\n workflows: readonly WorkflowBuilder[];\n segments?: readonly SegmentRef[];\n actions?: readonly ActionRef<object>[];\n templates?: TemplateRegistry<E, P>;\n emails?: EmailConfig;\n /** How the engine behaves per mode (`development`, `production`, …): time scale, message logging. */\n runtime?: JourneyRuntimeConfig;\n apns?: ApnsConfig;\n fcm?: FcmConfig;\n webPush?: WebPushConfig;\n /** Where a Postgres store keeps the engine's tables (the D1 store ignores it). */\n database?: DatabaseConfig;\n}\n\nexport interface JourneyConfig<\n E extends Record<string, EmailTemplate> = Record<string, EmailTemplate>,\n P extends Record<string, PushTemplate> = Record<string, PushTemplate>,\n> extends JourneyConfigDefinition<E, P> {\n readonly kind: 'journey-config';\n}\n\nexport function defineConfig<\n E extends Record<string, EmailTemplate> = Record<never, never>,\n P extends Record<string, PushTemplate> = Record<never, never>,\n>(definition: JourneyConfigDefinition<E, P>): JourneyConfig<E, P> {\n return { kind: 'journey-config', ...definition };\n}\n\nexport function isConfig(value: unknown): value is JourneyConfig {\n return (\n typeof value === 'object' &&\n value !== null &&\n (value as { kind?: unknown }).kind === 'journey-config'\n );\n}\n\n/** The APNs environment for `mode`: the configured one, else production only for production. */\nexport function apnsEnvironment(config: ApnsConfig, mode: JourneyMode): 'sandbox' | 'production' {\n return config.environment?.[mode] ?? (mode === 'production' ? 'production' : 'sandbox');\n}\n\n/** A message node the config's registries cannot serve, or whose literal props its template refuses. */\nexport class ConfigCompileError extends Error {\n override readonly name = 'ConfigCompileError';\n}\n\n/**\n * Compile the config: the bundle `compileBundle` would produce, with the\n * template manifest generated from the registries (every registered template,\n * not a hand-kept list), after checking every message node against them —\n * an unregistered or cross-channel template reference fails with the workflow,\n * node and channel named, and so does a literal prop the template's schema\n * refuses (a missing required prop, an undeclared one, a wrong literal).\n * User-property references cannot be parsed here (their values exist only at\n * send time) — they are checked by name only, and parsed before sending.\n */\nexport function compileConfig(config: JourneyConfig): BundleIR {\n const registered = (channel: ChannelIR) => Object.keys(registryOf(config, channel) ?? {});\n const bundle = compileBundle({\n workflows: config.workflows,\n ...(config.segments === undefined ? {} : { segments: config.segments }),\n ...(config.actions === undefined ? {} : { actions: config.actions }),\n templates: [\n ...registered('email').map((key) => template.email(key)),\n ...registered('push').map((key) => template.push(key)),\n ...registered('discord').map((key) => template.discord(key)),\n ...registered('slack').map((key) => template.slack(key)),\n ],\n });\n for (const workflow of bundle.workflows) checkMessages(workflow, workflow.flow, config);\n return bundle;\n}\n\nconst TEMPLATE_CHANNELS: readonly ChannelIR[] = ['email', 'push', 'discord', 'slack'];\n\n/** The config's registry for a channel; undefined for a channel templates cannot be declared for. */\nfunction registryOf(\n config: JourneyConfig,\n channel: ChannelIR\n): Readonly<Record<string, { props: PropsSchema }>> | undefined {\n switch (channel) {\n case 'email':\n return config.templates?.email;\n case 'push':\n return config.templates?.push;\n case 'discord':\n return config.templates?.discord;\n case 'slack':\n return config.templates?.slack;\n default:\n return undefined;\n }\n}\n\n/** A field that may be left out: optional, or with a default the schema fills in. */\nfunction canBeOmitted(schema: core.$ZodType): boolean {\n const type = schema._zod.def.type;\n return type === 'optional' || type === 'default' || type === 'prefault';\n}\n\nfunction checkMessages(\n workflow: WorkflowIR,\n nodes: readonly NodeIR[],\n config: JourneyConfig\n): void {\n for (const node of nodes) {\n switch (node.type) {\n case 'message':\n checkMessage(workflow, node, config);\n break;\n case 'send_event':\n checkEmit(workflow, node, config);\n break;\n case 'branch':\n for (const c of node.cases) checkMessages(workflow, c.flow, config);\n if (node.otherwise) checkMessages(workflow, node.otherwise, config);\n break;\n case 'cohort':\n for (const arm of node.arms) checkMessages(workflow, arm.flow, config);\n break;\n case 'wait_until':\n if (Array.isArray(node.onTimeout)) checkMessages(workflow, node.onTimeout, config);\n break;\n default:\n break;\n }\n }\n}\n\nfunction checkMessage(\n workflow: WorkflowIR,\n node: Extract<NodeIR, { type: 'message' }>,\n config: JourneyConfig\n): void {\n const where = `workflow '${workflow.id}' node ${node.id}`;\n const registry = registryOf(config, node.channel);\n // Channels without a registry (sms, …) are rendered by app-supplied senders\n if (registry === undefined && !TEMPLATE_CHANNELS.includes(node.channel)) return;\n if (registry === undefined || !Object.hasOwn(registry, node.template)) {\n const other = TEMPLATE_CHANNELS.find(\n (channel) =>\n channel !== node.channel && Object.hasOwn(registryOf(config, channel) ?? {}, node.template)\n );\n const hint = other === undefined ? '' : ` (it is registered as a ${other} template)`;\n throw new ConfigCompileError(\n `${where}: ${node.channel} template '${node.template}' is not registered${hint}`\n );\n }\n const entry = registry[node.template];\n const shape = (entry.props._zod.def as core.$ZodObjectDef).shape;\n const problems: string[] = [];\n for (const [field, value] of Object.entries(node.props)) {\n if (!Object.hasOwn(shape, field)) {\n problems.push(`'${field}' is not a prop of the template`);\n continue;\n }\n if (typeof value === 'object' && value !== null && 'type' in value) {\n // A user ref's value exists only at send time (parsed then); here its name can be checked\n const profile = config.contract?.schemas.profile._zod.def;\n if (\n profile !== undefined &&\n value.path !== 'userId' &&\n !Object.hasOwn(profile.shape, value.path)\n ) {\n problems.push(\n `'${field}' reads u.${value.path}, which the contract's profile does not declare`\n );\n }\n continue;\n }\n const result = z.safeParse(shape[field], value);\n if (!result.success)\n problems.push(`'${field}' = ${JSON.stringify(value)} is refused by the schema`);\n }\n for (const [field, schema] of Object.entries(shape)) {\n const optional = canBeOmitted(schema);\n if (!optional && !Object.hasOwn(node.props, field))\n problems.push(`required prop '${field}' is missing`);\n }\n if (problems.length > 0) {\n throw new ConfigCompileError(\n `${where}: ${node.channel} '${node.template}': ${problems.join('; ')}`\n );\n }\n}\n\n/**\n * An emitted event against the contract, as far as compile time can see: the\n * event is declared, literal payload values parse against their field, and no\n * required field is missing. Values read from the profile are checked when the\n * event is emitted, by the same contract.\n */\nfunction checkEmit(\n workflow: WorkflowIR,\n node: Extract<NodeIR, { type: 'send_event' }>,\n config: JourneyConfig\n): void {\n const contract = config.contract;\n if (contract === undefined) return;\n const where = `workflow '${workflow.id}' node ${node.id}`;\n if (!contract.isEvent(node.event)) {\n throw new ConfigCompileError(\n `${where}: emits '${String(node.event)}', which is not in the contract`\n );\n }\n const schema = contract.schemas.events[node.event];\n const shape = (schema._zod.def as core.$ZodObjectDef).shape;\n const problems: string[] = [];\n for (const [field, value] of Object.entries(node.payload)) {\n if (typeof value === 'object' && value !== null && 'type' in value) continue; // read at emit time\n if (!Object.hasOwn(shape, field)) continue; // a loose payload may carry extra fields\n const result = z.safeParse(shape[field], value);\n if (!result.success)\n problems.push(`'${field}' = ${JSON.stringify(value)} is refused by the contract`);\n }\n for (const [field, fieldSchema] of Object.entries(shape)) {\n const optional = canBeOmitted(fieldSchema);\n if (!optional && !Object.hasOwn(node.payload, field))\n problems.push(`required field '${field}' is missing`);\n }\n if (problems.length > 0) {\n throw new ConfigCompileError(`${where}: event '${node.event}': ${problems.join('; ')}`);\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"config.cjs","names":["indexTemplates","compileBundle","templateUses","CHANNEL_OF","z"],"sources":["../src/config.ts"],"sourcesContent":["import * as z from 'zod/mini';\nimport type * as core from 'zod/v4/core';\nimport type { Contract } from './contract';\nimport { type ActionRef, type SegmentRef, type WorkflowBuilder, compileBundle } from './dsl';\nimport { templateUses } from './dsl/flow';\nimport type { BundleIR, ChannelIR, NodeIR, WorkflowIR } from './ir';\nimport type { JourneyMode, JourneyRuntimeConfig } from './runtime';\nimport {\n type AnyTemplate,\n CHANNEL_OF,\n type TemplateChannel,\n type TemplateRegistry,\n indexTemplates,\n} from './templates';\n\n/**\n * `workflow.config.ts` — the project's journey configuration, typed, flat and\n * committed with the code: what it deploys (workflows, segments, actions),\n * the template registries behind them, the contract every inbound event is\n * checked against, the email envelope, and how the engine runs per\n * environment (time scale, APNs, FCM). The compiler (`compileConfig`), the\n * Worker (`journeyWorker({ config })`) and the Workflow Studio all read this\n * one object, so everything is registered exactly once:\n *\n * export default defineConfig({\n * title: 'Workflow Studio · Acme',\n * contract,\n * workflows, // [onboarding, notificationTest]\n * templates, // { email, push }: the definitions the workflows send\n * emails: { from: { email: 'noreply@acme.test', name: 'Acme' } },\n * runtime: { development: { timeScale: 600, logMessages: true } },\n * apns: { topic: 'io.acme.app', keyId: 'ABC123DEFG', teamId: 'TEAM123456' },\n * });\n *\n * Secrets are the one thing that stay out: a `.p8`, a service account, the\n * ingest bearer. The host reads them from its environment under fixed names\n * (see `JourneyEnv`), so the config says *which* key without carrying it. The\n * config is a source object (schemas, components, functions); only its\n * compiled bundle and preview-safe template data may cross to a browser.\n */\n\n/** An address as the transports take it: bare, or with a display name. */\nexport type EmailAddress = string | { email: string; name?: string };\n\n/** `Name <email>`, or the bare address. */\nexport function formatEmailAddress(address: EmailAddress): string {\n if (typeof address === 'string') return address;\n return address.name === undefined ? address.email : `${address.name} <${address.email}>`;\n}\n\n/** The envelope every journey email goes out with (and the studio shows). */\nexport interface EmailConfig {\n from: EmailAddress;\n replyTo?: EmailAddress;\n /**\n * The unsubscribe link the templates' footer carries. Tracked like every link, but following\n * it shows a confirmation page, and only the confirmation (a POST no link scanner sends) is\n * recorded as the message's unsubscribe.\n */\n unsubscribe?: string;\n /** How the Worker tells a person's opens and clicks from a machine's (see the reporting classifier). */\n tracking?: {\n /**\n * An open or click this many milliseconds after delivery is a weak machine signal — machine\n * only together with another (a second link within seconds); 1000 by default. A person\n * tapping a mail notification can click within a few seconds, so keep it short.\n */\n quickClickMs?: number;\n };\n}\n\n/** Token-based APNs, the `.p8` auth key; the key itself is `APNS_PRIVATE_KEY_BASE64` in the environment. */\nexport interface ApnsConfig {\n /** The app's bundle id (`apns-topic`). */\n topic: string;\n /** The key's 10-character id. */\n keyId: string;\n /** The Apple Developer team id. */\n teamId: string;\n /**\n * Which APNs host each mode's device tokens belong to: `sandbox` for builds\n * signed with `aps-environment: development` (Xcode, the simulator),\n * `production` for App Store and TestFlight builds. Unlisted modes default\n * to `production` for the production mode and `sandbox` for every other.\n */\n environment?: Partial<Record<JourneyMode, 'sandbox' | 'production'>>;\n}\n\n/**\n * Standard Web Push for browsers (VAPID); the key pair is `VAPID_PUBLIC_KEY` /\n * `VAPID_PRIVATE_KEY` in the environment, and the site subscribes with the same public key.\n */\nexport interface WebPushConfig {\n /** Who push services may contact about this sender: `mailto:` or an `https:` URL. */\n subject: string;\n /**\n * The contract event a notification tap the browser reports (`opened_url`) is also recorded\n * as, with `{ message_id }`: the app reports its own taps as an event, and a service worker has\n * no session to do so — without this, browser opens reach the reports but not the conditions\n * (a `performed(e.push_opened)` segment). One event per copy, however often it is tapped.\n */\n openedEvent?: string;\n}\n\n/** FCM HTTP v1 for Android; the service account is `GOOGLE_APPLICATION_CREDENTIALS_BASE64` in the environment. */\nexport interface FcmConfig {\n /** Android notification channel the pushes land in (`android.notification.channel_id`). */\n androidChannelId?: string;\n}\n\n/**\n * The engine's tables live in a Postgres schema of their own, so their short names (`event`,\n * `profile`, `message`, …) never meet the app's. `workflow-ui generate` writes them as one drizzle\n * file on this schema; the host migrates it with its own tooling.\n */\nexport interface DatabaseConfig {\n /** The Postgres schema; `workflow` by default. */\n schema?: string;\n}\n\nexport interface JourneyConfigDefinition {\n /** Studio title (the browser tab names the project). */\n title?: string;\n /** The shared `defineContract(...)` the Worker validates ingest against. */\n contract?: Contract;\n workflows: readonly WorkflowBuilder[];\n segments?: readonly SegmentRef[];\n actions?: readonly ActionRef<object>[];\n /** The template definitions, per channel; each is identified by its own `id`. */\n templates?: TemplateRegistry;\n emails?: EmailConfig;\n /** How the engine behaves per mode (`development`, `production`, …): time scale, message logging. */\n runtime?: JourneyRuntimeConfig;\n apns?: ApnsConfig;\n fcm?: FcmConfig;\n webPush?: WebPushConfig;\n /** Where a Postgres store keeps the engine's tables (the D1 store ignores it). */\n database?: DatabaseConfig;\n}\n\nexport interface JourneyConfig extends JourneyConfigDefinition {\n readonly kind: 'journey-config';\n}\n\nexport function defineConfig(definition: JourneyConfigDefinition): JourneyConfig {\n return { kind: 'journey-config', ...definition };\n}\n\nexport function isConfig(value: unknown): value is JourneyConfig {\n return (\n typeof value === 'object' &&\n value !== null &&\n (value as { kind?: unknown }).kind === 'journey-config'\n );\n}\n\n/** The APNs environment for `mode`: the configured one, else production only for production. */\nexport function apnsEnvironment(config: ApnsConfig, mode: JourneyMode): 'sandbox' | 'production' {\n return config.environment?.[mode] ?? (mode === 'production' ? 'production' : 'sandbox');\n}\n\n/** A message node the config's registries cannot serve, or whose literal props its template refuses. */\nexport class ConfigCompileError extends Error {\n override readonly name = 'ConfigCompileError';\n}\n\n/**\n * Compile the config: the bundle `compileBundle` would produce, with the\n * template manifest generated from the registry (every registered template,\n * by id, not a hand-kept list). The registry is checked first, used or not:\n * every entry is a template of its channel with an id, and an id is registered\n * once per channel. Then every message node is checked against it — an\n * unregistered or cross-channel template fails with the workflow, node and\n * channel named, and so does a literal prop the template's schema refuses (a\n * missing required prop, an undeclared one, a wrong literal) — and every\n * definition a workflow sends must be the very object registered under its id,\n * not merely one with the same id. User-property references cannot be parsed\n * here (their values exist only at send time) — they are checked by name only,\n * and parsed before sending.\n */\nexport function compileConfig(config: JourneyConfig): BundleIR {\n let index: RegistryIndex;\n try {\n index = {\n email: indexTemplates('email', config.templates?.email),\n push: indexTemplates('push', config.templates?.push),\n discord: indexTemplates('discord', config.templates?.discord),\n slack: indexTemplates('slack', config.templates?.slack),\n };\n } catch (error) {\n throw new ConfigCompileError((error as Error).message);\n }\n const bundle = compileBundle({\n workflows: config.workflows,\n ...(config.segments === undefined ? {} : { segments: config.segments }),\n ...(config.actions === undefined ? {} : { actions: config.actions }),\n templates: TEMPLATE_CHANNELS.flatMap((channel) => [...index[channel].values()]),\n });\n bundle.workflows.forEach((workflow, i) => {\n checkMessages(workflow, workflow.flow, index, config);\n checkUses(workflow, config.workflows[i], index, config);\n });\n return bundle;\n}\n\nconst TEMPLATE_CHANNELS = ['email', 'push', 'discord', 'slack'] as const;\n\n/** The registry by id, per channel. */\ntype RegistryIndex = Record<TemplateChannel, ReadonlyMap<string, AnyTemplate>>;\n\n/** The channel's templates by id; undefined for a channel templates cannot be declared for. */\nfunction indexOf(\n index: RegistryIndex,\n channel: ChannelIR\n): ReadonlyMap<string, AnyTemplate> | undefined {\n return (TEMPLATE_CHANNELS as readonly ChannelIR[]).includes(channel)\n ? index[channel as TemplateChannel]\n : undefined;\n}\n\n/**\n * The definitions a workflow sends against the registered ones: same id is not enough — a\n * workflow typed by one definition and a registry holding another under that id would render\n * with a schema the call was never checked against.\n */\nfunction checkUses(\n workflow: WorkflowIR,\n builder: WorkflowBuilder,\n index: RegistryIndex,\n config: JourneyConfig\n): void {\n for (const { template, loc } of templateUses(builder)) {\n const channel = CHANNEL_OF[template.kind];\n const registered = index[channel].get(template.id);\n if (registered === template) continue;\n const at = loc === undefined ? '' : ` (${loc.file}:${loc.line})`;\n const where = `workflow '${workflow.id}'${at}: ${channel} template '${template.id}'`;\n if (registered === undefined) throw new ConfigCompileError(`${where} is not registered`);\n const name = Object.entries(config.templates?.[channel] ?? {}).find(\n ([, entry]) => entry === registered\n )?.[0];\n throw new ConfigCompileError(\n `${where} is not the definition registered under that id (templates.${channel}.${name}) — two definitions share the id`\n );\n }\n}\n\n/** A field that may be left out: optional, or with a default the schema fills in. */\nfunction canBeOmitted(schema: core.$ZodType): boolean {\n const type = schema._zod.def.type;\n return type === 'optional' || type === 'default' || type === 'prefault';\n}\n\nfunction checkMessages(\n workflow: WorkflowIR,\n nodes: readonly NodeIR[],\n index: RegistryIndex,\n config: JourneyConfig\n): void {\n for (const node of nodes) {\n switch (node.type) {\n case 'message':\n checkMessage(workflow, node, index, config);\n break;\n case 'send_event':\n checkEmit(workflow, node, config);\n break;\n case 'branch':\n for (const c of node.cases) checkMessages(workflow, c.flow, index, config);\n if (node.otherwise) checkMessages(workflow, node.otherwise, index, config);\n break;\n case 'cohort':\n for (const arm of node.arms) checkMessages(workflow, arm.flow, index, config);\n break;\n case 'wait_until':\n if (Array.isArray(node.onTimeout)) checkMessages(workflow, node.onTimeout, index, config);\n break;\n default:\n break;\n }\n }\n}\n\nfunction checkMessage(\n workflow: WorkflowIR,\n node: Extract<NodeIR, { type: 'message' }>,\n index: RegistryIndex,\n config: JourneyConfig\n): void {\n const where = `workflow '${workflow.id}' node ${node.id}`;\n const registry = indexOf(index, node.channel);\n // Channels without template definitions (sms, …) are rendered by app-supplied senders\n if (registry === undefined) return;\n const entry = registry.get(node.template);\n if (entry === undefined) {\n const other = TEMPLATE_CHANNELS.find(\n (channel) => channel !== node.channel && index[channel].has(node.template)\n );\n const hint = other === undefined ? '' : ` (it is registered as a ${other} template)`;\n throw new ConfigCompileError(\n `${where}: ${node.channel} template '${node.template}' is not registered${hint}`\n );\n }\n const shape = (entry.props._zod.def as core.$ZodObjectDef).shape;\n const problems: string[] = [];\n for (const [field, value] of Object.entries(node.props)) {\n if (!Object.hasOwn(shape, field)) {\n problems.push(`'${field}' is not a prop of the template`);\n continue;\n }\n if (typeof value === 'object' && value !== null && 'type' in value) {\n // A user ref's value exists only at send time (parsed then); here its name can be checked\n const profile = config.contract?.schemas.profile._zod.def;\n if (\n profile !== undefined &&\n value.path !== 'userId' &&\n !Object.hasOwn(profile.shape, value.path)\n ) {\n problems.push(\n `'${field}' reads u.${value.path}, which the contract's profile does not declare`\n );\n }\n continue;\n }\n const result = z.safeParse(shape[field], value);\n if (!result.success)\n problems.push(`'${field}' = ${JSON.stringify(value)} is refused by the schema`);\n }\n for (const [field, schema] of Object.entries(shape)) {\n const optional = canBeOmitted(schema);\n if (!optional && !Object.hasOwn(node.props, field))\n problems.push(`required prop '${field}' is missing`);\n }\n if (problems.length > 0) {\n throw new ConfigCompileError(\n `${where}: ${node.channel} '${node.template}': ${problems.join('; ')}`\n );\n }\n}\n\n/**\n * An emitted event against the contract, as far as compile time can see: the\n * event is declared, literal payload values parse against their field, and no\n * required field is missing. Values read from the profile are checked when the\n * event is emitted, by the same contract.\n */\nfunction checkEmit(\n workflow: WorkflowIR,\n node: Extract<NodeIR, { type: 'send_event' }>,\n config: JourneyConfig\n): void {\n const contract = config.contract;\n if (contract === undefined) return;\n const where = `workflow '${workflow.id}' node ${node.id}`;\n if (!contract.isEvent(node.event)) {\n throw new ConfigCompileError(\n `${where}: emits '${String(node.event)}', which is not in the contract`\n );\n }\n const schema = contract.schemas.events[node.event];\n const shape = (schema._zod.def as core.$ZodObjectDef).shape;\n const problems: string[] = [];\n for (const [field, value] of Object.entries(node.payload)) {\n if (typeof value === 'object' && value !== null && 'type' in value) continue; // read at emit time\n if (!Object.hasOwn(shape, field)) continue; // a loose payload may carry extra fields\n const result = z.safeParse(shape[field], value);\n if (!result.success)\n problems.push(`'${field}' = ${JSON.stringify(value)} is refused by the contract`);\n }\n for (const [field, fieldSchema] of Object.entries(shape)) {\n const optional = canBeOmitted(fieldSchema);\n if (!optional && !Object.hasOwn(node.payload, field))\n problems.push(`required field '${field}' is missing`);\n }\n if (problems.length > 0) {\n throw new ConfigCompileError(`${where}: event '${node.event}': ${problems.join('; ')}`);\n }\n}\n"],"mappings":"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"}
package/dist/config.d.cts CHANGED
@@ -1,7 +1,6 @@
1
1
  import { BundleIR } from "./ir.cjs";
2
- import { EmailTemplate, PushTemplate } from "./templates.cjs";
2
+ import { TemplateRegistry } from "./templates.cjs";
3
3
  import { ActionRef } from "./dsl/action.cjs";
4
- import { TemplateRegistry } from "./dsl/template.cjs";
5
4
  import { SegmentRef } from "./dsl/condition.cjs";
6
5
  import { WorkflowBuilder } from "./dsl/workflow.cjs";
7
6
  import "./dsl/index.cjs";
@@ -20,8 +19,8 @@ import { JourneyMode, JourneyRuntimeConfig } from "./runtime.cjs";
20
19
  * export default defineConfig({
21
20
  * title: 'Workflow Studio · Acme',
22
21
  * contract,
23
- * workflows: [onboarding, notificationTest],
24
- * templates: registry, // { email, push }; `t = templates(registry)` for the workflows
22
+ * workflows, // [onboarding, notificationTest]
23
+ * templates, // { email, push }: the definitions the workflows send
25
24
  * emails: { from: { email: 'noreply@acme.test', name: 'Acme' } },
26
25
  * runtime: { development: { timeScale: 600, logMessages: true } },
27
26
  * apns: { topic: 'io.acme.app', keyId: 'ABC123DEFG', teamId: 'TEAM123456' },
@@ -105,7 +104,7 @@ export interface DatabaseConfig {
105
104
  /** The Postgres schema; `workflow` by default. */
106
105
  schema?: string;
107
106
  }
108
- export interface JourneyConfigDefinition<E extends Record<string, EmailTemplate> = Record<string, EmailTemplate>, P extends Record<string, PushTemplate> = Record<string, PushTemplate>> {
107
+ export interface JourneyConfigDefinition {
109
108
  /** Studio title (the browser tab names the project). */
110
109
  title?: string;
111
110
  /** The shared `defineContract(...)` the Worker validates ingest against. */
@@ -113,7 +112,8 @@ export interface JourneyConfigDefinition<E extends Record<string, EmailTemplate>
113
112
  workflows: readonly WorkflowBuilder[];
114
113
  segments?: readonly SegmentRef[];
115
114
  actions?: readonly ActionRef<object>[];
116
- templates?: TemplateRegistry<E, P>;
115
+ /** The template definitions, per channel; each is identified by its own `id`. */
116
+ templates?: TemplateRegistry;
117
117
  emails?: EmailConfig;
118
118
  /** How the engine behaves per mode (`development`, `production`, …): time scale, message logging. */
119
119
  runtime?: JourneyRuntimeConfig;
@@ -123,10 +123,10 @@ export interface JourneyConfigDefinition<E extends Record<string, EmailTemplate>
123
123
  /** Where a Postgres store keeps the engine's tables (the D1 store ignores it). */
124
124
  database?: DatabaseConfig;
125
125
  }
126
- export interface JourneyConfig<E extends Record<string, EmailTemplate> = Record<string, EmailTemplate>, P extends Record<string, PushTemplate> = Record<string, PushTemplate>> extends JourneyConfigDefinition<E, P> {
126
+ export interface JourneyConfig extends JourneyConfigDefinition {
127
127
  readonly kind: 'journey-config';
128
128
  }
129
- export declare function defineConfig<E extends Record<string, EmailTemplate> = Record<never, never>, P extends Record<string, PushTemplate> = Record<never, never>>(definition: JourneyConfigDefinition<E, P>): JourneyConfig<E, P>;
129
+ export declare function defineConfig(definition: JourneyConfigDefinition): JourneyConfig;
130
130
  export declare function isConfig(value: unknown): value is JourneyConfig;
131
131
  /** The APNs environment for `mode`: the configured one, else production only for production. */
132
132
  export declare function apnsEnvironment(config: ApnsConfig, mode: JourneyMode): 'sandbox' | 'production';
@@ -136,13 +136,17 @@ export declare class ConfigCompileError extends Error {
136
136
  }
137
137
  /**
138
138
  * Compile the config: the bundle `compileBundle` would produce, with the
139
- * template manifest generated from the registries (every registered template,
140
- * not a hand-kept list), after checking every message node against them —
141
- * an unregistered or cross-channel template reference fails with the workflow,
142
- * node and channel named, and so does a literal prop the template's schema
143
- * refuses (a missing required prop, an undeclared one, a wrong literal).
144
- * User-property references cannot be parsed here (their values exist only at
145
- * send time) — they are checked by name only, and parsed before sending.
139
+ * template manifest generated from the registry (every registered template,
140
+ * by id, not a hand-kept list). The registry is checked first, used or not:
141
+ * every entry is a template of its channel with an id, and an id is registered
142
+ * once per channel. Then every message node is checked against it — an
143
+ * unregistered or cross-channel template fails with the workflow, node and
144
+ * channel named, and so does a literal prop the template's schema refuses (a
145
+ * missing required prop, an undeclared one, a wrong literal) — and every
146
+ * definition a workflow sends must be the very object registered under its id,
147
+ * not merely one with the same id. User-property references cannot be parsed
148
+ * here (their values exist only at send time) — they are checked by name only,
149
+ * and parsed before sending.
146
150
  */
147
151
  export declare function compileConfig(config: JourneyConfig): BundleIR;
148
152
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.cts","names":[],"sources":["../src/config.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;YA0CY;EAA0B;EAAe;;;wBAGrC,mBAAmB,SAAS;;iBAM3B;EACf,MAAM;EACN,UAAU;;;;;;EAMV;;EAEA;;;;;;IAME;;;;iBAKa;;EAEf;;EAEA;;EAEA;;;;;;;EAOA,cAAc,QAAQ,OAAO;;;;;;iBAOd;;EAEf;;;;;;;EAOA;;;iBAIe;;EAEf;;;;;;;iBAQe;;EAEf;;iBAGe,wBACf,UAAU,eAAe,iBAAiB,eAAe,gBACzD,UAAU,eAAe,gBAAgB,eAAe;;EAGxD;;EAEA,WAAW;EACX,oBAAoB;EACpB,oBAAoB;EACpB,mBAAmB;EACnB,YAAY,iBAAiB,GAAG;EAChC,SAAS;;EAET,UAAU;EACV,OAAO;EACP,MAAM;EACN,UAAU;;EAEV,WAAW;;iBAGI,cACf,UAAU,eAAe,iBAAiB,eAAe,gBACzD,UAAU,eAAe,gBAAgB,eAAe,uBAChD,wBAAwB,GAAG;WAC1B;;wBAGK,aACd,UAAU,eAAe,iBAAiB,sBAC1C,UAAU,eAAe,gBAAgB,sBACzC,YAAY,wBAAwB,GAAG,KAAK,cAAc,GAAG;wBAI/C,SAAS,iBAAiB,SAAS;;wBASnC,gBAAgB,QAAQ,YAAY,MAAM;;qBAK7C,2BAA2B;WACpB;;;;;;;;;;;;wBAaJ,cAAc,QAAQ,gBAAgB"}
1
+ {"version":3,"file":"config.d.cts","names":[],"sources":["../src/config.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;YA0CY;EAA0B;EAAe;;;wBAGrC,mBAAmB,SAAS;;iBAM3B;EACf,MAAM;EACN,UAAU;;;;;;EAMV;;EAEA;;;;;;IAME;;;;iBAKa;;EAEf;;EAEA;;EAEA;;;;;;;EAOA,cAAc,QAAQ,OAAO;;;;;;iBAOd;;EAEf;;;;;;;EAOA;;;iBAIe;;EAEf;;;;;;;iBAQe;;EAEf;;iBAGe;;EAEf;;EAEA,WAAW;EACX,oBAAoB;EACpB,oBAAoB;EACpB,mBAAmB;;EAEnB,YAAY;EACZ,SAAS;;EAET,UAAU;EACV,OAAO;EACP,MAAM;EACN,UAAU;;EAEV,WAAW;;iBAGI,sBAAsB;WAC5B;;wBAGK,aAAa,YAAY,0BAA0B;wBAInD,SAAS,iBAAiB,SAAS;;wBASnC,gBAAgB,QAAQ,YAAY,MAAM;;qBAK7C,2BAA2B;WACpB;;;;;;;;;;;;;;;;wBAiBJ,cAAc,QAAQ,gBAAgB"}
package/dist/config.d.mts CHANGED
@@ -1,7 +1,6 @@
1
1
  import { BundleIR } from "./ir.mjs";
2
- import { EmailTemplate, PushTemplate } from "./templates.mjs";
2
+ import { TemplateRegistry } from "./templates.mjs";
3
3
  import { ActionRef } from "./dsl/action.mjs";
4
- import { TemplateRegistry } from "./dsl/template.mjs";
5
4
  import { SegmentRef } from "./dsl/condition.mjs";
6
5
  import { WorkflowBuilder } from "./dsl/workflow.mjs";
7
6
  import "./dsl/index.mjs";
@@ -20,8 +19,8 @@ import { JourneyMode, JourneyRuntimeConfig } from "./runtime.mjs";
20
19
  * export default defineConfig({
21
20
  * title: 'Workflow Studio · Acme',
22
21
  * contract,
23
- * workflows: [onboarding, notificationTest],
24
- * templates: registry, // { email, push }; `t = templates(registry)` for the workflows
22
+ * workflows, // [onboarding, notificationTest]
23
+ * templates, // { email, push }: the definitions the workflows send
25
24
  * emails: { from: { email: 'noreply@acme.test', name: 'Acme' } },
26
25
  * runtime: { development: { timeScale: 600, logMessages: true } },
27
26
  * apns: { topic: 'io.acme.app', keyId: 'ABC123DEFG', teamId: 'TEAM123456' },
@@ -105,7 +104,7 @@ export interface DatabaseConfig {
105
104
  /** The Postgres schema; `workflow` by default. */
106
105
  schema?: string;
107
106
  }
108
- export interface JourneyConfigDefinition<E extends Record<string, EmailTemplate> = Record<string, EmailTemplate>, P extends Record<string, PushTemplate> = Record<string, PushTemplate>> {
107
+ export interface JourneyConfigDefinition {
109
108
  /** Studio title (the browser tab names the project). */
110
109
  title?: string;
111
110
  /** The shared `defineContract(...)` the Worker validates ingest against. */
@@ -113,7 +112,8 @@ export interface JourneyConfigDefinition<E extends Record<string, EmailTemplate>
113
112
  workflows: readonly WorkflowBuilder[];
114
113
  segments?: readonly SegmentRef[];
115
114
  actions?: readonly ActionRef<object>[];
116
- templates?: TemplateRegistry<E, P>;
115
+ /** The template definitions, per channel; each is identified by its own `id`. */
116
+ templates?: TemplateRegistry;
117
117
  emails?: EmailConfig;
118
118
  /** How the engine behaves per mode (`development`, `production`, …): time scale, message logging. */
119
119
  runtime?: JourneyRuntimeConfig;
@@ -123,10 +123,10 @@ export interface JourneyConfigDefinition<E extends Record<string, EmailTemplate>
123
123
  /** Where a Postgres store keeps the engine's tables (the D1 store ignores it). */
124
124
  database?: DatabaseConfig;
125
125
  }
126
- export interface JourneyConfig<E extends Record<string, EmailTemplate> = Record<string, EmailTemplate>, P extends Record<string, PushTemplate> = Record<string, PushTemplate>> extends JourneyConfigDefinition<E, P> {
126
+ export interface JourneyConfig extends JourneyConfigDefinition {
127
127
  readonly kind: 'journey-config';
128
128
  }
129
- export declare function defineConfig<E extends Record<string, EmailTemplate> = Record<never, never>, P extends Record<string, PushTemplate> = Record<never, never>>(definition: JourneyConfigDefinition<E, P>): JourneyConfig<E, P>;
129
+ export declare function defineConfig(definition: JourneyConfigDefinition): JourneyConfig;
130
130
  export declare function isConfig(value: unknown): value is JourneyConfig;
131
131
  /** The APNs environment for `mode`: the configured one, else production only for production. */
132
132
  export declare function apnsEnvironment(config: ApnsConfig, mode: JourneyMode): 'sandbox' | 'production';
@@ -136,13 +136,17 @@ export declare class ConfigCompileError extends Error {
136
136
  }
137
137
  /**
138
138
  * Compile the config: the bundle `compileBundle` would produce, with the
139
- * template manifest generated from the registries (every registered template,
140
- * not a hand-kept list), after checking every message node against them —
141
- * an unregistered or cross-channel template reference fails with the workflow,
142
- * node and channel named, and so does a literal prop the template's schema
143
- * refuses (a missing required prop, an undeclared one, a wrong literal).
144
- * User-property references cannot be parsed here (their values exist only at
145
- * send time) — they are checked by name only, and parsed before sending.
139
+ * template manifest generated from the registry (every registered template,
140
+ * by id, not a hand-kept list). The registry is checked first, used or not:
141
+ * every entry is a template of its channel with an id, and an id is registered
142
+ * once per channel. Then every message node is checked against it — an
143
+ * unregistered or cross-channel template fails with the workflow, node and
144
+ * channel named, and so does a literal prop the template's schema refuses (a
145
+ * missing required prop, an undeclared one, a wrong literal) — and every
146
+ * definition a workflow sends must be the very object registered under its id,
147
+ * not merely one with the same id. User-property references cannot be parsed
148
+ * here (their values exist only at send time) — they are checked by name only,
149
+ * and parsed before sending.
146
150
  */
147
151
  export declare function compileConfig(config: JourneyConfig): BundleIR;
148
152
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.mts","names":[],"sources":["../src/config.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;YA0CY;EAA0B;EAAe;;;wBAGrC,mBAAmB,SAAS;;iBAM3B;EACf,MAAM;EACN,UAAU;;;;;;EAMV;;EAEA;;;;;;IAME;;;;iBAKa;;EAEf;;EAEA;;EAEA;;;;;;;EAOA,cAAc,QAAQ,OAAO;;;;;;iBAOd;;EAEf;;;;;;;EAOA;;;iBAIe;;EAEf;;;;;;;iBAQe;;EAEf;;iBAGe,wBACf,UAAU,eAAe,iBAAiB,eAAe,gBACzD,UAAU,eAAe,gBAAgB,eAAe;;EAGxD;;EAEA,WAAW;EACX,oBAAoB;EACpB,oBAAoB;EACpB,mBAAmB;EACnB,YAAY,iBAAiB,GAAG;EAChC,SAAS;;EAET,UAAU;EACV,OAAO;EACP,MAAM;EACN,UAAU;;EAEV,WAAW;;iBAGI,cACf,UAAU,eAAe,iBAAiB,eAAe,gBACzD,UAAU,eAAe,gBAAgB,eAAe,uBAChD,wBAAwB,GAAG;WAC1B;;wBAGK,aACd,UAAU,eAAe,iBAAiB,sBAC1C,UAAU,eAAe,gBAAgB,sBACzC,YAAY,wBAAwB,GAAG,KAAK,cAAc,GAAG;wBAI/C,SAAS,iBAAiB,SAAS;;wBASnC,gBAAgB,QAAQ,YAAY,MAAM;;qBAK7C,2BAA2B;WACpB;;;;;;;;;;;;wBAaJ,cAAc,QAAQ,gBAAgB"}
1
+ {"version":3,"file":"config.d.mts","names":[],"sources":["../src/config.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;YA0CY;EAA0B;EAAe;;;wBAGrC,mBAAmB,SAAS;;iBAM3B;EACf,MAAM;EACN,UAAU;;;;;;EAMV;;EAEA;;;;;;IAME;;;;iBAKa;;EAEf;;EAEA;;EAEA;;;;;;;EAOA,cAAc,QAAQ,OAAO;;;;;;iBAOd;;EAEf;;;;;;;EAOA;;;iBAIe;;EAEf;;;;;;;iBAQe;;EAEf;;iBAGe;;EAEf;;EAEA,WAAW;EACX,oBAAoB;EACpB,oBAAoB;EACpB,mBAAmB;;EAEnB,YAAY;EACZ,SAAS;;EAET,UAAU;EACV,OAAO;EACP,MAAM;EACN,UAAU;;EAEV,WAAW;;iBAGI,sBAAsB;WAC5B;;wBAGK,aAAa,YAAY,0BAA0B;wBAInD,SAAS,iBAAiB,SAAS;;wBASnC,gBAAgB,QAAQ,YAAY,MAAM;;qBAK7C,2BAA2B;WACpB;;;;;;;;;;;;;;;;wBAiBJ,cAAc,QAAQ,gBAAgB"}
package/dist/config.mjs CHANGED
@@ -1,4 +1,5 @@
1
- import { template } from "./dsl/template.mjs";
1
+ import { CHANNEL_OF, indexTemplates } from "./templates.mjs";
2
+ import { templateUses } from "./dsl/flow.mjs";
2
3
  import { compileBundle } from "./dsl/bundle.mjs";
3
4
  import "./dsl/index.mjs";
4
5
  import * as z from "zod/mini";
@@ -27,28 +28,40 @@ var ConfigCompileError = class extends Error {
27
28
  };
28
29
  /**
29
30
  * Compile the config: the bundle `compileBundle` would produce, with the
30
- * template manifest generated from the registries (every registered template,
31
- * not a hand-kept list), after checking every message node against them —
32
- * an unregistered or cross-channel template reference fails with the workflow,
33
- * node and channel named, and so does a literal prop the template's schema
34
- * refuses (a missing required prop, an undeclared one, a wrong literal).
35
- * User-property references cannot be parsed here (their values exist only at
36
- * send time) — they are checked by name only, and parsed before sending.
31
+ * template manifest generated from the registry (every registered template,
32
+ * by id, not a hand-kept list). The registry is checked first, used or not:
33
+ * every entry is a template of its channel with an id, and an id is registered
34
+ * once per channel. Then every message node is checked against it — an
35
+ * unregistered or cross-channel template fails with the workflow, node and
36
+ * channel named, and so does a literal prop the template's schema refuses (a
37
+ * missing required prop, an undeclared one, a wrong literal) — and every
38
+ * definition a workflow sends must be the very object registered under its id,
39
+ * not merely one with the same id. User-property references cannot be parsed
40
+ * here (their values exist only at send time) — they are checked by name only,
41
+ * and parsed before sending.
37
42
  */
38
43
  function compileConfig(config) {
39
- const registered = (channel) => Object.keys(registryOf(config, channel) ?? {});
44
+ let index;
45
+ try {
46
+ index = {
47
+ email: indexTemplates("email", config.templates?.email),
48
+ push: indexTemplates("push", config.templates?.push),
49
+ discord: indexTemplates("discord", config.templates?.discord),
50
+ slack: indexTemplates("slack", config.templates?.slack)
51
+ };
52
+ } catch (error) {
53
+ throw new ConfigCompileError(error.message);
54
+ }
40
55
  const bundle = compileBundle({
41
56
  workflows: config.workflows,
42
57
  ...config.segments === void 0 ? {} : { segments: config.segments },
43
58
  ...config.actions === void 0 ? {} : { actions: config.actions },
44
- templates: [
45
- ...registered("email").map((key) => template.email(key)),
46
- ...registered("push").map((key) => template.push(key)),
47
- ...registered("discord").map((key) => template.discord(key)),
48
- ...registered("slack").map((key) => template.slack(key))
49
- ]
59
+ templates: TEMPLATE_CHANNELS.flatMap((channel) => [...index[channel].values()])
60
+ });
61
+ bundle.workflows.forEach((workflow, i) => {
62
+ checkMessages(workflow, workflow.flow, index, config);
63
+ checkUses(workflow, config.workflows[i], index, config);
50
64
  });
51
- for (const workflow of bundle.workflows) checkMessages(workflow, workflow.flow, config);
52
65
  return bundle;
53
66
  }
54
67
  const TEMPLATE_CHANNELS = [
@@ -57,14 +70,25 @@ const TEMPLATE_CHANNELS = [
57
70
  "discord",
58
71
  "slack"
59
72
  ];
60
- /** The config's registry for a channel; undefined for a channel templates cannot be declared for. */
61
- function registryOf(config, channel) {
62
- switch (channel) {
63
- case "email": return config.templates?.email;
64
- case "push": return config.templates?.push;
65
- case "discord": return config.templates?.discord;
66
- case "slack": return config.templates?.slack;
67
- default: return;
73
+ /** The channel's templates by id; undefined for a channel templates cannot be declared for. */
74
+ function indexOf(index, channel) {
75
+ return TEMPLATE_CHANNELS.includes(channel) ? index[channel] : void 0;
76
+ }
77
+ /**
78
+ * The definitions a workflow sends against the registered ones: same id is not enough — a
79
+ * workflow typed by one definition and a registry holding another under that id would render
80
+ * with a schema the call was never checked against.
81
+ */
82
+ function checkUses(workflow, builder, index, config) {
83
+ for (const { template, loc } of templateUses(builder)) {
84
+ const channel = CHANNEL_OF[template.kind];
85
+ const registered = index[channel].get(template.id);
86
+ if (registered === template) continue;
87
+ const at = loc === void 0 ? "" : ` (${loc.file}:${loc.line})`;
88
+ const where = `workflow '${workflow.id}'${at}: ${channel} template '${template.id}'`;
89
+ if (registered === void 0) throw new ConfigCompileError(`${where} is not registered`);
90
+ const name = Object.entries(config.templates?.[channel] ?? {}).find(([, entry]) => entry === registered)?.[0];
91
+ throw new ConfigCompileError(`${where} is not the definition registered under that id (templates.${channel}.${name}) — two definitions share the id`);
68
92
  }
69
93
  }
70
94
  /** A field that may be left out: optional, or with a default the schema fills in. */
@@ -72,34 +96,35 @@ function canBeOmitted(schema) {
72
96
  const type = schema._zod.def.type;
73
97
  return type === "optional" || type === "default" || type === "prefault";
74
98
  }
75
- function checkMessages(workflow, nodes, config) {
99
+ function checkMessages(workflow, nodes, index, config) {
76
100
  for (const node of nodes) switch (node.type) {
77
101
  case "message":
78
- checkMessage(workflow, node, config);
102
+ checkMessage(workflow, node, index, config);
79
103
  break;
80
104
  case "send_event":
81
105
  checkEmit(workflow, node, config);
82
106
  break;
83
107
  case "branch":
84
- for (const c of node.cases) checkMessages(workflow, c.flow, config);
85
- if (node.otherwise) checkMessages(workflow, node.otherwise, config);
108
+ for (const c of node.cases) checkMessages(workflow, c.flow, index, config);
109
+ if (node.otherwise) checkMessages(workflow, node.otherwise, index, config);
86
110
  break;
87
111
  case "cohort":
88
- for (const arm of node.arms) checkMessages(workflow, arm.flow, config);
112
+ for (const arm of node.arms) checkMessages(workflow, arm.flow, index, config);
89
113
  break;
90
- case "wait_until": if (Array.isArray(node.onTimeout)) checkMessages(workflow, node.onTimeout, config);
114
+ case "wait_until": if (Array.isArray(node.onTimeout)) checkMessages(workflow, node.onTimeout, index, config);
91
115
  }
92
116
  }
93
- function checkMessage(workflow, node, config) {
117
+ function checkMessage(workflow, node, index, config) {
94
118
  const where = `workflow '${workflow.id}' node ${node.id}`;
95
- const registry = registryOf(config, node.channel);
96
- if (registry === void 0 && !TEMPLATE_CHANNELS.includes(node.channel)) return;
97
- if (registry === void 0 || !Object.hasOwn(registry, node.template)) {
98
- const other = TEMPLATE_CHANNELS.find((channel) => channel !== node.channel && Object.hasOwn(registryOf(config, channel) ?? {}, node.template));
119
+ const registry = indexOf(index, node.channel);
120
+ if (registry === void 0) return;
121
+ const entry = registry.get(node.template);
122
+ if (entry === void 0) {
123
+ const other = TEMPLATE_CHANNELS.find((channel) => channel !== node.channel && index[channel].has(node.template));
99
124
  const hint = other === void 0 ? "" : ` (it is registered as a ${other} template)`;
100
125
  throw new ConfigCompileError(`${where}: ${node.channel} template '${node.template}' is not registered${hint}`);
101
126
  }
102
- const shape = registry[node.template].props._zod.def.shape;
127
+ const shape = entry.props._zod.def.shape;
103
128
  const problems = [];
104
129
  for (const [field, value] of Object.entries(node.props)) {
105
130
  if (!Object.hasOwn(shape, field)) {
@@ -1 +1 @@
1
- {"version":3,"file":"config.mjs","names":[],"sources":["../src/config.ts"],"sourcesContent":["import * as z from 'zod/mini';\nimport type * as core from 'zod/v4/core';\nimport type { Contract } from './contract';\nimport {\n type ActionRef,\n type SegmentRef,\n type TemplateRegistry,\n type WorkflowBuilder,\n compileBundle,\n template,\n} from './dsl';\nimport type { BundleIR, ChannelIR, NodeIR, WorkflowIR } from './ir';\nimport type { JourneyMode, JourneyRuntimeConfig } from './runtime';\nimport type { EmailTemplate, PropsSchema, PushTemplate } from './templates';\n\n/**\n * `workflow.config.ts` — the project's journey configuration, typed, flat and\n * committed with the code: what it deploys (workflows, segments, actions),\n * the template registries behind them, the contract every inbound event is\n * checked against, the email envelope, and how the engine runs per\n * environment (time scale, APNs, FCM). The compiler (`compileConfig`), the\n * Worker (`journeyWorker({ config })`) and the Workflow Studio all read this\n * one object, so everything is registered exactly once:\n *\n * export default defineConfig({\n * title: 'Workflow Studio · Acme',\n * contract,\n * workflows: [onboarding, notificationTest],\n * templates: registry, // { email, push }; `t = templates(registry)` for the workflows\n * emails: { from: { email: 'noreply@acme.test', name: 'Acme' } },\n * runtime: { development: { timeScale: 600, logMessages: true } },\n * apns: { topic: 'io.acme.app', keyId: 'ABC123DEFG', teamId: 'TEAM123456' },\n * });\n *\n * Secrets are the one thing that stay out: a `.p8`, a service account, the\n * ingest bearer. The host reads them from its environment under fixed names\n * (see `JourneyEnv`), so the config says *which* key without carrying it. The\n * config is a source object (schemas, components, functions); only its\n * compiled bundle and preview-safe template data may cross to a browser.\n */\n\n/** An address as the transports take it: bare, or with a display name. */\nexport type EmailAddress = string | { email: string; name?: string };\n\n/** `Name <email>`, or the bare address. */\nexport function formatEmailAddress(address: EmailAddress): string {\n if (typeof address === 'string') return address;\n return address.name === undefined ? address.email : `${address.name} <${address.email}>`;\n}\n\n/** The envelope every journey email goes out with (and the studio shows). */\nexport interface EmailConfig {\n from: EmailAddress;\n replyTo?: EmailAddress;\n /**\n * The unsubscribe link the templates' footer carries. Tracked like every link, but following\n * it shows a confirmation page, and only the confirmation (a POST no link scanner sends) is\n * recorded as the message's unsubscribe.\n */\n unsubscribe?: string;\n /** How the Worker tells a person's opens and clicks from a machine's (see the reporting classifier). */\n tracking?: {\n /**\n * An open or click this many milliseconds after delivery is a weak machine signal — machine\n * only together with another (a second link within seconds); 1000 by default. A person\n * tapping a mail notification can click within a few seconds, so keep it short.\n */\n quickClickMs?: number;\n };\n}\n\n/** Token-based APNs, the `.p8` auth key; the key itself is `APNS_PRIVATE_KEY_BASE64` in the environment. */\nexport interface ApnsConfig {\n /** The app's bundle id (`apns-topic`). */\n topic: string;\n /** The key's 10-character id. */\n keyId: string;\n /** The Apple Developer team id. */\n teamId: string;\n /**\n * Which APNs host each mode's device tokens belong to: `sandbox` for builds\n * signed with `aps-environment: development` (Xcode, the simulator),\n * `production` for App Store and TestFlight builds. Unlisted modes default\n * to `production` for the production mode and `sandbox` for every other.\n */\n environment?: Partial<Record<JourneyMode, 'sandbox' | 'production'>>;\n}\n\n/**\n * Standard Web Push for browsers (VAPID); the key pair is `VAPID_PUBLIC_KEY` /\n * `VAPID_PRIVATE_KEY` in the environment, and the site subscribes with the same public key.\n */\nexport interface WebPushConfig {\n /** Who push services may contact about this sender: `mailto:` or an `https:` URL. */\n subject: string;\n /**\n * The contract event a notification tap the browser reports (`opened_url`) is also recorded\n * as, with `{ message_id }`: the app reports its own taps as an event, and a service worker has\n * no session to do so — without this, browser opens reach the reports but not the conditions\n * (a `performed(e.push_opened)` segment). One event per copy, however often it is tapped.\n */\n openedEvent?: string;\n}\n\n/** FCM HTTP v1 for Android; the service account is `GOOGLE_APPLICATION_CREDENTIALS_BASE64` in the environment. */\nexport interface FcmConfig {\n /** Android notification channel the pushes land in (`android.notification.channel_id`). */\n androidChannelId?: string;\n}\n\n/**\n * The engine's tables live in a Postgres schema of their own, so their short names (`event`,\n * `profile`, `message`, …) never meet the app's. `workflow-ui generate` writes them as one drizzle\n * file on this schema; the host migrates it with its own tooling.\n */\nexport interface DatabaseConfig {\n /** The Postgres schema; `workflow` by default. */\n schema?: string;\n}\n\nexport interface JourneyConfigDefinition<\n E extends Record<string, EmailTemplate> = Record<string, EmailTemplate>,\n P extends Record<string, PushTemplate> = Record<string, PushTemplate>,\n> {\n /** Studio title (the browser tab names the project). */\n title?: string;\n /** The shared `defineContract(...)` the Worker validates ingest against. */\n contract?: Contract;\n workflows: readonly WorkflowBuilder[];\n segments?: readonly SegmentRef[];\n actions?: readonly ActionRef<object>[];\n templates?: TemplateRegistry<E, P>;\n emails?: EmailConfig;\n /** How the engine behaves per mode (`development`, `production`, …): time scale, message logging. */\n runtime?: JourneyRuntimeConfig;\n apns?: ApnsConfig;\n fcm?: FcmConfig;\n webPush?: WebPushConfig;\n /** Where a Postgres store keeps the engine's tables (the D1 store ignores it). */\n database?: DatabaseConfig;\n}\n\nexport interface JourneyConfig<\n E extends Record<string, EmailTemplate> = Record<string, EmailTemplate>,\n P extends Record<string, PushTemplate> = Record<string, PushTemplate>,\n> extends JourneyConfigDefinition<E, P> {\n readonly kind: 'journey-config';\n}\n\nexport function defineConfig<\n E extends Record<string, EmailTemplate> = Record<never, never>,\n P extends Record<string, PushTemplate> = Record<never, never>,\n>(definition: JourneyConfigDefinition<E, P>): JourneyConfig<E, P> {\n return { kind: 'journey-config', ...definition };\n}\n\nexport function isConfig(value: unknown): value is JourneyConfig {\n return (\n typeof value === 'object' &&\n value !== null &&\n (value as { kind?: unknown }).kind === 'journey-config'\n );\n}\n\n/** The APNs environment for `mode`: the configured one, else production only for production. */\nexport function apnsEnvironment(config: ApnsConfig, mode: JourneyMode): 'sandbox' | 'production' {\n return config.environment?.[mode] ?? (mode === 'production' ? 'production' : 'sandbox');\n}\n\n/** A message node the config's registries cannot serve, or whose literal props its template refuses. */\nexport class ConfigCompileError extends Error {\n override readonly name = 'ConfigCompileError';\n}\n\n/**\n * Compile the config: the bundle `compileBundle` would produce, with the\n * template manifest generated from the registries (every registered template,\n * not a hand-kept list), after checking every message node against them —\n * an unregistered or cross-channel template reference fails with the workflow,\n * node and channel named, and so does a literal prop the template's schema\n * refuses (a missing required prop, an undeclared one, a wrong literal).\n * User-property references cannot be parsed here (their values exist only at\n * send time) — they are checked by name only, and parsed before sending.\n */\nexport function compileConfig(config: JourneyConfig): BundleIR {\n const registered = (channel: ChannelIR) => Object.keys(registryOf(config, channel) ?? {});\n const bundle = compileBundle({\n workflows: config.workflows,\n ...(config.segments === undefined ? {} : { segments: config.segments }),\n ...(config.actions === undefined ? {} : { actions: config.actions }),\n templates: [\n ...registered('email').map((key) => template.email(key)),\n ...registered('push').map((key) => template.push(key)),\n ...registered('discord').map((key) => template.discord(key)),\n ...registered('slack').map((key) => template.slack(key)),\n ],\n });\n for (const workflow of bundle.workflows) checkMessages(workflow, workflow.flow, config);\n return bundle;\n}\n\nconst TEMPLATE_CHANNELS: readonly ChannelIR[] = ['email', 'push', 'discord', 'slack'];\n\n/** The config's registry for a channel; undefined for a channel templates cannot be declared for. */\nfunction registryOf(\n config: JourneyConfig,\n channel: ChannelIR\n): Readonly<Record<string, { props: PropsSchema }>> | undefined {\n switch (channel) {\n case 'email':\n return config.templates?.email;\n case 'push':\n return config.templates?.push;\n case 'discord':\n return config.templates?.discord;\n case 'slack':\n return config.templates?.slack;\n default:\n return undefined;\n }\n}\n\n/** A field that may be left out: optional, or with a default the schema fills in. */\nfunction canBeOmitted(schema: core.$ZodType): boolean {\n const type = schema._zod.def.type;\n return type === 'optional' || type === 'default' || type === 'prefault';\n}\n\nfunction checkMessages(\n workflow: WorkflowIR,\n nodes: readonly NodeIR[],\n config: JourneyConfig\n): void {\n for (const node of nodes) {\n switch (node.type) {\n case 'message':\n checkMessage(workflow, node, config);\n break;\n case 'send_event':\n checkEmit(workflow, node, config);\n break;\n case 'branch':\n for (const c of node.cases) checkMessages(workflow, c.flow, config);\n if (node.otherwise) checkMessages(workflow, node.otherwise, config);\n break;\n case 'cohort':\n for (const arm of node.arms) checkMessages(workflow, arm.flow, config);\n break;\n case 'wait_until':\n if (Array.isArray(node.onTimeout)) checkMessages(workflow, node.onTimeout, config);\n break;\n default:\n break;\n }\n }\n}\n\nfunction checkMessage(\n workflow: WorkflowIR,\n node: Extract<NodeIR, { type: 'message' }>,\n config: JourneyConfig\n): void {\n const where = `workflow '${workflow.id}' node ${node.id}`;\n const registry = registryOf(config, node.channel);\n // Channels without a registry (sms, …) are rendered by app-supplied senders\n if (registry === undefined && !TEMPLATE_CHANNELS.includes(node.channel)) return;\n if (registry === undefined || !Object.hasOwn(registry, node.template)) {\n const other = TEMPLATE_CHANNELS.find(\n (channel) =>\n channel !== node.channel && Object.hasOwn(registryOf(config, channel) ?? {}, node.template)\n );\n const hint = other === undefined ? '' : ` (it is registered as a ${other} template)`;\n throw new ConfigCompileError(\n `${where}: ${node.channel} template '${node.template}' is not registered${hint}`\n );\n }\n const entry = registry[node.template];\n const shape = (entry.props._zod.def as core.$ZodObjectDef).shape;\n const problems: string[] = [];\n for (const [field, value] of Object.entries(node.props)) {\n if (!Object.hasOwn(shape, field)) {\n problems.push(`'${field}' is not a prop of the template`);\n continue;\n }\n if (typeof value === 'object' && value !== null && 'type' in value) {\n // A user ref's value exists only at send time (parsed then); here its name can be checked\n const profile = config.contract?.schemas.profile._zod.def;\n if (\n profile !== undefined &&\n value.path !== 'userId' &&\n !Object.hasOwn(profile.shape, value.path)\n ) {\n problems.push(\n `'${field}' reads u.${value.path}, which the contract's profile does not declare`\n );\n }\n continue;\n }\n const result = z.safeParse(shape[field], value);\n if (!result.success)\n problems.push(`'${field}' = ${JSON.stringify(value)} is refused by the schema`);\n }\n for (const [field, schema] of Object.entries(shape)) {\n const optional = canBeOmitted(schema);\n if (!optional && !Object.hasOwn(node.props, field))\n problems.push(`required prop '${field}' is missing`);\n }\n if (problems.length > 0) {\n throw new ConfigCompileError(\n `${where}: ${node.channel} '${node.template}': ${problems.join('; ')}`\n );\n }\n}\n\n/**\n * An emitted event against the contract, as far as compile time can see: the\n * event is declared, literal payload values parse against their field, and no\n * required field is missing. Values read from the profile are checked when the\n * event is emitted, by the same contract.\n */\nfunction checkEmit(\n workflow: WorkflowIR,\n node: Extract<NodeIR, { type: 'send_event' }>,\n config: JourneyConfig\n): void {\n const contract = config.contract;\n if (contract === undefined) return;\n const where = `workflow '${workflow.id}' node ${node.id}`;\n if (!contract.isEvent(node.event)) {\n throw new ConfigCompileError(\n `${where}: emits '${String(node.event)}', which is not in the contract`\n );\n }\n const schema = contract.schemas.events[node.event];\n const shape = (schema._zod.def as core.$ZodObjectDef).shape;\n const problems: string[] = [];\n for (const [field, value] of Object.entries(node.payload)) {\n if (typeof value === 'object' && value !== null && 'type' in value) continue; // read at emit time\n if (!Object.hasOwn(shape, field)) continue; // a loose payload may carry extra fields\n const result = z.safeParse(shape[field], value);\n if (!result.success)\n problems.push(`'${field}' = ${JSON.stringify(value)} is refused by the contract`);\n }\n for (const [field, fieldSchema] of Object.entries(shape)) {\n const optional = canBeOmitted(fieldSchema);\n if (!optional && !Object.hasOwn(node.payload, field))\n problems.push(`required field '${field}' is missing`);\n }\n if (problems.length > 0) {\n throw new ConfigCompileError(`${where}: event '${node.event}': ${problems.join('; ')}`);\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"config.mjs","names":[],"sources":["../src/config.ts"],"sourcesContent":["import * as z from 'zod/mini';\nimport type * as core from 'zod/v4/core';\nimport type { Contract } from './contract';\nimport { type ActionRef, type SegmentRef, type WorkflowBuilder, compileBundle } from './dsl';\nimport { templateUses } from './dsl/flow';\nimport type { BundleIR, ChannelIR, NodeIR, WorkflowIR } from './ir';\nimport type { JourneyMode, JourneyRuntimeConfig } from './runtime';\nimport {\n type AnyTemplate,\n CHANNEL_OF,\n type TemplateChannel,\n type TemplateRegistry,\n indexTemplates,\n} from './templates';\n\n/**\n * `workflow.config.ts` — the project's journey configuration, typed, flat and\n * committed with the code: what it deploys (workflows, segments, actions),\n * the template registries behind them, the contract every inbound event is\n * checked against, the email envelope, and how the engine runs per\n * environment (time scale, APNs, FCM). The compiler (`compileConfig`), the\n * Worker (`journeyWorker({ config })`) and the Workflow Studio all read this\n * one object, so everything is registered exactly once:\n *\n * export default defineConfig({\n * title: 'Workflow Studio · Acme',\n * contract,\n * workflows, // [onboarding, notificationTest]\n * templates, // { email, push }: the definitions the workflows send\n * emails: { from: { email: 'noreply@acme.test', name: 'Acme' } },\n * runtime: { development: { timeScale: 600, logMessages: true } },\n * apns: { topic: 'io.acme.app', keyId: 'ABC123DEFG', teamId: 'TEAM123456' },\n * });\n *\n * Secrets are the one thing that stay out: a `.p8`, a service account, the\n * ingest bearer. The host reads them from its environment under fixed names\n * (see `JourneyEnv`), so the config says *which* key without carrying it. The\n * config is a source object (schemas, components, functions); only its\n * compiled bundle and preview-safe template data may cross to a browser.\n */\n\n/** An address as the transports take it: bare, or with a display name. */\nexport type EmailAddress = string | { email: string; name?: string };\n\n/** `Name <email>`, or the bare address. */\nexport function formatEmailAddress(address: EmailAddress): string {\n if (typeof address === 'string') return address;\n return address.name === undefined ? address.email : `${address.name} <${address.email}>`;\n}\n\n/** The envelope every journey email goes out with (and the studio shows). */\nexport interface EmailConfig {\n from: EmailAddress;\n replyTo?: EmailAddress;\n /**\n * The unsubscribe link the templates' footer carries. Tracked like every link, but following\n * it shows a confirmation page, and only the confirmation (a POST no link scanner sends) is\n * recorded as the message's unsubscribe.\n */\n unsubscribe?: string;\n /** How the Worker tells a person's opens and clicks from a machine's (see the reporting classifier). */\n tracking?: {\n /**\n * An open or click this many milliseconds after delivery is a weak machine signal — machine\n * only together with another (a second link within seconds); 1000 by default. A person\n * tapping a mail notification can click within a few seconds, so keep it short.\n */\n quickClickMs?: number;\n };\n}\n\n/** Token-based APNs, the `.p8` auth key; the key itself is `APNS_PRIVATE_KEY_BASE64` in the environment. */\nexport interface ApnsConfig {\n /** The app's bundle id (`apns-topic`). */\n topic: string;\n /** The key's 10-character id. */\n keyId: string;\n /** The Apple Developer team id. */\n teamId: string;\n /**\n * Which APNs host each mode's device tokens belong to: `sandbox` for builds\n * signed with `aps-environment: development` (Xcode, the simulator),\n * `production` for App Store and TestFlight builds. Unlisted modes default\n * to `production` for the production mode and `sandbox` for every other.\n */\n environment?: Partial<Record<JourneyMode, 'sandbox' | 'production'>>;\n}\n\n/**\n * Standard Web Push for browsers (VAPID); the key pair is `VAPID_PUBLIC_KEY` /\n * `VAPID_PRIVATE_KEY` in the environment, and the site subscribes with the same public key.\n */\nexport interface WebPushConfig {\n /** Who push services may contact about this sender: `mailto:` or an `https:` URL. */\n subject: string;\n /**\n * The contract event a notification tap the browser reports (`opened_url`) is also recorded\n * as, with `{ message_id }`: the app reports its own taps as an event, and a service worker has\n * no session to do so — without this, browser opens reach the reports but not the conditions\n * (a `performed(e.push_opened)` segment). One event per copy, however often it is tapped.\n */\n openedEvent?: string;\n}\n\n/** FCM HTTP v1 for Android; the service account is `GOOGLE_APPLICATION_CREDENTIALS_BASE64` in the environment. */\nexport interface FcmConfig {\n /** Android notification channel the pushes land in (`android.notification.channel_id`). */\n androidChannelId?: string;\n}\n\n/**\n * The engine's tables live in a Postgres schema of their own, so their short names (`event`,\n * `profile`, `message`, …) never meet the app's. `workflow-ui generate` writes them as one drizzle\n * file on this schema; the host migrates it with its own tooling.\n */\nexport interface DatabaseConfig {\n /** The Postgres schema; `workflow` by default. */\n schema?: string;\n}\n\nexport interface JourneyConfigDefinition {\n /** Studio title (the browser tab names the project). */\n title?: string;\n /** The shared `defineContract(...)` the Worker validates ingest against. */\n contract?: Contract;\n workflows: readonly WorkflowBuilder[];\n segments?: readonly SegmentRef[];\n actions?: readonly ActionRef<object>[];\n /** The template definitions, per channel; each is identified by its own `id`. */\n templates?: TemplateRegistry;\n emails?: EmailConfig;\n /** How the engine behaves per mode (`development`, `production`, …): time scale, message logging. */\n runtime?: JourneyRuntimeConfig;\n apns?: ApnsConfig;\n fcm?: FcmConfig;\n webPush?: WebPushConfig;\n /** Where a Postgres store keeps the engine's tables (the D1 store ignores it). */\n database?: DatabaseConfig;\n}\n\nexport interface JourneyConfig extends JourneyConfigDefinition {\n readonly kind: 'journey-config';\n}\n\nexport function defineConfig(definition: JourneyConfigDefinition): JourneyConfig {\n return { kind: 'journey-config', ...definition };\n}\n\nexport function isConfig(value: unknown): value is JourneyConfig {\n return (\n typeof value === 'object' &&\n value !== null &&\n (value as { kind?: unknown }).kind === 'journey-config'\n );\n}\n\n/** The APNs environment for `mode`: the configured one, else production only for production. */\nexport function apnsEnvironment(config: ApnsConfig, mode: JourneyMode): 'sandbox' | 'production' {\n return config.environment?.[mode] ?? (mode === 'production' ? 'production' : 'sandbox');\n}\n\n/** A message node the config's registries cannot serve, or whose literal props its template refuses. */\nexport class ConfigCompileError extends Error {\n override readonly name = 'ConfigCompileError';\n}\n\n/**\n * Compile the config: the bundle `compileBundle` would produce, with the\n * template manifest generated from the registry (every registered template,\n * by id, not a hand-kept list). The registry is checked first, used or not:\n * every entry is a template of its channel with an id, and an id is registered\n * once per channel. Then every message node is checked against it — an\n * unregistered or cross-channel template fails with the workflow, node and\n * channel named, and so does a literal prop the template's schema refuses (a\n * missing required prop, an undeclared one, a wrong literal) — and every\n * definition a workflow sends must be the very object registered under its id,\n * not merely one with the same id. User-property references cannot be parsed\n * here (their values exist only at send time) — they are checked by name only,\n * and parsed before sending.\n */\nexport function compileConfig(config: JourneyConfig): BundleIR {\n let index: RegistryIndex;\n try {\n index = {\n email: indexTemplates('email', config.templates?.email),\n push: indexTemplates('push', config.templates?.push),\n discord: indexTemplates('discord', config.templates?.discord),\n slack: indexTemplates('slack', config.templates?.slack),\n };\n } catch (error) {\n throw new ConfigCompileError((error as Error).message);\n }\n const bundle = compileBundle({\n workflows: config.workflows,\n ...(config.segments === undefined ? {} : { segments: config.segments }),\n ...(config.actions === undefined ? {} : { actions: config.actions }),\n templates: TEMPLATE_CHANNELS.flatMap((channel) => [...index[channel].values()]),\n });\n bundle.workflows.forEach((workflow, i) => {\n checkMessages(workflow, workflow.flow, index, config);\n checkUses(workflow, config.workflows[i], index, config);\n });\n return bundle;\n}\n\nconst TEMPLATE_CHANNELS = ['email', 'push', 'discord', 'slack'] as const;\n\n/** The registry by id, per channel. */\ntype RegistryIndex = Record<TemplateChannel, ReadonlyMap<string, AnyTemplate>>;\n\n/** The channel's templates by id; undefined for a channel templates cannot be declared for. */\nfunction indexOf(\n index: RegistryIndex,\n channel: ChannelIR\n): ReadonlyMap<string, AnyTemplate> | undefined {\n return (TEMPLATE_CHANNELS as readonly ChannelIR[]).includes(channel)\n ? index[channel as TemplateChannel]\n : undefined;\n}\n\n/**\n * The definitions a workflow sends against the registered ones: same id is not enough — a\n * workflow typed by one definition and a registry holding another under that id would render\n * with a schema the call was never checked against.\n */\nfunction checkUses(\n workflow: WorkflowIR,\n builder: WorkflowBuilder,\n index: RegistryIndex,\n config: JourneyConfig\n): void {\n for (const { template, loc } of templateUses(builder)) {\n const channel = CHANNEL_OF[template.kind];\n const registered = index[channel].get(template.id);\n if (registered === template) continue;\n const at = loc === undefined ? '' : ` (${loc.file}:${loc.line})`;\n const where = `workflow '${workflow.id}'${at}: ${channel} template '${template.id}'`;\n if (registered === undefined) throw new ConfigCompileError(`${where} is not registered`);\n const name = Object.entries(config.templates?.[channel] ?? {}).find(\n ([, entry]) => entry === registered\n )?.[0];\n throw new ConfigCompileError(\n `${where} is not the definition registered under that id (templates.${channel}.${name}) — two definitions share the id`\n );\n }\n}\n\n/** A field that may be left out: optional, or with a default the schema fills in. */\nfunction canBeOmitted(schema: core.$ZodType): boolean {\n const type = schema._zod.def.type;\n return type === 'optional' || type === 'default' || type === 'prefault';\n}\n\nfunction checkMessages(\n workflow: WorkflowIR,\n nodes: readonly NodeIR[],\n index: RegistryIndex,\n config: JourneyConfig\n): void {\n for (const node of nodes) {\n switch (node.type) {\n case 'message':\n checkMessage(workflow, node, index, config);\n break;\n case 'send_event':\n checkEmit(workflow, node, config);\n break;\n case 'branch':\n for (const c of node.cases) checkMessages(workflow, c.flow, index, config);\n if (node.otherwise) checkMessages(workflow, node.otherwise, index, config);\n break;\n case 'cohort':\n for (const arm of node.arms) checkMessages(workflow, arm.flow, index, config);\n break;\n case 'wait_until':\n if (Array.isArray(node.onTimeout)) checkMessages(workflow, node.onTimeout, index, config);\n break;\n default:\n break;\n }\n }\n}\n\nfunction checkMessage(\n workflow: WorkflowIR,\n node: Extract<NodeIR, { type: 'message' }>,\n index: RegistryIndex,\n config: JourneyConfig\n): void {\n const where = `workflow '${workflow.id}' node ${node.id}`;\n const registry = indexOf(index, node.channel);\n // Channels without template definitions (sms, …) are rendered by app-supplied senders\n if (registry === undefined) return;\n const entry = registry.get(node.template);\n if (entry === undefined) {\n const other = TEMPLATE_CHANNELS.find(\n (channel) => channel !== node.channel && index[channel].has(node.template)\n );\n const hint = other === undefined ? '' : ` (it is registered as a ${other} template)`;\n throw new ConfigCompileError(\n `${where}: ${node.channel} template '${node.template}' is not registered${hint}`\n );\n }\n const shape = (entry.props._zod.def as core.$ZodObjectDef).shape;\n const problems: string[] = [];\n for (const [field, value] of Object.entries(node.props)) {\n if (!Object.hasOwn(shape, field)) {\n problems.push(`'${field}' is not a prop of the template`);\n continue;\n }\n if (typeof value === 'object' && value !== null && 'type' in value) {\n // A user ref's value exists only at send time (parsed then); here its name can be checked\n const profile = config.contract?.schemas.profile._zod.def;\n if (\n profile !== undefined &&\n value.path !== 'userId' &&\n !Object.hasOwn(profile.shape, value.path)\n ) {\n problems.push(\n `'${field}' reads u.${value.path}, which the contract's profile does not declare`\n );\n }\n continue;\n }\n const result = z.safeParse(shape[field], value);\n if (!result.success)\n problems.push(`'${field}' = ${JSON.stringify(value)} is refused by the schema`);\n }\n for (const [field, schema] of Object.entries(shape)) {\n const optional = canBeOmitted(schema);\n if (!optional && !Object.hasOwn(node.props, field))\n problems.push(`required prop '${field}' is missing`);\n }\n if (problems.length > 0) {\n throw new ConfigCompileError(\n `${where}: ${node.channel} '${node.template}': ${problems.join('; ')}`\n );\n }\n}\n\n/**\n * An emitted event against the contract, as far as compile time can see: the\n * event is declared, literal payload values parse against their field, and no\n * required field is missing. Values read from the profile are checked when the\n * event is emitted, by the same contract.\n */\nfunction checkEmit(\n workflow: WorkflowIR,\n node: Extract<NodeIR, { type: 'send_event' }>,\n config: JourneyConfig\n): void {\n const contract = config.contract;\n if (contract === undefined) return;\n const where = `workflow '${workflow.id}' node ${node.id}`;\n if (!contract.isEvent(node.event)) {\n throw new ConfigCompileError(\n `${where}: emits '${String(node.event)}', which is not in the contract`\n );\n }\n const schema = contract.schemas.events[node.event];\n const shape = (schema._zod.def as core.$ZodObjectDef).shape;\n const problems: string[] = [];\n for (const [field, value] of Object.entries(node.payload)) {\n if (typeof value === 'object' && value !== null && 'type' in value) continue; // read at emit time\n if (!Object.hasOwn(shape, field)) continue; // a loose payload may carry extra fields\n const result = z.safeParse(shape[field], value);\n if (!result.success)\n problems.push(`'${field}' = ${JSON.stringify(value)} is refused by the contract`);\n }\n for (const [field, fieldSchema] of Object.entries(shape)) {\n const optional = canBeOmitted(fieldSchema);\n if (!optional && !Object.hasOwn(node.payload, field))\n problems.push(`required field '${field}' is missing`);\n }\n if (problems.length > 0) {\n throw new ConfigCompileError(`${where}: event '${node.event}': ${problems.join('; ')}`);\n }\n}\n"],"mappings":"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"}
@@ -87,4 +87,5 @@ export declare class ContractValidationError extends Error {
87
87
  export declare function checkEvent(contract: Contract, name: string, payload: unknown): ContractResult<Record<string, unknown>>;
88
88
  export declare function isContract(value: unknown): value is Contract;
89
89
  //#endregion
90
+ export {};
90
91
  //# sourceMappingURL=contract.d.cts.map
@@ -87,4 +87,5 @@ export declare class ContractValidationError extends Error {
87
87
  export declare function checkEvent(contract: Contract, name: string, payload: unknown): ContractResult<Record<string, unknown>>;
88
88
  export declare function isContract(value: unknown): value is Contract;
89
89
  //#endregion
90
+ export {};
90
91
  //# sourceMappingURL=contract.d.mts.map
@@ -3,7 +3,7 @@ const require_hash = require("../hash.cjs");
3
3
  //#region src/dsl/action.ts
4
4
  /**
5
5
  * Define a custom action. The generic `A` types the args `.run()` accepts —
6
- * the only type injection point, mirroring `template.email<P>()`:
6
+ * the only type injection point, mirroring `template.sms<P>()`:
7
7
  *
8
8
  * const syncCrm = action<{ plan: string }>('sync_crm', async ({ plan }, { userId }) => {
9
9
  * await fetch('https://crm.example.com/sync', { method: 'POST', body: JSON.stringify({ userId, plan }) });
@@ -1 +1 @@
1
- {"version":3,"file":"action.cjs","names":["sha256Hex"],"sources":["../../src/dsl/action.ts"],"sourcesContent":["import { sha256Hex } from '../hash';\n\n/**\n * Custom actions — the escape hatch for side effects the built-in nodes do not\n * cover (CRM sync, coupon issuing, outbound webhooks with custom auth, …).\n *\n * Dual-plane model: the *code* solidifies into the Worker bundle at deploy\n * time (it ships with the app, like any other module), while the *identity*\n * solidifies into IR as `{ type: 'action', action: name, args, codeHash }`.\n * The runtime resolves the name against a registry (see engine/actions.ts) and\n * compares codeHash to detect version skew between a pinned IR and the\n * currently deployed implementation.\n *\n * Deliberately NOT supported: custom *conditions*. Branching stays declarative\n * so the canvas can always explain why a user took a path — an action that\n * needs to influence routing writes a property or emits an event, and the\n * decision is expressed as a normal predicate on that data.\n */\n\n/** Runtime context handed to the handler; deliberately small — facts and messaging have their own nodes. */\nexport interface ActionContext {\n userId: string;\n /**\n * `${instanceId}:${nodeId}` — stable across replays and retries of this one\n * action node. A handler with an external side effect (charging, issuing,\n * POSTing) must pass it along so the far side collapses the duplicate: the\n * step body can re-run after the handler already resolved.\n */\n idempotencyKey: string;\n}\n\n/**\n * The handler: plain code, executed inside a durable step (`step.do`), so a\n * throw is retried by the runtime and a success is checkpointed — it should be\n * idempotent under retry, which is what `ctx.idempotencyKey` is for. Args\n * arrive with user_property references already resolved to values.\n */\nexport type ActionHandler<A extends object> = (args: A, ctx: ActionContext) => Promise<void> | void;\n\n/**\n * A registered custom action: the named asset `.run()` refers to. Carries the\n * implementation for the runtime registry and the codeHash for the IR pin.\n */\nexport interface ActionRef<A extends object = Record<never, never>> {\n readonly name: string;\n /** Hash of the handler's source text at definition time — the code-identity pin recorded in IR. */\n readonly codeHash: string;\n /** Method syntax on purpose: bivariant args let refs with different arg shapes share one list (compileBundle, registries). */\n handler(args: A, ctx: ActionContext): Promise<void> | void;\n}\n\n/**\n * Define a custom action. The generic `A` types the args `.run()` accepts —\n * the only type injection point, mirroring `template.email<P>()`:\n *\n * const syncCrm = action<{ plan: string }>('sync_crm', async ({ plan }, { userId }) => {\n * await fetch('https://crm.example.com/sync', { method: 'POST', body: JSON.stringify({ userId, plan }) });\n * });\n *\n * workflow(...).run(syncCrm, { plan: u.subscription_plan })\n *\n * codeHash comes from `handler.toString()`, so it changes when the code\n * changes and stays put when it does not. Different build pipelines can print\n * the same function differently (minification), which is why the default\n * runtime policy on mismatch is warn, not fail.\n */\nexport function action<A extends object = Record<never, never>>(\n name: string,\n handler: ActionHandler<A>\n): ActionRef<A> {\n return { name, codeHash: sha256Hex(handler.toString()).slice(0, 32), handler };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAkEA,SAAgB,OACd,MACA,SACc;CACd,OAAO;EAAE;EAAM,UAAUA,aAAAA,UAAU,QAAQ,SAAS,CAAC,CAAC,CAAC,MAAM,GAAG,EAAE;EAAG;CAAQ;AAC/E"}
1
+ {"version":3,"file":"action.cjs","names":["sha256Hex"],"sources":["../../src/dsl/action.ts"],"sourcesContent":["import { sha256Hex } from '../hash';\n\n/**\n * Custom actions — the escape hatch for side effects the built-in nodes do not\n * cover (CRM sync, coupon issuing, outbound webhooks with custom auth, …).\n *\n * Dual-plane model: the *code* solidifies into the Worker bundle at deploy\n * time (it ships with the app, like any other module), while the *identity*\n * solidifies into IR as `{ type: 'action', action: name, args, codeHash }`.\n * The runtime resolves the name against a registry (see engine/actions.ts) and\n * compares codeHash to detect version skew between a pinned IR and the\n * currently deployed implementation.\n *\n * Deliberately NOT supported: custom *conditions*. Branching stays declarative\n * so the canvas can always explain why a user took a path — an action that\n * needs to influence routing writes a property or emits an event, and the\n * decision is expressed as a normal predicate on that data.\n */\n\n/** Runtime context handed to the handler; deliberately small — facts and messaging have their own nodes. */\nexport interface ActionContext {\n userId: string;\n /**\n * `${instanceId}:${nodeId}` — stable across replays and retries of this one\n * action node. A handler with an external side effect (charging, issuing,\n * POSTing) must pass it along so the far side collapses the duplicate: the\n * step body can re-run after the handler already resolved.\n */\n idempotencyKey: string;\n}\n\n/**\n * The handler: plain code, executed inside a durable step (`step.do`), so a\n * throw is retried by the runtime and a success is checkpointed — it should be\n * idempotent under retry, which is what `ctx.idempotencyKey` is for. Args\n * arrive with user_property references already resolved to values.\n */\nexport type ActionHandler<A extends object> = (args: A, ctx: ActionContext) => Promise<void> | void;\n\n/**\n * A registered custom action: the named asset `.run()` refers to. Carries the\n * implementation for the runtime registry and the codeHash for the IR pin.\n */\nexport interface ActionRef<A extends object = Record<never, never>> {\n readonly name: string;\n /** Hash of the handler's source text at definition time — the code-identity pin recorded in IR. */\n readonly codeHash: string;\n /** Method syntax on purpose: bivariant args let refs with different arg shapes share one list (compileBundle, registries). */\n handler(args: A, ctx: ActionContext): Promise<void> | void;\n}\n\n/**\n * Define a custom action. The generic `A` types the args `.run()` accepts —\n * the only type injection point, mirroring `template.sms<P>()`:\n *\n * const syncCrm = action<{ plan: string }>('sync_crm', async ({ plan }, { userId }) => {\n * await fetch('https://crm.example.com/sync', { method: 'POST', body: JSON.stringify({ userId, plan }) });\n * });\n *\n * workflow(...).run(syncCrm, { plan: u.subscription_plan })\n *\n * codeHash comes from `handler.toString()`, so it changes when the code\n * changes and stays put when it does not. Different build pipelines can print\n * the same function differently (minification), which is why the default\n * runtime policy on mismatch is warn, not fail.\n */\nexport function action<A extends object = Record<never, never>>(\n name: string,\n handler: ActionHandler<A>\n): ActionRef<A> {\n return { name, codeHash: sha256Hex(handler.toString()).slice(0, 32), handler };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAkEA,SAAgB,OACd,MACA,SACc;CACd,OAAO;EAAE;EAAM,UAAUA,aAAAA,UAAU,QAAQ,SAAS,CAAC,CAAC,CAAC,MAAM,GAAG,EAAE;EAAG;CAAQ;AAC/E"}
@@ -46,7 +46,7 @@ export interface ActionRef<A extends object = Record<never, never>> {
46
46
  }
47
47
  /**
48
48
  * Define a custom action. The generic `A` types the args `.run()` accepts —
49
- * the only type injection point, mirroring `template.email<P>()`:
49
+ * the only type injection point, mirroring `template.sms<P>()`:
50
50
  *
51
51
  * const syncCrm = action<{ plan: string }>('sync_crm', async ({ plan }, { userId }) => {
52
52
  * await fetch('https://crm.example.com/sync', { method: 'POST', body: JSON.stringify({ userId, plan }) });
@@ -46,7 +46,7 @@ export interface ActionRef<A extends object = Record<never, never>> {
46
46
  }
47
47
  /**
48
48
  * Define a custom action. The generic `A` types the args `.run()` accepts —
49
- * the only type injection point, mirroring `template.email<P>()`:
49
+ * the only type injection point, mirroring `template.sms<P>()`:
50
50
  *
51
51
  * const syncCrm = action<{ plan: string }>('sync_crm', async ({ plan }, { userId }) => {
52
52
  * await fetch('https://crm.example.com/sync', { method: 'POST', body: JSON.stringify({ userId, plan }) });
@@ -2,7 +2,7 @@ import { sha256Hex } from "../hash.mjs";
2
2
  //#region src/dsl/action.ts
3
3
  /**
4
4
  * Define a custom action. The generic `A` types the args `.run()` accepts —
5
- * the only type injection point, mirroring `template.email<P>()`:
5
+ * the only type injection point, mirroring `template.sms<P>()`:
6
6
  *
7
7
  * const syncCrm = action<{ plan: string }>('sync_crm', async ({ plan }, { userId }) => {
8
8
  * await fetch('https://crm.example.com/sync', { method: 'POST', body: JSON.stringify({ userId, plan }) });