@shware/workflow 5.0.1 → 6.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +224 -53
- package/dist/aws/index.cjs +1 -1
- package/dist/aws/index.mjs +1 -1
- package/dist/client.cjs +68 -4
- package/dist/client.cjs.map +1 -1
- package/dist/client.d.cts +39 -3
- package/dist/client.d.cts.map +1 -1
- package/dist/client.d.mts +39 -3
- package/dist/client.d.mts.map +1 -1
- package/dist/client.mjs +68 -4
- package/dist/client.mjs.map +1 -1
- package/dist/cloudflare/index.cjs +4 -4
- package/dist/cloudflare/index.mjs +4 -4
- package/dist/cloudflare/runner.cjs +1 -1
- package/dist/cloudflare/runner.d.cts +1 -1
- package/dist/cloudflare/runner.d.mts +1 -1
- package/dist/cloudflare/runner.mjs +1 -1
- package/dist/config.cjs +24 -3
- package/dist/config.cjs.map +1 -1
- package/dist/config.d.cts +15 -9
- package/dist/config.d.cts.map +1 -1
- package/dist/config.d.mts +15 -9
- package/dist/config.d.mts.map +1 -1
- package/dist/config.mjs +25 -4
- package/dist/config.mjs.map +1 -1
- package/dist/contract.cjs +11 -0
- package/dist/contract.cjs.map +1 -1
- package/dist/contract.d.cts +28 -7
- package/dist/contract.d.cts.map +1 -1
- package/dist/contract.d.mts +28 -7
- package/dist/contract.d.mts.map +1 -1
- package/dist/contract.mjs +11 -0
- package/dist/contract.mjs.map +1 -1
- package/dist/dsl/bundle.cjs +4 -3
- package/dist/dsl/bundle.cjs.map +1 -1
- package/dist/dsl/bundle.d.cts +5 -1
- package/dist/dsl/bundle.d.cts.map +1 -1
- package/dist/dsl/bundle.d.mts +5 -1
- package/dist/dsl/bundle.d.mts.map +1 -1
- package/dist/dsl/bundle.mjs +4 -3
- package/dist/dsl/bundle.mjs.map +1 -1
- package/dist/dsl/flow.cjs +2 -1
- package/dist/dsl/flow.cjs.map +1 -1
- package/dist/dsl/flow.d.cts +1 -1
- package/dist/dsl/flow.d.cts.map +1 -1
- package/dist/dsl/flow.d.mts +1 -1
- package/dist/dsl/flow.d.mts.map +1 -1
- package/dist/dsl/flow.mjs +2 -1
- package/dist/dsl/flow.mjs.map +1 -1
- package/dist/dsl/workflow.cjs +2 -2
- package/dist/dsl/workflow.mjs +2 -2
- package/dist/email-sender.cjs +3 -2
- package/dist/email-sender.cjs.map +1 -1
- package/dist/email-sender.d.cts +5 -4
- package/dist/email-sender.d.cts.map +1 -1
- package/dist/email-sender.d.mts +5 -4
- package/dist/email-sender.d.mts.map +1 -1
- package/dist/email-sender.mjs +3 -2
- package/dist/email-sender.mjs.map +1 -1
- package/dist/email.cjs +66 -11
- package/dist/email.cjs.map +1 -1
- package/dist/email.d.cts +72 -12
- package/dist/email.d.cts.map +1 -1
- package/dist/email.d.mts +72 -12
- package/dist/email.d.mts.map +1 -1
- package/dist/email.mjs +63 -12
- package/dist/email.mjs.map +1 -1
- package/dist/engine/condition.cjs.map +1 -1
- package/dist/engine/condition.d.cts.map +1 -1
- package/dist/engine/condition.d.mts.map +1 -1
- package/dist/engine/condition.mjs.map +1 -1
- package/dist/engine/index.cjs +1 -1
- package/dist/engine/index.mjs +1 -1
- package/dist/engine/interpreter.cjs +21 -4
- package/dist/engine/interpreter.cjs.map +1 -1
- package/dist/engine/interpreter.mjs +21 -4
- package/dist/engine/interpreter.mjs.map +1 -1
- package/dist/engine/ports.d.cts +14 -2
- package/dist/engine/ports.d.cts.map +1 -1
- package/dist/engine/ports.d.mts +14 -2
- package/dist/engine/ports.d.mts.map +1 -1
- package/dist/host/channels.cjs +1 -1
- package/dist/host/channels.mjs +1 -1
- package/dist/host/execute.cjs +2 -2
- package/dist/host/execute.d.cts +1 -1
- package/dist/host/execute.d.mts +1 -1
- package/dist/host/execute.mjs +2 -2
- package/dist/host/http.cjs +75 -5
- package/dist/host/http.cjs.map +1 -1
- package/dist/host/http.d.cts +2 -2
- package/dist/host/http.d.cts.map +1 -1
- package/dist/host/http.d.mts +2 -2
- package/dist/host/http.d.mts.map +1 -1
- package/dist/host/http.mjs +76 -6
- package/dist/host/http.mjs.map +1 -1
- package/dist/host/ingest.cjs +173 -4
- package/dist/host/ingest.cjs.map +1 -1
- package/dist/host/ingest.d.cts +99 -0
- package/dist/host/ingest.d.cts.map +1 -1
- package/dist/host/ingest.d.mts +99 -0
- package/dist/host/ingest.d.mts.map +1 -1
- package/dist/host/ingest.mjs +167 -5
- package/dist/host/ingest.mjs.map +1 -1
- package/dist/host/journey-host.cjs +10 -8
- package/dist/host/journey-host.cjs.map +1 -1
- package/dist/host/journey-host.mjs +10 -8
- package/dist/host/journey-host.mjs.map +1 -1
- package/dist/index.cjs +8 -2
- package/dist/index.d.cts +5 -5
- package/dist/index.d.mts +5 -5
- package/dist/index.mjs +5 -5
- package/dist/ir.cjs +11 -2
- package/dist/ir.cjs.map +1 -1
- package/dist/ir.d.cts +17 -0
- package/dist/ir.d.cts.map +1 -1
- package/dist/ir.d.mts +17 -0
- package/dist/ir.d.mts.map +1 -1
- package/dist/ir.mjs +11 -3
- package/dist/ir.mjs.map +1 -1
- package/dist/react-email.cjs +6 -5
- package/dist/react-email.cjs.map +1 -1
- package/dist/react-email.d.cts.map +1 -1
- package/dist/react-email.d.mts.map +1 -1
- package/dist/react-email.mjs +6 -5
- package/dist/react-email.mjs.map +1 -1
- package/dist/reporting/observer.cjs +4 -1
- package/dist/reporting/observer.cjs.map +1 -1
- package/dist/reporting/observer.d.cts +2 -1
- package/dist/reporting/observer.d.cts.map +1 -1
- package/dist/reporting/observer.d.mts +2 -1
- package/dist/reporting/observer.d.mts.map +1 -1
- package/dist/reporting/observer.mjs +4 -1
- package/dist/reporting/observer.mjs.map +1 -1
- package/dist/reporting/tracking.cjs +31 -10
- package/dist/reporting/tracking.cjs.map +1 -1
- package/dist/reporting/tracking.d.cts +15 -6
- package/dist/reporting/tracking.d.cts.map +1 -1
- package/dist/reporting/tracking.d.mts +15 -6
- package/dist/reporting/tracking.d.mts.map +1 -1
- package/dist/reporting/tracking.mjs +31 -11
- package/dist/reporting/tracking.mjs.map +1 -1
- package/dist/store/facts.cjs +3 -3
- package/dist/store/facts.cjs.map +1 -1
- package/dist/store/facts.d.cts +2 -2
- package/dist/store/facts.d.cts.map +1 -1
- package/dist/store/facts.d.mts +2 -2
- package/dist/store/facts.d.mts.map +1 -1
- package/dist/store/facts.mjs +3 -3
- package/dist/store/facts.mjs.map +1 -1
- package/dist/store/index.cjs +1 -1
- package/dist/store/index.cjs.map +1 -1
- package/dist/store/index.d.cts +44 -13
- package/dist/store/index.d.cts.map +1 -1
- package/dist/store/index.d.mts +44 -13
- package/dist/store/index.d.mts.map +1 -1
- package/dist/store/index.mjs +1 -1
- package/dist/store/index.mjs.map +1 -1
- package/dist/store/postgres-reporting.cjs +30 -10
- package/dist/store/postgres-reporting.cjs.map +1 -1
- package/dist/store/postgres-reporting.d.cts +4 -2
- package/dist/store/postgres-reporting.d.cts.map +1 -1
- package/dist/store/postgres-reporting.d.mts +4 -2
- package/dist/store/postgres-reporting.d.mts.map +1 -1
- package/dist/store/postgres-reporting.mjs +30 -10
- package/dist/store/postgres-reporting.mjs.map +1 -1
- package/dist/store/postgres.cjs +39 -3
- package/dist/store/postgres.cjs.map +1 -1
- package/dist/store/postgres.d.cts +4 -2
- package/dist/store/postgres.d.cts.map +1 -1
- package/dist/store/postgres.d.mts +4 -2
- package/dist/store/postgres.d.mts.map +1 -1
- package/dist/store/postgres.mjs +40 -4
- package/dist/store/postgres.mjs.map +1 -1
- package/dist/store/tables.cjs +75 -11
- package/dist/store/tables.cjs.map +1 -1
- package/dist/store/tables.d.cts +17 -1
- package/dist/store/tables.d.cts.map +1 -1
- package/dist/store/tables.d.mts +17 -1
- package/dist/store/tables.d.mts.map +1 -1
- package/dist/store/tables.mjs +74 -12
- package/dist/store/tables.mjs.map +1 -1
- package/dist/templates.cjs +60 -7
- package/dist/templates.cjs.map +1 -1
- package/dist/templates.d.cts +101 -13
- package/dist/templates.d.cts.map +1 -1
- package/dist/templates.d.mts +101 -13
- package/dist/templates.d.mts.map +1 -1
- package/dist/templates.mjs +61 -9
- package/dist/templates.mjs.map +1 -1
- package/package.json +1 -1
package/dist/email.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"email.mjs","names":[],"sources":["../src/email.ts"],"sourcesContent":["import type { EmailTemplate } from './templates';\n\n/**\n * Email metadata: who a template's email comes from, where replies go, and the unsubscribe link\n * its layout carries. Each `emailTemplate` declares its own `from` (and `replyTo`, if replies should\n * go elsewhere); the unsubscribe link is the template's `unsubscribeUrl`, else the config's\n * `email.unsubscribeUrl`, else none. `resolveEmail` is that rule, and the sender, the renderer,\n * the Worker's preview and the studio all go through it. No React and no I/O here, so a browser\n * can import it.\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\nconst ASCII = /^[\\x20-\\x7e]*$/;\nconst SPECIALS = /[()<>[\\]:;@\\\\,.\"]/;\n\n/**\n * An address as a mail API reads it (RFC 5322): a display name with specials is quoted, one\n * outside ASCII is a MIME encoded-word — raw non-ASCII names are refused (SES), and an unquoted\n * comma splits the address in two.\n */\nexport function mimeAddress(address: EmailAddress): string {\n if (typeof address === 'string') return address;\n const { email, name } = address;\n if (name === undefined || name === '') return email;\n if (!ASCII.test(name)) {\n const bytes = new TextEncoder().encode(name);\n let binary = '';\n for (const byte of bytes) binary += String.fromCharCode(byte);\n return `=?UTF-8?B?${btoa(binary)}?= <${email}>`;\n }\n if (SPECIALS.test(name)) return `\"${name.replace(/([\"\\\\])/g, '\\\\$1')}\" <${email}>`;\n return `${name} <${email}>`;\n}\n\n/** The config's `email`: what every email template shares. */\nexport interface EmailConfig {\n /**\n * The unsubscribe link of every email template that names none of its own. The layout reads it\n * with `useEmailContext()`; sent, it is tracked like every link, but following it shows a\n * confirmation page, and only the confirmation (a POST no link scanner sends) unsubscribes.\n */\n unsubscribeUrl?: 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/** One template's email metadata, resolved against the config. */\nexport interface ResolvedEmail {\n from: EmailAddress;\n /** Absent when the template names none: the email goes out without a Reply-To. */\n replyTo?: EmailAddress;\n /** Absent when neither the template nor the config names one: the email has no unsubscribe link. */\n unsubscribeUrl?: string;\n}\n\n/** The template's metadata, its `unsubscribeUrl` falling back to the config's. Leaves both untouched. */\nexport function resolveEmail(\n template: Pick<EmailTemplate, 'from' | 'replyTo' | 'unsubscribeUrl'>,\n config?: EmailConfig\n): ResolvedEmail {\n const unsubscribeUrl = template.unsubscribeUrl ?? config?.unsubscribeUrl;\n return {\n from: template.from,\n ...(template.replyTo === undefined ? {} : { replyTo: template.replyTo }),\n ...(unsubscribeUrl === undefined ? {} : { unsubscribeUrl }),\n };\n}\n\nconst ADDRESS = /^[^\\s@<>]+@[^\\s@<>]+$/;\n\n/** Throws naming `where` when `address` is neither `user@host` nor `{ email: 'user@host', name? }`. */\nexport function assertEmailAddress(address: unknown, where: string): void {\n const email =\n typeof address === 'object' && address !== null\n ? (address as { email?: unknown }).email\n : address;\n const name =\n typeof address === 'object' && address !== null\n ? (address as { name?: unknown }).name\n : undefined;\n if (typeof email !== 'string' || !ADDRESS.test(email)) {\n throw new TypeError(`${where} must be an email address or { email, name? }`);\n }\n if (name !== undefined && typeof name !== 'string') {\n throw new TypeError(`${where}.name must be a string`);\n }\n}\n\n/** Throws naming `where` unless `url` is an absolute http(s) URL; '' and null are not a way to leave it out. */\nexport function assertUnsubscribeUrl(url: unknown, where: string): void {\n let parsed: URL | undefined;\n try {\n parsed = typeof url === 'string' ? new URL(url) : undefined;\n } catch {\n parsed = undefined;\n }\n if (parsed === undefined || (parsed.protocol !== 'https:' && parsed.protocol !== 'http:')) {\n throw new TypeError(`${where} must be an http(s) URL (leave it out for none)`);\n }\n}\n\n/** The config's `email` and every registered template's metadata; throws on the first invalid value. */\nexport function assertEmailConfig(\n config: { email?: EmailConfig },\n templates: Iterable<EmailTemplate>\n): void {\n if (Object.hasOwn(config, 'emails')) {\n throw new TypeError(\n \"the config's `emails` is gone: `from` and `replyTo` are declared on each emailTemplate, \" +\n 'and the rest moved to `email: { unsubscribeUrl, tracking }`'\n );\n }\n const email = config.email;\n if (email !== undefined) {\n if (email.unsubscribeUrl !== undefined) {\n assertUnsubscribeUrl(email.unsubscribeUrl, 'email.unsubscribeUrl');\n }\n const quick = email.tracking?.quickClickMs;\n if (\n quick !== undefined &&\n !(typeof quick === 'number' && Number.isFinite(quick) && quick >= 0)\n ) {\n throw new TypeError('email.tracking.quickClickMs must be a non-negative number');\n }\n }\n for (const template of templates)\n assertEmailMetadata(template, `email template '${template.id}'`);\n}\n\n/** A template's own `from` / `replyTo` / `unsubscribeUrl`, as `emailTemplate` checks them. */\nexport function assertEmailMetadata(\n template: { from?: unknown; replyTo?: unknown; unsubscribeUrl?: unknown },\n where: string\n): void {\n assertEmailAddress(template.from, `${where}: from`);\n if (template.replyTo !== undefined) assertEmailAddress(template.replyTo, `${where}: replyTo`);\n if (template.unsubscribeUrl !== undefined) {\n assertUnsubscribeUrl(template.unsubscribeUrl, `${where}: unsubscribeUrl`);\n }\n}\n\n/** The unsubscribe link may carry its own query (a token, a list): the path names it. */\nexport function isUnsubscribeLink(url: string, unsubscribeUrl: string | undefined): boolean {\n if (unsubscribeUrl === undefined) return false;\n try {\n const a = new URL(url);\n const b = new URL(unsubscribeUrl);\n return a.origin === b.origin && a.pathname === b.pathname;\n } catch {\n return false;\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"email.mjs","names":[],"sources":["../src/email.ts"],"sourcesContent":["import type { EmailTemplate } from './templates';\n\n/**\n * Email metadata: who a template's email comes from, where replies go, and the unsubscribe and\n * preference links its layout carries. Each `emailTemplate` declares its own `from` (and\n * `replyTo`, if replies should go elsewhere) and says what it is: a `topic` the recipient may\n * unsubscribe from, or `transactional: true`. A topic email's unsubscribe link is the template's\n * `unsubscribeUrl`, else the config's `email.unsubscribeUrl`, else none; its preferences link is\n * the config's `email.preferencesUrl` with the copy's message id, when the copy has one (reporting\n * on); a transactional email never has either, whatever the config says. `resolveEmail` is that\n * rule, and the sender, the renderer, the Worker's preview and the studio all go through it. No\n * React and no I/O here, so a browser can import it.\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\nconst ASCII = /^[\\x20-\\x7e]*$/;\nconst SPECIALS = /[()<>[\\]:;@\\\\,.\"]/;\n\n/**\n * An address as a mail API reads it (RFC 5322): a display name with specials is quoted, one\n * outside ASCII is a MIME encoded-word — raw non-ASCII names are refused (SES), and an unquoted\n * comma splits the address in two.\n */\nexport function mimeAddress(address: EmailAddress): string {\n if (typeof address === 'string') return address;\n const { email, name } = address;\n if (name === undefined || name === '') return email;\n if (!ASCII.test(name)) {\n const bytes = new TextEncoder().encode(name);\n let binary = '';\n for (const byte of bytes) binary += String.fromCharCode(byte);\n return `=?UTF-8?B?${btoa(binary)}?= <${email}>`;\n }\n if (SPECIALS.test(name)) return `\"${name.replace(/([\"\\\\])/g, '\\\\$1')}\" <${email}>`;\n return `${name} <${email}>`;\n}\n\n/** The config's `email`: what every email template shares. */\nexport interface EmailConfig {\n /**\n * The unsubscribe link of every email template that names none of its own. The layout reads it\n * with `useEmailContext()`; sent, it is tracked like every link, but following it shows a\n * confirmation page, and only the confirmation (a POST no link scanner sends) unsubscribes.\n */\n unsubscribeUrl?: string;\n /**\n * The app's email preference page. A topic email sent with reporting on links to it with the\n * copy's message id as the `m` query parameter (`useEmailContext().preferencesUrl`), and the\n * page reads and sets the recipient's subscriptions by that id — no sign-in — through\n * `GET` / `PUT /subscriptions/by-message/:messageId` (`JourneyClient.getSubscriptionsByMessage`\n * / `setSubscriptionsByMessage`). Tracked as an ordinary link; it must not share its origin and\n * path with `unsubscribeUrl`, which would make it the unsubscribe link instead.\n */\n preferencesUrl?: 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/** One template's email metadata, resolved against the config. */\nexport interface ResolvedEmail {\n from: EmailAddress;\n /** The template's subscription topic; null for a transactional email. */\n topic: string | null;\n /** Absent when the template names none: the email goes out without a Reply-To. */\n replyTo?: EmailAddress;\n /**\n * Absent when the email is transactional, or neither the template nor the config names one:\n * the email has no unsubscribe link.\n */\n unsubscribeUrl?: string;\n /**\n * The config's `preferencesUrl` with this copy's message id as `m`. Absent when the email is\n * transactional, the config names no page, or the copy has no message id (reporting off): the\n * email has no preferences link, and a layout renders none.\n */\n preferencesUrl?: string;\n}\n\n/** What a render knows of the copy beyond the template: its message id, when reporting records one. */\nexport interface EmailRenderOptions {\n /** The copy's id in the reporting tables — what `/t/...` and `/subscriptions/by-message` key on. */\n messageId?: string | undefined;\n}\n\n/**\n * The message id a preview renders with: the preferences link shows in the studio and\n * `GET /preview`, pointing at a copy that does not exist.\n */\nexport const PREVIEW_MESSAGE_ID = '00000000-0000-0000-0000-000000000000';\n\n/**\n * The template's metadata, its `unsubscribeUrl` falling back to the config's, and the\n * preferences link built from the config's page and the copy's message id — for a topic email; a\n * transactional one resolves to neither link. Leaves the inputs untouched.\n */\nexport function resolveEmail(\n template: Pick<EmailTemplate, 'from' | 'replyTo' | 'unsubscribeUrl' | 'topic'>,\n config?: EmailConfig,\n options: EmailRenderOptions = {}\n): ResolvedEmail {\n const topical = template.topic !== null;\n const unsubscribeUrl = topical ? (template.unsubscribeUrl ?? config?.unsubscribeUrl) : undefined;\n const preferencesUrl =\n topical && config?.preferencesUrl !== undefined && options.messageId !== undefined\n ? preferencesLink(config.preferencesUrl, options.messageId)\n : undefined;\n return {\n from: template.from,\n topic: template.topic,\n ...(template.replyTo === undefined ? {} : { replyTo: template.replyTo }),\n ...(unsubscribeUrl === undefined ? {} : { unsubscribeUrl }),\n ...(preferencesUrl === undefined ? {} : { preferencesUrl }),\n };\n}\n\n/** The preference page's URL for one copy: `m=<message id>` set on its query, the rest of it kept. */\nexport function preferencesLink(preferencesUrl: string, messageId: string): string {\n const url = new URL(preferencesUrl);\n url.searchParams.set('m', messageId);\n return url.toString();\n}\n\nconst ADDRESS = /^[^\\s@<>]+@[^\\s@<>]+$/;\n\n/** Throws naming `where` when `address` is neither `user@host` nor `{ email: 'user@host', name? }`. */\nexport function assertEmailAddress(address: unknown, where: string): void {\n const email =\n typeof address === 'object' && address !== null\n ? (address as { email?: unknown }).email\n : address;\n const name =\n typeof address === 'object' && address !== null\n ? (address as { name?: unknown }).name\n : undefined;\n if (typeof email !== 'string' || !ADDRESS.test(email)) {\n throw new TypeError(`${where} must be an email address or { email, name? }`);\n }\n if (name !== undefined && typeof name !== 'string') {\n throw new TypeError(`${where}.name must be a string`);\n }\n}\n\n/** Throws naming `where` unless `url` is an absolute http(s) URL; '' and null are not a way to leave it out. */\nexport function assertHttpUrl(url: unknown, where: string): void {\n let parsed: URL | undefined;\n try {\n parsed = typeof url === 'string' ? new URL(url) : undefined;\n } catch {\n parsed = undefined;\n }\n if (parsed === undefined || (parsed.protocol !== 'https:' && parsed.protocol !== 'http:')) {\n throw new TypeError(`${where} must be an http(s) URL (leave it out for none)`);\n }\n}\n\n/** `assertHttpUrl`, under its earlier name. */\nexport const assertUnsubscribeUrl: typeof assertHttpUrl = assertHttpUrl;\n\n/** The config's `email` and every registered template's metadata; throws on the first invalid value. */\nexport function assertEmailConfig(\n config: { email?: EmailConfig },\n templates: Iterable<EmailTemplate>\n): void {\n if (Object.hasOwn(config, 'emails')) {\n throw new TypeError(\n \"the config's `emails` is gone: `from` and `replyTo` are declared on each emailTemplate, \" +\n 'and the rest moved to `email: { unsubscribeUrl, preferencesUrl, tracking }`'\n );\n }\n const email = config.email;\n if (email !== undefined) {\n if (email.unsubscribeUrl !== undefined) {\n assertHttpUrl(email.unsubscribeUrl, 'email.unsubscribeUrl');\n }\n if (email.preferencesUrl !== undefined) {\n assertHttpUrl(email.preferencesUrl, 'email.preferencesUrl');\n // The sender tells the unsubscribe link from the others by origin and path: a preference\n // page at the same address would be tracked as the unsubscribe link and land on `/t/u`\n if (isUnsubscribeLink(email.preferencesUrl, email.unsubscribeUrl)) {\n throw new TypeError(\n 'email.preferencesUrl must not share its origin and path with email.unsubscribeUrl: ' +\n 'the sender would track the preferences link as the unsubscribe link'\n );\n }\n }\n const quick = email.tracking?.quickClickMs;\n if (\n quick !== undefined &&\n !(typeof quick === 'number' && Number.isFinite(quick) && quick >= 0)\n ) {\n throw new TypeError('email.tracking.quickClickMs must be a non-negative number');\n }\n }\n for (const template of templates) {\n const where = `email template '${template.id}'`;\n assertEmailMetadata(template, where, true);\n if (\n email?.preferencesUrl !== undefined &&\n isUnsubscribeLink(email.preferencesUrl, template.unsubscribeUrl)\n ) {\n throw new TypeError(\n `${where}: its unsubscribeUrl shares its origin and path with email.preferencesUrl, so ` +\n 'the sender would track the preferences link as the unsubscribe link'\n );\n }\n }\n}\n\n/**\n * A template's own `from` / `replyTo` / `unsubscribeUrl` and what it is (`topic` or\n * `transactional: true`), as `emailTemplate` checks them — and `compileConfig` again, for a\n * definition that did not come through `emailTemplate`. A declared template carries `topic`\n * (a string, or null for transactional); a definition carries one of the two options.\n */\nexport function assertEmailMetadata(\n template: {\n from?: unknown;\n replyTo?: unknown;\n unsubscribeUrl?: unknown;\n topic?: unknown;\n transactional?: unknown;\n },\n where: string,\n /** True for a declared template (`topic: string | null`), false for `emailTemplate`'s argument. */\n declared = false\n): void {\n assertEmailAddress(template.from, `${where}: from`);\n if (template.replyTo !== undefined) assertEmailAddress(template.replyTo, `${where}: replyTo`);\n if (template.unsubscribeUrl !== undefined) {\n assertHttpUrl(template.unsubscribeUrl, `${where}: unsubscribeUrl`);\n }\n const transactional = assertTemplateTopic(template, where, 'email', declared);\n if (transactional && template.unsubscribeUrl !== undefined) {\n throw new TypeError(\n `${where}: a transactional email has no unsubscribe link, so it takes no 'unsubscribeUrl'`\n );\n }\n}\n\n/**\n * What a template is — `topic: <id>` or `transactional: true`, one of the two — as `emailTemplate`\n * and `pushTemplate` check their argument and `compileConfig` checks a declared template (where\n * `topic: null` is transactional). Answers whether the template is transactional. Whether the\n * topic is one the contract declares is `compileConfig`'s check, where the contract is at hand.\n */\nexport function assertTemplateTopic(\n template: { topic?: unknown; transactional?: unknown },\n where: string,\n channel: 'email' | 'push',\n declared = false\n): boolean {\n const { topic } = template;\n // A declared template says `topic: null` for transactional; a definition says `transactional: true`\n const transactional = template.transactional === true || (declared && topic === null);\n if (template.transactional !== undefined && template.transactional !== true) {\n throw new TypeError(\n `${where}: 'transactional' must be the literal true (leave it out for a topic ${channel})`\n );\n }\n const hasTopic = topic !== undefined && topic !== null;\n if (hasTopic && (typeof topic !== 'string' || topic.trim() === '')) {\n throw new TypeError(`${where}: 'topic' must be a topic id the contract declares`);\n }\n if (hasTopic && transactional) {\n throw new TypeError(`${where}: give either 'topic' or 'transactional: true', not both`);\n }\n if (!hasTopic && !transactional) {\n throw new TypeError(\n `${where}: every ${channel} template says what it is — 'topic: <id>' (one the contract's subscriptions.topics declares) or 'transactional: true'`\n );\n }\n return transactional;\n}\n\n/** The unsubscribe link may carry its own query (a token, a list): the path names it. */\nexport function isUnsubscribeLink(url: string, unsubscribeUrl: string | undefined): boolean {\n if (unsubscribeUrl === undefined) return false;\n try {\n const a = new URL(url);\n const b = new URL(unsubscribeUrl);\n return a.origin === b.origin && a.pathname === b.pathname;\n } catch {\n return false;\n }\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"condition.cjs","names":["RECIPIENT_PROPERTY"],"sources":["../../src/engine/condition.ts"],"sourcesContent":["import type { ConditionIR, PropertyOperatorIR, ScalarIR } from '../ir';\nimport type { FactSource } from './ports';\nimport { RECIPIENT_PROPERTY } from './recipient';\n\n/**\n * Evaluation options.\n * - anchorMs: the earliest instant `performed` counts events from — the goal\n * guard anchors at workflow entry (a conversion must happen *after* entry),\n * waitUntil at the moment the wait began (a wake must not be satisfied by\n * pre-existing history). Combined with `within` by taking the later bound.\n * - untilMs: the latest instant `performed` counts events up to — the goal's\n * attribution window ends there, so an event after it is no conversion even\n * when the check runs later (a resumed instance, a delayed wake).\n */\nexport interface EvaluateOptions {\n anchorMs?: number;\n untilMs?: number;\n}\n\n/**\n * ConditionIR evaluation: pure logic, with facts injected through FactSource,\n * so the store-backed implementation and the in-memory test one share a single set of\n * semantics and cannot drift into two evaluators.\n */\nexport async function evaluateCondition(\n condition: ConditionIR,\n facts: FactSource,\n nowMs: number,\n opts?: EvaluateOptions\n): Promise<boolean> {\n return evaluate(condition, facts, nowMs, opts, []);\n}\n\n/**\n * `enclosing` is the chain of segment names currently being expanded. A\n * segment that reaches itself has no truth value to converge on, and left\n * unchecked the recursion runs until memory gives out, one store round trip per\n * level — inside a step, so Workflows retries it and the instance never gets\n * out. Failing immediately keeps it bounded, and the message names the cycle\n * because only a deployed bundle can fix it. Only the *path* is tracked, so a\n * diamond (two branches referencing one segment) still evaluates twice.\n */\nasync function evaluate(\n condition: ConditionIR,\n facts: FactSource,\n nowMs: number,\n opts: EvaluateOptions | undefined,\n enclosing: readonly string[]\n): Promise<boolean> {\n switch (condition.type) {\n case 'and': {\n for (const child of condition.conditions) {\n if (!(await evaluate(child, facts, nowMs, opts, enclosing))) return false;\n }\n return true;\n }\n case 'or': {\n for (const child of condition.conditions) {\n if (await evaluate(child, facts, nowMs, opts, enclosing)) return true;\n }\n return false;\n }\n case 'not':\n return !(await evaluate(condition.condition, facts, nowMs, opts, enclosing));\n case 'segment': {\n if (enclosing.includes(condition.segment)) {\n throw new Error(\n `segment '${condition.segment}' references itself: ${[...enclosing, condition.segment].join(' -> ')}`\n );\n }\n const def = await facts.getSegmentCondition(condition.segment);\n if (def === undefined) {\n throw new Error(`Unknown segment: '${condition.segment}' (bundle not deployed?)`);\n }\n return evaluate(def, facts, nowMs, opts, [...enclosing, condition.segment]);\n }\n case 'performed': {\n const bounds: number[] = [];\n if (condition.within !== undefined) bounds.push(nowMs - condition.within.ms);\n if (opts?.anchorMs !== undefined) bounds.push(opts.anchorMs);\n const sinceMs = bounds.length > 0 ? Math.max(...bounds) : undefined;\n const count = await facts.countEvents(condition.event, {\n ...(sinceMs !== undefined ? { sinceMs } : {}),\n ...(opts?.untilMs !== undefined ? { untilMs: opts.untilMs } : {}),\n ...(condition.where !== undefined ? { where: condition.where } : {}),\n });\n return count >= (condition.count ?? 1);\n }\n case 'property': {\n const actual = await facts.getProperty(condition.path);\n return compareProperty(condition, actual);\n }\n case 'reachable': {\n if (await facts.isSuppressed?.(condition.channel)) return false;\n if (condition.channel === 'push') return (await facts.listDevices()).length > 0;\n const path = RECIPIENT_PROPERTY[condition.channel];\n // in_app, slack and discord need no address: a signed-in user is reachable in the app, and\n // the webhook channels post to a team channel, not to the user\n return path === undefined || (await facts.getProperty(path)) !== undefined;\n }\n case 'payload':\n // Placement is validated at compile; reaching here means hand-built IR\n throw new Error(\n 'payload conditions are only valid inside performed({ where }) or a trigger where clause'\n );\n }\n}\n\n/**\n * Evaluate a where tree against one event's payload. Shared by performed\n * counting (FactSource implementations) and the ingest router's trigger gate.\n * Dotted paths descend nested objects; a missing or non-scalar leaf behaves\n * like a missing property (exists sees objects, comparisons do not).\n */\nexport function matchesWhere(payload: unknown, where: ConditionIR): boolean {\n switch (where.type) {\n case 'and':\n return where.conditions.every((child) => matchesWhere(payload, child));\n case 'or':\n return where.conditions.some((child) => matchesWhere(payload, child));\n case 'not':\n return !matchesWhere(payload, where.condition);\n case 'payload': {\n const raw = valueAtPath(payload, where.path);\n if (where.op === 'exists') return raw !== undefined && raw !== null;\n if (where.op === 'not_exists') return raw === undefined || raw === null;\n const actual =\n typeof raw === 'string' || typeof raw === 'number' || typeof raw === 'boolean'\n ? raw\n : undefined;\n return compareProperty(where, actual);\n }\n default:\n // compileBundle rejects these; defend against hand-built IR\n throw new Error(`where clause may not contain a '${where.type}' condition`);\n }\n}\n\nfunction valueAtPath(payload: unknown, path: string): unknown {\n let current: unknown = payload;\n for (const key of path.split('.')) {\n if (current === null || typeof current !== 'object') return undefined;\n current = (current as Record<string, unknown>)[key];\n }\n return current;\n}\n\nfunction compareProperty(\n condition: {\n op: PropertyOperatorIR;\n value?: ScalarIR | undefined;\n values?: ScalarIR[] | undefined;\n },\n actual: ScalarIR | undefined\n): boolean {\n const { op, value, values } = condition;\n switch (op) {\n case 'exists':\n return actual !== undefined;\n case 'not_exists':\n return actual === undefined;\n case 'eq':\n return actual === value;\n case 'ne':\n return actual !== value;\n case 'gt':\n return actual !== undefined && value !== undefined && actual > value;\n case 'gte':\n return actual !== undefined && value !== undefined && actual >= value;\n case 'lt':\n return actual !== undefined && value !== undefined && actual < value;\n case 'lte':\n return actual !== undefined && value !== undefined && actual <= value;\n case 'between':\n return isBetween(actual, values);\n case 'not_between':\n // A missing property is outside any range, mirroring not_in_array.\n return !isBetween(actual, values);\n case 'in_array':\n return actual !== undefined && (values ?? []).includes(actual);\n case 'not_in_array':\n return actual === undefined || !(values ?? []).includes(actual);\n case 'contains':\n return typeof actual === 'string' && typeof value === 'string' && actual.includes(value);\n case 'not_contains':\n return !(typeof actual === 'string' && typeof value === 'string' && actual.includes(value));\n }\n}\n\nfunction isBetween(actual: ScalarIR | undefined, values: ScalarIR[] | undefined): boolean {\n if (actual === undefined || values === undefined || values.length < 2) return false;\n const [min, max] = values;\n return actual >= min && actual <= max;\n}\n\n/**\n * Wake-up channel for profile changes: a property predicate has no event name\n * of its own, so subscriptions use this reserved name and the ingest router\n * fires it on every /identify. The '$' prefix marks internal names — real\n * events may never use it (the router rejects them).\n */\nexport const PROFILE_UPDATED_EVENT = '$profile_updated';\n\n/**\n * The wake-up channel for one profile property: `$profile_updated:<path>`. A property predicate\n * subscribes to its own property's channel, and `/identify` fires one per property it patched —\n * so an identify that touches `plan` does not wake a wait on `email`.\n */\nexport function profileUpdatedEvent(path: string): string {\n return `${PROFILE_UPDATED_EVENT}:${path}`;\n}\n\n/**\n * Wake-up channel for device changes: a device registered for the user, moved to another account\n * or was unbound or removed (`POST /devices`, `/devices/unbind`, `/devices/remove`). `reachable('push')` subscribes to it.\n */\nexport const DEVICES_UPDATED_EVENT = '$devices_updated';\n\n/**\n * The set of event names a condition cares about — what wait_until subscribes\n * to for wake-ups. `performed` contributes its event; a property predicate\n * contributes its property's channel (`profileUpdatedEvent(path)`, so an\n * /identify touching that property wakes the wait and one touching another\n * does not); `reachable` contributes the recipient property's channel, or the\n * device channel for push (in_app, slack and discord have no address to wait\n * on). Segment references are expanded, which is why a FactSource is needed to\n * resolve their definitions.\n */\nexport async function relevantEvents(condition: ConditionIR, facts: FactSource): Promise<string[]> {\n const found = new Set<string>();\n // Names already expanded. The result is a union, so re-walking a segment can\n // only add what is already there — and a segment that reaches itself would\n // otherwise recurse until the stack blows, taking a store read per level.\n const expanded = new Set<string>();\n async function walk(c: ConditionIR): Promise<void> {\n switch (c.type) {\n case 'and':\n case 'or':\n for (const child of c.conditions) await walk(child);\n break;\n case 'not':\n await walk(c.condition);\n break;\n case 'performed':\n found.add(c.event);\n break;\n case 'segment': {\n if (expanded.has(c.segment)) break;\n expanded.add(c.segment);\n const def = await facts.getSegmentCondition(c.segment);\n if (def) await walk(def);\n break;\n }\n case 'property':\n found.add(profileUpdatedEvent(c.path));\n break;\n case 'reachable': {\n if (c.channel === 'push') {\n found.add(DEVICES_UPDATED_EVENT);\n break;\n }\n const path = RECIPIENT_PROPERTY[c.channel];\n if (path !== undefined) found.add(profileUpdatedEvent(path));\n break;\n }\n case 'payload':\n break; // only appears inside performed.where, which is not walked\n }\n }\n await walk(condition);\n return [...found];\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"condition.cjs","names":["RECIPIENT_PROPERTY"],"sources":["../../src/engine/condition.ts"],"sourcesContent":["import type { ConditionIR, PropertyOperatorIR, ScalarIR } from '../ir';\nimport type { FactSource } from './ports';\nimport { RECIPIENT_PROPERTY } from './recipient';\n\n/**\n * Evaluation options.\n * - anchorMs: the earliest instant `performed` counts events from — the goal\n * guard anchors at workflow entry (a conversion must happen *after* entry),\n * waitUntil at the moment the wait began (a wake must not be satisfied by\n * pre-existing history). Combined with `within` by taking the later bound.\n * - untilMs: the latest instant `performed` counts events up to — the goal's\n * attribution window ends there, so an event after it is no conversion even\n * when the check runs later (a resumed instance, a delayed wake).\n */\nexport interface EvaluateOptions {\n anchorMs?: number;\n untilMs?: number;\n}\n\n/**\n * ConditionIR evaluation: pure logic, with facts injected through FactSource,\n * so the store-backed implementation and the in-memory test one share a single set of\n * semantics and cannot drift into two evaluators.\n */\nexport async function evaluateCondition(\n condition: ConditionIR,\n facts: FactSource,\n nowMs: number,\n opts?: EvaluateOptions\n): Promise<boolean> {\n return evaluate(condition, facts, nowMs, opts, []);\n}\n\n/**\n * `enclosing` is the chain of segment names currently being expanded. A\n * segment that reaches itself has no truth value to converge on, and left\n * unchecked the recursion runs until memory gives out, one store round trip per\n * level — inside a step, so Workflows retries it and the instance never gets\n * out. Failing immediately keeps it bounded, and the message names the cycle\n * because only a deployed bundle can fix it. Only the *path* is tracked, so a\n * diamond (two branches referencing one segment) still evaluates twice.\n */\nasync function evaluate(\n condition: ConditionIR,\n facts: FactSource,\n nowMs: number,\n opts: EvaluateOptions | undefined,\n enclosing: readonly string[]\n): Promise<boolean> {\n switch (condition.type) {\n case 'and': {\n for (const child of condition.conditions) {\n if (!(await evaluate(child, facts, nowMs, opts, enclosing))) return false;\n }\n return true;\n }\n case 'or': {\n for (const child of condition.conditions) {\n if (await evaluate(child, facts, nowMs, opts, enclosing)) return true;\n }\n return false;\n }\n case 'not':\n return !(await evaluate(condition.condition, facts, nowMs, opts, enclosing));\n case 'segment': {\n if (enclosing.includes(condition.segment)) {\n throw new Error(\n `segment '${condition.segment}' references itself: ${[...enclosing, condition.segment].join(' -> ')}`\n );\n }\n const def = await facts.getSegmentCondition(condition.segment);\n if (def === undefined) {\n throw new Error(`Unknown segment: '${condition.segment}' (bundle not deployed?)`);\n }\n return evaluate(def, facts, nowMs, opts, [...enclosing, condition.segment]);\n }\n case 'performed': {\n const bounds: number[] = [];\n if (condition.within !== undefined) bounds.push(nowMs - condition.within.ms);\n if (opts?.anchorMs !== undefined) bounds.push(opts.anchorMs);\n const sinceMs = bounds.length > 0 ? Math.max(...bounds) : undefined;\n const count = await facts.countEvents(condition.event, {\n ...(sinceMs !== undefined ? { sinceMs } : {}),\n ...(opts?.untilMs !== undefined ? { untilMs: opts.untilMs } : {}),\n ...(condition.where !== undefined ? { where: condition.where } : {}),\n });\n return count >= (condition.count ?? 1);\n }\n case 'property': {\n const actual = await facts.getProperty(condition.path);\n return compareProperty(condition, actual);\n }\n case 'reachable': {\n // The channel-wide opt-out only: a user opted out of one topic (an email topic, a push\n // topic) is still reachable on the channel, so `exitWhen: not(reachable('email'))` does\n // not fire on it\n if (await facts.isSuppressed?.(condition.channel)) return false;\n if (condition.channel === 'push') return (await facts.listDevices()).length > 0;\n const path = RECIPIENT_PROPERTY[condition.channel];\n // in_app, slack and discord need no address: a signed-in user is reachable in the app, and\n // the webhook channels post to a team channel, not to the user\n return path === undefined || (await facts.getProperty(path)) !== undefined;\n }\n case 'payload':\n // Placement is validated at compile; reaching here means hand-built IR\n throw new Error(\n 'payload conditions are only valid inside performed({ where }) or a trigger where clause'\n );\n }\n}\n\n/**\n * Evaluate a where tree against one event's payload. Shared by performed\n * counting (FactSource implementations) and the ingest router's trigger gate.\n * Dotted paths descend nested objects; a missing or non-scalar leaf behaves\n * like a missing property (exists sees objects, comparisons do not).\n */\nexport function matchesWhere(payload: unknown, where: ConditionIR): boolean {\n switch (where.type) {\n case 'and':\n return where.conditions.every((child) => matchesWhere(payload, child));\n case 'or':\n return where.conditions.some((child) => matchesWhere(payload, child));\n case 'not':\n return !matchesWhere(payload, where.condition);\n case 'payload': {\n const raw = valueAtPath(payload, where.path);\n if (where.op === 'exists') return raw !== undefined && raw !== null;\n if (where.op === 'not_exists') return raw === undefined || raw === null;\n const actual =\n typeof raw === 'string' || typeof raw === 'number' || typeof raw === 'boolean'\n ? raw\n : undefined;\n return compareProperty(where, actual);\n }\n default:\n // compileBundle rejects these; defend against hand-built IR\n throw new Error(`where clause may not contain a '${where.type}' condition`);\n }\n}\n\nfunction valueAtPath(payload: unknown, path: string): unknown {\n let current: unknown = payload;\n for (const key of path.split('.')) {\n if (current === null || typeof current !== 'object') return undefined;\n current = (current as Record<string, unknown>)[key];\n }\n return current;\n}\n\nfunction compareProperty(\n condition: {\n op: PropertyOperatorIR;\n value?: ScalarIR | undefined;\n values?: ScalarIR[] | undefined;\n },\n actual: ScalarIR | undefined\n): boolean {\n const { op, value, values } = condition;\n switch (op) {\n case 'exists':\n return actual !== undefined;\n case 'not_exists':\n return actual === undefined;\n case 'eq':\n return actual === value;\n case 'ne':\n return actual !== value;\n case 'gt':\n return actual !== undefined && value !== undefined && actual > value;\n case 'gte':\n return actual !== undefined && value !== undefined && actual >= value;\n case 'lt':\n return actual !== undefined && value !== undefined && actual < value;\n case 'lte':\n return actual !== undefined && value !== undefined && actual <= value;\n case 'between':\n return isBetween(actual, values);\n case 'not_between':\n // A missing property is outside any range, mirroring not_in_array.\n return !isBetween(actual, values);\n case 'in_array':\n return actual !== undefined && (values ?? []).includes(actual);\n case 'not_in_array':\n return actual === undefined || !(values ?? []).includes(actual);\n case 'contains':\n return typeof actual === 'string' && typeof value === 'string' && actual.includes(value);\n case 'not_contains':\n return !(typeof actual === 'string' && typeof value === 'string' && actual.includes(value));\n }\n}\n\nfunction isBetween(actual: ScalarIR | undefined, values: ScalarIR[] | undefined): boolean {\n if (actual === undefined || values === undefined || values.length < 2) return false;\n const [min, max] = values;\n return actual >= min && actual <= max;\n}\n\n/**\n * Wake-up channel for profile changes: a property predicate has no event name\n * of its own, so subscriptions use this reserved name and the ingest router\n * fires it on every /identify. The '$' prefix marks internal names — real\n * events may never use it (the router rejects them).\n */\nexport const PROFILE_UPDATED_EVENT = '$profile_updated';\n\n/**\n * The wake-up channel for one profile property: `$profile_updated:<path>`. A property predicate\n * subscribes to its own property's channel, and `/identify` fires one per property it patched —\n * so an identify that touches `plan` does not wake a wait on `email`.\n */\nexport function profileUpdatedEvent(path: string): string {\n return `${PROFILE_UPDATED_EVENT}:${path}`;\n}\n\n/**\n * Wake-up channel for device changes: a device registered for the user, moved to another account\n * or was unbound or removed (`POST /devices`, `/devices/unbind`, `/devices/remove`). `reachable('push')` subscribes to it.\n */\nexport const DEVICES_UPDATED_EVENT = '$devices_updated';\n\n/**\n * The set of event names a condition cares about — what wait_until subscribes\n * to for wake-ups. `performed` contributes its event; a property predicate\n * contributes its property's channel (`profileUpdatedEvent(path)`, so an\n * /identify touching that property wakes the wait and one touching another\n * does not); `reachable` contributes the recipient property's channel, or the\n * device channel for push (in_app, slack and discord have no address to wait\n * on). Segment references are expanded, which is why a FactSource is needed to\n * resolve their definitions.\n */\nexport async function relevantEvents(condition: ConditionIR, facts: FactSource): Promise<string[]> {\n const found = new Set<string>();\n // Names already expanded. The result is a union, so re-walking a segment can\n // only add what is already there — and a segment that reaches itself would\n // otherwise recurse until the stack blows, taking a store read per level.\n const expanded = new Set<string>();\n async function walk(c: ConditionIR): Promise<void> {\n switch (c.type) {\n case 'and':\n case 'or':\n for (const child of c.conditions) await walk(child);\n break;\n case 'not':\n await walk(c.condition);\n break;\n case 'performed':\n found.add(c.event);\n break;\n case 'segment': {\n if (expanded.has(c.segment)) break;\n expanded.add(c.segment);\n const def = await facts.getSegmentCondition(c.segment);\n if (def) await walk(def);\n break;\n }\n case 'property':\n found.add(profileUpdatedEvent(c.path));\n break;\n case 'reachable': {\n if (c.channel === 'push') {\n found.add(DEVICES_UPDATED_EVENT);\n break;\n }\n const path = RECIPIENT_PROPERTY[c.channel];\n if (path !== undefined) found.add(profileUpdatedEvent(path));\n break;\n }\n case 'payload':\n break; // only appears inside performed.where, which is not walked\n }\n }\n await walk(condition);\n return [...found];\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"condition.d.cts","names":[],"sources":["../../src/engine/condition.ts"],"mappings":";;;;;;;;;;;;iBAciB;EACf;EACA;;;;;;;wBAQoB,kBACpB,WAAW,aACX,OAAO,YACP,eACA,OAAO,kBACN;;;;;;;
|
|
1
|
+
{"version":3,"file":"condition.d.cts","names":[],"sources":["../../src/engine/condition.ts"],"mappings":";;;;;;;;;;;;iBAciB;EACf;EACA;;;;;;;wBAQoB,kBACpB,WAAW,aACX,OAAO,YACP,eACA,OAAO,kBACN;;;;;;;wBAwFa,aAAa,kBAAkB,OAAO;;;;;;;qBAuFzC;;;;;;wBAOG,oBAAoB;;;;;qBAQvB;;;;;;;;;;;wBAYS,eAAe,WAAW,aAAa,OAAO,aAAa"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"condition.d.mts","names":[],"sources":["../../src/engine/condition.ts"],"mappings":";;;;;;;;;;;;iBAciB;EACf;EACA;;;;;;;wBAQoB,kBACpB,WAAW,aACX,OAAO,YACP,eACA,OAAO,kBACN;;;;;;;
|
|
1
|
+
{"version":3,"file":"condition.d.mts","names":[],"sources":["../../src/engine/condition.ts"],"mappings":";;;;;;;;;;;;iBAciB;EACf;EACA;;;;;;;wBAQoB,kBACpB,WAAW,aACX,OAAO,YACP,eACA,OAAO,kBACN;;;;;;;wBAwFa,aAAa,kBAAkB,OAAO;;;;;;;qBAuFzC;;;;;;wBAOG,oBAAoB;;;;;qBAQvB;;;;;;;;;;;wBAYS,eAAe,WAAW,aAAa,OAAO,aAAa"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"condition.mjs","names":[],"sources":["../../src/engine/condition.ts"],"sourcesContent":["import type { ConditionIR, PropertyOperatorIR, ScalarIR } from '../ir';\nimport type { FactSource } from './ports';\nimport { RECIPIENT_PROPERTY } from './recipient';\n\n/**\n * Evaluation options.\n * - anchorMs: the earliest instant `performed` counts events from — the goal\n * guard anchors at workflow entry (a conversion must happen *after* entry),\n * waitUntil at the moment the wait began (a wake must not be satisfied by\n * pre-existing history). Combined with `within` by taking the later bound.\n * - untilMs: the latest instant `performed` counts events up to — the goal's\n * attribution window ends there, so an event after it is no conversion even\n * when the check runs later (a resumed instance, a delayed wake).\n */\nexport interface EvaluateOptions {\n anchorMs?: number;\n untilMs?: number;\n}\n\n/**\n * ConditionIR evaluation: pure logic, with facts injected through FactSource,\n * so the store-backed implementation and the in-memory test one share a single set of\n * semantics and cannot drift into two evaluators.\n */\nexport async function evaluateCondition(\n condition: ConditionIR,\n facts: FactSource,\n nowMs: number,\n opts?: EvaluateOptions\n): Promise<boolean> {\n return evaluate(condition, facts, nowMs, opts, []);\n}\n\n/**\n * `enclosing` is the chain of segment names currently being expanded. A\n * segment that reaches itself has no truth value to converge on, and left\n * unchecked the recursion runs until memory gives out, one store round trip per\n * level — inside a step, so Workflows retries it and the instance never gets\n * out. Failing immediately keeps it bounded, and the message names the cycle\n * because only a deployed bundle can fix it. Only the *path* is tracked, so a\n * diamond (two branches referencing one segment) still evaluates twice.\n */\nasync function evaluate(\n condition: ConditionIR,\n facts: FactSource,\n nowMs: number,\n opts: EvaluateOptions | undefined,\n enclosing: readonly string[]\n): Promise<boolean> {\n switch (condition.type) {\n case 'and': {\n for (const child of condition.conditions) {\n if (!(await evaluate(child, facts, nowMs, opts, enclosing))) return false;\n }\n return true;\n }\n case 'or': {\n for (const child of condition.conditions) {\n if (await evaluate(child, facts, nowMs, opts, enclosing)) return true;\n }\n return false;\n }\n case 'not':\n return !(await evaluate(condition.condition, facts, nowMs, opts, enclosing));\n case 'segment': {\n if (enclosing.includes(condition.segment)) {\n throw new Error(\n `segment '${condition.segment}' references itself: ${[...enclosing, condition.segment].join(' -> ')}`\n );\n }\n const def = await facts.getSegmentCondition(condition.segment);\n if (def === undefined) {\n throw new Error(`Unknown segment: '${condition.segment}' (bundle not deployed?)`);\n }\n return evaluate(def, facts, nowMs, opts, [...enclosing, condition.segment]);\n }\n case 'performed': {\n const bounds: number[] = [];\n if (condition.within !== undefined) bounds.push(nowMs - condition.within.ms);\n if (opts?.anchorMs !== undefined) bounds.push(opts.anchorMs);\n const sinceMs = bounds.length > 0 ? Math.max(...bounds) : undefined;\n const count = await facts.countEvents(condition.event, {\n ...(sinceMs !== undefined ? { sinceMs } : {}),\n ...(opts?.untilMs !== undefined ? { untilMs: opts.untilMs } : {}),\n ...(condition.where !== undefined ? { where: condition.where } : {}),\n });\n return count >= (condition.count ?? 1);\n }\n case 'property': {\n const actual = await facts.getProperty(condition.path);\n return compareProperty(condition, actual);\n }\n case 'reachable': {\n if (await facts.isSuppressed?.(condition.channel)) return false;\n if (condition.channel === 'push') return (await facts.listDevices()).length > 0;\n const path = RECIPIENT_PROPERTY[condition.channel];\n // in_app, slack and discord need no address: a signed-in user is reachable in the app, and\n // the webhook channels post to a team channel, not to the user\n return path === undefined || (await facts.getProperty(path)) !== undefined;\n }\n case 'payload':\n // Placement is validated at compile; reaching here means hand-built IR\n throw new Error(\n 'payload conditions are only valid inside performed({ where }) or a trigger where clause'\n );\n }\n}\n\n/**\n * Evaluate a where tree against one event's payload. Shared by performed\n * counting (FactSource implementations) and the ingest router's trigger gate.\n * Dotted paths descend nested objects; a missing or non-scalar leaf behaves\n * like a missing property (exists sees objects, comparisons do not).\n */\nexport function matchesWhere(payload: unknown, where: ConditionIR): boolean {\n switch (where.type) {\n case 'and':\n return where.conditions.every((child) => matchesWhere(payload, child));\n case 'or':\n return where.conditions.some((child) => matchesWhere(payload, child));\n case 'not':\n return !matchesWhere(payload, where.condition);\n case 'payload': {\n const raw = valueAtPath(payload, where.path);\n if (where.op === 'exists') return raw !== undefined && raw !== null;\n if (where.op === 'not_exists') return raw === undefined || raw === null;\n const actual =\n typeof raw === 'string' || typeof raw === 'number' || typeof raw === 'boolean'\n ? raw\n : undefined;\n return compareProperty(where, actual);\n }\n default:\n // compileBundle rejects these; defend against hand-built IR\n throw new Error(`where clause may not contain a '${where.type}' condition`);\n }\n}\n\nfunction valueAtPath(payload: unknown, path: string): unknown {\n let current: unknown = payload;\n for (const key of path.split('.')) {\n if (current === null || typeof current !== 'object') return undefined;\n current = (current as Record<string, unknown>)[key];\n }\n return current;\n}\n\nfunction compareProperty(\n condition: {\n op: PropertyOperatorIR;\n value?: ScalarIR | undefined;\n values?: ScalarIR[] | undefined;\n },\n actual: ScalarIR | undefined\n): boolean {\n const { op, value, values } = condition;\n switch (op) {\n case 'exists':\n return actual !== undefined;\n case 'not_exists':\n return actual === undefined;\n case 'eq':\n return actual === value;\n case 'ne':\n return actual !== value;\n case 'gt':\n return actual !== undefined && value !== undefined && actual > value;\n case 'gte':\n return actual !== undefined && value !== undefined && actual >= value;\n case 'lt':\n return actual !== undefined && value !== undefined && actual < value;\n case 'lte':\n return actual !== undefined && value !== undefined && actual <= value;\n case 'between':\n return isBetween(actual, values);\n case 'not_between':\n // A missing property is outside any range, mirroring not_in_array.\n return !isBetween(actual, values);\n case 'in_array':\n return actual !== undefined && (values ?? []).includes(actual);\n case 'not_in_array':\n return actual === undefined || !(values ?? []).includes(actual);\n case 'contains':\n return typeof actual === 'string' && typeof value === 'string' && actual.includes(value);\n case 'not_contains':\n return !(typeof actual === 'string' && typeof value === 'string' && actual.includes(value));\n }\n}\n\nfunction isBetween(actual: ScalarIR | undefined, values: ScalarIR[] | undefined): boolean {\n if (actual === undefined || values === undefined || values.length < 2) return false;\n const [min, max] = values;\n return actual >= min && actual <= max;\n}\n\n/**\n * Wake-up channel for profile changes: a property predicate has no event name\n * of its own, so subscriptions use this reserved name and the ingest router\n * fires it on every /identify. The '$' prefix marks internal names — real\n * events may never use it (the router rejects them).\n */\nexport const PROFILE_UPDATED_EVENT = '$profile_updated';\n\n/**\n * The wake-up channel for one profile property: `$profile_updated:<path>`. A property predicate\n * subscribes to its own property's channel, and `/identify` fires one per property it patched —\n * so an identify that touches `plan` does not wake a wait on `email`.\n */\nexport function profileUpdatedEvent(path: string): string {\n return `${PROFILE_UPDATED_EVENT}:${path}`;\n}\n\n/**\n * Wake-up channel for device changes: a device registered for the user, moved to another account\n * or was unbound or removed (`POST /devices`, `/devices/unbind`, `/devices/remove`). `reachable('push')` subscribes to it.\n */\nexport const DEVICES_UPDATED_EVENT = '$devices_updated';\n\n/**\n * The set of event names a condition cares about — what wait_until subscribes\n * to for wake-ups. `performed` contributes its event; a property predicate\n * contributes its property's channel (`profileUpdatedEvent(path)`, so an\n * /identify touching that property wakes the wait and one touching another\n * does not); `reachable` contributes the recipient property's channel, or the\n * device channel for push (in_app, slack and discord have no address to wait\n * on). Segment references are expanded, which is why a FactSource is needed to\n * resolve their definitions.\n */\nexport async function relevantEvents(condition: ConditionIR, facts: FactSource): Promise<string[]> {\n const found = new Set<string>();\n // Names already expanded. The result is a union, so re-walking a segment can\n // only add what is already there — and a segment that reaches itself would\n // otherwise recurse until the stack blows, taking a store read per level.\n const expanded = new Set<string>();\n async function walk(c: ConditionIR): Promise<void> {\n switch (c.type) {\n case 'and':\n case 'or':\n for (const child of c.conditions) await walk(child);\n break;\n case 'not':\n await walk(c.condition);\n break;\n case 'performed':\n found.add(c.event);\n break;\n case 'segment': {\n if (expanded.has(c.segment)) break;\n expanded.add(c.segment);\n const def = await facts.getSegmentCondition(c.segment);\n if (def) await walk(def);\n break;\n }\n case 'property':\n found.add(profileUpdatedEvent(c.path));\n break;\n case 'reachable': {\n if (c.channel === 'push') {\n found.add(DEVICES_UPDATED_EVENT);\n break;\n }\n const path = RECIPIENT_PROPERTY[c.channel];\n if (path !== undefined) found.add(profileUpdatedEvent(path));\n break;\n }\n case 'payload':\n break; // only appears inside performed.where, which is not walked\n }\n }\n await walk(condition);\n return [...found];\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"condition.mjs","names":[],"sources":["../../src/engine/condition.ts"],"sourcesContent":["import type { ConditionIR, PropertyOperatorIR, ScalarIR } from '../ir';\nimport type { FactSource } from './ports';\nimport { RECIPIENT_PROPERTY } from './recipient';\n\n/**\n * Evaluation options.\n * - anchorMs: the earliest instant `performed` counts events from — the goal\n * guard anchors at workflow entry (a conversion must happen *after* entry),\n * waitUntil at the moment the wait began (a wake must not be satisfied by\n * pre-existing history). Combined with `within` by taking the later bound.\n * - untilMs: the latest instant `performed` counts events up to — the goal's\n * attribution window ends there, so an event after it is no conversion even\n * when the check runs later (a resumed instance, a delayed wake).\n */\nexport interface EvaluateOptions {\n anchorMs?: number;\n untilMs?: number;\n}\n\n/**\n * ConditionIR evaluation: pure logic, with facts injected through FactSource,\n * so the store-backed implementation and the in-memory test one share a single set of\n * semantics and cannot drift into two evaluators.\n */\nexport async function evaluateCondition(\n condition: ConditionIR,\n facts: FactSource,\n nowMs: number,\n opts?: EvaluateOptions\n): Promise<boolean> {\n return evaluate(condition, facts, nowMs, opts, []);\n}\n\n/**\n * `enclosing` is the chain of segment names currently being expanded. A\n * segment that reaches itself has no truth value to converge on, and left\n * unchecked the recursion runs until memory gives out, one store round trip per\n * level — inside a step, so Workflows retries it and the instance never gets\n * out. Failing immediately keeps it bounded, and the message names the cycle\n * because only a deployed bundle can fix it. Only the *path* is tracked, so a\n * diamond (two branches referencing one segment) still evaluates twice.\n */\nasync function evaluate(\n condition: ConditionIR,\n facts: FactSource,\n nowMs: number,\n opts: EvaluateOptions | undefined,\n enclosing: readonly string[]\n): Promise<boolean> {\n switch (condition.type) {\n case 'and': {\n for (const child of condition.conditions) {\n if (!(await evaluate(child, facts, nowMs, opts, enclosing))) return false;\n }\n return true;\n }\n case 'or': {\n for (const child of condition.conditions) {\n if (await evaluate(child, facts, nowMs, opts, enclosing)) return true;\n }\n return false;\n }\n case 'not':\n return !(await evaluate(condition.condition, facts, nowMs, opts, enclosing));\n case 'segment': {\n if (enclosing.includes(condition.segment)) {\n throw new Error(\n `segment '${condition.segment}' references itself: ${[...enclosing, condition.segment].join(' -> ')}`\n );\n }\n const def = await facts.getSegmentCondition(condition.segment);\n if (def === undefined) {\n throw new Error(`Unknown segment: '${condition.segment}' (bundle not deployed?)`);\n }\n return evaluate(def, facts, nowMs, opts, [...enclosing, condition.segment]);\n }\n case 'performed': {\n const bounds: number[] = [];\n if (condition.within !== undefined) bounds.push(nowMs - condition.within.ms);\n if (opts?.anchorMs !== undefined) bounds.push(opts.anchorMs);\n const sinceMs = bounds.length > 0 ? Math.max(...bounds) : undefined;\n const count = await facts.countEvents(condition.event, {\n ...(sinceMs !== undefined ? { sinceMs } : {}),\n ...(opts?.untilMs !== undefined ? { untilMs: opts.untilMs } : {}),\n ...(condition.where !== undefined ? { where: condition.where } : {}),\n });\n return count >= (condition.count ?? 1);\n }\n case 'property': {\n const actual = await facts.getProperty(condition.path);\n return compareProperty(condition, actual);\n }\n case 'reachable': {\n // The channel-wide opt-out only: a user opted out of one topic (an email topic, a push\n // topic) is still reachable on the channel, so `exitWhen: not(reachable('email'))` does\n // not fire on it\n if (await facts.isSuppressed?.(condition.channel)) return false;\n if (condition.channel === 'push') return (await facts.listDevices()).length > 0;\n const path = RECIPIENT_PROPERTY[condition.channel];\n // in_app, slack and discord need no address: a signed-in user is reachable in the app, and\n // the webhook channels post to a team channel, not to the user\n return path === undefined || (await facts.getProperty(path)) !== undefined;\n }\n case 'payload':\n // Placement is validated at compile; reaching here means hand-built IR\n throw new Error(\n 'payload conditions are only valid inside performed({ where }) or a trigger where clause'\n );\n }\n}\n\n/**\n * Evaluate a where tree against one event's payload. Shared by performed\n * counting (FactSource implementations) and the ingest router's trigger gate.\n * Dotted paths descend nested objects; a missing or non-scalar leaf behaves\n * like a missing property (exists sees objects, comparisons do not).\n */\nexport function matchesWhere(payload: unknown, where: ConditionIR): boolean {\n switch (where.type) {\n case 'and':\n return where.conditions.every((child) => matchesWhere(payload, child));\n case 'or':\n return where.conditions.some((child) => matchesWhere(payload, child));\n case 'not':\n return !matchesWhere(payload, where.condition);\n case 'payload': {\n const raw = valueAtPath(payload, where.path);\n if (where.op === 'exists') return raw !== undefined && raw !== null;\n if (where.op === 'not_exists') return raw === undefined || raw === null;\n const actual =\n typeof raw === 'string' || typeof raw === 'number' || typeof raw === 'boolean'\n ? raw\n : undefined;\n return compareProperty(where, actual);\n }\n default:\n // compileBundle rejects these; defend against hand-built IR\n throw new Error(`where clause may not contain a '${where.type}' condition`);\n }\n}\n\nfunction valueAtPath(payload: unknown, path: string): unknown {\n let current: unknown = payload;\n for (const key of path.split('.')) {\n if (current === null || typeof current !== 'object') return undefined;\n current = (current as Record<string, unknown>)[key];\n }\n return current;\n}\n\nfunction compareProperty(\n condition: {\n op: PropertyOperatorIR;\n value?: ScalarIR | undefined;\n values?: ScalarIR[] | undefined;\n },\n actual: ScalarIR | undefined\n): boolean {\n const { op, value, values } = condition;\n switch (op) {\n case 'exists':\n return actual !== undefined;\n case 'not_exists':\n return actual === undefined;\n case 'eq':\n return actual === value;\n case 'ne':\n return actual !== value;\n case 'gt':\n return actual !== undefined && value !== undefined && actual > value;\n case 'gte':\n return actual !== undefined && value !== undefined && actual >= value;\n case 'lt':\n return actual !== undefined && value !== undefined && actual < value;\n case 'lte':\n return actual !== undefined && value !== undefined && actual <= value;\n case 'between':\n return isBetween(actual, values);\n case 'not_between':\n // A missing property is outside any range, mirroring not_in_array.\n return !isBetween(actual, values);\n case 'in_array':\n return actual !== undefined && (values ?? []).includes(actual);\n case 'not_in_array':\n return actual === undefined || !(values ?? []).includes(actual);\n case 'contains':\n return typeof actual === 'string' && typeof value === 'string' && actual.includes(value);\n case 'not_contains':\n return !(typeof actual === 'string' && typeof value === 'string' && actual.includes(value));\n }\n}\n\nfunction isBetween(actual: ScalarIR | undefined, values: ScalarIR[] | undefined): boolean {\n if (actual === undefined || values === undefined || values.length < 2) return false;\n const [min, max] = values;\n return actual >= min && actual <= max;\n}\n\n/**\n * Wake-up channel for profile changes: a property predicate has no event name\n * of its own, so subscriptions use this reserved name and the ingest router\n * fires it on every /identify. The '$' prefix marks internal names — real\n * events may never use it (the router rejects them).\n */\nexport const PROFILE_UPDATED_EVENT = '$profile_updated';\n\n/**\n * The wake-up channel for one profile property: `$profile_updated:<path>`. A property predicate\n * subscribes to its own property's channel, and `/identify` fires one per property it patched —\n * so an identify that touches `plan` does not wake a wait on `email`.\n */\nexport function profileUpdatedEvent(path: string): string {\n return `${PROFILE_UPDATED_EVENT}:${path}`;\n}\n\n/**\n * Wake-up channel for device changes: a device registered for the user, moved to another account\n * or was unbound or removed (`POST /devices`, `/devices/unbind`, `/devices/remove`). `reachable('push')` subscribes to it.\n */\nexport const DEVICES_UPDATED_EVENT = '$devices_updated';\n\n/**\n * The set of event names a condition cares about — what wait_until subscribes\n * to for wake-ups. `performed` contributes its event; a property predicate\n * contributes its property's channel (`profileUpdatedEvent(path)`, so an\n * /identify touching that property wakes the wait and one touching another\n * does not); `reachable` contributes the recipient property's channel, or the\n * device channel for push (in_app, slack and discord have no address to wait\n * on). Segment references are expanded, which is why a FactSource is needed to\n * resolve their definitions.\n */\nexport async function relevantEvents(condition: ConditionIR, facts: FactSource): Promise<string[]> {\n const found = new Set<string>();\n // Names already expanded. The result is a union, so re-walking a segment can\n // only add what is already there — and a segment that reaches itself would\n // otherwise recurse until the stack blows, taking a store read per level.\n const expanded = new Set<string>();\n async function walk(c: ConditionIR): Promise<void> {\n switch (c.type) {\n case 'and':\n case 'or':\n for (const child of c.conditions) await walk(child);\n break;\n case 'not':\n await walk(c.condition);\n break;\n case 'performed':\n found.add(c.event);\n break;\n case 'segment': {\n if (expanded.has(c.segment)) break;\n expanded.add(c.segment);\n const def = await facts.getSegmentCondition(c.segment);\n if (def) await walk(def);\n break;\n }\n case 'property':\n found.add(profileUpdatedEvent(c.path));\n break;\n case 'reachable': {\n if (c.channel === 'push') {\n found.add(DEVICES_UPDATED_EVENT);\n break;\n }\n const path = RECIPIENT_PROPERTY[c.channel];\n if (path !== undefined) found.add(profileUpdatedEvent(path));\n break;\n }\n case 'payload':\n break; // only appears inside performed.where, which is not walked\n }\n }\n await walk(condition);\n return [...found];\n}\n"],"mappings":";;;;;;;AAwBA,eAAsB,kBACpB,WACA,OACA,OACA,MACkB;CAClB,OAAO,SAAS,WAAW,OAAO,OAAO,MAAM,CAAC,CAAC;AACnD;;;;;;;;;;AAWA,eAAe,SACb,WACA,OACA,OACA,MACA,WACkB;CAClB,QAAQ,UAAU,MAAlB;EACE,KAAK;GACH,KAAK,MAAM,SAAS,UAAU,YAC5B,IAAI,CAAE,MAAM,SAAS,OAAO,OAAO,OAAO,MAAM,SAAS,GAAI,OAAO;GAEtE,OAAO;EAET,KAAK;GACH,KAAK,MAAM,SAAS,UAAU,YAC5B,IAAI,MAAM,SAAS,OAAO,OAAO,OAAO,MAAM,SAAS,GAAG,OAAO;GAEnE,OAAO;EAET,KAAK,OACH,OAAO,CAAE,MAAM,SAAS,UAAU,WAAW,OAAO,OAAO,MAAM,SAAS;EAC5E,KAAK,WAAW;GACd,IAAI,UAAU,SAAS,UAAU,OAAO,GACtC,MAAM,IAAI,MACR,YAAY,UAAU,QAAQ,uBAAuB,CAAC,GAAG,WAAW,UAAU,OAAO,CAAC,CAAC,KAAK,MAAM,GACpG;GAEF,MAAM,MAAM,MAAM,MAAM,oBAAoB,UAAU,OAAO;GAC7D,IAAI,QAAQ,KAAA,GACV,MAAM,IAAI,MAAM,qBAAqB,UAAU,QAAQ,yBAAyB;GAElF,OAAO,SAAS,KAAK,OAAO,OAAO,MAAM,CAAC,GAAG,WAAW,UAAU,OAAO,CAAC;EAC5E;EACA,KAAK,aAAa;GAChB,MAAM,SAAmB,CAAC;GAC1B,IAAI,UAAU,WAAW,KAAA,GAAW,OAAO,KAAK,QAAQ,UAAU,OAAO,EAAE;GAC3E,IAAI,MAAM,aAAa,KAAA,GAAW,OAAO,KAAK,KAAK,QAAQ;GAC3D,MAAM,UAAU,OAAO,SAAS,IAAI,KAAK,IAAI,GAAG,MAAM,IAAI,KAAA;GAM1D,OAAO,MALa,MAAM,YAAY,UAAU,OAAO;IACrD,GAAI,YAAY,KAAA,IAAY,EAAE,QAAQ,IAAI,CAAC;IAC3C,GAAI,MAAM,YAAY,KAAA,IAAY,EAAE,SAAS,KAAK,QAAQ,IAAI,CAAC;IAC/D,GAAI,UAAU,UAAU,KAAA,IAAY,EAAE,OAAO,UAAU,MAAM,IAAI,CAAC;GACpE,CAAC,MACgB,UAAU,SAAS;EACtC;EACA,KAAK,YAEH,OAAO,gBAAgB,WAAW,MADb,MAAM,YAAY,UAAU,IAAI,CACb;EAE1C,KAAK,aAAa;GAIhB,IAAI,MAAM,MAAM,eAAe,UAAU,OAAO,GAAG,OAAO;GAC1D,IAAI,UAAU,YAAY,QAAQ,QAAQ,MAAM,MAAM,YAAY,EAAA,CAAG,SAAS;GAC9E,MAAM,OAAO,mBAAmB,UAAU;GAG1C,OAAO,SAAS,KAAA,KAAc,MAAM,MAAM,YAAY,IAAI,MAAO,KAAA;EACnE;EACA,KAAK,WAEH,MAAM,IAAI,MACR,yFACF;CACJ;AACF;;;;;;;AAQA,SAAgB,aAAa,SAAkB,OAA6B;CAC1E,QAAQ,MAAM,MAAd;EACE,KAAK,OACH,OAAO,MAAM,WAAW,OAAO,UAAU,aAAa,SAAS,KAAK,CAAC;EACvE,KAAK,MACH,OAAO,MAAM,WAAW,MAAM,UAAU,aAAa,SAAS,KAAK,CAAC;EACtE,KAAK,OACH,OAAO,CAAC,aAAa,SAAS,MAAM,SAAS;EAC/C,KAAK,WAAW;GACd,MAAM,MAAM,YAAY,SAAS,MAAM,IAAI;GAC3C,IAAI,MAAM,OAAO,UAAU,OAAO,QAAQ,KAAA,KAAa,QAAQ;GAC/D,IAAI,MAAM,OAAO,cAAc,OAAO,QAAQ,KAAA,KAAa,QAAQ;GAKnE,OAAO,gBAAgB,OAHrB,OAAO,QAAQ,YAAY,OAAO,QAAQ,YAAY,OAAO,QAAQ,YACjE,MACA,KAAA,CAC8B;EACtC;EACA,SAEE,MAAM,IAAI,MAAM,mCAAmC,MAAM,KAAK,YAAY;CAC9E;AACF;AAEA,SAAS,YAAY,SAAkB,MAAuB;CAC5D,IAAI,UAAmB;CACvB,KAAK,MAAM,OAAO,KAAK,MAAM,GAAG,GAAG;EACjC,IAAI,YAAY,QAAQ,OAAO,YAAY,UAAU,OAAO,KAAA;EAC5D,UAAW,QAAoC;CACjD;CACA,OAAO;AACT;AAEA,SAAS,gBACP,WAKA,QACS;CACT,MAAM,EAAE,IAAI,OAAO,WAAW;CAC9B,QAAQ,IAAR;EACE,KAAK,UACH,OAAO,WAAW,KAAA;EACpB,KAAK,cACH,OAAO,WAAW,KAAA;EACpB,KAAK,MACH,OAAO,WAAW;EACpB,KAAK,MACH,OAAO,WAAW;EACpB,KAAK,MACH,OAAO,WAAW,KAAA,KAAa,UAAU,KAAA,KAAa,SAAS;EACjE,KAAK,OACH,OAAO,WAAW,KAAA,KAAa,UAAU,KAAA,KAAa,UAAU;EAClE,KAAK,MACH,OAAO,WAAW,KAAA,KAAa,UAAU,KAAA,KAAa,SAAS;EACjE,KAAK,OACH,OAAO,WAAW,KAAA,KAAa,UAAU,KAAA,KAAa,UAAU;EAClE,KAAK,WACH,OAAO,UAAU,QAAQ,MAAM;EACjC,KAAK,eAEH,OAAO,CAAC,UAAU,QAAQ,MAAM;EAClC,KAAK,YACH,OAAO,WAAW,KAAA,MAAc,UAAU,CAAC,EAAA,CAAG,SAAS,MAAM;EAC/D,KAAK,gBACH,OAAO,WAAW,KAAA,KAAa,EAAE,UAAU,CAAC,EAAA,CAAG,SAAS,MAAM;EAChE,KAAK,YACH,OAAO,OAAO,WAAW,YAAY,OAAO,UAAU,YAAY,OAAO,SAAS,KAAK;EACzF,KAAK,gBACH,OAAO,EAAE,OAAO,WAAW,YAAY,OAAO,UAAU,YAAY,OAAO,SAAS,KAAK;CAC7F;AACF;AAEA,SAAS,UAAU,QAA8B,QAAyC;CACxF,IAAI,WAAW,KAAA,KAAa,WAAW,KAAA,KAAa,OAAO,SAAS,GAAG,OAAO;CAC9E,MAAM,CAAC,KAAK,OAAO;CACnB,OAAO,UAAU,OAAO,UAAU;AACpC;;;;;;;AAQA,MAAa,wBAAwB;;;;;;AAOrC,SAAgB,oBAAoB,MAAsB;CACxD,OAAO,GAAG,sBAAsB,GAAG;AACrC;;;;;AAMA,MAAa,wBAAwB;;;;;;;;;;;AAYrC,eAAsB,eAAe,WAAwB,OAAsC;CACjG,MAAM,wBAAQ,IAAI,IAAY;CAI9B,MAAM,2BAAW,IAAI,IAAY;CACjC,eAAe,KAAK,GAA+B;EACjD,QAAQ,EAAE,MAAV;GACE,KAAK;GACL,KAAK;IACH,KAAK,MAAM,SAAS,EAAE,YAAY,MAAM,KAAK,KAAK;IAClD;GACF,KAAK;IACH,MAAM,KAAK,EAAE,SAAS;IACtB;GACF,KAAK;IACH,MAAM,IAAI,EAAE,KAAK;IACjB;GACF,KAAK,WAAW;IACd,IAAI,SAAS,IAAI,EAAE,OAAO,GAAG;IAC7B,SAAS,IAAI,EAAE,OAAO;IACtB,MAAM,MAAM,MAAM,MAAM,oBAAoB,EAAE,OAAO;IACrD,IAAI,KAAK,MAAM,KAAK,GAAG;IACvB;GACF;GACA,KAAK;IACH,MAAM,IAAI,oBAAoB,EAAE,IAAI,CAAC;IACrC;GACF,KAAK,aAAa;IAChB,IAAI,EAAE,YAAY,QAAQ;KACxB,MAAM,IAAI,qBAAqB;KAC/B;IACF;IACA,MAAM,OAAO,mBAAmB,EAAE;IAClC,IAAI,SAAS,KAAA,GAAW,MAAM,IAAI,oBAAoB,IAAI,CAAC;IAC3D;GACF;EAGF;CACF;CACA,MAAM,KAAK,SAAS;CACpB,OAAO,CAAC,GAAG,KAAK;AAClB"}
|
package/dist/engine/index.cjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
2
|
const require_engine_retry = require("./retry.cjs");
|
|
3
|
-
const require_engine_duration = require("./duration.cjs");
|
|
4
3
|
const require_engine_bucket = require("./bucket.cjs");
|
|
5
4
|
const require_engine_condition = require("./condition.cjs");
|
|
5
|
+
const require_engine_duration = require("./duration.cjs");
|
|
6
6
|
const require_engine_time_window = require("./time-window.cjs");
|
|
7
7
|
const require_engine_interpreter = require("./interpreter.cjs");
|
|
8
8
|
const require_engine_time_scale = require("./time-scale.cjs");
|
package/dist/engine/index.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { PermanentError, isRetryable, isRetryableStatus } from "./retry.mjs";
|
|
2
|
-
import { longestJourneyMs } from "./duration.mjs";
|
|
3
2
|
import { hashToBucket, murmur3 } from "./bucket.mjs";
|
|
4
3
|
import { DEVICES_UPDATED_EVENT, PROFILE_UPDATED_EVENT, evaluateCondition, matchesWhere, profileUpdatedEvent, relevantEvents } from "./condition.mjs";
|
|
4
|
+
import { longestJourneyMs } from "./duration.mjs";
|
|
5
5
|
import { nextWindowStart, resolveTimeZone } from "./time-window.mjs";
|
|
6
6
|
import { runJourney } from "./interpreter.mjs";
|
|
7
7
|
import { scaleCondition, scaleDurations, scaleSegmentWindows } from "./time-scale.mjs";
|
|
@@ -198,6 +198,21 @@ function validate(node, props, ctx) {
|
|
|
198
198
|
}
|
|
199
199
|
}
|
|
200
200
|
/**
|
|
201
|
+
* Whether the user opted out of what this step sends. An email or push step checks its topic —
|
|
202
|
+
* an opt-out of the topic on that channel or of the whole channel skips it — and a transactional
|
|
203
|
+
* message (`topic: null`) is never skipped: a receipt or a code goes out whatever the user opted
|
|
204
|
+
* out of. A node compiled before topics existed (no `topic`) checks the channel-wide opt-out
|
|
205
|
+
* only, as it always did. Other channels check the channel.
|
|
206
|
+
*/
|
|
207
|
+
async function suppressed(node, ctx) {
|
|
208
|
+
if (node.topic === null) return false;
|
|
209
|
+
return await ctx.facts.isSuppressed?.(node.channel, node.topic) === true;
|
|
210
|
+
}
|
|
211
|
+
/** The copy's topic, for the channels that have one: what the observer records and the senders may read. */
|
|
212
|
+
function topicOf(node) {
|
|
213
|
+
return node.channel === "email" || node.channel === "push" ? { topic: node.topic ?? null } : {};
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
201
216
|
* A push goes to devices, not to a profile address: one copy per device the
|
|
202
217
|
* host's directory lists (or only the most recent, `devices: 'latest'`). Each
|
|
203
218
|
* copy's idempotency key carries its token, so a retried step that already
|
|
@@ -214,7 +229,7 @@ function validate(node, props, ctx) {
|
|
|
214
229
|
async function sendPush(node, ir, ctx) {
|
|
215
230
|
const plan = await ctx.step.do(`${node.id}:send`, async () => {
|
|
216
231
|
const props = validate(node, await resolveValues(node.props, ctx.facts), ctx);
|
|
217
|
-
if (await
|
|
232
|
+
if (await suppressed(node, ctx)) return null;
|
|
218
233
|
const listed = await ctx.facts.listDevices();
|
|
219
234
|
return {
|
|
220
235
|
props,
|
|
@@ -237,7 +252,8 @@ async function sendPush(node, ir, ctx) {
|
|
|
237
252
|
userId: ctx.userId,
|
|
238
253
|
recipient: device.token,
|
|
239
254
|
device,
|
|
240
|
-
idempotencyKey: `${ctx.instanceId}:${node.id}:${device.token}
|
|
255
|
+
idempotencyKey: `${ctx.instanceId}:${node.id}:${device.token}`,
|
|
256
|
+
...topicOf(node)
|
|
241
257
|
}, ctx);
|
|
242
258
|
});
|
|
243
259
|
}
|
|
@@ -250,7 +266,7 @@ async function runNode(node, ir, ctx) {
|
|
|
250
266
|
}
|
|
251
267
|
await ctx.step.do(`${node.id}:send`, async () => {
|
|
252
268
|
const props = validate(node, await resolveValues(node.props, ctx.facts), ctx);
|
|
253
|
-
if (await
|
|
269
|
+
if (await suppressed(node, ctx)) return;
|
|
254
270
|
const recipientPath = require_engine_recipient.RECIPIENT_PROPERTY[node.channel];
|
|
255
271
|
const recipient = recipientPath === void 0 ? void 0 : await ctx.facts.getProperty(recipientPath);
|
|
256
272
|
await deliver({
|
|
@@ -262,7 +278,8 @@ async function runNode(node, ir, ctx) {
|
|
|
262
278
|
props,
|
|
263
279
|
userId: ctx.userId,
|
|
264
280
|
recipient: recipient === void 0 ? void 0 : String(recipient),
|
|
265
|
-
idempotencyKey: `${ctx.instanceId}:${node.id}
|
|
281
|
+
idempotencyKey: `${ctx.instanceId}:${node.id}`,
|
|
282
|
+
...topicOf(node)
|
|
266
283
|
}, ctx);
|
|
267
284
|
});
|
|
268
285
|
return FALL_THROUGH;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"interpreter.cjs","names":["evaluateCondition","RECIPIENT_PROPERTY","nextWindowStart","hashToBucket","relevantEvents"],"sources":["../../src/engine/interpreter.ts"],"sourcesContent":["import type { NodeIR, PropValueIR, ScalarIR, WorkflowIR } from '../ir';\nimport { hashToBucket } from './bucket';\nimport { evaluateCondition, relevantEvents } from './condition';\nimport type {\n FactSource,\n JourneyContext,\n JourneyOutcome,\n OutboundMessage,\n PushDevice,\n} from './ports';\nimport { RECIPIENT_PROPERTY } from './recipient';\nimport { nextWindowStart } from './time-window';\n\n/**\n * IR interpreter: the execution core for one user's journey instance.\n *\n * Pure logic — it imports nothing platform-specific; every external effect\n * arrives through the JourneyContext ports, and durable semantics\n * (checkpoint/replay) are the EngineStep adapter's job.\n *\n * Step naming: an IR node id *is* the step name (derived from the structural\n * path, so it is deterministic and unique). When one node needs several steps,\n * a suffix is appended (`${id}:guard`, `${id}:check:${n}`).\n *\n * Guard semantics: goal / exitWhen are checked at journey entry and after\n * every suspension point (delay / random_delay / time_window / wait_until) —\n * the moments where wall-clock time passed and the user's state may have\n * moved. Between two synchronous nodes no meaningful time passes, so checking\n * there would only burn step budget (each checkpoint costs a state write).\n */\n\n/**\n * Cap on wait_until re-evaluations, so a wake-up storm cannot exhaust the platform's step\n * budget. Each loop iteration costs a handful of steps, so 64 checks stay well inside the\n * platform step budgets while absorbing far more wake churn than 16 did. Past the cap the wait\n * is not given up: it sleeps to its deadline and checks once more there (see waitLoop) — the\n * answer is still right at the deadline, it just stops being event-driven in between.\n */\nconst MAX_WAIT_CHECKS = 64;\n\n/** The user property holding an IANA timezone id, read when a time_window says tz: 'user'. Same convention-table status as RECIPIENT_PROPERTY. */\nconst TIMEZONE_PROPERTY = 'timezone';\n\ntype FlowSignal =\n | { kind: 'fall_through' }\n | { kind: 'exit'; reason?: string }\n | { kind: 'goal' }\n | { kind: 'exit_when' };\n\nconst FALL_THROUGH: FlowSignal = { kind: 'fall_through' };\n\n/**\n * Whether and when the instance converted — a fact of its own, apart from how it ends: with\n * `exitOnMatch: false` a conversion lets the flow go on, and the instance still ends `completed`\n * (or `exited`, `exit_when`) carrying it. Set only from checkpointed step results, so a replay\n * rebuilds the same state.\n */\ninterface GoalState {\n convertedAt?: number;\n}\n\nconst goalStates = new WeakMap<JourneyContext, GoalState>();\n\nfunction goalState(ctx: JourneyContext): GoalState {\n let state = goalStates.get(ctx);\n if (state === undefined) {\n state = {};\n goalStates.set(ctx, state);\n }\n return state;\n}\n\n/** When the goal's attribution window closes, measured from entry; none without `within`. */\nfunction goalDeadline(ir: WorkflowIR, ctx: JourneyContext): number | undefined {\n return ir.goal?.within === undefined ? undefined : ctx.enteredAtMs + ir.goal.within.ms;\n}\n\n/**\n * Whether the goal holds for events inside its window: after entry, and not after the window\n * closed — an instance resumed past the deadline must not count a purchase made after it. The\n * conversion time is when the check saw it, capped at the deadline.\n */\nasync function goalReached(\n ir: WorkflowIR,\n ctx: JourneyContext,\n now: number\n): Promise<{ at: number } | null> {\n const goal = ir.goal;\n if (goal === undefined) return null;\n const deadline = goalDeadline(ir, ctx);\n const met = await evaluateCondition(goal.condition, ctx.facts, now, {\n anchorMs: ctx.enteredAtMs,\n ...(deadline === undefined ? {} : { untilMs: deadline }),\n });\n return met ? { at: deadline === undefined ? now : Math.min(now, deadline) } : null;\n}\n\nexport async function runJourney(ir: WorkflowIR, ctx: JourneyContext): Promise<JourneyOutcome> {\n await started(ir, ctx);\n const entry = await checkGuards('entry', ir, ctx);\n let signal = entry ?? (await runFlow(ir.flow, ir, ctx));\n // The flow is done but the conversion window is not: keep watching it (see awaitTail)\n if (signal.kind === 'fall_through') signal = (await awaitTail(ir, ctx)) ?? FALL_THROUGH;\n const { convertedAt } = goalState(ctx);\n const converted = convertedAt === undefined ? {} : { convertedAt };\n switch (signal.kind) {\n case 'fall_through':\n return { status: 'completed', ...converted };\n case 'exit':\n return signal.reason !== undefined\n ? { status: 'exited', reason: signal.reason, ...converted }\n : { status: 'exited', ...converted };\n case 'goal':\n return { status: 'goal', ...converted };\n case 'exit_when':\n return { status: 'exit_when', ...converted };\n }\n}\n\nasync function runFlow(nodes: NodeIR[], ir: WorkflowIR, ctx: JourneyContext): Promise<FlowSignal> {\n for (const node of nodes) {\n await reached(node, ir, ctx);\n const signal = await runNode(node, ir, ctx);\n if (signal.kind !== 'fall_through') return signal;\n }\n return FALL_THROUGH;\n}\n\n/**\n * Tell the observer the instance entered — before the entry guards, which can end it before the\n * first node: an instance is counted from its entry, not from its first node.\n */\nasync function started(ir: WorkflowIR, ctx: JourneyContext): Promise<void> {\n const observer = ctx.observer;\n if (observer?.started === undefined) return;\n await ctx.step.do('entry:started', async () => {\n await observer.started?.({\n workflow: ir.id,\n instanceId: ctx.instanceId,\n userId: ctx.userId,\n at: ctx.enteredAtMs,\n });\n });\n}\n\n/**\n * Tell the observer the instance is at `node`. A step of its own, so a replay does not report\n * the nodes it passes on the way back again, and the arrival time stays the first one.\n */\nasync function reached(node: NodeIR, ir: WorkflowIR, ctx: JourneyContext): Promise<void> {\n const observer = ctx.observer;\n if (observer?.nodeReached === undefined) return;\n await ctx.step.do(`${node.id}:reached`, async () => {\n await observer.nodeReached?.({\n workflow: ir.id,\n instanceId: ctx.instanceId,\n userId: ctx.userId,\n nodeId: node.id,\n at: Date.now(),\n });\n });\n}\n\n/**\n * One copy out through the host's sender, with the observer on both sides: it may attach\n * tracking before, and hears how the transport took the copy after. A send that throws is\n * not reported — the step retries it, and reports when it settles. A report that fails after\n * the send is logged, never thrown: the step would retry and the transport — a webhook, a web\n * push, with no idempotency key of its own — would deliver the copy again.\n */\nasync function deliver(message: OutboundMessage, ctx: JourneyContext): Promise<void> {\n const observer = ctx.observer;\n const tracking = await observer?.messagePrepared?.(message);\n const copy = tracking === undefined ? message : { ...message, tracking };\n const result = await ctx.messages.send(copy);\n try {\n await observer?.messageSent?.(copy, result ?? { delivered: true });\n } catch (error) {\n console.error(`[workflow] reporting ${copy.idempotencyKey} as sent failed`, error);\n }\n}\n\n/**\n * Goal / exitWhen guards (see the header for when they run).\n *\n * The goal condition is anchored at workflow entry: a `performed` inside it\n * only counts events after the user entered, so pre-existing history cannot\n * register as a conversion. exitWhen is deliberately unanchored — \"user\n * unsubscribed\" should exit no matter when it happened.\n */\nasync function checkGuards(\n stepId: string,\n ir: WorkflowIR,\n ctx: JourneyContext\n): Promise<FlowSignal | null> {\n const goal = ir.goal;\n const exitWhen = ir.exitWhen;\n if (goal === undefined && exitWhen === undefined) return null;\n\n const state = goalState(ctx);\n const result = await ctx.step.do(`${stepId}:guard`, async () => {\n const now = Date.now();\n // Already converted (exitOnMatch: false): the conversion stands, no need to look again\n const goalHit =\n goal !== undefined && state.convertedAt === undefined\n ? await goalReached(ir, ctx, now)\n : null;\n const exitMet = exitWhen !== undefined && (await evaluateCondition(exitWhen, ctx.facts, now));\n return { goalAt: goalHit?.at ?? null, exitMet };\n });\n\n if (result.goalAt !== null) {\n state.convertedAt ??= result.goalAt;\n if (goal?.exitOnMatch ?? true) return { kind: 'goal' };\n }\n if (result.exitMet) return { kind: 'exit_when' };\n return null;\n}\n\n/**\n * The host's template check, with the instance and node added to the message:\n * a refused message must point at the journey that produced it. The error\n * keeps its class (and `name`), so adapters can stop retrying it.\n */\nfunction validate(\n node: Extract<NodeIR, { type: 'message' }>,\n props: Record<string, ScalarIR | undefined>,\n ctx: JourneyContext\n): Record<string, ScalarIR | undefined> {\n if (ctx.validator === undefined) return props;\n try {\n return ctx.validator.validate({ channel: node.channel, template: node.template, props });\n } catch (error) {\n if (error instanceof Error) {\n error.message = `[${ctx.instanceId} ${node.id}] ${error.message}`;\n }\n throw error;\n }\n}\n\ntype PushPlan = { props: Record<string, ScalarIR | undefined>; devices: PushDevice[] };\n\n/**\n * A push goes to devices, not to a profile address: one copy per device the\n * host's directory lists (or only the most recent, `devices: 'latest'`). Each\n * copy's idempotency key carries its token, so a retried step that already\n * reached some devices re-sends to the rest and the senders drop the repeats —\n * keyed by token rather than position, because the list can change between\n * attempts. A user with no device is skipped, not failed: being unreachable is\n * a state of the user, and `reachable('push')` is how a journey branches on it.\n */\n/**\n * A push: one step settles what goes out and to which devices, then each device gets a step of\n * its own — a failure on one device retries that device alone, and the copies already delivered\n * are not sent again (APNs and FCM carry the idempotency key but do not de-duplicate on it).\n */\nasync function sendPush(\n node: Extract<NodeIR, { type: 'message' }>,\n ir: WorkflowIR,\n ctx: JourneyContext\n): Promise<void> {\n const plan = await ctx.step.do<PushPlan | null>(`${node.id}:send`, async () => {\n const props = validate(node, await resolveValues(node.props, ctx.facts), ctx);\n if (await ctx.facts.isSuppressed?.(node.channel)) return null;\n const listed = await ctx.facts.listDevices();\n const devices = node.devices === 'latest' ? listed.slice(0, 1) : listed;\n return { props, devices };\n });\n // null: the channel is opted out\n if (plan === null) return;\n if (plan.devices.length === 0) {\n console.log(`[journey] push ${node.template} skipped: no device for user ${ctx.userId}`);\n return;\n }\n for (const [index, device] of plan.devices.entries()) {\n await ctx.step.do(`${node.id}:send:${index}`, async () => {\n await deliver(\n {\n workflow: ir.id,\n instanceId: ctx.instanceId,\n nodeId: node.id,\n channel: 'push',\n template: node.template,\n props: plan.props,\n userId: ctx.userId,\n recipient: device.token,\n device,\n idempotencyKey: `${ctx.instanceId}:${node.id}:${device.token}`,\n },\n ctx\n );\n });\n }\n}\n\nasync function runNode(node: NodeIR, ir: WorkflowIR, ctx: JourneyContext): Promise<FlowSignal> {\n switch (node.type) {\n case 'message': {\n if (node.channel === 'push') {\n await sendPush(node, ir, ctx);\n return FALL_THROUGH;\n }\n await ctx.step.do(`${node.id}:send`, async () => {\n const props = validate(node, await resolveValues(node.props, ctx.facts), ctx);\n // An opted-out channel (an email unsubscribe) is skipped, like a push with no device\n if (await ctx.facts.isSuppressed?.(node.channel)) return;\n const recipientPath = RECIPIENT_PROPERTY[node.channel];\n const recipient =\n recipientPath === undefined ? undefined : await ctx.facts.getProperty(recipientPath);\n await deliver(\n {\n workflow: ir.id,\n instanceId: ctx.instanceId,\n nodeId: node.id,\n channel: node.channel,\n template: node.template,\n props,\n userId: ctx.userId,\n recipient: recipient === undefined ? undefined : String(recipient),\n idempotencyKey: `${ctx.instanceId}:${node.id}`,\n },\n ctx\n );\n });\n return FALL_THROUGH;\n }\n\n case 'delay': {\n await ctx.step.sleep(node.id, node.duration.ms);\n return (await checkGuards(node.id, ir, ctx)) ?? FALL_THROUGH;\n }\n\n case 'random_delay': {\n // The random value is drawn and persisted inside the step, so replay does not re-roll it\n const ms = await ctx.step.do(`${node.id}:roll`, async () => {\n const span = node.max.ms - node.min.ms;\n return node.min.ms + Math.floor(Math.random() * Math.max(span, 0));\n });\n await ctx.step.sleep(`${node.id}:sleep`, ms);\n return (await checkGuards(node.id, ir, ctx)) ?? FALL_THROUGH;\n }\n\n case 'time_window': {\n const target = await ctx.step.do(`${node.id}:target`, async () => {\n const tz =\n node.tz === 'user'\n ? String((await ctx.facts.getProperty(TIMEZONE_PROPERTY)) ?? 'UTC')\n : node.tz;\n return nextWindowStart(Date.now(), node.days, node.between, tz);\n });\n if (target === null) return FALL_THROUGH; // already inside the window: no suspension, no guard\n // The remaining time is checkpointed: a replay must not recompute it from a later clock\n // (the target has passed by then, and a negative sleep is an error on some runtimes)\n const ms = await ctx.step.do(`${node.id}:remaining`, async () => target - Date.now());\n if (ms > 0) await ctx.step.sleep(`${node.id}:sleep`, ms);\n return (await checkGuards(node.id, ir, ctx)) ?? FALL_THROUGH;\n }\n\n case 'wait_until': {\n const met = await waitUntil(node, ctx);\n // Time passed while waiting — guard before acting on the outcome (goal wins over the wait result)\n const guard = await checkGuards(node.id, ir, ctx);\n if (guard) return guard;\n if (met) return FALL_THROUGH;\n const onTimeout = node.onTimeout;\n if (onTimeout === 'continue') return FALL_THROUGH;\n if (onTimeout === 'exit') return { kind: 'exit', reason: `${node.id}:timeout` };\n return runFlow(onTimeout, ir, ctx);\n }\n\n case 'branch': {\n const matched = await ctx.step.do(`${node.id}:eval`, async () => {\n const now = Date.now();\n for (const [i, branchCase] of node.cases.entries()) {\n if (await evaluateCondition(branchCase.condition, ctx.facts, now)) {\n return i;\n }\n }\n return -1; // no match → default arm, or simply continue\n });\n const flow = matched >= 0 ? node.cases[matched]?.flow : node.otherwise;\n if (flow === undefined) return FALL_THROUGH;\n const signal = await runFlow(flow, ir, ctx);\n // Structural rejoin: once an arm falls through, execution resumes after the branch\n return signal;\n }\n\n case 'filter': {\n const pass = await ctx.step.do(`${node.id}:eval`, async () =>\n evaluateCondition(node.condition, ctx.facts, Date.now())\n );\n return pass ? FALL_THROUGH : { kind: 'exit', reason: node.reason ?? `${node.id}:filtered` };\n }\n\n case 'cohort': {\n // Deterministic bucketing, so replay needs no checkpoint. The explicit\n // experiment key (when set) keeps the assignment stable across workflow\n // versions; the structural id fallback is fine for one-off splits.\n const bucket = hashToBucket(`${ctx.userId}:${node.key ?? node.id}`);\n // Integer basis points on both sides (the DSL guarantees weights sit on\n // the 0.01% grid), so no float ever meets the comparison.\n let cumulative = 0;\n for (const arm of node.arms) {\n cumulative += Math.round(arm.weight * 100);\n if (bucket < cumulative) return runFlow(arm.flow, ir, ctx);\n }\n return FALL_THROUGH;\n }\n\n case 'exit':\n return node.reason !== undefined ? { kind: 'exit', reason: node.reason } : { kind: 'exit' };\n\n case 'send_event': {\n await ctx.step.do(`${node.id}:emit`, async () => {\n const payload = await resolveValues(node.payload, ctx.facts);\n try {\n await ctx.events.emit(node.event, payload, `${ctx.instanceId}:${node.id}`);\n } catch (error) {\n // A refused event must point at the journey that emitted it\n if (error instanceof Error)\n error.message = `[${ctx.instanceId} ${node.id}] ${error.message}`;\n throw error;\n }\n });\n return FALL_THROUGH;\n }\n\n case 'action': {\n // Custom code runs inside step.do: retried on throw, checkpointed on success\n const invoker = ctx.actions;\n if (invoker === undefined) {\n throw new Error(\n `action '${node.action}': this runtime has no ActionInvoker configured — register the action on the adapter (see engine/actions.ts)`\n );\n }\n await ctx.step.do(`${node.id}:run`, async () => {\n const args = await resolveValues(node.args, ctx.facts);\n await invoker.invoke({\n action: node.action,\n args,\n userId: ctx.userId,\n idempotencyKey: `${ctx.instanceId}:${node.id}`,\n });\n });\n return FALL_THROUGH;\n }\n }\n}\n\nasync function waitUntil(\n node: Extract<NodeIR, { type: 'wait_until' }>,\n ctx: JourneyContext\n): Promise<boolean> {\n const deadline = await ctx.step.do(\n `${node.id}:deadline`,\n async () => Date.now() + node.timeout.ms\n );\n const events = await ctx.step.do(`${node.id}:events`, async () =>\n relevantEvents(node.condition, ctx.facts)\n );\n // Anchor at the moment the wait began (deadline is checkpointed, so this is\n // replay-stable): `performed` inside the condition only counts events that\n // happen during the wait — pre-existing history must not satisfy it.\n const anchorMs = deadline - node.timeout.ms;\n const met = await waitLoop(node.id, ctx, deadline, events, async (now) =>\n (await evaluateCondition(node.condition, ctx.facts, now, { anchorMs })) ? true : null\n );\n await ctx.step.unsubscribe(`${node.id}:unsubscribe`);\n return met === true;\n}\n\n/**\n * The conversion window outlives the flow: a journey whose steps are done while its goal's\n * `within` is still open keeps watching until the window closes — the purchase that follows the\n * last offer email is that email's conversion, and ending the instance at the email would report\n * it as a plain completion. `exitWhen` still applies while it waits. Nothing to wait for without\n * a window, or once the instance has converted.\n */\nasync function awaitTail(ir: WorkflowIR, ctx: JourneyContext): Promise<FlowSignal | null> {\n const deadline = goalDeadline(ir, ctx);\n const goal = ir.goal;\n if (goal === undefined || deadline === undefined) return null;\n const state = goalState(ctx);\n if (state.convertedAt !== undefined) return null;\n const open = await ctx.step.do('goal:open', async () => deadline > Date.now());\n if (!open) return null;\n const exitWhen = ir.exitWhen;\n const events = await ctx.step.do('goal:events', async () => [\n ...new Set([\n ...(await relevantEvents(goal.condition, ctx.facts)),\n ...(exitWhen === undefined ? [] : await relevantEvents(exitWhen, ctx.facts)),\n ]),\n ]);\n const hit = await waitLoop('goal', ctx, deadline, events, async (now) => {\n const reached = await goalReached(ir, ctx, now);\n if (reached !== null) return { kind: 'goal' as const, at: reached.at };\n if (exitWhen !== undefined && (await evaluateCondition(exitWhen, ctx.facts, now))) {\n return { kind: 'exit_when' as const, at: now };\n }\n return null;\n });\n await ctx.step.unsubscribe('goal:unsubscribe');\n if (hit === null) return null;\n if (hit.kind === 'goal') {\n state.convertedAt ??= hit.at;\n return { kind: 'goal' };\n }\n return { kind: 'exit_when' };\n}\n\n/**\n * Wait until `check` finds something or the deadline passes: subscribe, check, sleep until a\n * relevant event wakes the instance or time runs out. `check` runs inside a step, so its result\n * must be serializable; null means \"not yet\". Null from here means the deadline passed with\n * nothing found — never a failure, and never a wait given up on early.\n */\nasync function waitLoop<R>(\n id: string,\n ctx: JourneyContext,\n deadline: number,\n events: string[],\n check: (now: number) => Promise<R | null>\n): Promise<R | null> {\n for (let attempt = 0; attempt < MAX_WAIT_CHECKS; attempt++) {\n // Subscribe before the check, every attempt: one subscribe arms at least\n // one wake-up (AWS callbacks are consumed on delivery, so each attempt\n // needs a fresh arm; Cloudflare rows make the repeat call idempotent), and\n // arming first closes the race where the event lands between the check and\n // the registration.\n await ctx.step.subscribe(`${id}:subscribe:${attempt}`, events, { deadlineMs: deadline });\n\n const met = await ctx.step.do(`${id}:check:${attempt}`, async () => check(Date.now()));\n if (met !== null) return met;\n\n // Checkpointed: replay re-runs this control flow with wall-clock time far\n // ahead of the original run, so the branch must come from a persisted value.\n const remaining = await ctx.step.do(\n `${id}:remaining:${attempt}`,\n async () => deadline - Date.now()\n );\n // The condition was checked just above — expiring here needs no extra final check\n if (remaining <= 0) return null;\n\n const wake = await ctx.step.waitForWake(`${id}:wait:${attempt}`, {\n timeoutMs: remaining,\n deadlineMs: deadline,\n });\n // Final check after timeout: in a tight race the event may land exactly as the timer fires\n if (wake === 'timeout') return finalCheck(id, ctx, check);\n // Woken → back to the top of the loop to re-evaluate (at-least-once wake-up semantics)\n }\n // Check budget exhausted (see MAX_WAIT_CHECKS). Not a timeout: the wait keeps its deadline,\n // only the wake-ups are given up — sleep to the deadline, then check once more there. The\n // subscription rows stay until the caller's unsubscribe; a wake landing meanwhile is dropped\n // by the runtime (nothing is parked on it), which is the point.\n console.warn(\n `[journey] ${ctx.instanceId} ${id}: ${MAX_WAIT_CHECKS} wake-ups without the condition holding — sleeping to the deadline`\n );\n const budgetRemaining = await ctx.step.do(\n `${id}:budget:remaining`,\n async () => deadline - Date.now()\n );\n if (budgetRemaining > 0) await ctx.step.sleep(`${id}:budget:sleep`, budgetRemaining);\n return finalCheck(id, ctx, check);\n}\n\n/** The check once the deadline is reached — one step for all the ways of getting there. */\nasync function finalCheck<R>(\n id: string,\n ctx: JourneyContext,\n check: (now: number) => Promise<R | null>\n): Promise<R | null> {\n return ctx.step.do(`${id}:final`, async () => check(Date.now()));\n}\n\nasync function resolveValues(\n values: Record<string, PropValueIR>,\n facts: FactSource\n): Promise<Record<string, ScalarIR | undefined>> {\n const resolved: Record<string, ScalarIR | undefined> = {};\n for (const [key, value] of Object.entries(values)) {\n resolved[key] = typeof value === 'object' ? await facts.getProperty(value.path) : value;\n }\n return resolved;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"interpreter.cjs","names":["evaluateCondition","RECIPIENT_PROPERTY","nextWindowStart","hashToBucket","relevantEvents"],"sources":["../../src/engine/interpreter.ts"],"sourcesContent":["import type { NodeIR, PropValueIR, ScalarIR, WorkflowIR } from '../ir';\nimport { hashToBucket } from './bucket';\nimport { evaluateCondition, relevantEvents } from './condition';\nimport type {\n FactSource,\n JourneyContext,\n JourneyOutcome,\n OutboundMessage,\n PushDevice,\n} from './ports';\nimport { RECIPIENT_PROPERTY } from './recipient';\nimport { nextWindowStart } from './time-window';\n\n/**\n * IR interpreter: the execution core for one user's journey instance.\n *\n * Pure logic — it imports nothing platform-specific; every external effect\n * arrives through the JourneyContext ports, and durable semantics\n * (checkpoint/replay) are the EngineStep adapter's job.\n *\n * Step naming: an IR node id *is* the step name (derived from the structural\n * path, so it is deterministic and unique). When one node needs several steps,\n * a suffix is appended (`${id}:guard`, `${id}:check:${n}`).\n *\n * Guard semantics: goal / exitWhen are checked at journey entry and after\n * every suspension point (delay / random_delay / time_window / wait_until) —\n * the moments where wall-clock time passed and the user's state may have\n * moved. Between two synchronous nodes no meaningful time passes, so checking\n * there would only burn step budget (each checkpoint costs a state write).\n */\n\n/**\n * Cap on wait_until re-evaluations, so a wake-up storm cannot exhaust the platform's step\n * budget. Each loop iteration costs a handful of steps, so 64 checks stay well inside the\n * platform step budgets while absorbing far more wake churn than 16 did. Past the cap the wait\n * is not given up: it sleeps to its deadline and checks once more there (see waitLoop) — the\n * answer is still right at the deadline, it just stops being event-driven in between.\n */\nconst MAX_WAIT_CHECKS = 64;\n\n/** The user property holding an IANA timezone id, read when a time_window says tz: 'user'. Same convention-table status as RECIPIENT_PROPERTY. */\nconst TIMEZONE_PROPERTY = 'timezone';\n\ntype FlowSignal =\n | { kind: 'fall_through' }\n | { kind: 'exit'; reason?: string }\n | { kind: 'goal' }\n | { kind: 'exit_when' };\n\nconst FALL_THROUGH: FlowSignal = { kind: 'fall_through' };\n\n/**\n * Whether and when the instance converted — a fact of its own, apart from how it ends: with\n * `exitOnMatch: false` a conversion lets the flow go on, and the instance still ends `completed`\n * (or `exited`, `exit_when`) carrying it. Set only from checkpointed step results, so a replay\n * rebuilds the same state.\n */\ninterface GoalState {\n convertedAt?: number;\n}\n\nconst goalStates = new WeakMap<JourneyContext, GoalState>();\n\nfunction goalState(ctx: JourneyContext): GoalState {\n let state = goalStates.get(ctx);\n if (state === undefined) {\n state = {};\n goalStates.set(ctx, state);\n }\n return state;\n}\n\n/** When the goal's attribution window closes, measured from entry; none without `within`. */\nfunction goalDeadline(ir: WorkflowIR, ctx: JourneyContext): number | undefined {\n return ir.goal?.within === undefined ? undefined : ctx.enteredAtMs + ir.goal.within.ms;\n}\n\n/**\n * Whether the goal holds for events inside its window: after entry, and not after the window\n * closed — an instance resumed past the deadline must not count a purchase made after it. The\n * conversion time is when the check saw it, capped at the deadline.\n */\nasync function goalReached(\n ir: WorkflowIR,\n ctx: JourneyContext,\n now: number\n): Promise<{ at: number } | null> {\n const goal = ir.goal;\n if (goal === undefined) return null;\n const deadline = goalDeadline(ir, ctx);\n const met = await evaluateCondition(goal.condition, ctx.facts, now, {\n anchorMs: ctx.enteredAtMs,\n ...(deadline === undefined ? {} : { untilMs: deadline }),\n });\n return met ? { at: deadline === undefined ? now : Math.min(now, deadline) } : null;\n}\n\nexport async function runJourney(ir: WorkflowIR, ctx: JourneyContext): Promise<JourneyOutcome> {\n await started(ir, ctx);\n const entry = await checkGuards('entry', ir, ctx);\n let signal = entry ?? (await runFlow(ir.flow, ir, ctx));\n // The flow is done but the conversion window is not: keep watching it (see awaitTail)\n if (signal.kind === 'fall_through') signal = (await awaitTail(ir, ctx)) ?? FALL_THROUGH;\n const { convertedAt } = goalState(ctx);\n const converted = convertedAt === undefined ? {} : { convertedAt };\n switch (signal.kind) {\n case 'fall_through':\n return { status: 'completed', ...converted };\n case 'exit':\n return signal.reason !== undefined\n ? { status: 'exited', reason: signal.reason, ...converted }\n : { status: 'exited', ...converted };\n case 'goal':\n return { status: 'goal', ...converted };\n case 'exit_when':\n return { status: 'exit_when', ...converted };\n }\n}\n\nasync function runFlow(nodes: NodeIR[], ir: WorkflowIR, ctx: JourneyContext): Promise<FlowSignal> {\n for (const node of nodes) {\n await reached(node, ir, ctx);\n const signal = await runNode(node, ir, ctx);\n if (signal.kind !== 'fall_through') return signal;\n }\n return FALL_THROUGH;\n}\n\n/**\n * Tell the observer the instance entered — before the entry guards, which can end it before the\n * first node: an instance is counted from its entry, not from its first node.\n */\nasync function started(ir: WorkflowIR, ctx: JourneyContext): Promise<void> {\n const observer = ctx.observer;\n if (observer?.started === undefined) return;\n await ctx.step.do('entry:started', async () => {\n await observer.started?.({\n workflow: ir.id,\n instanceId: ctx.instanceId,\n userId: ctx.userId,\n at: ctx.enteredAtMs,\n });\n });\n}\n\n/**\n * Tell the observer the instance is at `node`. A step of its own, so a replay does not report\n * the nodes it passes on the way back again, and the arrival time stays the first one.\n */\nasync function reached(node: NodeIR, ir: WorkflowIR, ctx: JourneyContext): Promise<void> {\n const observer = ctx.observer;\n if (observer?.nodeReached === undefined) return;\n await ctx.step.do(`${node.id}:reached`, async () => {\n await observer.nodeReached?.({\n workflow: ir.id,\n instanceId: ctx.instanceId,\n userId: ctx.userId,\n nodeId: node.id,\n at: Date.now(),\n });\n });\n}\n\n/**\n * One copy out through the host's sender, with the observer on both sides: it may attach\n * tracking before, and hears how the transport took the copy after. A send that throws is\n * not reported — the step retries it, and reports when it settles. A report that fails after\n * the send is logged, never thrown: the step would retry and the transport — a webhook, a web\n * push, with no idempotency key of its own — would deliver the copy again.\n */\nasync function deliver(message: OutboundMessage, ctx: JourneyContext): Promise<void> {\n const observer = ctx.observer;\n const tracking = await observer?.messagePrepared?.(message);\n const copy = tracking === undefined ? message : { ...message, tracking };\n const result = await ctx.messages.send(copy);\n try {\n await observer?.messageSent?.(copy, result ?? { delivered: true });\n } catch (error) {\n console.error(`[workflow] reporting ${copy.idempotencyKey} as sent failed`, error);\n }\n}\n\n/**\n * Goal / exitWhen guards (see the header for when they run).\n *\n * The goal condition is anchored at workflow entry: a `performed` inside it\n * only counts events after the user entered, so pre-existing history cannot\n * register as a conversion. exitWhen is deliberately unanchored — \"user\n * unsubscribed\" should exit no matter when it happened.\n */\nasync function checkGuards(\n stepId: string,\n ir: WorkflowIR,\n ctx: JourneyContext\n): Promise<FlowSignal | null> {\n const goal = ir.goal;\n const exitWhen = ir.exitWhen;\n if (goal === undefined && exitWhen === undefined) return null;\n\n const state = goalState(ctx);\n const result = await ctx.step.do(`${stepId}:guard`, async () => {\n const now = Date.now();\n // Already converted (exitOnMatch: false): the conversion stands, no need to look again\n const goalHit =\n goal !== undefined && state.convertedAt === undefined\n ? await goalReached(ir, ctx, now)\n : null;\n const exitMet = exitWhen !== undefined && (await evaluateCondition(exitWhen, ctx.facts, now));\n return { goalAt: goalHit?.at ?? null, exitMet };\n });\n\n if (result.goalAt !== null) {\n state.convertedAt ??= result.goalAt;\n if (goal?.exitOnMatch ?? true) return { kind: 'goal' };\n }\n if (result.exitMet) return { kind: 'exit_when' };\n return null;\n}\n\n/**\n * The host's template check, with the instance and node added to the message:\n * a refused message must point at the journey that produced it. The error\n * keeps its class (and `name`), so adapters can stop retrying it.\n */\nfunction validate(\n node: Extract<NodeIR, { type: 'message' }>,\n props: Record<string, ScalarIR | undefined>,\n ctx: JourneyContext\n): Record<string, ScalarIR | undefined> {\n if (ctx.validator === undefined) return props;\n try {\n return ctx.validator.validate({ channel: node.channel, template: node.template, props });\n } catch (error) {\n if (error instanceof Error) {\n error.message = `[${ctx.instanceId} ${node.id}] ${error.message}`;\n }\n throw error;\n }\n}\n\n/**\n * Whether the user opted out of what this step sends. An email or push step checks its topic —\n * an opt-out of the topic on that channel or of the whole channel skips it — and a transactional\n * message (`topic: null`) is never skipped: a receipt or a code goes out whatever the user opted\n * out of. A node compiled before topics existed (no `topic`) checks the channel-wide opt-out\n * only, as it always did. Other channels check the channel.\n */\nasync function suppressed(\n node: Extract<NodeIR, { type: 'message' }>,\n ctx: JourneyContext\n): Promise<boolean> {\n if (node.topic === null) return false;\n return (await ctx.facts.isSuppressed?.(node.channel, node.topic)) === true;\n}\n\n/** The copy's topic, for the channels that have one: what the observer records and the senders may read. */\nfunction topicOf(node: Extract<NodeIR, { type: 'message' }>): { topic?: string | null } {\n return node.channel === 'email' || node.channel === 'push' ? { topic: node.topic ?? null } : {};\n}\n\ntype PushPlan = { props: Record<string, ScalarIR | undefined>; devices: PushDevice[] };\n\n/**\n * A push goes to devices, not to a profile address: one copy per device the\n * host's directory lists (or only the most recent, `devices: 'latest'`). Each\n * copy's idempotency key carries its token, so a retried step that already\n * reached some devices re-sends to the rest and the senders drop the repeats —\n * keyed by token rather than position, because the list can change between\n * attempts. A user with no device is skipped, not failed: being unreachable is\n * a state of the user, and `reachable('push')` is how a journey branches on it.\n */\n/**\n * A push: one step settles what goes out and to which devices, then each device gets a step of\n * its own — a failure on one device retries that device alone, and the copies already delivered\n * are not sent again (APNs and FCM carry the idempotency key but do not de-duplicate on it).\n */\nasync function sendPush(\n node: Extract<NodeIR, { type: 'message' }>,\n ir: WorkflowIR,\n ctx: JourneyContext\n): Promise<void> {\n const plan = await ctx.step.do<PushPlan | null>(`${node.id}:send`, async () => {\n const props = validate(node, await resolveValues(node.props, ctx.facts), ctx);\n // Once per step, before any device: the topic's opt-out or the channel's (never a transactional push)\n if (await suppressed(node, ctx)) return null;\n const listed = await ctx.facts.listDevices();\n const devices = node.devices === 'latest' ? listed.slice(0, 1) : listed;\n return { props, devices };\n });\n // null: the topic or the channel is opted out\n if (plan === null) return;\n if (plan.devices.length === 0) {\n console.log(`[journey] push ${node.template} skipped: no device for user ${ctx.userId}`);\n return;\n }\n for (const [index, device] of plan.devices.entries()) {\n await ctx.step.do(`${node.id}:send:${index}`, async () => {\n await deliver(\n {\n workflow: ir.id,\n instanceId: ctx.instanceId,\n nodeId: node.id,\n channel: 'push',\n template: node.template,\n props: plan.props,\n userId: ctx.userId,\n recipient: device.token,\n device,\n idempotencyKey: `${ctx.instanceId}:${node.id}:${device.token}`,\n ...topicOf(node),\n },\n ctx\n );\n });\n }\n}\n\nasync function runNode(node: NodeIR, ir: WorkflowIR, ctx: JourneyContext): Promise<FlowSignal> {\n switch (node.type) {\n case 'message': {\n if (node.channel === 'push') {\n await sendPush(node, ir, ctx);\n return FALL_THROUGH;\n }\n await ctx.step.do(`${node.id}:send`, async () => {\n const props = validate(node, await resolveValues(node.props, ctx.facts), ctx);\n // An opted-out channel (an email unsubscribe) is skipped, like a push with no device\n if (await suppressed(node, ctx)) return;\n const recipientPath = RECIPIENT_PROPERTY[node.channel];\n const recipient =\n recipientPath === undefined ? undefined : await ctx.facts.getProperty(recipientPath);\n await deliver(\n {\n workflow: ir.id,\n instanceId: ctx.instanceId,\n nodeId: node.id,\n channel: node.channel,\n template: node.template,\n props,\n userId: ctx.userId,\n recipient: recipient === undefined ? undefined : String(recipient),\n idempotencyKey: `${ctx.instanceId}:${node.id}`,\n ...topicOf(node),\n },\n ctx\n );\n });\n return FALL_THROUGH;\n }\n\n case 'delay': {\n await ctx.step.sleep(node.id, node.duration.ms);\n return (await checkGuards(node.id, ir, ctx)) ?? FALL_THROUGH;\n }\n\n case 'random_delay': {\n // The random value is drawn and persisted inside the step, so replay does not re-roll it\n const ms = await ctx.step.do(`${node.id}:roll`, async () => {\n const span = node.max.ms - node.min.ms;\n return node.min.ms + Math.floor(Math.random() * Math.max(span, 0));\n });\n await ctx.step.sleep(`${node.id}:sleep`, ms);\n return (await checkGuards(node.id, ir, ctx)) ?? FALL_THROUGH;\n }\n\n case 'time_window': {\n const target = await ctx.step.do(`${node.id}:target`, async () => {\n const tz =\n node.tz === 'user'\n ? String((await ctx.facts.getProperty(TIMEZONE_PROPERTY)) ?? 'UTC')\n : node.tz;\n return nextWindowStart(Date.now(), node.days, node.between, tz);\n });\n if (target === null) return FALL_THROUGH; // already inside the window: no suspension, no guard\n // The remaining time is checkpointed: a replay must not recompute it from a later clock\n // (the target has passed by then, and a negative sleep is an error on some runtimes)\n const ms = await ctx.step.do(`${node.id}:remaining`, async () => target - Date.now());\n if (ms > 0) await ctx.step.sleep(`${node.id}:sleep`, ms);\n return (await checkGuards(node.id, ir, ctx)) ?? FALL_THROUGH;\n }\n\n case 'wait_until': {\n const met = await waitUntil(node, ctx);\n // Time passed while waiting — guard before acting on the outcome (goal wins over the wait result)\n const guard = await checkGuards(node.id, ir, ctx);\n if (guard) return guard;\n if (met) return FALL_THROUGH;\n const onTimeout = node.onTimeout;\n if (onTimeout === 'continue') return FALL_THROUGH;\n if (onTimeout === 'exit') return { kind: 'exit', reason: `${node.id}:timeout` };\n return runFlow(onTimeout, ir, ctx);\n }\n\n case 'branch': {\n const matched = await ctx.step.do(`${node.id}:eval`, async () => {\n const now = Date.now();\n for (const [i, branchCase] of node.cases.entries()) {\n if (await evaluateCondition(branchCase.condition, ctx.facts, now)) {\n return i;\n }\n }\n return -1; // no match → default arm, or simply continue\n });\n const flow = matched >= 0 ? node.cases[matched]?.flow : node.otherwise;\n if (flow === undefined) return FALL_THROUGH;\n const signal = await runFlow(flow, ir, ctx);\n // Structural rejoin: once an arm falls through, execution resumes after the branch\n return signal;\n }\n\n case 'filter': {\n const pass = await ctx.step.do(`${node.id}:eval`, async () =>\n evaluateCondition(node.condition, ctx.facts, Date.now())\n );\n return pass ? FALL_THROUGH : { kind: 'exit', reason: node.reason ?? `${node.id}:filtered` };\n }\n\n case 'cohort': {\n // Deterministic bucketing, so replay needs no checkpoint. The explicit\n // experiment key (when set) keeps the assignment stable across workflow\n // versions; the structural id fallback is fine for one-off splits.\n const bucket = hashToBucket(`${ctx.userId}:${node.key ?? node.id}`);\n // Integer basis points on both sides (the DSL guarantees weights sit on\n // the 0.01% grid), so no float ever meets the comparison.\n let cumulative = 0;\n for (const arm of node.arms) {\n cumulative += Math.round(arm.weight * 100);\n if (bucket < cumulative) return runFlow(arm.flow, ir, ctx);\n }\n return FALL_THROUGH;\n }\n\n case 'exit':\n return node.reason !== undefined ? { kind: 'exit', reason: node.reason } : { kind: 'exit' };\n\n case 'send_event': {\n await ctx.step.do(`${node.id}:emit`, async () => {\n const payload = await resolveValues(node.payload, ctx.facts);\n try {\n await ctx.events.emit(node.event, payload, `${ctx.instanceId}:${node.id}`);\n } catch (error) {\n // A refused event must point at the journey that emitted it\n if (error instanceof Error)\n error.message = `[${ctx.instanceId} ${node.id}] ${error.message}`;\n throw error;\n }\n });\n return FALL_THROUGH;\n }\n\n case 'action': {\n // Custom code runs inside step.do: retried on throw, checkpointed on success\n const invoker = ctx.actions;\n if (invoker === undefined) {\n throw new Error(\n `action '${node.action}': this runtime has no ActionInvoker configured — register the action on the adapter (see engine/actions.ts)`\n );\n }\n await ctx.step.do(`${node.id}:run`, async () => {\n const args = await resolveValues(node.args, ctx.facts);\n await invoker.invoke({\n action: node.action,\n args,\n userId: ctx.userId,\n idempotencyKey: `${ctx.instanceId}:${node.id}`,\n });\n });\n return FALL_THROUGH;\n }\n }\n}\n\nasync function waitUntil(\n node: Extract<NodeIR, { type: 'wait_until' }>,\n ctx: JourneyContext\n): Promise<boolean> {\n const deadline = await ctx.step.do(\n `${node.id}:deadline`,\n async () => Date.now() + node.timeout.ms\n );\n const events = await ctx.step.do(`${node.id}:events`, async () =>\n relevantEvents(node.condition, ctx.facts)\n );\n // Anchor at the moment the wait began (deadline is checkpointed, so this is\n // replay-stable): `performed` inside the condition only counts events that\n // happen during the wait — pre-existing history must not satisfy it.\n const anchorMs = deadline - node.timeout.ms;\n const met = await waitLoop(node.id, ctx, deadline, events, async (now) =>\n (await evaluateCondition(node.condition, ctx.facts, now, { anchorMs })) ? true : null\n );\n await ctx.step.unsubscribe(`${node.id}:unsubscribe`);\n return met === true;\n}\n\n/**\n * The conversion window outlives the flow: a journey whose steps are done while its goal's\n * `within` is still open keeps watching until the window closes — the purchase that follows the\n * last offer email is that email's conversion, and ending the instance at the email would report\n * it as a plain completion. `exitWhen` still applies while it waits. Nothing to wait for without\n * a window, or once the instance has converted.\n */\nasync function awaitTail(ir: WorkflowIR, ctx: JourneyContext): Promise<FlowSignal | null> {\n const deadline = goalDeadline(ir, ctx);\n const goal = ir.goal;\n if (goal === undefined || deadline === undefined) return null;\n const state = goalState(ctx);\n if (state.convertedAt !== undefined) return null;\n const open = await ctx.step.do('goal:open', async () => deadline > Date.now());\n if (!open) return null;\n const exitWhen = ir.exitWhen;\n const events = await ctx.step.do('goal:events', async () => [\n ...new Set([\n ...(await relevantEvents(goal.condition, ctx.facts)),\n ...(exitWhen === undefined ? [] : await relevantEvents(exitWhen, ctx.facts)),\n ]),\n ]);\n const hit = await waitLoop('goal', ctx, deadline, events, async (now) => {\n const reached = await goalReached(ir, ctx, now);\n if (reached !== null) return { kind: 'goal' as const, at: reached.at };\n if (exitWhen !== undefined && (await evaluateCondition(exitWhen, ctx.facts, now))) {\n return { kind: 'exit_when' as const, at: now };\n }\n return null;\n });\n await ctx.step.unsubscribe('goal:unsubscribe');\n if (hit === null) return null;\n if (hit.kind === 'goal') {\n state.convertedAt ??= hit.at;\n return { kind: 'goal' };\n }\n return { kind: 'exit_when' };\n}\n\n/**\n * Wait until `check` finds something or the deadline passes: subscribe, check, sleep until a\n * relevant event wakes the instance or time runs out. `check` runs inside a step, so its result\n * must be serializable; null means \"not yet\". Null from here means the deadline passed with\n * nothing found — never a failure, and never a wait given up on early.\n */\nasync function waitLoop<R>(\n id: string,\n ctx: JourneyContext,\n deadline: number,\n events: string[],\n check: (now: number) => Promise<R | null>\n): Promise<R | null> {\n for (let attempt = 0; attempt < MAX_WAIT_CHECKS; attempt++) {\n // Subscribe before the check, every attempt: one subscribe arms at least\n // one wake-up (AWS callbacks are consumed on delivery, so each attempt\n // needs a fresh arm; Cloudflare rows make the repeat call idempotent), and\n // arming first closes the race where the event lands between the check and\n // the registration.\n await ctx.step.subscribe(`${id}:subscribe:${attempt}`, events, { deadlineMs: deadline });\n\n const met = await ctx.step.do(`${id}:check:${attempt}`, async () => check(Date.now()));\n if (met !== null) return met;\n\n // Checkpointed: replay re-runs this control flow with wall-clock time far\n // ahead of the original run, so the branch must come from a persisted value.\n const remaining = await ctx.step.do(\n `${id}:remaining:${attempt}`,\n async () => deadline - Date.now()\n );\n // The condition was checked just above — expiring here needs no extra final check\n if (remaining <= 0) return null;\n\n const wake = await ctx.step.waitForWake(`${id}:wait:${attempt}`, {\n timeoutMs: remaining,\n deadlineMs: deadline,\n });\n // Final check after timeout: in a tight race the event may land exactly as the timer fires\n if (wake === 'timeout') return finalCheck(id, ctx, check);\n // Woken → back to the top of the loop to re-evaluate (at-least-once wake-up semantics)\n }\n // Check budget exhausted (see MAX_WAIT_CHECKS). Not a timeout: the wait keeps its deadline,\n // only the wake-ups are given up — sleep to the deadline, then check once more there. The\n // subscription rows stay until the caller's unsubscribe; a wake landing meanwhile is dropped\n // by the runtime (nothing is parked on it), which is the point.\n console.warn(\n `[journey] ${ctx.instanceId} ${id}: ${MAX_WAIT_CHECKS} wake-ups without the condition holding — sleeping to the deadline`\n );\n const budgetRemaining = await ctx.step.do(\n `${id}:budget:remaining`,\n async () => deadline - Date.now()\n );\n if (budgetRemaining > 0) await ctx.step.sleep(`${id}:budget:sleep`, budgetRemaining);\n return finalCheck(id, ctx, check);\n}\n\n/** The check once the deadline is reached — one step for all the ways of getting there. */\nasync function finalCheck<R>(\n id: string,\n ctx: JourneyContext,\n check: (now: number) => Promise<R | null>\n): Promise<R | null> {\n return ctx.step.do(`${id}:final`, async () => check(Date.now()));\n}\n\nasync function resolveValues(\n values: Record<string, PropValueIR>,\n facts: FactSource\n): Promise<Record<string, ScalarIR | undefined>> {\n const resolved: Record<string, ScalarIR | undefined> = {};\n for (const [key, value] of Object.entries(values)) {\n resolved[key] = typeof value === 'object' ? await facts.getProperty(value.path) : value;\n }\n return resolved;\n}\n"],"mappings":"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"}
|
|
@@ -197,6 +197,21 @@ function validate(node, props, ctx) {
|
|
|
197
197
|
}
|
|
198
198
|
}
|
|
199
199
|
/**
|
|
200
|
+
* Whether the user opted out of what this step sends. An email or push step checks its topic —
|
|
201
|
+
* an opt-out of the topic on that channel or of the whole channel skips it — and a transactional
|
|
202
|
+
* message (`topic: null`) is never skipped: a receipt or a code goes out whatever the user opted
|
|
203
|
+
* out of. A node compiled before topics existed (no `topic`) checks the channel-wide opt-out
|
|
204
|
+
* only, as it always did. Other channels check the channel.
|
|
205
|
+
*/
|
|
206
|
+
async function suppressed(node, ctx) {
|
|
207
|
+
if (node.topic === null) return false;
|
|
208
|
+
return await ctx.facts.isSuppressed?.(node.channel, node.topic) === true;
|
|
209
|
+
}
|
|
210
|
+
/** The copy's topic, for the channels that have one: what the observer records and the senders may read. */
|
|
211
|
+
function topicOf(node) {
|
|
212
|
+
return node.channel === "email" || node.channel === "push" ? { topic: node.topic ?? null } : {};
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
200
215
|
* A push goes to devices, not to a profile address: one copy per device the
|
|
201
216
|
* host's directory lists (or only the most recent, `devices: 'latest'`). Each
|
|
202
217
|
* copy's idempotency key carries its token, so a retried step that already
|
|
@@ -213,7 +228,7 @@ function validate(node, props, ctx) {
|
|
|
213
228
|
async function sendPush(node, ir, ctx) {
|
|
214
229
|
const plan = await ctx.step.do(`${node.id}:send`, async () => {
|
|
215
230
|
const props = validate(node, await resolveValues(node.props, ctx.facts), ctx);
|
|
216
|
-
if (await
|
|
231
|
+
if (await suppressed(node, ctx)) return null;
|
|
217
232
|
const listed = await ctx.facts.listDevices();
|
|
218
233
|
return {
|
|
219
234
|
props,
|
|
@@ -236,7 +251,8 @@ async function sendPush(node, ir, ctx) {
|
|
|
236
251
|
userId: ctx.userId,
|
|
237
252
|
recipient: device.token,
|
|
238
253
|
device,
|
|
239
|
-
idempotencyKey: `${ctx.instanceId}:${node.id}:${device.token}
|
|
254
|
+
idempotencyKey: `${ctx.instanceId}:${node.id}:${device.token}`,
|
|
255
|
+
...topicOf(node)
|
|
240
256
|
}, ctx);
|
|
241
257
|
});
|
|
242
258
|
}
|
|
@@ -249,7 +265,7 @@ async function runNode(node, ir, ctx) {
|
|
|
249
265
|
}
|
|
250
266
|
await ctx.step.do(`${node.id}:send`, async () => {
|
|
251
267
|
const props = validate(node, await resolveValues(node.props, ctx.facts), ctx);
|
|
252
|
-
if (await
|
|
268
|
+
if (await suppressed(node, ctx)) return;
|
|
253
269
|
const recipientPath = RECIPIENT_PROPERTY[node.channel];
|
|
254
270
|
const recipient = recipientPath === void 0 ? void 0 : await ctx.facts.getProperty(recipientPath);
|
|
255
271
|
await deliver({
|
|
@@ -261,7 +277,8 @@ async function runNode(node, ir, ctx) {
|
|
|
261
277
|
props,
|
|
262
278
|
userId: ctx.userId,
|
|
263
279
|
recipient: recipient === void 0 ? void 0 : String(recipient),
|
|
264
|
-
idempotencyKey: `${ctx.instanceId}:${node.id}
|
|
280
|
+
idempotencyKey: `${ctx.instanceId}:${node.id}`,
|
|
281
|
+
...topicOf(node)
|
|
265
282
|
}, ctx);
|
|
266
283
|
});
|
|
267
284
|
return FALL_THROUGH;
|