@shware/workflow 7.0.0 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/README.md +121 -34
  2. package/dist/aws/engine-step.cjs +4 -1
  3. package/dist/aws/engine-step.cjs.map +1 -1
  4. package/dist/aws/engine-step.d.cts.map +1 -1
  5. package/dist/aws/engine-step.d.mts.map +1 -1
  6. package/dist/aws/engine-step.mjs +4 -1
  7. package/dist/aws/engine-step.mjs.map +1 -1
  8. package/dist/client.cjs +66 -41
  9. package/dist/client.cjs.map +1 -1
  10. package/dist/client.d.cts +72 -22
  11. package/dist/client.d.cts.map +1 -1
  12. package/dist/client.d.mts +72 -22
  13. package/dist/client.d.mts.map +1 -1
  14. package/dist/client.mjs +66 -42
  15. package/dist/client.mjs.map +1 -1
  16. package/dist/cloudflare/index.d.cts +4 -3
  17. package/dist/cloudflare/index.d.mts +4 -3
  18. package/dist/cloudflare/runner.cjs +3 -1
  19. package/dist/cloudflare/runner.cjs.map +1 -1
  20. package/dist/cloudflare/runner.d.cts.map +1 -1
  21. package/dist/cloudflare/runner.d.mts.map +1 -1
  22. package/dist/cloudflare/runner.mjs +3 -1
  23. package/dist/cloudflare/runner.mjs.map +1 -1
  24. package/dist/cloudflare/worker.cjs +12 -1
  25. package/dist/cloudflare/worker.cjs.map +1 -1
  26. package/dist/cloudflare/worker.d.cts +9 -1
  27. package/dist/cloudflare/worker.d.cts.map +1 -1
  28. package/dist/cloudflare/worker.d.mts +9 -1
  29. package/dist/cloudflare/worker.d.mts.map +1 -1
  30. package/dist/cloudflare/worker.mjs +12 -1
  31. package/dist/cloudflare/worker.mjs.map +1 -1
  32. package/dist/contract.cjs.map +1 -1
  33. package/dist/contract.d.cts +4 -4
  34. package/dist/contract.d.mts +4 -4
  35. package/dist/contract.mjs.map +1 -1
  36. package/dist/dsl/trigger.cjs +2 -1
  37. package/dist/dsl/trigger.cjs.map +1 -1
  38. package/dist/dsl/trigger.d.cts +6 -0
  39. package/dist/dsl/trigger.d.cts.map +1 -1
  40. package/dist/dsl/trigger.d.mts +6 -0
  41. package/dist/dsl/trigger.d.mts.map +1 -1
  42. package/dist/dsl/trigger.mjs +2 -1
  43. package/dist/dsl/trigger.mjs.map +1 -1
  44. package/dist/engine/condition.cjs +3 -3
  45. package/dist/engine/condition.cjs.map +1 -1
  46. package/dist/engine/condition.d.cts +3 -3
  47. package/dist/engine/condition.d.mts +3 -3
  48. package/dist/engine/condition.mjs +3 -3
  49. package/dist/engine/condition.mjs.map +1 -1
  50. package/dist/engine/interpreter.cjs +9 -5
  51. package/dist/engine/interpreter.cjs.map +1 -1
  52. package/dist/engine/interpreter.d.cts.map +1 -1
  53. package/dist/engine/interpreter.d.mts.map +1 -1
  54. package/dist/engine/interpreter.mjs +9 -5
  55. package/dist/engine/interpreter.mjs.map +1 -1
  56. package/dist/engine/ports.d.cts +2 -0
  57. package/dist/engine/ports.d.cts.map +1 -1
  58. package/dist/engine/ports.d.mts +2 -0
  59. package/dist/engine/ports.d.mts.map +1 -1
  60. package/dist/host/batch.cjs +172 -0
  61. package/dist/host/batch.cjs.map +1 -0
  62. package/dist/host/batch.d.cts +88 -0
  63. package/dist/host/batch.d.cts.map +1 -0
  64. package/dist/host/batch.d.mts +88 -0
  65. package/dist/host/batch.d.mts.map +1 -0
  66. package/dist/host/batch.mjs +163 -0
  67. package/dist/host/batch.mjs.map +1 -0
  68. package/dist/host/execute.cjs +10 -6
  69. package/dist/host/execute.cjs.map +1 -1
  70. package/dist/host/execute.mjs +10 -6
  71. package/dist/host/execute.mjs.map +1 -1
  72. package/dist/host/http.cjs +30 -89
  73. package/dist/host/http.cjs.map +1 -1
  74. package/dist/host/http.d.cts +1 -1
  75. package/dist/host/http.d.cts.map +1 -1
  76. package/dist/host/http.d.mts +1 -1
  77. package/dist/host/http.d.mts.map +1 -1
  78. package/dist/host/http.mjs +24 -83
  79. package/dist/host/http.mjs.map +1 -1
  80. package/dist/host/ingest.cjs +56 -13
  81. package/dist/host/ingest.cjs.map +1 -1
  82. package/dist/host/ingest.d.cts +17 -6
  83. package/dist/host/ingest.d.cts.map +1 -1
  84. package/dist/host/ingest.d.mts +17 -6
  85. package/dist/host/ingest.d.mts.map +1 -1
  86. package/dist/host/ingest.mjs +55 -14
  87. package/dist/host/ingest.mjs.map +1 -1
  88. package/dist/host/journey-host.cjs +6 -0
  89. package/dist/host/journey-host.cjs.map +1 -1
  90. package/dist/host/journey-host.d.cts +5 -2
  91. package/dist/host/journey-host.d.cts.map +1 -1
  92. package/dist/host/journey-host.d.mts +5 -2
  93. package/dist/host/journey-host.d.mts.map +1 -1
  94. package/dist/host/journey-host.mjs +6 -0
  95. package/dist/host/journey-host.mjs.map +1 -1
  96. package/dist/host/platform.cjs.map +1 -1
  97. package/dist/host/platform.d.cts +2 -0
  98. package/dist/host/platform.d.cts.map +1 -1
  99. package/dist/host/platform.d.mts +2 -0
  100. package/dist/host/platform.d.mts.map +1 -1
  101. package/dist/host/platform.mjs.map +1 -1
  102. package/dist/ir.cjs +2 -1
  103. package/dist/ir.cjs.map +1 -1
  104. package/dist/ir.d.cts +3 -0
  105. package/dist/ir.d.cts.map +1 -1
  106. package/dist/ir.d.mts +3 -0
  107. package/dist/ir.d.mts.map +1 -1
  108. package/dist/ir.mjs +2 -1
  109. package/dist/ir.mjs.map +1 -1
  110. package/dist/store/facts.cjs +13 -9
  111. package/dist/store/facts.cjs.map +1 -1
  112. package/dist/store/facts.d.cts +6 -2
  113. package/dist/store/facts.d.cts.map +1 -1
  114. package/dist/store/facts.d.mts +6 -2
  115. package/dist/store/facts.d.mts.map +1 -1
  116. package/dist/store/facts.mjs +13 -9
  117. package/dist/store/facts.mjs.map +1 -1
  118. package/dist/store/identity.cjs +34 -0
  119. package/dist/store/identity.cjs.map +1 -0
  120. package/dist/store/identity.d.cts +13 -0
  121. package/dist/store/identity.d.cts.map +1 -0
  122. package/dist/store/identity.d.mts +13 -0
  123. package/dist/store/identity.d.mts.map +1 -0
  124. package/dist/store/identity.mjs +30 -0
  125. package/dist/store/identity.mjs.map +1 -0
  126. package/dist/store/index.cjs +5 -0
  127. package/dist/store/index.cjs.map +1 -1
  128. package/dist/store/index.d.cts +49 -15
  129. package/dist/store/index.d.cts.map +1 -1
  130. package/dist/store/index.d.mts +49 -15
  131. package/dist/store/index.d.mts.map +1 -1
  132. package/dist/store/index.mjs +2 -1
  133. package/dist/store/index.mjs.map +1 -1
  134. package/dist/store/postgres.cjs +206 -44
  135. package/dist/store/postgres.cjs.map +1 -1
  136. package/dist/store/postgres.d.cts +17 -4
  137. package/dist/store/postgres.d.cts.map +1 -1
  138. package/dist/store/postgres.d.mts +17 -4
  139. package/dist/store/postgres.d.mts.map +1 -1
  140. package/dist/store/postgres.mjs +207 -45
  141. package/dist/store/postgres.mjs.map +1 -1
  142. package/dist/store/tables.cjs +24 -6
  143. package/dist/store/tables.cjs.map +1 -1
  144. package/dist/store/tables.d.cts +1 -0
  145. package/dist/store/tables.d.cts.map +1 -1
  146. package/dist/store/tables.d.mts +1 -0
  147. package/dist/store/tables.d.mts.map +1 -1
  148. package/dist/store/tables.mjs +24 -6
  149. package/dist/store/tables.mjs.map +1 -1
  150. package/dist/webpush.cjs.map +1 -1
  151. package/dist/webpush.d.cts +1 -1
  152. package/dist/webpush.d.mts +1 -1
  153. package/dist/webpush.mjs.map +1 -1
  154. package/package.json +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"contract.mjs","names":[],"sources":["../src/contract.ts"],"sourcesContent":["import * as z from 'zod/mini';\nimport type * as core from 'zod/v4/core';\nimport { type EventRefs, type UserRefs, event, user } from './dsl';\n\n/**\n * `defineContract({ events, profile, subscriptions? })` — the producer/engine contract as Zod\n * schemas, written once in a package both sides import. From it:\n *\n * - `contract.events` / `contract.user` are the DSL's typed reference tables\n * (what `event<E>()` / `user<U>()` used to be, now derived from the schemas);\n * - `contract.topics` are the subscription topics: every email and push template names one (or\n * is transactional), a user opts out of one at a time per channel, and the compile refuses a\n * topic the contract does not declare;\n * - `JourneyClient({ contract })` types `track` / `identify` by event name and\n * checks payloads before sending, and `getSubscriptions` / `setSubscriptions` by topic;\n * - `journeyWorker({ contract })` re-validates every inbound `/events`,\n * `/identify` and `/subscriptions` — never trusting the caller used the SDK — and refuses a bad\n * one with a 4xx before anything is stored, woken or started.\n *\n * Supported schemas (v1), synchronous and JSON-shaped: event payloads are\n * object schemas (`z.looseObject` keeps a producer's extra analytics fields,\n * `z.strictObject` refuses them) and may nest objects and arrays; the profile\n * is a flat object of string / number / boolean / enum / literal fields. Input\n * and output must mean the same thing, so defaults, transforms, coercion,\n * custom refinements and async checks are refused when the contract is\n * defined. The Zod instances stay in the host; nothing here changes the IR.\n */\n\ntype ObjectSchema = core.$ZodObject;\n\n/** The declared fields of an object schema — without a loose object's `[key: string]: unknown`, so the DSL only offers fields that were written down. */\nexport type DeclaredOutput<S> =\n S extends core.$ZodObject<infer Shape>\n ? core.$InferObjectOutput<Shape, core.$strict['out']>\n : never;\n\n/**\n * The subscription topics a producer and the engine agree on: content categories ('offers',\n * 'tips'), not channels — an email or push template declares one (`emailTemplate({ topic })`,\n * `pushTemplate({ topic })`), and a user opts out of a topic per channel (the preference page is\n * topic × channel). Ids only — display names belong to the app's i18n. A plain array literal\n * is enough: `defineContract` keeps the literals (no `as const` needed), and the union they form\n * types the templates, the config and `JourneyClient.setSubscriptions`.\n */\nexport interface SubscriptionsDefinition<T extends string> {\n topics: readonly T[];\n}\n\nexport interface ContractDefinition<\n E extends Record<string, ObjectSchema>,\n P extends ObjectSchema,\n T extends string = never,\n> {\n events: E;\n profile: P;\n /** The topics, e.g. `{ topics: ['promotions', 'newsletter'] }`; none without it (transactional email and push only). */\n subscriptions?: SubscriptionsDefinition<T>;\n}\n\n/** One rejected field, with the dotted path the producer can fix. */\nexport interface ContractIssue {\n path: string;\n message: string;\n}\n\nexport type ContractResult<T> = { ok: true; data: T } | { ok: false; issues: ContractIssue[] };\n\nexport interface Contract<\n E extends Record<string, ObjectSchema> = Record<string, ObjectSchema>,\n P extends ObjectSchema = ObjectSchema,\n T extends string = string,\n> {\n readonly kind: 'journey-contract';\n /** The declared subscription topics (`subscriptions.topics`), empty when none were declared. */\n readonly topics: readonly T[];\n isTopic(topic: string): topic is T;\n /** The DSL's event reference table: `e.purchase` — payload refs from the declared fields only. */\n readonly events: EventRefs<{ [K in keyof E]: DeclaredOutput<E[K]> }>;\n /** The DSL's user-property reference table: `u.name`, plus the system `u.userId`. */\n readonly user: UserRefs<DeclaredOutput<P> & { userId: string }>;\n /** The raw schemas, for hosts that need them (validation, docs). */\n readonly schemas: { readonly events: E; readonly profile: P };\n /** Every event name the contract declares — what a producer forwards. */\n readonly eventNames: readonly (keyof E & string)[];\n isEvent(name: string): name is keyof E & string;\n /** Validate one event's payload; the parsed value is what may be stored. */\n parseEvent<K extends keyof E & string>(\n name: K,\n payload: unknown\n ): ContractResult<core.output<E[K]>>;\n /**\n * Validate an `/identify` merge patch: omitted fields stay as they are,\n * `null` removes a field, every provided value is checked against its field\n * schema, and an undeclared field is refused — a patch, never a full parse\n * that would fill defaults over fields the caller did not send.\n */\n parseProfilePatch(patch: unknown): ContractResult<ProfilePatch<P>>;\n}\n\n/** An `/identify` patch over the profile: any subset of fields, `null` to remove one. */\nexport type ProfilePatch<P> = { [K in keyof DeclaredOutput<P>]?: DeclaredOutput<P>[K] | null };\n\n/** Payload type of one event, for producers: `EventPayload<typeof contract, 'purchase'>`. */\nexport type EventPayload<C, K extends string> =\n C extends Contract<infer E> ? (K extends keyof E ? core.input<E[K]> : never) : never;\n\n/** The topics a contract declares, as a union: `Topic<typeof contract>`; `never` without any. */\nexport type Topic<C> =\n C extends Contract<Record<string, ObjectSchema>, ObjectSchema, infer T> ? T : never;\n\n/** A topic id: what goes in a URL, a JSON key and a database column, so no spaces or specials. */\nconst TOPIC_ID = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;\n\nexport function defineContract<\n E extends Record<string, ObjectSchema>,\n P extends ObjectSchema,\n const T extends string = never,\n>(definition: ContractDefinition<E, P, T>): Contract<E, P, T> {\n const topics = [...(definition.subscriptions?.topics ?? [])];\n const seen = new Set<string>();\n for (const topic of topics) {\n if (typeof topic !== 'string' || !TOPIC_ID.test(topic)) {\n throw new TypeError(\n `contract subscriptions.topics: '${topic as string}' is not a topic id (letters, digits, '_', '-' and '.', starting with a letter or digit)`\n );\n }\n if (seen.has(topic))\n throw new TypeError(`contract subscriptions.topics lists '${topic}' twice`);\n seen.add(topic);\n }\n for (const [name, schema] of Object.entries(definition.events)) {\n if (name.startsWith('$')) {\n throw new TypeError(`contract event '${name}': names starting with $ are reserved`);\n }\n assertJsonSchema(schema, `event '${name}'`, true);\n }\n assertJsonSchema(definition.profile, 'profile', true);\n const profileShape = (definition.profile._zod.def as core.$ZodObjectDef).shape;\n for (const [field, schema] of Object.entries(profileShape)) {\n assertFlatScalar(schema, `profile.${field}`);\n }\n const strictProfile = definition.profile._zod.def.catchall?._zod.def.type === 'never';\n\n const names = Object.keys(definition.events) as (keyof E & string)[];\n return {\n kind: 'journey-contract',\n events: event() as never,\n user: user() as never,\n schemas: { events: definition.events, profile: definition.profile },\n eventNames: names,\n topics,\n isTopic: (topic): topic is T => seen.has(topic),\n isEvent: (name): name is keyof E & string => Object.hasOwn(definition.events, name),\n parseEvent(name, payload) {\n if (!Object.hasOwn(definition.events, name)) {\n return { ok: false, issues: [{ path: '', message: `unknown event '${name}'` }] };\n }\n return toResult(z.safeParse(definition.events[name], payload));\n },\n parseProfilePatch(patch) {\n if (typeof patch !== 'object' || patch === null || Array.isArray(patch)) {\n return { ok: false, issues: [{ path: '', message: 'props must be an object' }] };\n }\n const issues: ContractIssue[] = [];\n const data: Record<string, unknown> = {};\n for (const [field, value] of Object.entries(patch)) {\n if (!Object.hasOwn(profileShape, field)) {\n // userId is the request's identity, not a profile field\n if (strictProfile || field === 'userId') {\n issues.push({ path: field, message: 'not a declared profile field' });\n } else {\n data[field] = value;\n }\n continue;\n }\n if (value === null) {\n data[field] = null;\n continue;\n }\n const result = z.safeParse(profileShape[field], value);\n if (result.success) data[field] = result.data;\n else issues.push(...prefixed(field, result.error));\n }\n return issues.length > 0 ? { ok: false, issues } : { ok: true, data: data as never };\n },\n };\n}\n\n/**\n * An event the contract refuses — raised for events a journey emits itself\n * (`.sendEvent()`), which never pass the HTTP ingest: they go through the same\n * check, and a refusal is deterministic, so it is not retried.\n */\nexport class ContractValidationError extends Error {\n override readonly name = 'ContractValidationError';\n constructor(\n readonly event: string,\n readonly issues: ContractIssue[]\n ) {\n super(\n `event '${event}' refused by the contract: ${issues\n .map((i) => `${i.path || '(root)'} ${i.message}`)\n .join('; ')}`\n );\n }\n}\n\n/** The one check every event passes, from HTTP or from a journey: declared, and its payload parses. */\nexport function checkEvent(\n contract: Contract,\n name: string,\n payload: unknown\n): ContractResult<Record<string, unknown>> {\n if (!contract.isEvent(name)) {\n return {\n ok: false,\n issues: [{ path: '', message: `'${String(name)}' is not in the contract` }],\n };\n }\n return contract.parseEvent(name, payload);\n}\n\nexport function isContract(value: unknown): value is Contract {\n return (\n typeof value === 'object' &&\n value !== null &&\n (value as { kind?: unknown }).kind === 'journey-contract'\n );\n}\n\nfunction toResult<T>(result: core.util.SafeParseResult<T>): ContractResult<T> {\n return result.success\n ? { ok: true, data: result.data }\n : { ok: false, issues: prefixed('', result.error) };\n}\n\nfunction prefixed(prefix: string, error: core.$ZodError): ContractIssue[] {\n return error.issues.map((issue) => ({\n path: [prefix, ...issue.path.map(String)].filter((p) => p !== '').join('.'),\n message: issue.message,\n }));\n}\n\n/* ---------------------------- supported schemas ---------------------------- */\n\n/**\n * The schema types a contract may use — JSON shapes that parse synchronously\n * and mean the same before and after parsing. Anything not listed (default,\n * transform, pipe, lazy, promise, custom, date, bigint, …) is refused, and\n * every container is walked so nothing unsupported hides inside one.\n */\nconst ALLOWED = new Set([\n 'object',\n 'array',\n 'tuple',\n 'union',\n 'intersection',\n 'record',\n 'optional',\n 'nullable',\n 'readonly',\n 'nonoptional',\n 'string',\n 'number',\n 'boolean',\n 'null',\n 'enum',\n 'literal',\n 'unknown',\n 'any',\n 'never',\n]);\n\n/** Checks that change the value (`overwrite`: `.trim()`, `.toLowerCase()`, …) or run arbitrary code (`custom`: `.refine()`, async or not). */\nconst REFUSED_CHECKS = new Set(['custom', 'overwrite']);\n\nfunction assertJsonSchema(schema: unknown, where: string, top = false): void {\n const def = (schema as { _zod?: { def?: core.$ZodTypeDef } } | null | undefined)?._zod?.def;\n if (def === undefined) throw new TypeError(`contract ${where}: not a Zod schema`);\n if (top && def.type !== 'object') {\n throw new TypeError(\n `contract ${where}: must be an object schema (z.looseObject / z.strictObject)`\n );\n }\n if (!ALLOWED.has(def.type)) {\n throw new TypeError(\n `contract ${where}: ${def.type} schemas are not supported (input and output must mean the same JSON, parsed synchronously)`\n );\n }\n if ((def as { coerce?: boolean }).coerce === true) {\n throw new TypeError(`contract ${where}: coercion (z.coerce) is not supported`);\n }\n for (const check of def.checks ?? []) {\n if (REFUSED_CHECKS.has(check._zod.def.check)) {\n throw new TypeError(\n `contract ${where}: ${check._zod.def.check === 'overwrite' ? 'value-changing checks (.overwrite, .trim, …)' : 'custom refinements'} are not supported`\n );\n }\n }\n const children: [string, unknown][] = [];\n switch (def.type) {\n case 'object': {\n const object = def as core.$ZodObjectDef;\n for (const [k, v] of Object.entries(object.shape)) children.push([`${where}.${k}`, v]);\n if (object.catchall !== undefined) children.push([`${where}.*`, object.catchall]);\n break;\n }\n case 'optional':\n case 'nullable':\n case 'readonly':\n case 'nonoptional':\n children.push([where, (def as unknown as { innerType: unknown }).innerType]);\n break;\n case 'array':\n children.push([`${where}[]`, (def as core.$ZodArrayDef).element]);\n break;\n case 'tuple': {\n const tuple = def as core.$ZodTupleDef;\n tuple.items.forEach((item, index) => children.push([`${where}[${index}]`, item]));\n if (tuple.rest !== null) children.push([`${where}[…]`, tuple.rest]);\n break;\n }\n case 'union':\n for (const option of (def as core.$ZodUnionDef).options) children.push([where, option]);\n break;\n case 'intersection': {\n const intersection = def as core.$ZodIntersectionDef;\n children.push([where, intersection.left], [where, intersection.right]);\n break;\n }\n case 'record': {\n const record = def as core.$ZodRecordDef;\n children.push([`${where}{key}`, record.keyType], [`${where}{}`, record.valueType]);\n break;\n }\n default:\n break;\n }\n for (const [path, child] of children) assertJsonSchema(child, path);\n}\n\nconst SCALARS = new Set(['string', 'number', 'boolean', 'enum', 'literal']);\n\nfunction assertFlatScalar(schema: unknown, where: string): void {\n let def = (schema as core.$ZodType)._zod.def;\n if (def.type === 'optional') def = (def as core.$ZodOptionalDef).innerType._zod.def;\n if (!SCALARS.has(def.type)) {\n throw new TypeError(\n `contract ${where}: profile fields must be flat scalars (string, number, boolean, enum, literal), got ${def.type}`\n );\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"contract.mjs","names":[],"sources":["../src/contract.ts"],"sourcesContent":["import * as z from 'zod/mini';\nimport type * as core from 'zod/v4/core';\nimport { type EventRefs, type UserRefs, event, user } from './dsl';\n\n/**\n * `defineContract({ events, profile, subscriptions? })` — the producer/engine contract as Zod\n * schemas, written once in a package both sides import. From it:\n *\n * - `contract.events` / `contract.user` are the DSL's typed reference tables\n * (what `event<E>()` / `user<U>()` used to be, now derived from the schemas);\n * - `contract.topics` are the subscription topics: every email and push template names one (or\n * is transactional), a user opts out of one at a time per channel, and the compile refuses a\n * topic the contract does not declare;\n * - `JourneyClient({ contract })` types `track` / `identify` by event name and\n * checks payloads before sending, and `getSubscriptions` / `setSubscriptions` by topic;\n * - `journeyWorker({ contract })` re-validates every inbound `/ingest`\n * and `/subscriptions` — never trusting the caller used the SDK — and refuses a bad\n * one with a 4xx before anything is stored, woken or started.\n *\n * Supported schemas (v1), synchronous and JSON-shaped: event payloads are\n * object schemas (`z.looseObject` keeps a producer's extra analytics fields,\n * `z.strictObject` refuses them) and may nest objects and arrays; the profile\n * is a flat object of string / number / boolean / enum / literal fields. Input\n * and output must mean the same thing, so defaults, transforms, coercion,\n * custom refinements and async checks are refused when the contract is\n * defined. The Zod instances stay in the host; nothing here changes the IR.\n */\n\ntype ObjectSchema = core.$ZodObject;\n\n/** The declared fields of an object schema — without a loose object's `[key: string]: unknown`, so the DSL only offers fields that were written down. */\nexport type DeclaredOutput<S> =\n S extends core.$ZodObject<infer Shape>\n ? core.$InferObjectOutput<Shape, core.$strict['out']>\n : never;\n\n/**\n * The subscription topics a producer and the engine agree on: content categories ('offers',\n * 'tips'), not channels — an email or push template declares one (`emailTemplate({ topic })`,\n * `pushTemplate({ topic })`), and a user opts out of a topic per channel (the preference page is\n * topic × channel). Ids only — display names belong to the app's i18n. A plain array literal\n * is enough: `defineContract` keeps the literals (no `as const` needed), and the union they form\n * types the templates, the config and `JourneyClient.setSubscriptions`.\n */\nexport interface SubscriptionsDefinition<T extends string> {\n topics: readonly T[];\n}\n\nexport interface ContractDefinition<\n E extends Record<string, ObjectSchema>,\n P extends ObjectSchema,\n T extends string = never,\n> {\n events: E;\n profile: P;\n /** The topics, e.g. `{ topics: ['promotions', 'newsletter'] }`; none without it (transactional email and push only). */\n subscriptions?: SubscriptionsDefinition<T>;\n}\n\n/** One rejected field, with the dotted path the producer can fix. */\nexport interface ContractIssue {\n path: string;\n message: string;\n}\n\nexport type ContractResult<T> = { ok: true; data: T } | { ok: false; issues: ContractIssue[] };\n\nexport interface Contract<\n E extends Record<string, ObjectSchema> = Record<string, ObjectSchema>,\n P extends ObjectSchema = ObjectSchema,\n T extends string = string,\n> {\n readonly kind: 'journey-contract';\n /** The declared subscription topics (`subscriptions.topics`), empty when none were declared. */\n readonly topics: readonly T[];\n isTopic(topic: string): topic is T;\n /** The DSL's event reference table: `e.purchase` — payload refs from the declared fields only. */\n readonly events: EventRefs<{ [K in keyof E]: DeclaredOutput<E[K]> }>;\n /** The DSL's user-property reference table: `u.name`, plus the system `u.userId`. */\n readonly user: UserRefs<DeclaredOutput<P> & { userId: string }>;\n /** The raw schemas, for hosts that need them (validation, docs). */\n readonly schemas: { readonly events: E; readonly profile: P };\n /** Every event name the contract declares — what a producer forwards. */\n readonly eventNames: readonly (keyof E & string)[];\n isEvent(name: string): name is keyof E & string;\n /** Validate one event's payload; the parsed value is what may be stored. */\n parseEvent<K extends keyof E & string>(\n name: K,\n payload: unknown\n ): ContractResult<core.output<E[K]>>;\n /**\n * Validate a profile merge patch: omitted fields stay as they are,\n * `null` removes a field, every provided value is checked against its field\n * schema, and an undeclared field is refused — a patch, never a full parse\n * that would fill defaults over fields the caller did not send.\n */\n parseProfilePatch(patch: unknown): ContractResult<ProfilePatch<P>>;\n}\n\n/** A patch over the profile: any subset of fields, `null` to remove one. */\nexport type ProfilePatch<P> = { [K in keyof DeclaredOutput<P>]?: DeclaredOutput<P>[K] | null };\n\n/** Payload type of one event, for producers: `EventPayload<typeof contract, 'purchase'>`. */\nexport type EventPayload<C, K extends string> =\n C extends Contract<infer E> ? (K extends keyof E ? core.input<E[K]> : never) : never;\n\n/** The topics a contract declares, as a union: `Topic<typeof contract>`; `never` without any. */\nexport type Topic<C> =\n C extends Contract<Record<string, ObjectSchema>, ObjectSchema, infer T> ? T : never;\n\n/** A topic id: what goes in a URL, a JSON key and a database column, so no spaces or specials. */\nconst TOPIC_ID = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;\n\nexport function defineContract<\n E extends Record<string, ObjectSchema>,\n P extends ObjectSchema,\n const T extends string = never,\n>(definition: ContractDefinition<E, P, T>): Contract<E, P, T> {\n const topics = [...(definition.subscriptions?.topics ?? [])];\n const seen = new Set<string>();\n for (const topic of topics) {\n if (typeof topic !== 'string' || !TOPIC_ID.test(topic)) {\n throw new TypeError(\n `contract subscriptions.topics: '${topic as string}' is not a topic id (letters, digits, '_', '-' and '.', starting with a letter or digit)`\n );\n }\n if (seen.has(topic))\n throw new TypeError(`contract subscriptions.topics lists '${topic}' twice`);\n seen.add(topic);\n }\n for (const [name, schema] of Object.entries(definition.events)) {\n if (name.startsWith('$')) {\n throw new TypeError(`contract event '${name}': names starting with $ are reserved`);\n }\n assertJsonSchema(schema, `event '${name}'`, true);\n }\n assertJsonSchema(definition.profile, 'profile', true);\n const profileShape = (definition.profile._zod.def as core.$ZodObjectDef).shape;\n for (const [field, schema] of Object.entries(profileShape)) {\n assertFlatScalar(schema, `profile.${field}`);\n }\n const strictProfile = definition.profile._zod.def.catchall?._zod.def.type === 'never';\n\n const names = Object.keys(definition.events) as (keyof E & string)[];\n return {\n kind: 'journey-contract',\n events: event() as never,\n user: user() as never,\n schemas: { events: definition.events, profile: definition.profile },\n eventNames: names,\n topics,\n isTopic: (topic): topic is T => seen.has(topic),\n isEvent: (name): name is keyof E & string => Object.hasOwn(definition.events, name),\n parseEvent(name, payload) {\n if (!Object.hasOwn(definition.events, name)) {\n return { ok: false, issues: [{ path: '', message: `unknown event '${name}'` }] };\n }\n return toResult(z.safeParse(definition.events[name], payload));\n },\n parseProfilePatch(patch) {\n if (typeof patch !== 'object' || patch === null || Array.isArray(patch)) {\n return { ok: false, issues: [{ path: '', message: 'props must be an object' }] };\n }\n const issues: ContractIssue[] = [];\n const data: Record<string, unknown> = {};\n for (const [field, value] of Object.entries(patch)) {\n if (!Object.hasOwn(profileShape, field)) {\n // userId is the request's identity, not a profile field\n if (strictProfile || field === 'userId') {\n issues.push({ path: field, message: 'not a declared profile field' });\n } else {\n data[field] = value;\n }\n continue;\n }\n if (value === null) {\n data[field] = null;\n continue;\n }\n const result = z.safeParse(profileShape[field], value);\n if (result.success) data[field] = result.data;\n else issues.push(...prefixed(field, result.error));\n }\n return issues.length > 0 ? { ok: false, issues } : { ok: true, data: data as never };\n },\n };\n}\n\n/**\n * An event the contract refuses — raised for events a journey emits itself\n * (`.sendEvent()`), which never pass the HTTP ingest: they go through the same\n * check, and a refusal is deterministic, so it is not retried.\n */\nexport class ContractValidationError extends Error {\n override readonly name = 'ContractValidationError';\n constructor(\n readonly event: string,\n readonly issues: ContractIssue[]\n ) {\n super(\n `event '${event}' refused by the contract: ${issues\n .map((i) => `${i.path || '(root)'} ${i.message}`)\n .join('; ')}`\n );\n }\n}\n\n/** The one check every event passes, from HTTP or from a journey: declared, and its payload parses. */\nexport function checkEvent(\n contract: Contract,\n name: string,\n payload: unknown\n): ContractResult<Record<string, unknown>> {\n if (!contract.isEvent(name)) {\n return {\n ok: false,\n issues: [{ path: '', message: `'${String(name)}' is not in the contract` }],\n };\n }\n return contract.parseEvent(name, payload);\n}\n\nexport function isContract(value: unknown): value is Contract {\n return (\n typeof value === 'object' &&\n value !== null &&\n (value as { kind?: unknown }).kind === 'journey-contract'\n );\n}\n\nfunction toResult<T>(result: core.util.SafeParseResult<T>): ContractResult<T> {\n return result.success\n ? { ok: true, data: result.data }\n : { ok: false, issues: prefixed('', result.error) };\n}\n\nfunction prefixed(prefix: string, error: core.$ZodError): ContractIssue[] {\n return error.issues.map((issue) => ({\n path: [prefix, ...issue.path.map(String)].filter((p) => p !== '').join('.'),\n message: issue.message,\n }));\n}\n\n/* ---------------------------- supported schemas ---------------------------- */\n\n/**\n * The schema types a contract may use — JSON shapes that parse synchronously\n * and mean the same before and after parsing. Anything not listed (default,\n * transform, pipe, lazy, promise, custom, date, bigint, …) is refused, and\n * every container is walked so nothing unsupported hides inside one.\n */\nconst ALLOWED = new Set([\n 'object',\n 'array',\n 'tuple',\n 'union',\n 'intersection',\n 'record',\n 'optional',\n 'nullable',\n 'readonly',\n 'nonoptional',\n 'string',\n 'number',\n 'boolean',\n 'null',\n 'enum',\n 'literal',\n 'unknown',\n 'any',\n 'never',\n]);\n\n/** Checks that change the value (`overwrite`: `.trim()`, `.toLowerCase()`, …) or run arbitrary code (`custom`: `.refine()`, async or not). */\nconst REFUSED_CHECKS = new Set(['custom', 'overwrite']);\n\nfunction assertJsonSchema(schema: unknown, where: string, top = false): void {\n const def = (schema as { _zod?: { def?: core.$ZodTypeDef } } | null | undefined)?._zod?.def;\n if (def === undefined) throw new TypeError(`contract ${where}: not a Zod schema`);\n if (top && def.type !== 'object') {\n throw new TypeError(\n `contract ${where}: must be an object schema (z.looseObject / z.strictObject)`\n );\n }\n if (!ALLOWED.has(def.type)) {\n throw new TypeError(\n `contract ${where}: ${def.type} schemas are not supported (input and output must mean the same JSON, parsed synchronously)`\n );\n }\n if ((def as { coerce?: boolean }).coerce === true) {\n throw new TypeError(`contract ${where}: coercion (z.coerce) is not supported`);\n }\n for (const check of def.checks ?? []) {\n if (REFUSED_CHECKS.has(check._zod.def.check)) {\n throw new TypeError(\n `contract ${where}: ${check._zod.def.check === 'overwrite' ? 'value-changing checks (.overwrite, .trim, …)' : 'custom refinements'} are not supported`\n );\n }\n }\n const children: [string, unknown][] = [];\n switch (def.type) {\n case 'object': {\n const object = def as core.$ZodObjectDef;\n for (const [k, v] of Object.entries(object.shape)) children.push([`${where}.${k}`, v]);\n if (object.catchall !== undefined) children.push([`${where}.*`, object.catchall]);\n break;\n }\n case 'optional':\n case 'nullable':\n case 'readonly':\n case 'nonoptional':\n children.push([where, (def as unknown as { innerType: unknown }).innerType]);\n break;\n case 'array':\n children.push([`${where}[]`, (def as core.$ZodArrayDef).element]);\n break;\n case 'tuple': {\n const tuple = def as core.$ZodTupleDef;\n tuple.items.forEach((item, index) => children.push([`${where}[${index}]`, item]));\n if (tuple.rest !== null) children.push([`${where}[…]`, tuple.rest]);\n break;\n }\n case 'union':\n for (const option of (def as core.$ZodUnionDef).options) children.push([where, option]);\n break;\n case 'intersection': {\n const intersection = def as core.$ZodIntersectionDef;\n children.push([where, intersection.left], [where, intersection.right]);\n break;\n }\n case 'record': {\n const record = def as core.$ZodRecordDef;\n children.push([`${where}{key}`, record.keyType], [`${where}{}`, record.valueType]);\n break;\n }\n default:\n break;\n }\n for (const [path, child] of children) assertJsonSchema(child, path);\n}\n\nconst SCALARS = new Set(['string', 'number', 'boolean', 'enum', 'literal']);\n\nfunction assertFlatScalar(schema: unknown, where: string): void {\n let def = (schema as core.$ZodType)._zod.def;\n if (def.type === 'optional') def = (def as core.$ZodOptionalDef).innerType._zod.def;\n if (!SCALARS.has(def.type)) {\n throw new TypeError(\n `contract ${where}: profile fields must be flat scalars (string, number, boolean, enum, literal), got ${def.type}`\n );\n }\n}\n"],"mappings":"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"}
@@ -21,7 +21,8 @@ const trigger = {
21
21
  type: "event",
22
22
  event: ev.name,
23
23
  ...where !== void 0 ? { where: require_dsl_condition.condIR(where(require_dsl_refs.payloadRefs())) } : {},
24
- ...opts?.filter !== void 0 ? { filter: require_dsl_condition.condIR(opts.filter) } : {}
24
+ ...opts?.filter !== void 0 ? { filter: require_dsl_condition.condIR(opts.filter) } : {},
25
+ ...opts?.visitors === true ? { visitors: true } : {}
25
26
  });
26
27
  },
27
28
  segment: (seg) => makeTrigger({
@@ -1 +1 @@
1
- {"version":3,"file":"trigger.cjs","names":["condIR","payloadRefs"],"sources":["../../src/dsl/trigger.ts"],"sourcesContent":["import type { TriggerIR } from '../ir';\nimport { type Condition, type PayloadWhere, type SegmentRef, condIR } from './condition';\nimport { type EventRef, payloadRefs } from './refs';\n\n/**\n * Triggers — the four ways into a workflow, mirroring customer.io's campaign\n * trigger types. A trigger is a plain reusable object; the event name and\n * payload types flow in from an e.xxx reference, so the factory itself needs\n * no generics.\n */\n\n/** 'YYYY-MM-DD HH:mm:ss'. TODO: validate at runtime — template literals cannot express leading zeros. */\nexport type DateTime = string;\n\n/** Trigger reference: what trigger.xxx() returns, reusable across workflows. */\nexport interface TriggerRef {\n readonly type: 'event' | 'segment' | 'date' | 'webhook';\n}\n\n/** @internal the compiled shape behind a TriggerRef — workflow.toIR reads .ir off it */\nexport interface TriggerInternal extends TriggerRef {\n readonly ir: TriggerIR;\n}\n\nconst makeTrigger = (ir: TriggerIR): TriggerRef => {\n const impl: TriggerInternal = { type: ir.type, ir };\n return impl;\n};\n\nexport interface EventTriggerOptions {\n filter?: Condition;\n}\n\n/** Trigger factory: four ways in, mirroring customer.io's campaign trigger types. */\nexport interface TriggerFactory {\n /**\n * Event trigger: the event name comes from an e.xxx reference (types flow in\n * from it, so the factory itself needs no generics). Two independent gates:\n * - the optional second argument is a payload where clause (typed refs, the\n * ordinary predicates — compiles to IR data the UI renders):\n * trigger.event(e.login, (p) => eq(p.platform, 'web'))\n * - `filter` tests the user's profile (customer.io's trigger Filters).\n * The user enters only if the event fired *and* both gates hold.\n */\n event<P>(event: EventRef<P>, opts?: EventTriggerOptions): TriggerRef;\n event<P>(event: EventRef<P>, where: PayloadWhere<P>, opts?: EventTriggerOptions): TriggerRef;\n\n /**\n * Segment-entry trigger: the user enters the moment they go from not\n * matching to matching. Only a named segment is accepted — entry semantics\n * need a membership table, which an anonymous expression cannot provide.\n * Runtime note: membership is maintained lazily (re-evaluated on the user's\n * ingest/identify activity), so purely time-driven drift — e.g. an\n * inactivity segment becoming true by clock alone — is only observed on the\n * user's next activity. TODO: a cron sweep for time-driven segments.\n */\n segment(segment: SegmentRef): TriggerRef;\n\n /**\n * Scheduled trigger (one-shot, e.g. '2026-12-25 09:00:00', interpreted as\n * UTC). TODO: not routed by the runtime yet — deployBundle reports it in\n * `unrouted`; the implementation goes through cron + Queues (instance\n * creation is rate-limited, a date trigger fans out to the whole audience at\n * once). TODO: cron/recurring form.\n */\n date(at: DateTime): TriggerRef;\n\n /** Webhook trigger: third-party or external code fires it via a URL; the runtime allocates the endpoint. */\n webhook(): TriggerRef;\n}\n\n/**\n * Trigger definition: a plain object — event names come from e.xxx references.\n *\n * const login = trigger.event(e.login, { filter: newUsers7d });\n */\nexport const trigger: TriggerFactory = {\n event: <P>(\n ev: EventRef<P>,\n whereOrOpts?: PayloadWhere<P> | EventTriggerOptions,\n maybeOpts?: EventTriggerOptions\n ) => {\n const where = typeof whereOrOpts === 'function' ? whereOrOpts : undefined;\n const opts = typeof whereOrOpts === 'function' ? maybeOpts : whereOrOpts;\n return makeTrigger({\n type: 'event',\n event: ev.name,\n ...(where !== undefined ? { where: condIR(where(payloadRefs<P>())) } : {}),\n ...(opts?.filter !== undefined ? { filter: condIR(opts.filter) } : {}),\n });\n },\n segment: (seg) => makeTrigger({ type: 'segment', segment: seg.id }),\n date: (at) => makeTrigger({ type: 'date', at }),\n webhook: () => makeTrigger({ type: 'webhook' }),\n};\n"],"mappings":";;;;AAwBA,MAAM,eAAe,OAA8B;CAEjD,OAAO;EADyB,MAAM,GAAG;EAAM;CACrC;AACZ;;;;;;AAiDA,MAAa,UAA0B;CACrC,QACE,IACA,aACA,cACG;EACH,MAAM,QAAQ,OAAO,gBAAgB,aAAa,cAAc,KAAA;EAChE,MAAM,OAAO,OAAO,gBAAgB,aAAa,YAAY;EAC7D,OAAO,YAAY;GACjB,MAAM;GACN,OAAO,GAAG;GACV,GAAI,UAAU,KAAA,IAAY,EAAE,OAAOA,sBAAAA,OAAO,MAAMC,iBAAAA,YAAe,CAAC,CAAC,EAAE,IAAI,CAAC;GACxE,GAAI,MAAM,WAAW,KAAA,IAAY,EAAE,QAAQD,sBAAAA,OAAO,KAAK,MAAM,EAAE,IAAI,CAAC;EACtE,CAAC;CACH;CACA,UAAU,QAAQ,YAAY;EAAE,MAAM;EAAW,SAAS,IAAI;CAAG,CAAC;CAClE,OAAO,OAAO,YAAY;EAAE,MAAM;EAAQ;CAAG,CAAC;CAC9C,eAAe,YAAY,EAAE,MAAM,UAAU,CAAC;AAChD"}
1
+ {"version":3,"file":"trigger.cjs","names":["condIR","payloadRefs"],"sources":["../../src/dsl/trigger.ts"],"sourcesContent":["import type { TriggerIR } from '../ir';\nimport { type Condition, type PayloadWhere, type SegmentRef, condIR } from './condition';\nimport { type EventRef, payloadRefs } from './refs';\n\n/**\n * Triggers — the four ways into a workflow, mirroring customer.io's campaign\n * trigger types. A trigger is a plain reusable object; the event name and\n * payload types flow in from an e.xxx reference, so the factory itself needs\n * no generics.\n */\n\n/** 'YYYY-MM-DD HH:mm:ss'. TODO: validate at runtime — template literals cannot express leading zeros. */\nexport type DateTime = string;\n\n/** Trigger reference: what trigger.xxx() returns, reusable across workflows. */\nexport interface TriggerRef {\n readonly type: 'event' | 'segment' | 'date' | 'webhook';\n}\n\n/** @internal the compiled shape behind a TriggerRef — workflow.toIR reads .ir off it */\nexport interface TriggerInternal extends TriggerRef {\n readonly ir: TriggerIR;\n}\n\nconst makeTrigger = (ir: TriggerIR): TriggerRef => {\n const impl: TriggerInternal = { type: ir.type, ir };\n return impl;\n};\n\nexport interface EventTriggerOptions {\n filter?: Condition;\n /**\n * Let a visitor who has not signed in enter: such a journey reaches them by push, having no\n * address to email. Off by default, because a user enters a workflow once: an entry made as a\n * visitor is still theirs after signing in.\n */\n visitors?: boolean;\n}\n\n/** Trigger factory: four ways in, mirroring customer.io's campaign trigger types. */\nexport interface TriggerFactory {\n /**\n * Event trigger: the event name comes from an e.xxx reference (types flow in\n * from it, so the factory itself needs no generics). Two independent gates:\n * - the optional second argument is a payload where clause (typed refs, the\n * ordinary predicates — compiles to IR data the UI renders):\n * trigger.event(e.login, (p) => eq(p.platform, 'web'))\n * - `filter` tests the user's profile (customer.io's trigger Filters).\n * The user enters only if the event fired *and* both gates hold.\n */\n event<P>(event: EventRef<P>, opts?: EventTriggerOptions): TriggerRef;\n event<P>(event: EventRef<P>, where: PayloadWhere<P>, opts?: EventTriggerOptions): TriggerRef;\n\n /**\n * Segment-entry trigger: the user enters the moment they go from not\n * matching to matching. Only a named segment is accepted — entry semantics\n * need a membership table, which an anonymous expression cannot provide.\n * Runtime note: membership is maintained lazily (re-evaluated on the user's\n * ingest/identify activity), so purely time-driven drift — e.g. an\n * inactivity segment becoming true by clock alone — is only observed on the\n * user's next activity. TODO: a cron sweep for time-driven segments.\n */\n segment(segment: SegmentRef): TriggerRef;\n\n /**\n * Scheduled trigger (one-shot, e.g. '2026-12-25 09:00:00', interpreted as\n * UTC). TODO: not routed by the runtime yet — deployBundle reports it in\n * `unrouted`; the implementation goes through cron + Queues (instance\n * creation is rate-limited, a date trigger fans out to the whole audience at\n * once). TODO: cron/recurring form.\n */\n date(at: DateTime): TriggerRef;\n\n /** Webhook trigger: third-party or external code fires it via a URL; the runtime allocates the endpoint. */\n webhook(): TriggerRef;\n}\n\n/**\n * Trigger definition: a plain object — event names come from e.xxx references.\n *\n * const login = trigger.event(e.login, { filter: newUsers7d });\n */\nexport const trigger: TriggerFactory = {\n event: <P>(\n ev: EventRef<P>,\n whereOrOpts?: PayloadWhere<P> | EventTriggerOptions,\n maybeOpts?: EventTriggerOptions\n ) => {\n const where = typeof whereOrOpts === 'function' ? whereOrOpts : undefined;\n const opts = typeof whereOrOpts === 'function' ? maybeOpts : whereOrOpts;\n return makeTrigger({\n type: 'event',\n event: ev.name,\n ...(where !== undefined ? { where: condIR(where(payloadRefs<P>())) } : {}),\n ...(opts?.filter !== undefined ? { filter: condIR(opts.filter) } : {}),\n ...(opts?.visitors === true ? { visitors: true } : {}),\n });\n },\n segment: (seg) => makeTrigger({ type: 'segment', segment: seg.id }),\n date: (at) => makeTrigger({ type: 'date', at }),\n webhook: () => makeTrigger({ type: 'webhook' }),\n};\n"],"mappings":";;;;AAwBA,MAAM,eAAe,OAA8B;CAEjD,OAAO;EADyB,MAAM,GAAG;EAAM;CACrC;AACZ;;;;;;AAuDA,MAAa,UAA0B;CACrC,QACE,IACA,aACA,cACG;EACH,MAAM,QAAQ,OAAO,gBAAgB,aAAa,cAAc,KAAA;EAChE,MAAM,OAAO,OAAO,gBAAgB,aAAa,YAAY;EAC7D,OAAO,YAAY;GACjB,MAAM;GACN,OAAO,GAAG;GACV,GAAI,UAAU,KAAA,IAAY,EAAE,OAAOA,sBAAAA,OAAO,MAAMC,iBAAAA,YAAe,CAAC,CAAC,EAAE,IAAI,CAAC;GACxE,GAAI,MAAM,WAAW,KAAA,IAAY,EAAE,QAAQD,sBAAAA,OAAO,KAAK,MAAM,EAAE,IAAI,CAAC;GACpE,GAAI,MAAM,aAAa,OAAO,EAAE,UAAU,KAAK,IAAI,CAAC;EACtD,CAAC;CACH;CACA,UAAU,QAAQ,YAAY;EAAE,MAAM;EAAW,SAAS,IAAI;CAAG,CAAC;CAClE,OAAO,OAAO,YAAY;EAAE,MAAM;EAAQ;CAAG,CAAC;CAC9C,eAAe,YAAY,EAAE,MAAM,UAAU,CAAC;AAChD"}
@@ -19,6 +19,12 @@ export interface TriggerInternal extends TriggerRef {
19
19
  }
20
20
  export interface EventTriggerOptions {
21
21
  filter?: Condition;
22
+ /**
23
+ * Let a visitor who has not signed in enter: such a journey reaches them by push, having no
24
+ * address to email. Off by default, because a user enters a workflow once: an entry made as a
25
+ * visitor is still theirs after signing in.
26
+ */
27
+ visitors?: boolean;
22
28
  }
23
29
  /** Trigger factory: four ways in, mirroring customer.io's campaign trigger types. */
24
30
  export interface TriggerFactory {
@@ -1 +1 @@
1
- {"version":3,"file":"trigger.d.cts","names":[],"sources":["../../src/dsl/trigger.ts"],"mappings":";;;;;;;;;;YAYY;;iBAGK;WACN;;;iBAIM,wBAAwB;WAC9B,IAAI;;iBAQE;EACf,SAAS;;;iBAIM;;;;;;;;;;EAUf,MAAM,GAAG,OAAO,SAAS,IAAI,OAAO,sBAAsB;EAC1D,MAAM,GAAG,OAAO,SAAS,IAAI,OAAO,aAAa,IAAI,OAAO,sBAAsB;;;;;;;;;;EAWlF,QAAQ,SAAS,aAAa;;;;;;;;EAS9B,KAAK,IAAI,WAAW;;EAGpB,WAAW;;;;;;;qBAQA,SAAS"}
1
+ {"version":3,"file":"trigger.d.cts","names":[],"sources":["../../src/dsl/trigger.ts"],"mappings":";;;;;;;;;;YAYY;;iBAGK;WACN;;;iBAIM,wBAAwB;WAC9B,IAAI;;iBAQE;EACf,SAAS;;;;;;EAMT;;;iBAIe;;;;;;;;;;EAUf,MAAM,GAAG,OAAO,SAAS,IAAI,OAAO,sBAAsB;EAC1D,MAAM,GAAG,OAAO,SAAS,IAAI,OAAO,aAAa,IAAI,OAAO,sBAAsB;;;;;;;;;;EAWlF,QAAQ,SAAS,aAAa;;;;;;;;EAS9B,KAAK,IAAI,WAAW;;EAGpB,WAAW;;;;;;;qBAQA,SAAS"}
@@ -19,6 +19,12 @@ export interface TriggerInternal extends TriggerRef {
19
19
  }
20
20
  export interface EventTriggerOptions {
21
21
  filter?: Condition;
22
+ /**
23
+ * Let a visitor who has not signed in enter: such a journey reaches them by push, having no
24
+ * address to email. Off by default, because a user enters a workflow once: an entry made as a
25
+ * visitor is still theirs after signing in.
26
+ */
27
+ visitors?: boolean;
22
28
  }
23
29
  /** Trigger factory: four ways in, mirroring customer.io's campaign trigger types. */
24
30
  export interface TriggerFactory {
@@ -1 +1 @@
1
- {"version":3,"file":"trigger.d.mts","names":[],"sources":["../../src/dsl/trigger.ts"],"mappings":";;;;;;;;;;YAYY;;iBAGK;WACN;;;iBAIM,wBAAwB;WAC9B,IAAI;;iBAQE;EACf,SAAS;;;iBAIM;;;;;;;;;;EAUf,MAAM,GAAG,OAAO,SAAS,IAAI,OAAO,sBAAsB;EAC1D,MAAM,GAAG,OAAO,SAAS,IAAI,OAAO,aAAa,IAAI,OAAO,sBAAsB;;;;;;;;;;EAWlF,QAAQ,SAAS,aAAa;;;;;;;;EAS9B,KAAK,IAAI,WAAW;;EAGpB,WAAW;;;;;;;qBAQA,SAAS"}
1
+ {"version":3,"file":"trigger.d.mts","names":[],"sources":["../../src/dsl/trigger.ts"],"mappings":";;;;;;;;;;YAYY;;iBAGK;WACN;;;iBAIM,wBAAwB;WAC9B,IAAI;;iBAQE;EACf,SAAS;;;;;;EAMT;;;iBAIe;;;;;;;;;;EAUf,MAAM,GAAG,OAAO,SAAS,IAAI,OAAO,sBAAsB;EAC1D,MAAM,GAAG,OAAO,SAAS,IAAI,OAAO,aAAa,IAAI,OAAO,sBAAsB;;;;;;;;;;EAWlF,QAAQ,SAAS,aAAa;;;;;;;;EAS9B,KAAK,IAAI,WAAW;;EAGpB,WAAW;;;;;;;qBAQA,SAAS"}
@@ -20,7 +20,8 @@ const trigger = {
20
20
  type: "event",
21
21
  event: ev.name,
22
22
  ...where !== void 0 ? { where: condIR(where(payloadRefs())) } : {},
23
- ...opts?.filter !== void 0 ? { filter: condIR(opts.filter) } : {}
23
+ ...opts?.filter !== void 0 ? { filter: condIR(opts.filter) } : {},
24
+ ...opts?.visitors === true ? { visitors: true } : {}
24
25
  });
25
26
  },
26
27
  segment: (seg) => makeTrigger({
@@ -1 +1 @@
1
- {"version":3,"file":"trigger.mjs","names":[],"sources":["../../src/dsl/trigger.ts"],"sourcesContent":["import type { TriggerIR } from '../ir';\nimport { type Condition, type PayloadWhere, type SegmentRef, condIR } from './condition';\nimport { type EventRef, payloadRefs } from './refs';\n\n/**\n * Triggers — the four ways into a workflow, mirroring customer.io's campaign\n * trigger types. A trigger is a plain reusable object; the event name and\n * payload types flow in from an e.xxx reference, so the factory itself needs\n * no generics.\n */\n\n/** 'YYYY-MM-DD HH:mm:ss'. TODO: validate at runtime — template literals cannot express leading zeros. */\nexport type DateTime = string;\n\n/** Trigger reference: what trigger.xxx() returns, reusable across workflows. */\nexport interface TriggerRef {\n readonly type: 'event' | 'segment' | 'date' | 'webhook';\n}\n\n/** @internal the compiled shape behind a TriggerRef — workflow.toIR reads .ir off it */\nexport interface TriggerInternal extends TriggerRef {\n readonly ir: TriggerIR;\n}\n\nconst makeTrigger = (ir: TriggerIR): TriggerRef => {\n const impl: TriggerInternal = { type: ir.type, ir };\n return impl;\n};\n\nexport interface EventTriggerOptions {\n filter?: Condition;\n}\n\n/** Trigger factory: four ways in, mirroring customer.io's campaign trigger types. */\nexport interface TriggerFactory {\n /**\n * Event trigger: the event name comes from an e.xxx reference (types flow in\n * from it, so the factory itself needs no generics). Two independent gates:\n * - the optional second argument is a payload where clause (typed refs, the\n * ordinary predicates — compiles to IR data the UI renders):\n * trigger.event(e.login, (p) => eq(p.platform, 'web'))\n * - `filter` tests the user's profile (customer.io's trigger Filters).\n * The user enters only if the event fired *and* both gates hold.\n */\n event<P>(event: EventRef<P>, opts?: EventTriggerOptions): TriggerRef;\n event<P>(event: EventRef<P>, where: PayloadWhere<P>, opts?: EventTriggerOptions): TriggerRef;\n\n /**\n * Segment-entry trigger: the user enters the moment they go from not\n * matching to matching. Only a named segment is accepted — entry semantics\n * need a membership table, which an anonymous expression cannot provide.\n * Runtime note: membership is maintained lazily (re-evaluated on the user's\n * ingest/identify activity), so purely time-driven drift — e.g. an\n * inactivity segment becoming true by clock alone — is only observed on the\n * user's next activity. TODO: a cron sweep for time-driven segments.\n */\n segment(segment: SegmentRef): TriggerRef;\n\n /**\n * Scheduled trigger (one-shot, e.g. '2026-12-25 09:00:00', interpreted as\n * UTC). TODO: not routed by the runtime yet — deployBundle reports it in\n * `unrouted`; the implementation goes through cron + Queues (instance\n * creation is rate-limited, a date trigger fans out to the whole audience at\n * once). TODO: cron/recurring form.\n */\n date(at: DateTime): TriggerRef;\n\n /** Webhook trigger: third-party or external code fires it via a URL; the runtime allocates the endpoint. */\n webhook(): TriggerRef;\n}\n\n/**\n * Trigger definition: a plain object — event names come from e.xxx references.\n *\n * const login = trigger.event(e.login, { filter: newUsers7d });\n */\nexport const trigger: TriggerFactory = {\n event: <P>(\n ev: EventRef<P>,\n whereOrOpts?: PayloadWhere<P> | EventTriggerOptions,\n maybeOpts?: EventTriggerOptions\n ) => {\n const where = typeof whereOrOpts === 'function' ? whereOrOpts : undefined;\n const opts = typeof whereOrOpts === 'function' ? maybeOpts : whereOrOpts;\n return makeTrigger({\n type: 'event',\n event: ev.name,\n ...(where !== undefined ? { where: condIR(where(payloadRefs<P>())) } : {}),\n ...(opts?.filter !== undefined ? { filter: condIR(opts.filter) } : {}),\n });\n },\n segment: (seg) => makeTrigger({ type: 'segment', segment: seg.id }),\n date: (at) => makeTrigger({ type: 'date', at }),\n webhook: () => makeTrigger({ type: 'webhook' }),\n};\n"],"mappings":";;;AAwBA,MAAM,eAAe,OAA8B;CAEjD,OAAO;EADyB,MAAM,GAAG;EAAM;CACrC;AACZ;;;;;;AAiDA,MAAa,UAA0B;CACrC,QACE,IACA,aACA,cACG;EACH,MAAM,QAAQ,OAAO,gBAAgB,aAAa,cAAc,KAAA;EAChE,MAAM,OAAO,OAAO,gBAAgB,aAAa,YAAY;EAC7D,OAAO,YAAY;GACjB,MAAM;GACN,OAAO,GAAG;GACV,GAAI,UAAU,KAAA,IAAY,EAAE,OAAO,OAAO,MAAM,YAAe,CAAC,CAAC,EAAE,IAAI,CAAC;GACxE,GAAI,MAAM,WAAW,KAAA,IAAY,EAAE,QAAQ,OAAO,KAAK,MAAM,EAAE,IAAI,CAAC;EACtE,CAAC;CACH;CACA,UAAU,QAAQ,YAAY;EAAE,MAAM;EAAW,SAAS,IAAI;CAAG,CAAC;CAClE,OAAO,OAAO,YAAY;EAAE,MAAM;EAAQ;CAAG,CAAC;CAC9C,eAAe,YAAY,EAAE,MAAM,UAAU,CAAC;AAChD"}
1
+ {"version":3,"file":"trigger.mjs","names":[],"sources":["../../src/dsl/trigger.ts"],"sourcesContent":["import type { TriggerIR } from '../ir';\nimport { type Condition, type PayloadWhere, type SegmentRef, condIR } from './condition';\nimport { type EventRef, payloadRefs } from './refs';\n\n/**\n * Triggers — the four ways into a workflow, mirroring customer.io's campaign\n * trigger types. A trigger is a plain reusable object; the event name and\n * payload types flow in from an e.xxx reference, so the factory itself needs\n * no generics.\n */\n\n/** 'YYYY-MM-DD HH:mm:ss'. TODO: validate at runtime — template literals cannot express leading zeros. */\nexport type DateTime = string;\n\n/** Trigger reference: what trigger.xxx() returns, reusable across workflows. */\nexport interface TriggerRef {\n readonly type: 'event' | 'segment' | 'date' | 'webhook';\n}\n\n/** @internal the compiled shape behind a TriggerRef — workflow.toIR reads .ir off it */\nexport interface TriggerInternal extends TriggerRef {\n readonly ir: TriggerIR;\n}\n\nconst makeTrigger = (ir: TriggerIR): TriggerRef => {\n const impl: TriggerInternal = { type: ir.type, ir };\n return impl;\n};\n\nexport interface EventTriggerOptions {\n filter?: Condition;\n /**\n * Let a visitor who has not signed in enter: such a journey reaches them by push, having no\n * address to email. Off by default, because a user enters a workflow once: an entry made as a\n * visitor is still theirs after signing in.\n */\n visitors?: boolean;\n}\n\n/** Trigger factory: four ways in, mirroring customer.io's campaign trigger types. */\nexport interface TriggerFactory {\n /**\n * Event trigger: the event name comes from an e.xxx reference (types flow in\n * from it, so the factory itself needs no generics). Two independent gates:\n * - the optional second argument is a payload where clause (typed refs, the\n * ordinary predicates — compiles to IR data the UI renders):\n * trigger.event(e.login, (p) => eq(p.platform, 'web'))\n * - `filter` tests the user's profile (customer.io's trigger Filters).\n * The user enters only if the event fired *and* both gates hold.\n */\n event<P>(event: EventRef<P>, opts?: EventTriggerOptions): TriggerRef;\n event<P>(event: EventRef<P>, where: PayloadWhere<P>, opts?: EventTriggerOptions): TriggerRef;\n\n /**\n * Segment-entry trigger: the user enters the moment they go from not\n * matching to matching. Only a named segment is accepted — entry semantics\n * need a membership table, which an anonymous expression cannot provide.\n * Runtime note: membership is maintained lazily (re-evaluated on the user's\n * ingest/identify activity), so purely time-driven drift — e.g. an\n * inactivity segment becoming true by clock alone — is only observed on the\n * user's next activity. TODO: a cron sweep for time-driven segments.\n */\n segment(segment: SegmentRef): TriggerRef;\n\n /**\n * Scheduled trigger (one-shot, e.g. '2026-12-25 09:00:00', interpreted as\n * UTC). TODO: not routed by the runtime yet — deployBundle reports it in\n * `unrouted`; the implementation goes through cron + Queues (instance\n * creation is rate-limited, a date trigger fans out to the whole audience at\n * once). TODO: cron/recurring form.\n */\n date(at: DateTime): TriggerRef;\n\n /** Webhook trigger: third-party or external code fires it via a URL; the runtime allocates the endpoint. */\n webhook(): TriggerRef;\n}\n\n/**\n * Trigger definition: a plain object — event names come from e.xxx references.\n *\n * const login = trigger.event(e.login, { filter: newUsers7d });\n */\nexport const trigger: TriggerFactory = {\n event: <P>(\n ev: EventRef<P>,\n whereOrOpts?: PayloadWhere<P> | EventTriggerOptions,\n maybeOpts?: EventTriggerOptions\n ) => {\n const where = typeof whereOrOpts === 'function' ? whereOrOpts : undefined;\n const opts = typeof whereOrOpts === 'function' ? maybeOpts : whereOrOpts;\n return makeTrigger({\n type: 'event',\n event: ev.name,\n ...(where !== undefined ? { where: condIR(where(payloadRefs<P>())) } : {}),\n ...(opts?.filter !== undefined ? { filter: condIR(opts.filter) } : {}),\n ...(opts?.visitors === true ? { visitors: true } : {}),\n });\n },\n segment: (seg) => makeTrigger({ type: 'segment', segment: seg.id }),\n date: (at) => makeTrigger({ type: 'date', at }),\n webhook: () => makeTrigger({ type: 'webhook' }),\n};\n"],"mappings":";;;AAwBA,MAAM,eAAe,OAA8B;CAEjD,OAAO;EADyB,MAAM,GAAG;EAAM;CACrC;AACZ;;;;;;AAuDA,MAAa,UAA0B;CACrC,QACE,IACA,aACA,cACG;EACH,MAAM,QAAQ,OAAO,gBAAgB,aAAa,cAAc,KAAA;EAChE,MAAM,OAAO,OAAO,gBAAgB,aAAa,YAAY;EAC7D,OAAO,YAAY;GACjB,MAAM;GACN,OAAO,GAAG;GACV,GAAI,UAAU,KAAA,IAAY,EAAE,OAAO,OAAO,MAAM,YAAe,CAAC,CAAC,EAAE,IAAI,CAAC;GACxE,GAAI,MAAM,WAAW,KAAA,IAAY,EAAE,QAAQ,OAAO,KAAK,MAAM,EAAE,IAAI,CAAC;GACpE,GAAI,MAAM,aAAa,OAAO,EAAE,UAAU,KAAK,IAAI,CAAC;EACtD,CAAC;CACH;CACA,UAAU,QAAQ,YAAY;EAAE,MAAM;EAAW,SAAS,IAAI;CAAG,CAAC;CAClE,OAAO,OAAO,YAAY;EAAE,MAAM;EAAQ;CAAG,CAAC;CAC9C,eAAe,YAAY,EAAE,MAAM,UAAU,CAAC;AAChD"}
@@ -111,13 +111,13 @@ function isBetween(actual, values) {
111
111
  /**
112
112
  * Wake-up channel for profile changes: a property predicate has no event name
113
113
  * of its own, so subscriptions use this reserved name and the ingest router
114
- * fires it on every /identify. The '$' prefix marks internal names — real
114
+ * fires it on every profile merge. The '$' prefix marks internal names — real
115
115
  * events may never use it (the router rejects them).
116
116
  */
117
117
  const PROFILE_UPDATED_EVENT = "$profile_updated";
118
118
  /**
119
119
  * The wake-up channel for one profile property: `$profile_updated:<path>`. A property predicate
120
- * subscribes to its own property's channel, and `/identify` fires one per property it patched —
120
+ * subscribes to its own property's channel, and a merge fires one per property it patched —
121
121
  * so an identify that touches `plan` does not wake a wait on `email`.
122
122
  */
123
123
  function profileUpdatedEvent(path) {
@@ -132,7 +132,7 @@ const DEVICES_UPDATED_EVENT = "$devices_updated";
132
132
  * The set of event names a condition cares about — what wait_until subscribes
133
133
  * to for wake-ups. `performed` contributes its event; a property predicate
134
134
  * contributes its property's channel (`profileUpdatedEvent(path)`, so an
135
- * /identify touching that property wakes the wait and one touching another
135
+ * a merge touching that property wakes the wait and one touching another
136
136
  * does not); `reachable` contributes the recipient property's channel, or the
137
137
  * device channel for push (in_app, slack and discord have no address to wait
138
138
  * on). Segment references are expanded, which is why a FactSource is needed to
@@ -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 atLeast = condition.count ?? 1;\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 atLeast,\n });\n return count >= atLeast;\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
+ {"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 atLeast = condition.count ?? 1;\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 atLeast,\n });\n return count >= atLeast;\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 profile merge. 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 a merge 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 * a merge 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;GAC1D,MAAM,UAAU,UAAU,SAAS;GAOnC,OAAO,MANa,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;IAClE;GACF,CAAC,KACe;EAClB;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,OAAOA,yBAAAA,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,OAAOA,yBAAAA,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"}
@@ -30,13 +30,13 @@ export declare function matchesWhere(payload: unknown, where: ConditionIR): bool
30
30
  /**
31
31
  * Wake-up channel for profile changes: a property predicate has no event name
32
32
  * of its own, so subscriptions use this reserved name and the ingest router
33
- * fires it on every /identify. The '$' prefix marks internal names — real
33
+ * fires it on every profile merge. The '$' prefix marks internal names — real
34
34
  * events may never use it (the router rejects them).
35
35
  */
36
36
  export declare const PROFILE_UPDATED_EVENT = "$profile_updated";
37
37
  /**
38
38
  * The wake-up channel for one profile property: `$profile_updated:<path>`. A property predicate
39
- * subscribes to its own property's channel, and `/identify` fires one per property it patched —
39
+ * subscribes to its own property's channel, and a merge fires one per property it patched —
40
40
  * so an identify that touches `plan` does not wake a wait on `email`.
41
41
  */
42
42
  export declare function profileUpdatedEvent(path: string): string;
@@ -49,7 +49,7 @@ export declare const DEVICES_UPDATED_EVENT = "$devices_updated";
49
49
  * The set of event names a condition cares about — what wait_until subscribes
50
50
  * to for wake-ups. `performed` contributes its event; a property predicate
51
51
  * contributes its property's channel (`profileUpdatedEvent(path)`, so an
52
- * /identify touching that property wakes the wait and one touching another
52
+ * a merge touching that property wakes the wait and one touching another
53
53
  * does not); `reachable` contributes the recipient property's channel, or the
54
54
  * device channel for push (in_app, slack and discord have no address to wait
55
55
  * on). Segment references are expanded, which is why a FactSource is needed to
@@ -30,13 +30,13 @@ export declare function matchesWhere(payload: unknown, where: ConditionIR): bool
30
30
  /**
31
31
  * Wake-up channel for profile changes: a property predicate has no event name
32
32
  * of its own, so subscriptions use this reserved name and the ingest router
33
- * fires it on every /identify. The '$' prefix marks internal names — real
33
+ * fires it on every profile merge. The '$' prefix marks internal names — real
34
34
  * events may never use it (the router rejects them).
35
35
  */
36
36
  export declare const PROFILE_UPDATED_EVENT = "$profile_updated";
37
37
  /**
38
38
  * The wake-up channel for one profile property: `$profile_updated:<path>`. A property predicate
39
- * subscribes to its own property's channel, and `/identify` fires one per property it patched —
39
+ * subscribes to its own property's channel, and a merge fires one per property it patched —
40
40
  * so an identify that touches `plan` does not wake a wait on `email`.
41
41
  */
42
42
  export declare function profileUpdatedEvent(path: string): string;
@@ -49,7 +49,7 @@ export declare const DEVICES_UPDATED_EVENT = "$devices_updated";
49
49
  * The set of event names a condition cares about — what wait_until subscribes
50
50
  * to for wake-ups. `performed` contributes its event; a property predicate
51
51
  * contributes its property's channel (`profileUpdatedEvent(path)`, so an
52
- * /identify touching that property wakes the wait and one touching another
52
+ * a merge touching that property wakes the wait and one touching another
53
53
  * does not); `reachable` contributes the recipient property's channel, or the
54
54
  * device channel for push (in_app, slack and discord have no address to wait
55
55
  * on). Segment references are expanded, which is why a FactSource is needed to
@@ -110,13 +110,13 @@ function isBetween(actual, values) {
110
110
  /**
111
111
  * Wake-up channel for profile changes: a property predicate has no event name
112
112
  * of its own, so subscriptions use this reserved name and the ingest router
113
- * fires it on every /identify. The '$' prefix marks internal names — real
113
+ * fires it on every profile merge. The '$' prefix marks internal names — real
114
114
  * events may never use it (the router rejects them).
115
115
  */
116
116
  const PROFILE_UPDATED_EVENT = "$profile_updated";
117
117
  /**
118
118
  * The wake-up channel for one profile property: `$profile_updated:<path>`. A property predicate
119
- * subscribes to its own property's channel, and `/identify` fires one per property it patched —
119
+ * subscribes to its own property's channel, and a merge fires one per property it patched —
120
120
  * so an identify that touches `plan` does not wake a wait on `email`.
121
121
  */
122
122
  function profileUpdatedEvent(path) {
@@ -131,7 +131,7 @@ const DEVICES_UPDATED_EVENT = "$devices_updated";
131
131
  * The set of event names a condition cares about — what wait_until subscribes
132
132
  * to for wake-ups. `performed` contributes its event; a property predicate
133
133
  * contributes its property's channel (`profileUpdatedEvent(path)`, so an
134
- * /identify touching that property wakes the wait and one touching another
134
+ * a merge touching that property wakes the wait and one touching another
135
135
  * does not); `reachable` contributes the recipient property's channel, or the
136
136
  * device channel for push (in_app, slack and discord have no address to wait
137
137
  * on). Segment references are expanded, which is why a FactSource is needed to
@@ -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 atLeast = condition.count ?? 1;\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 atLeast,\n });\n return count >= atLeast;\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
+ {"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 atLeast = condition.count ?? 1;\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 atLeast,\n });\n return count >= atLeast;\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 profile merge. 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 a merge 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 * a merge 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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"}
@@ -32,6 +32,10 @@ const MAX_WAIT_CHECKS = 64;
32
32
  /** The user property holding an IANA timezone id, read when a time_window says tz: 'user'. Same convention-table status as RECIPIENT_PROPERTY. */
33
33
  const TIMEZONE_PROPERTY = "timezone";
34
34
  const FALL_THROUGH = { kind: "fall_through" };
35
+ /** Who a message or report is about now: a journey started by a visitor moves when they sign in. */
36
+ function ownerOf(ctx) {
37
+ return ctx.currentUserId?.() ?? Promise.resolve(ctx.userId);
38
+ }
35
39
  const goalStates = /* @__PURE__ */ new WeakMap();
36
40
  function goalState(ctx) {
37
41
  let state = goalStates.get(ctx);
@@ -107,7 +111,7 @@ async function started(ir, ctx) {
107
111
  await observer.started?.({
108
112
  workflow: ir.id,
109
113
  instanceId: ctx.instanceId,
110
- userId: ctx.userId,
114
+ userId: await ownerOf(ctx),
111
115
  at: ctx.enteredAtMs
112
116
  });
113
117
  });
@@ -123,7 +127,7 @@ async function reached(node, ir, ctx) {
123
127
  await observer.nodeReached?.({
124
128
  workflow: ir.id,
125
129
  instanceId: ctx.instanceId,
126
- userId: ctx.userId,
130
+ userId: await ownerOf(ctx),
127
131
  nodeId: node.id,
128
132
  at: Date.now()
129
133
  });
@@ -250,7 +254,7 @@ async function sendPush(node, ir, ctx) {
250
254
  channel: "push",
251
255
  template: node.template,
252
256
  props: plan.props,
253
- userId: ctx.userId,
257
+ userId: await ownerOf(ctx),
254
258
  recipient: device.token,
255
259
  device,
256
260
  idempotencyKey: `${ctx.instanceId}:${node.id}:${device.token}`,
@@ -277,7 +281,7 @@ async function runNode(node, ir, ctx) {
277
281
  channel: node.channel,
278
282
  template: node.template,
279
283
  props,
280
- userId: ctx.userId,
284
+ userId: await ownerOf(ctx),
281
285
  recipient: recipient === void 0 ? void 0 : String(recipient),
282
286
  idempotencyKey: `${ctx.instanceId}:${node.id}`,
283
287
  ...topicOf(node)
@@ -364,7 +368,7 @@ async function runNode(node, ir, ctx) {
364
368
  await invoker.invoke({
365
369
  action: node.action,
366
370
  args,
367
- userId: ctx.userId,
371
+ userId: await ownerOf(ctx),
368
372
  idempotencyKey: `${ctx.instanceId}:${node.id}`
369
373
  });
370
374
  });