@shware/workflow 3.3.0 → 4.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.
- package/README.md +91 -29
- package/dist/apns.cjs +113 -26
- package/dist/apns.cjs.map +1 -1
- package/dist/apns.d.cts +52 -6
- package/dist/apns.d.cts.map +1 -1
- package/dist/apns.d.mts +52 -6
- package/dist/apns.d.mts.map +1 -1
- package/dist/apns.mjs +111 -27
- package/dist/apns.mjs.map +1 -1
- package/dist/aws/engine-step.cjs +5 -5
- package/dist/aws/engine-step.cjs.map +1 -1
- package/dist/aws/engine-step.d.cts +8 -2
- package/dist/aws/engine-step.d.cts.map +1 -1
- package/dist/aws/engine-step.d.mts +8 -2
- package/dist/aws/engine-step.d.mts.map +1 -1
- package/dist/aws/engine-step.mjs +5 -5
- package/dist/aws/engine-step.mjs.map +1 -1
- package/dist/aws/index.cjs +47 -6
- package/dist/aws/index.cjs.map +1 -1
- package/dist/aws/index.d.cts +2 -2
- package/dist/aws/index.d.cts.map +1 -1
- package/dist/aws/index.d.mts +2 -2
- package/dist/aws/index.d.mts.map +1 -1
- package/dist/aws/index.mjs +47 -6
- package/dist/aws/index.mjs.map +1 -1
- package/dist/aws/instances.cjs +29 -6
- package/dist/aws/instances.cjs.map +1 -1
- package/dist/aws/instances.d.cts +11 -1
- package/dist/aws/instances.d.cts.map +1 -1
- package/dist/aws/instances.d.mts +11 -1
- package/dist/aws/instances.d.mts.map +1 -1
- package/dist/aws/instances.mjs +27 -4
- package/dist/aws/instances.mjs.map +1 -1
- package/dist/client.cjs +4 -1
- package/dist/client.cjs.map +1 -1
- package/dist/client.d.cts +2 -2
- package/dist/client.d.cts.map +1 -1
- package/dist/client.d.mts +2 -2
- package/dist/client.d.mts.map +1 -1
- package/dist/client.mjs +4 -1
- package/dist/client.mjs.map +1 -1
- package/dist/cloudflare/bindings.cjs +28 -25
- package/dist/cloudflare/bindings.cjs.map +1 -1
- package/dist/cloudflare/bindings.d.cts +15 -129
- package/dist/cloudflare/bindings.d.cts.map +1 -1
- package/dist/cloudflare/bindings.d.mts +15 -129
- package/dist/cloudflare/bindings.d.mts.map +1 -1
- package/dist/cloudflare/bindings.mjs +28 -23
- package/dist/cloudflare/bindings.mjs.map +1 -1
- package/dist/cloudflare/d1-env.cjs +6 -6
- package/dist/cloudflare/d1-env.cjs.map +1 -1
- package/dist/cloudflare/d1-env.d.cts +5 -5
- package/dist/cloudflare/d1-env.d.mts +5 -5
- package/dist/cloudflare/d1-env.mjs +6 -6
- package/dist/cloudflare/d1-env.mjs.map +1 -1
- package/dist/cloudflare/index.cjs +28 -18
- package/dist/cloudflare/index.d.cts +8 -4
- package/dist/cloudflare/index.d.mts +8 -4
- package/dist/cloudflare/index.mjs +9 -5
- package/dist/cloudflare/runner.cjs +29 -14
- package/dist/cloudflare/runner.cjs.map +1 -1
- package/dist/cloudflare/runner.d.cts +9 -2
- package/dist/cloudflare/runner.d.cts.map +1 -1
- package/dist/cloudflare/runner.d.mts +9 -2
- package/dist/cloudflare/runner.d.mts.map +1 -1
- package/dist/cloudflare/runner.mjs +28 -13
- package/dist/cloudflare/runner.mjs.map +1 -1
- package/dist/cloudflare/schema.sql +27 -6
- package/dist/cloudflare/worker.cjs +19 -9
- package/dist/cloudflare/worker.cjs.map +1 -1
- package/dist/cloudflare/worker.d.cts +2 -2
- package/dist/cloudflare/worker.d.cts.map +1 -1
- package/dist/cloudflare/worker.d.mts +2 -2
- package/dist/cloudflare/worker.d.mts.map +1 -1
- package/dist/cloudflare/worker.mjs +19 -9
- package/dist/cloudflare/worker.mjs.map +1 -1
- package/dist/config.cjs +21 -5
- package/dist/config.cjs.map +1 -1
- package/dist/config.mjs +21 -5
- package/dist/config.mjs.map +1 -1
- package/dist/dsl/action.cjs +3 -6
- package/dist/dsl/action.cjs.map +1 -1
- package/dist/dsl/action.d.cts +12 -11
- package/dist/dsl/action.d.cts.map +1 -1
- package/dist/dsl/action.d.mts +12 -11
- package/dist/dsl/action.d.mts.map +1 -1
- package/dist/dsl/action.mjs +3 -6
- package/dist/dsl/action.mjs.map +1 -1
- package/dist/dsl/bundle.cjs +35 -31
- package/dist/dsl/bundle.cjs.map +1 -1
- package/dist/dsl/bundle.d.cts.map +1 -1
- package/dist/dsl/bundle.d.mts.map +1 -1
- package/dist/dsl/bundle.mjs +35 -31
- package/dist/dsl/bundle.mjs.map +1 -1
- package/dist/dsl/condition.cjs +13 -1
- package/dist/dsl/condition.cjs.map +1 -1
- package/dist/dsl/condition.d.cts +9 -1
- package/dist/dsl/condition.d.cts.map +1 -1
- package/dist/dsl/condition.d.mts +9 -1
- package/dist/dsl/condition.d.mts.map +1 -1
- package/dist/dsl/condition.mjs +13 -2
- package/dist/dsl/condition.mjs.map +1 -1
- package/dist/dsl/flow.cjs +33 -11
- package/dist/dsl/flow.cjs.map +1 -1
- package/dist/dsl/flow.d.cts +9 -1
- package/dist/dsl/flow.d.cts.map +1 -1
- package/dist/dsl/flow.d.mts +9 -1
- package/dist/dsl/flow.d.mts.map +1 -1
- package/dist/dsl/flow.mjs +34 -13
- package/dist/dsl/flow.mjs.map +1 -1
- package/dist/dsl/workflow.cjs +2 -3
- package/dist/dsl/workflow.cjs.map +1 -1
- package/dist/dsl/workflow.d.cts.map +1 -1
- package/dist/dsl/workflow.d.mts.map +1 -1
- package/dist/dsl/workflow.mjs +3 -4
- package/dist/dsl/workflow.mjs.map +1 -1
- package/dist/engine/actions.cjs +1 -8
- package/dist/engine/actions.cjs.map +1 -1
- package/dist/engine/actions.d.cts +1 -12
- package/dist/engine/actions.d.cts.map +1 -1
- package/dist/engine/actions.d.mts +1 -12
- package/dist/engine/actions.d.mts.map +1 -1
- package/dist/engine/actions.mjs +1 -8
- package/dist/engine/actions.mjs.map +1 -1
- package/dist/engine/condition.cjs +25 -5
- package/dist/engine/condition.cjs.map +1 -1
- package/dist/engine/condition.d.cts +12 -3
- package/dist/engine/condition.d.cts.map +1 -1
- package/dist/engine/condition.d.mts +12 -3
- package/dist/engine/condition.d.mts.map +1 -1
- package/dist/engine/condition.mjs +25 -6
- package/dist/engine/condition.mjs.map +1 -1
- package/dist/engine/index.cjs +3 -0
- package/dist/engine/index.d.cts +5 -5
- package/dist/engine/index.d.mts +5 -5
- package/dist/engine/index.mjs +4 -4
- package/dist/engine/interpreter.cjs +27 -20
- package/dist/engine/interpreter.cjs.map +1 -1
- package/dist/engine/interpreter.d.cts.map +1 -1
- package/dist/engine/interpreter.d.mts.map +1 -1
- package/dist/engine/interpreter.mjs +27 -20
- package/dist/engine/interpreter.mjs.map +1 -1
- package/dist/engine/ports.d.cts +15 -6
- package/dist/engine/ports.d.cts.map +1 -1
- package/dist/engine/ports.d.mts +15 -6
- package/dist/engine/ports.d.mts.map +1 -1
- package/dist/engine/retry.cjs +13 -0
- package/dist/engine/retry.cjs.map +1 -1
- package/dist/engine/retry.d.cts +12 -0
- package/dist/engine/retry.d.cts.map +1 -1
- package/dist/engine/retry.d.mts +12 -0
- package/dist/engine/retry.d.mts.map +1 -1
- package/dist/engine/retry.mjs +13 -1
- package/dist/engine/retry.mjs.map +1 -1
- package/dist/engine/time-scale.cjs +90 -15
- package/dist/engine/time-scale.cjs.map +1 -1
- package/dist/engine/time-scale.d.cts +9 -6
- package/dist/engine/time-scale.d.cts.map +1 -1
- package/dist/engine/time-scale.d.mts +9 -6
- package/dist/engine/time-scale.d.mts.map +1 -1
- package/dist/engine/time-scale.mjs +90 -16
- package/dist/engine/time-scale.mjs.map +1 -1
- package/dist/fcm.cjs +111 -38
- package/dist/fcm.cjs.map +1 -1
- package/dist/fcm.d.cts +45 -5
- package/dist/fcm.d.cts.map +1 -1
- package/dist/fcm.d.mts +45 -5
- package/dist/fcm.d.mts.map +1 -1
- package/dist/fcm.mjs +110 -39
- package/dist/fcm.mjs.map +1 -1
- package/dist/hash.cjs +6 -6
- package/dist/hash.cjs.map +1 -1
- package/dist/hash.mjs +6 -6
- package/dist/hash.mjs.map +1 -1
- package/dist/host/auth.cjs +49 -0
- package/dist/host/auth.cjs.map +1 -0
- package/dist/host/auth.d.cts +21 -0
- package/dist/host/auth.d.cts.map +1 -0
- package/dist/host/auth.d.mts +21 -0
- package/dist/host/auth.d.mts.map +1 -0
- package/dist/host/auth.mjs +47 -0
- package/dist/host/auth.mjs.map +1 -0
- package/dist/host/channels.cjs +118 -40
- package/dist/host/channels.cjs.map +1 -1
- package/dist/host/channels.d.cts +27 -1
- package/dist/host/channels.d.cts.map +1 -1
- package/dist/host/channels.d.mts +27 -1
- package/dist/host/channels.d.mts.map +1 -1
- package/dist/host/channels.mjs +121 -44
- package/dist/host/channels.mjs.map +1 -1
- package/dist/host/execute.cjs +14 -2
- package/dist/host/execute.cjs.map +1 -1
- package/dist/host/execute.d.cts +4 -4
- package/dist/host/execute.d.cts.map +1 -1
- package/dist/host/execute.d.mts +4 -4
- package/dist/host/execute.d.mts.map +1 -1
- package/dist/host/execute.mjs +13 -1
- package/dist/host/execute.mjs.map +1 -1
- package/dist/host/fresh-read.cjs +30 -0
- package/dist/host/fresh-read.cjs.map +1 -0
- package/dist/host/fresh-read.d.cts +18 -0
- package/dist/host/fresh-read.d.cts.map +1 -0
- package/dist/host/fresh-read.d.mts +18 -0
- package/dist/host/fresh-read.d.mts.map +1 -0
- package/dist/host/fresh-read.mjs +29 -0
- package/dist/host/fresh-read.mjs.map +1 -0
- package/dist/host/http.cjs +321 -0
- package/dist/host/http.cjs.map +1 -0
- package/dist/host/http.d.cts +54 -0
- package/dist/host/http.d.cts.map +1 -0
- package/dist/host/http.d.mts +54 -0
- package/dist/host/http.d.mts.map +1 -0
- package/dist/host/http.mjs +317 -0
- package/dist/host/http.mjs.map +1 -0
- package/dist/host/{router.cjs → ingest.cjs} +147 -175
- package/dist/host/ingest.cjs.map +1 -0
- package/dist/host/{router.d.cts → ingest.d.cts} +48 -30
- package/dist/host/ingest.d.cts.map +1 -0
- package/dist/host/{router.d.mts → ingest.d.mts} +48 -30
- package/dist/host/ingest.d.mts.map +1 -0
- package/dist/host/{router.mjs → ingest.mjs} +144 -173
- package/dist/host/ingest.mjs.map +1 -0
- package/dist/host/journey-host.cjs +65 -93
- package/dist/host/journey-host.cjs.map +1 -1
- package/dist/host/journey-host.d.cts +19 -26
- package/dist/host/journey-host.d.cts.map +1 -1
- package/dist/host/journey-host.d.mts +19 -26
- package/dist/host/journey-host.d.mts.map +1 -1
- package/dist/host/journey-host.mjs +65 -93
- package/dist/host/journey-host.mjs.map +1 -1
- package/dist/host/platform.cjs +21 -0
- package/dist/host/platform.cjs.map +1 -0
- package/dist/host/platform.d.cts +126 -0
- package/dist/host/platform.d.cts.map +1 -0
- package/dist/host/platform.d.mts +126 -0
- package/dist/host/platform.d.mts.map +1 -0
- package/dist/host/platform.mjs +19 -0
- package/dist/host/platform.mjs.map +1 -0
- package/dist/host/senders.cjs +18 -9
- package/dist/host/senders.cjs.map +1 -1
- package/dist/host/senders.d.cts +9 -4
- package/dist/host/senders.d.cts.map +1 -1
- package/dist/host/senders.d.mts +9 -4
- package/dist/host/senders.d.mts.map +1 -1
- package/dist/host/senders.mjs +18 -9
- package/dist/host/senders.mjs.map +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.mjs +1 -1
- package/dist/ir.cjs +5 -7
- package/dist/ir.cjs.map +1 -1
- package/dist/ir.d.cts +3 -12
- package/dist/ir.d.cts.map +1 -1
- package/dist/ir.d.mts +3 -12
- package/dist/ir.d.mts.map +1 -1
- package/dist/ir.mjs +5 -7
- package/dist/ir.mjs.map +1 -1
- package/dist/plan.cjs +27 -33
- package/dist/plan.cjs.map +1 -1
- package/dist/plan.d.cts +1 -1
- package/dist/plan.d.cts.map +1 -1
- package/dist/plan.d.mts +1 -1
- package/dist/plan.d.mts.map +1 -1
- package/dist/plan.mjs +27 -33
- package/dist/plan.mjs.map +1 -1
- package/dist/reporting/tracking.cjs +14 -4
- package/dist/reporting/tracking.cjs.map +1 -1
- package/dist/reporting/tracking.d.cts +10 -10
- package/dist/reporting/tracking.d.cts.map +1 -1
- package/dist/reporting/tracking.d.mts +10 -10
- package/dist/reporting/tracking.d.mts.map +1 -1
- package/dist/reporting/tracking.mjs +14 -4
- package/dist/reporting/tracking.mjs.map +1 -1
- package/dist/store/d1.cjs +88 -29
- package/dist/store/d1.cjs.map +1 -1
- package/dist/store/d1.d.cts +29 -11
- package/dist/store/d1.d.cts.map +1 -1
- package/dist/store/d1.d.mts +29 -11
- package/dist/store/d1.d.mts.map +1 -1
- package/dist/store/d1.mjs +88 -29
- package/dist/store/d1.mjs.map +1 -1
- package/dist/store/erasure.cjs +22 -0
- package/dist/store/erasure.cjs.map +1 -0
- package/dist/store/erasure.d.cts +18 -0
- package/dist/store/erasure.d.cts.map +1 -0
- package/dist/store/erasure.d.mts +18 -0
- package/dist/store/erasure.d.mts.map +1 -0
- package/dist/store/erasure.mjs +21 -0
- package/dist/store/erasure.mjs.map +1 -0
- package/dist/store/index.cjs +2 -0
- package/dist/store/index.cjs.map +1 -1
- package/dist/store/index.d.cts +67 -13
- package/dist/store/index.d.cts.map +1 -1
- package/dist/store/index.d.mts +67 -13
- package/dist/store/index.d.mts.map +1 -1
- package/dist/store/index.mjs +2 -1
- package/dist/store/index.mjs.map +1 -1
- package/dist/store/postgres-reporting.cjs +125 -62
- package/dist/store/postgres-reporting.cjs.map +1 -1
- package/dist/store/postgres-reporting.d.cts +30 -2
- package/dist/store/postgres-reporting.d.cts.map +1 -1
- package/dist/store/postgres-reporting.d.mts +30 -2
- package/dist/store/postgres-reporting.d.mts.map +1 -1
- package/dist/store/postgres-reporting.mjs +125 -62
- package/dist/store/postgres-reporting.mjs.map +1 -1
- package/dist/store/postgres.cjs +179 -60
- package/dist/store/postgres.cjs.map +1 -1
- package/dist/store/postgres.d.cts +44 -7
- package/dist/store/postgres.d.cts.map +1 -1
- package/dist/store/postgres.d.mts +44 -7
- package/dist/store/postgres.d.mts.map +1 -1
- package/dist/store/postgres.mjs +179 -60
- package/dist/store/postgres.mjs.map +1 -1
- package/dist/store/tables.cjs +7 -5
- package/dist/store/tables.cjs.map +1 -1
- package/dist/store/tables.d.cts.map +1 -1
- package/dist/store/tables.d.mts.map +1 -1
- package/dist/store/tables.mjs +7 -5
- package/dist/store/tables.mjs.map +1 -1
- package/dist/store/user-lock.cjs +43 -0
- package/dist/store/user-lock.cjs.map +1 -0
- package/dist/store/user-lock.d.cts +32 -0
- package/dist/store/user-lock.d.cts.map +1 -0
- package/dist/store/user-lock.d.mts +32 -0
- package/dist/store/user-lock.d.mts.map +1 -0
- package/dist/store/user-lock.mjs +40 -0
- package/dist/store/user-lock.mjs.map +1 -0
- package/dist/transports/cloudflare-email.cjs +1 -1
- package/dist/transports/cloudflare-email.mjs +1 -1
- package/dist/transports/ses.cjs +47 -6
- package/dist/transports/ses.cjs.map +1 -1
- package/dist/transports/ses.d.cts +9 -1
- package/dist/transports/ses.d.cts.map +1 -1
- package/dist/transports/ses.d.mts +9 -1
- package/dist/transports/ses.d.mts.map +1 -1
- package/dist/transports/ses.mjs +47 -7
- package/dist/transports/ses.mjs.map +1 -1
- package/dist/webpush.cjs +133 -47
- package/dist/webpush.cjs.map +1 -1
- package/dist/webpush.d.cts +49 -8
- package/dist/webpush.d.cts.map +1 -1
- package/dist/webpush.d.mts +49 -8
- package/dist/webpush.d.mts.map +1 -1
- package/dist/webpush.mjs +130 -47
- package/dist/webpush.mjs.map +1 -1
- package/package.json +1 -1
- package/src/cloudflare/schema.sql +27 -6
- package/dist/host/router.cjs.map +0 -1
- package/dist/host/router.d.cts.map +0 -1
- package/dist/host/router.d.mts.map +0 -1
- package/dist/host/router.mjs.map +0 -1
package/dist/ir.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ir.mjs","names":[],"sources":["../src/ir.ts"],"sourcesContent":["import * as z from 'zod/mini';\n\n/**\n * IR (Intermediate Representation) — what the DSL compiles to: serializable\n * JSON. It is the three-way contract between the engine that interprets it,\n * the UI (react-flow) that renders it, and diff / audit tooling.\n *\n * Validation strategy: the zod/mini schemas are the authoritative definition.\n * TS types are either inferred from a schema or hand-written under the same\n * name (zod cannot infer recursive types; the hand-written type is anchored\n * with a ZodMiniType<T> annotation, so any drift fails to compile). IR crosses\n * process boundaries (database, network, UI), so runtime validation is a must.\n *\n * ## Versioning (two independent axes)\n *\n * 1. IR format version (irVersion): evolution of the IR schema itself. Adding\n * optional fields does not bump it; only structurally incompatible changes\n * do (changing a discriminant, dropping a field, altering semantics).\n * Readers dispatch on irVersion to the matching schema/migrator; writers\n * always write the current version.\n * 2. Content version (contentHash): a content-addressed id for a single\n * definition's **execution semantics** — the canonical-JSON hash after\n * metadata (meta / label / reason) is stripped, see hash.ts. It is used to:\n * - pin a version when a user enters, so an in-flight journey runs the\n * pinned version to completion (the default policy; compatible hot\n * updates are a future explicit-migration topic). Known exception:\n * segment conditions are resolved **by id at evaluation time** — a\n * segment redeploy changes behaviour of in-flight journeys that\n * reference it. Pinning covers the workflow's own structure; per-hash\n * segment snapshots are future work;\n * - diff on deploy: same hash means unchanged, skip publishing;\n * - audit: answer \"which version was this user on\" at any point.\n *\n * ## Node ids\n *\n * Derived from the structural path at compile time — stable and deterministic:\n * root level '0', '1', …; the j-th child of a branch's i-th case is\n * '{branchId}.c{i}.{j}', the default arm '{branchId}.o.{j}'; cohort arms use\n * the arm name '{cohortId}.{armName}.{j}'; a waitUntil timeout flow '{id}.t.{j}'.\n * Editing a node's parameters keeps its id; inserting a node shifts the ids of\n * its later siblings (a known trade-off — locating in-flight users relies on\n * the pinned version rather than cross-version id alignment, and cross-version\n * mapping is an explicit migrator's job).\n */\n\nexport const IR_VERSION = 2;\n\n/* -------------------------------- Base values ------------------------------- */\n\nexport const ScalarIR = z.union([z.string(), z.number(), z.boolean()]);\nexport type ScalarIR = z.infer<typeof ScalarIR>;\n\n/** Duration: `value` keeps the DSL literal ('23 hours', for display), `ms` is what the engine uses. */\n/*\n * TODO: `value` is hashed alongside `ms`, so '7 days' / '1 week' / '168 hours'\n * — identical to the engine, which only reads ms — produce three different\n * contentHashes. Rewording a duration therefore reports as a semantic change in\n * plan and repins in-flight users. Moving `value` out of the hash (it is a\n * display string) would fix it; deferred because it changes existing hashes.\n */\nexport const DurationIR = z.object({ value: z.string(), ms: z.number() });\nexport type DurationIR = z.infer<typeof DurationIR>;\n\n/** User-property reference — isomorphic to the DSL's UserPropertyRef once serialized (phantom type dropped). */\nexport const UserPropertyRefIR = z.object({\n type: z.literal('user_property'),\n path: z.string(),\n});\nexport type UserPropertyRefIR = z.infer<typeof UserPropertyRefIR>;\n\n/** A message prop / event payload value: either a literal or a user-property reference. */\nexport const PropValueIR = z.union([ScalarIR, UserPropertyRefIR]);\nexport type PropValueIR = z.infer<typeof PropValueIR>;\n\nexport const ChannelIR = z.enum(['email', 'sms', 'push', 'in_app', 'slack', 'discord', 'survey']);\nexport type ChannelIR = z.infer<typeof ChannelIR>;\n\nexport const WeekdayIR = z.enum(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']);\nexport type WeekdayIR = z.infer<typeof WeekdayIR>;\n\n/* --------------------------------- Provenance -------------------------------- */\n\n/**\n * Source location of the builder call that produced a definition — the IR's\n * source map. Lives under `meta`, so it is **excluded from contentHash**\n * (see hash.ts): provenance never affects execution identity or plan.\n * `file` is cwd-relative when compiled under Node, a URL under Vite dev.\n */\nexport const SourceLocIR = z.object({\n file: z.string(),\n line: z.number(),\n column: z.number(),\n});\nexport type SourceLocIR = z.infer<typeof SourceLocIR>;\n\n/** Node-level metadata: provenance only, for now. Excluded from contentHash. */\nexport const NodeMetaIR = z.object({\n loc: z.optional(SourceLocIR),\n});\nexport type NodeMetaIR = z.infer<typeof NodeMetaIR>;\n\n/* -------------------------------- Conditions -------------------------------- */\n\nexport const PropertyOperatorIR = z.enum([\n 'eq',\n 'ne',\n 'gt',\n 'gte',\n 'lt',\n 'lte',\n 'between',\n 'not_between',\n 'in_array',\n 'not_in_array',\n 'exists',\n 'not_exists',\n 'contains',\n 'not_contains',\n]);\nexport type PropertyOperatorIR = z.infer<typeof PropertyOperatorIR>;\n\n/**\n * Condition expression tree. The shape of value/values follows `op`:\n * eq/ne/gt/gte/lt/lte/contains/not_contains → value; in_array/not_in_array →\n * values; between/not_between → values = [min, max]; exists/not_exists → neither.\n *\n * `payload` predicates test the payload of an event (dotted paths reach nested\n * fields). They are only valid inside a `where` subtree — `performed.where`\n * and the event trigger's `where` — where an event row is in scope; the\n * compiler (compileBundle) and the evaluator both enforce the placement.\n * TODO: runtime refinement narrowing by op.\n */\n/**\n * Provenance rides on conditions too, so the studio can point at the exact\n * `eq(...)` / `and(...)` that produced a node — same rule as nodes: `meta` is\n * dropped before hashing, so carrying it never affects execution identity.\n */\ninterface ConditionBaseIR {\n meta?: NodeMetaIR | undefined;\n}\n\nexport type ConditionIR =\n | (ConditionBaseIR & { type: 'and'; conditions: ConditionIR[] })\n | (ConditionBaseIR & { type: 'or'; conditions: ConditionIR[] })\n | (ConditionBaseIR & { type: 'not'; condition: ConditionIR })\n /** Reference a segment by id (defined in SegmentIR, versioned separately). */\n | (ConditionBaseIR & { type: 'segment'; segment: string })\n | (ConditionBaseIR & {\n type: 'property';\n path: string;\n op: PropertyOperatorIR;\n value?: ScalarIR | undefined;\n values?: ScalarIR[] | undefined;\n })\n | (ConditionBaseIR & {\n /** Event payload predicate; `path` is dotted for nested fields ('tags.utm_source'). */\n type: 'payload';\n path: string;\n op: PropertyOperatorIR;\n value?: ScalarIR | undefined;\n values?: ScalarIR[] | undefined;\n })\n | (ConditionBaseIR & {\n type: 'performed';\n event: string;\n /** Payload filter: only events matching it count (a payload-only subtree). */\n where?: ConditionIR | undefined;\n within?: DurationIR | undefined;\n count?: number | undefined;\n })\n /**\n * The user can be reached on the channel right now: for push, the host's\n * device directory lists at least one device; for the others, the channel's\n * recipient property (email, phone, …) is set.\n */\n | (ConditionBaseIR & { type: 'reachable'; channel: ChannelIR });\n\nconst conditionBase = { meta: z.optional(NodeMetaIR) };\n\nexport const ConditionIR: z.ZodMiniType<ConditionIR> = z.lazy(() =>\n z.discriminatedUnion('type', [\n z.object({ ...conditionBase, type: z.literal('and'), conditions: z.array(ConditionIR) }),\n z.object({ ...conditionBase, type: z.literal('or'), conditions: z.array(ConditionIR) }),\n z.object({ ...conditionBase, type: z.literal('not'), condition: ConditionIR }),\n z.object({ ...conditionBase, type: z.literal('segment'), segment: z.string() }),\n z.object({\n ...conditionBase,\n type: z.literal('property'),\n path: z.string(),\n op: PropertyOperatorIR,\n value: z.optional(ScalarIR),\n values: z.optional(z.array(ScalarIR)),\n }),\n z.object({\n ...conditionBase,\n type: z.literal('payload'),\n path: z.string(),\n op: PropertyOperatorIR,\n value: z.optional(ScalarIR),\n values: z.optional(z.array(ScalarIR)),\n }),\n z.object({\n ...conditionBase,\n type: z.literal('performed'),\n event: z.string(),\n where: z.optional(ConditionIR),\n within: z.optional(DurationIR),\n count: z.optional(z.number()),\n }),\n z.object({ ...conditionBase, type: z.literal('reachable'), channel: ChannelIR }),\n ])\n);\n\n/* --------------------------------- Triggers --------------------------------- */\n\n/**\n * Trigger shapes. The event trigger has two independent gates: `where` tests\n * the triggering event's payload (payload-only subtree, evaluated against the\n * incoming event before anything else), `filter` tests the user's profile.\n */\nexport const TriggerIR = z.discriminatedUnion('type', [\n z.object({\n type: z.literal('event'),\n event: z.string(),\n where: z.optional(ConditionIR),\n filter: z.optional(ConditionIR),\n }),\n z.object({ type: z.literal('segment'), segment: z.string() }),\n z.object({ type: z.literal('date'), at: z.string() }),\n z.object({ type: z.literal('webhook') }),\n]);\nexport type TriggerIR = z.infer<typeof TriggerIR>;\n\n/* ----------------------------------- Nodes ---------------------------------- */\n\ninterface NodeBaseIR {\n /** Stable id derived from the structural path (rules in the file header). */\n id: string;\n /** Optional node name: UI title / observability handle (branch's optional first argument lands here). */\n label?: string | undefined;\n /** Metadata (provenance); excluded from contentHash. */\n meta?: NodeMetaIR | undefined;\n}\n\nexport type NodeIR =\n | (NodeBaseIR & {\n type: 'message';\n channel: ChannelIR;\n template: string;\n props: Record<string, PropValueIR>;\n /**\n * Push only: which of the user's devices get a copy. Absent means every\n * device the directory lists; 'latest' only the most recently registered.\n */\n devices?: 'latest' | undefined;\n })\n | (NodeBaseIR & { type: 'delay'; duration: DurationIR })\n | (NodeBaseIR & { type: 'random_delay'; min: DurationIR; max: DurationIR })\n | (NodeBaseIR & {\n type: 'time_window';\n days: WeekdayIR[];\n between: [string, string];\n tz: string;\n })\n | (NodeBaseIR & {\n type: 'wait_until';\n condition: ConditionIR;\n timeout: DurationIR;\n onTimeout: 'continue' | 'exit' | NodeIR[];\n })\n | (NodeBaseIR & {\n type: 'branch';\n cases: { label?: string | undefined; condition: ConditionIR; flow: NodeIR[] }[];\n /** Default arm (the DSL's bare tail argument). Absent = fall through to the main line. */\n otherwise?: NodeIR[] | undefined;\n })\n | (NodeBaseIR & {\n type: 'filter';\n /** Audit-log line for the users this gate drops. Metadata: excluded from contentHash. */\n reason?: string | undefined;\n condition: ConditionIR;\n })\n | (NodeBaseIR & {\n type: 'cohort';\n /**\n * Explicit experiment key: bucketing hashes `userId:key` instead of the\n * structural node id, so the assignment survives node insertion/moves and\n * the experiment keeps one identity across workflow versions. Absent →\n * the node id is used (fine for one-off splits).\n */\n key?: string | undefined;\n /**\n * Arms in authoring order — the order the interpreter accumulates weights\n * in, so it decides which bucket range each arm owns. The DSL keeps that\n * promise by rejecting integer-like arm names, which JavaScript would\n * hoist to the front of the object in numeric order. Weights sum to 100.\n */\n arms: { name: string; weight: number; flow: NodeIR[] }[];\n })\n | (NodeBaseIR & {\n /** `reason` is the audit-log line. Metadata: excluded from contentHash. */\n type: 'exit';\n reason?: string | undefined;\n })\n | (NodeBaseIR & { type: 'send_event'; event: string; payload: Record<string, PropValueIR> })\n | (NodeBaseIR & {\n type: 'action';\n /** Registry name of the custom action; the code itself lives in the deployed Worker bundle. */\n action: string;\n /** Argument values; user_property references are resolved at run time, like message props. */\n args: Record<string, PropValueIR>;\n /**\n * Hash of the action's source at compile time — the code-identity pin.\n * IR carries identity only (dual-plane model): the runtime looks the\n * action up by name and compares this hash against the registered\n * implementation to detect version skew (warn by default).\n */\n codeHash?: string | undefined;\n });\n\nconst nodeBase = { id: z.string(), label: z.optional(z.string()), meta: z.optional(NodeMetaIR) };\n\nexport const NodeIR: z.ZodMiniType<NodeIR> = z.lazy(() =>\n z.discriminatedUnion('type', [\n z.object({\n ...nodeBase,\n type: z.literal('message'),\n channel: ChannelIR,\n template: z.string(),\n props: z.record(z.string(), PropValueIR),\n devices: z.optional(z.literal('latest')),\n }),\n z.object({ ...nodeBase, type: z.literal('delay'), duration: DurationIR }),\n z.object({ ...nodeBase, type: z.literal('random_delay'), min: DurationIR, max: DurationIR }),\n z.object({\n ...nodeBase,\n type: z.literal('time_window'),\n days: z.array(WeekdayIR),\n between: z.tuple([z.string(), z.string()]),\n tz: z.string(),\n }),\n z.object({\n ...nodeBase,\n type: z.literal('wait_until'),\n condition: ConditionIR,\n timeout: DurationIR,\n onTimeout: z.union([z.literal('continue'), z.literal('exit'), z.array(NodeIR)]),\n }),\n z.object({\n ...nodeBase,\n type: z.literal('branch'),\n cases: z.array(\n z.object({\n label: z.optional(z.string()),\n condition: ConditionIR,\n flow: z.array(NodeIR),\n })\n ),\n otherwise: z.optional(z.array(NodeIR)),\n }),\n z.object({\n ...nodeBase,\n type: z.literal('filter'),\n condition: ConditionIR,\n reason: z.optional(z.string()),\n }),\n z.object({\n ...nodeBase,\n type: z.literal('cohort'),\n key: z.optional(z.string()),\n arms: z.array(z.object({ name: z.string(), weight: z.number(), flow: z.array(NodeIR) })),\n }),\n z.object({ ...nodeBase, type: z.literal('exit'), reason: z.optional(z.string()) }),\n z.object({\n ...nodeBase,\n type: z.literal('send_event'),\n event: z.string(),\n payload: z.record(z.string(), PropValueIR),\n }),\n z.object({\n ...nodeBase,\n type: z.literal('action'),\n action: z.string(),\n args: z.record(z.string(), PropValueIR),\n codeHash: z.optional(z.string()),\n }),\n ])\n);\n\n/* ---------------------- Top-level definitions and bundle --------------------- */\n\nexport const GoalIR = z.object({\n condition: ConditionIR,\n within: z.optional(DurationIR),\n exitOnMatch: z.boolean(),\n});\nexport type GoalIR = z.infer<typeof GoalIR>;\n\n/**\n * Human-facing metadata: **excluded from contentHash** (see hash.ts).\n * Editing a name or description does not touch execution semantics, so it never\n * invalidates the version in-flight users are pinned to, nor shows up in plan.\n */\nexport const MetaIR = z.object({\n /**\n * Human label. The asset's `id` is its identity — a wire key that routing\n * tables and the entry ledger are built on — so it is not something to\n * rename for readability. This is: it is metadata, dropped before hashing,\n * and the studio shows it in place of the key wherever it is set.\n */\n name: z.optional(z.string()),\n description: z.optional(z.string()),\n loc: z.optional(SourceLocIR),\n});\nexport type MetaIR = z.infer<typeof MetaIR>;\n\nexport const WorkflowIR = z.object({\n irVersion: z.literal(IR_VERSION),\n /** Wire identity (`workflow({ id })`): routing tables, the entry ledger and reporting are keyed on it. */\n id: z.string(),\n contentHash: z.string(),\n meta: z.optional(MetaIR),\n trigger: TriggerIR,\n goal: z.optional(GoalIR),\n exitWhen: z.optional(ConditionIR),\n flow: z.array(NodeIR),\n});\nexport type WorkflowIR = z.infer<typeof WorkflowIR>;\n\nexport const SegmentIR = z.object({\n irVersion: z.literal(IR_VERSION),\n /** Wire identity (`segment({ id })`): segment references and the membership store are keyed on it. */\n id: z.string(),\n contentHash: z.string(),\n /** Provenance plus the human label — MetaIR, same as a workflow's. */\n meta: z.optional(MetaIR),\n condition: ConditionIR,\n});\nexport type SegmentIR = z.infer<typeof SegmentIR>;\n\n/** Template manifest entry: IR records only the reference; the body lives in the template system. */\nexport const TemplateIR = z.object({\n irVersion: z.literal(IR_VERSION),\n key: z.string(),\n channel: ChannelIR,\n});\nexport type TemplateIR = z.infer<typeof TemplateIR>;\n\n/**\n * Action manifest entry: IR records the name and the code-identity hash; the\n * implementation solidifies into the Worker bundle at deploy time, never into\n * IR (custom code stays out of the data plane by design).\n */\nexport const ActionIR = z.object({\n irVersion: z.literal(IR_VERSION),\n name: z.string(),\n codeHash: z.string(),\n});\nexport type ActionIR = z.infer<typeof ActionIR>;\n\n/**\n * Deployment unit: the complete snapshot one `deploy` produces (terraform\n * apply, same mental model). The server diffs each definition's contentHash to\n * decide publish / skip / retire.\n */\nexport const BundleIR = z.object({\n irVersion: z.literal(IR_VERSION),\n workflows: z.array(WorkflowIR),\n segments: z.array(SegmentIR),\n templates: z.array(TemplateIR),\n actions: z.array(ActionIR),\n});\nexport type BundleIR = z.infer<typeof BundleIR>;\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"ir.mjs","names":[],"sources":["../src/ir.ts"],"sourcesContent":["import * as z from 'zod/mini';\n\n/**\n * IR (Intermediate Representation) — what the DSL compiles to: serializable\n * JSON. It is the three-way contract between the engine that interprets it,\n * the UI (react-flow) that renders it, and diff / audit tooling.\n *\n * Validation strategy: the zod/mini schemas are the authoritative definition.\n * TS types are either inferred from a schema or hand-written under the same\n * name (zod cannot infer recursive types; the hand-written type is anchored\n * with a ZodMiniType<T> annotation, so any drift fails to compile). IR crosses\n * process boundaries (database, network, UI), so runtime validation is a must.\n *\n * ## Versioning (two independent axes)\n *\n * 1. IR format version (irVersion): evolution of the IR schema itself. Adding\n * optional fields does not bump it; only structurally incompatible changes\n * do (changing a discriminant, dropping a field, altering semantics).\n * Readers dispatch on irVersion to the matching schema/migrator; writers\n * always write the current version.\n * 2. Content version (contentHash): a content-addressed id for a single\n * definition's **execution semantics** — the canonical-JSON hash after\n * metadata (meta / label / reason) is stripped, see hash.ts. It is used to:\n * - pin a version when a user enters, so an in-flight journey runs the\n * pinned version to completion (the default policy; compatible hot\n * updates are a future explicit-migration topic). Known exception:\n * segment conditions are resolved **by id at evaluation time** — a\n * segment redeploy changes behaviour of in-flight journeys that\n * reference it. Pinning covers the workflow's own structure; per-hash\n * segment snapshots are future work;\n * - diff on deploy: same hash means unchanged, skip publishing;\n * - audit: answer \"which version was this user on\" at any point.\n *\n * ## Node ids\n *\n * Derived from the structural path at compile time — stable and deterministic:\n * root level '0', '1', …; the j-th child of a branch's i-th case is\n * '{branchId}.c{i}.{j}', the default arm '{branchId}.o.{j}'; cohort arms use\n * the arm name '{cohortId}.{armName}.{j}'; a waitUntil timeout flow '{id}.t.{j}'.\n * Editing a node's parameters keeps its id; inserting a node shifts the ids of\n * its later siblings (a known trade-off — locating in-flight users relies on\n * the pinned version rather than cross-version id alignment, and cross-version\n * mapping is an explicit migrator's job).\n */\n\nexport const IR_VERSION = 2;\n\n/* -------------------------------- Base values ------------------------------- */\n\nexport const ScalarIR = z.union([z.string(), z.number(), z.boolean()]);\nexport type ScalarIR = z.infer<typeof ScalarIR>;\n\n/** Duration: `value` keeps the DSL literal ('23 hours', for display), `ms` is what the engine uses. */\n/*\n * TODO: `value` is hashed alongside `ms`, so '7 days' / '1 week' / '168 hours'\n * — identical to the engine, which only reads ms — produce three different\n * contentHashes. Rewording a duration therefore reports as a semantic change in\n * plan and repins in-flight users. Moving `value` out of the hash (it is a\n * display string) would fix it; deferred because it changes existing hashes.\n */\nexport const DurationIR = z.object({ value: z.string(), ms: z.number() });\nexport type DurationIR = z.infer<typeof DurationIR>;\n\n/** User-property reference — isomorphic to the DSL's UserPropertyRef once serialized (phantom type dropped). */\nexport const UserPropertyRefIR = z.object({\n type: z.literal('user_property'),\n path: z.string(),\n});\nexport type UserPropertyRefIR = z.infer<typeof UserPropertyRefIR>;\n\n/** A message prop / event payload value: either a literal or a user-property reference. */\nexport const PropValueIR = z.union([ScalarIR, UserPropertyRefIR]);\nexport type PropValueIR = z.infer<typeof PropValueIR>;\n\nexport const ChannelIR = z.enum(['email', 'sms', 'push', 'in_app', 'slack', 'discord', 'survey']);\nexport type ChannelIR = z.infer<typeof ChannelIR>;\n\nexport const WeekdayIR = z.enum(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']);\nexport type WeekdayIR = z.infer<typeof WeekdayIR>;\n\n/* --------------------------------- Provenance -------------------------------- */\n\n/**\n * Source location of the builder call that produced a definition — the IR's\n * source map. Lives under `meta`, so it is **excluded from contentHash**\n * (see hash.ts): provenance never affects execution identity or plan.\n * `file` is cwd-relative when compiled under Node, a URL under Vite dev.\n */\nexport const SourceLocIR = z.object({\n file: z.string(),\n line: z.number(),\n column: z.number(),\n});\nexport type SourceLocIR = z.infer<typeof SourceLocIR>;\n\n/** Node-level metadata: provenance only, for now. Excluded from contentHash. */\nexport const NodeMetaIR = z.object({\n loc: z.optional(SourceLocIR),\n});\nexport type NodeMetaIR = z.infer<typeof NodeMetaIR>;\n\n/* -------------------------------- Conditions -------------------------------- */\n\nexport const PropertyOperatorIR = z.enum([\n 'eq',\n 'ne',\n 'gt',\n 'gte',\n 'lt',\n 'lte',\n 'between',\n 'not_between',\n 'in_array',\n 'not_in_array',\n 'exists',\n 'not_exists',\n 'contains',\n 'not_contains',\n]);\nexport type PropertyOperatorIR = z.infer<typeof PropertyOperatorIR>;\n\n/**\n * Condition expression tree. The shape of value/values follows `op`:\n * eq/ne/gt/gte/lt/lte/contains/not_contains → value; in_array/not_in_array →\n * values; between/not_between → values = [min, max]; exists/not_exists → neither.\n *\n * `payload` predicates test the payload of an event (dotted paths reach nested\n * fields). They are only valid inside a `where` subtree — `performed.where`\n * and the event trigger's `where` — where an event row is in scope; the\n * compiler (compileBundle) and the evaluator both enforce the placement.\n * TODO: runtime refinement narrowing by op.\n */\n/**\n * Provenance rides on conditions too, so the studio can point at the exact\n * `eq(...)` / `and(...)` that produced a node — same rule as nodes: `meta` is\n * dropped before hashing, so carrying it never affects execution identity.\n */\ninterface ConditionBaseIR {\n meta?: NodeMetaIR | undefined;\n}\n\nexport type ConditionIR =\n | (ConditionBaseIR & { type: 'and'; conditions: ConditionIR[] })\n | (ConditionBaseIR & { type: 'or'; conditions: ConditionIR[] })\n | (ConditionBaseIR & { type: 'not'; condition: ConditionIR })\n /** Reference a segment by id (defined in SegmentIR, versioned separately). */\n | (ConditionBaseIR & { type: 'segment'; segment: string })\n | (ConditionBaseIR & {\n type: 'property';\n path: string;\n op: PropertyOperatorIR;\n value?: ScalarIR | undefined;\n values?: ScalarIR[] | undefined;\n })\n | (ConditionBaseIR & {\n /** Event payload predicate; `path` is dotted for nested fields ('tags.utm_source'). */\n type: 'payload';\n path: string;\n op: PropertyOperatorIR;\n value?: ScalarIR | undefined;\n values?: ScalarIR[] | undefined;\n })\n | (ConditionBaseIR & {\n type: 'performed';\n event: string;\n /** Payload filter: only events matching it count (a payload-only subtree). */\n where?: ConditionIR | undefined;\n within?: DurationIR | undefined;\n count?: number | undefined;\n })\n /**\n * The user can be reached on the channel right now: for push, the host's\n * device directory lists at least one device; for the others, the channel's\n * recipient property (email, phone, …) is set.\n */\n | (ConditionBaseIR & { type: 'reachable'; channel: ChannelIR });\n\nconst conditionBase = { meta: z.optional(NodeMetaIR) };\n\nexport const ConditionIR: z.ZodMiniType<ConditionIR> = z.lazy(() =>\n z.discriminatedUnion('type', [\n z.object({ ...conditionBase, type: z.literal('and'), conditions: z.array(ConditionIR) }),\n z.object({ ...conditionBase, type: z.literal('or'), conditions: z.array(ConditionIR) }),\n z.object({ ...conditionBase, type: z.literal('not'), condition: ConditionIR }),\n z.object({ ...conditionBase, type: z.literal('segment'), segment: z.string() }),\n z.object({\n ...conditionBase,\n type: z.literal('property'),\n path: z.string(),\n op: PropertyOperatorIR,\n value: z.optional(ScalarIR),\n values: z.optional(z.array(ScalarIR)),\n }),\n z.object({\n ...conditionBase,\n type: z.literal('payload'),\n path: z.string(),\n op: PropertyOperatorIR,\n value: z.optional(ScalarIR),\n values: z.optional(z.array(ScalarIR)),\n }),\n z.object({\n ...conditionBase,\n type: z.literal('performed'),\n event: z.string(),\n where: z.optional(ConditionIR),\n within: z.optional(DurationIR),\n count: z.optional(z.number()),\n }),\n z.object({ ...conditionBase, type: z.literal('reachable'), channel: ChannelIR }),\n ])\n);\n\n/* --------------------------------- Triggers --------------------------------- */\n\n/**\n * Trigger shapes. The event trigger has two independent gates: `where` tests\n * the triggering event's payload (payload-only subtree, evaluated against the\n * incoming event before anything else), `filter` tests the user's profile.\n */\nexport const TriggerIR = z.discriminatedUnion('type', [\n z.object({\n type: z.literal('event'),\n event: z.string(),\n where: z.optional(ConditionIR),\n filter: z.optional(ConditionIR),\n }),\n z.object({ type: z.literal('segment'), segment: z.string() }),\n z.object({ type: z.literal('date'), at: z.string() }),\n z.object({ type: z.literal('webhook') }),\n]);\nexport type TriggerIR = z.infer<typeof TriggerIR>;\n\n/* ----------------------------------- Nodes ---------------------------------- */\n\ninterface NodeBaseIR {\n /** Stable id derived from the structural path (rules in the file header). */\n id: string;\n /** Optional node name: UI title / observability handle (branch's optional first argument lands here). */\n label?: string | undefined;\n /** Metadata (provenance); excluded from contentHash. */\n meta?: NodeMetaIR | undefined;\n}\n\nexport type NodeIR =\n | (NodeBaseIR & {\n type: 'message';\n channel: ChannelIR;\n template: string;\n props: Record<string, PropValueIR>;\n /**\n * Push only: which of the user's devices get a copy. Absent means every\n * device the directory lists; 'latest' only the most recently registered.\n */\n devices?: 'latest' | undefined;\n })\n | (NodeBaseIR & { type: 'delay'; duration: DurationIR })\n | (NodeBaseIR & { type: 'random_delay'; min: DurationIR; max: DurationIR })\n | (NodeBaseIR & {\n type: 'time_window';\n days: WeekdayIR[];\n between: [string, string];\n tz: string;\n })\n | (NodeBaseIR & {\n type: 'wait_until';\n condition: ConditionIR;\n timeout: DurationIR;\n onTimeout: 'continue' | 'exit' | NodeIR[];\n })\n | (NodeBaseIR & {\n type: 'branch';\n cases: { label?: string | undefined; condition: ConditionIR; flow: NodeIR[] }[];\n /** Default arm (the DSL's bare tail argument). Absent = fall through to the main line. */\n otherwise?: NodeIR[] | undefined;\n })\n | (NodeBaseIR & {\n type: 'filter';\n /** Audit-log line for the users this gate drops. Metadata: excluded from contentHash. */\n reason?: string | undefined;\n condition: ConditionIR;\n })\n | (NodeBaseIR & {\n type: 'cohort';\n /**\n * Explicit experiment key: bucketing hashes `userId:key` instead of the\n * structural node id, so the assignment survives node insertion/moves and\n * the experiment keeps one identity across workflow versions. Absent →\n * the node id is used (fine for one-off splits).\n */\n key?: string | undefined;\n /**\n * Arms in authoring order — the order the interpreter accumulates weights\n * in, so it decides which bucket range each arm owns. The DSL keeps that\n * promise by rejecting integer-like arm names, which JavaScript would\n * hoist to the front of the object in numeric order. Weights sum to 100.\n */\n arms: { name: string; weight: number; flow: NodeIR[] }[];\n })\n | (NodeBaseIR & {\n /** `reason` is the audit-log line. Metadata: excluded from contentHash. */\n type: 'exit';\n reason?: string | undefined;\n })\n | (NodeBaseIR & { type: 'send_event'; event: string; payload: Record<string, PropValueIR> })\n | (NodeBaseIR & {\n type: 'action';\n /** Registry name of the custom action; the code itself lives in the deployed Worker bundle. */\n action: string;\n /** Argument values; user_property references are resolved at run time, like message props. */\n args: Record<string, PropValueIR>;\n });\n\nconst nodeBase = { id: z.string(), label: z.optional(z.string()), meta: z.optional(NodeMetaIR) };\n\nexport const NodeIR: z.ZodMiniType<NodeIR> = z.lazy(() =>\n z.discriminatedUnion('type', [\n z.object({\n ...nodeBase,\n type: z.literal('message'),\n channel: ChannelIR,\n template: z.string(),\n props: z.record(z.string(), PropValueIR),\n devices: z.optional(z.literal('latest')),\n }),\n z.object({ ...nodeBase, type: z.literal('delay'), duration: DurationIR }),\n z.object({ ...nodeBase, type: z.literal('random_delay'), min: DurationIR, max: DurationIR }),\n z.object({\n ...nodeBase,\n type: z.literal('time_window'),\n days: z.array(WeekdayIR),\n between: z.tuple([z.string(), z.string()]),\n tz: z.string(),\n }),\n z.object({\n ...nodeBase,\n type: z.literal('wait_until'),\n condition: ConditionIR,\n timeout: DurationIR,\n onTimeout: z.union([z.literal('continue'), z.literal('exit'), z.array(NodeIR)]),\n }),\n z.object({\n ...nodeBase,\n type: z.literal('branch'),\n cases: z.array(\n z.object({\n label: z.optional(z.string()),\n condition: ConditionIR,\n flow: z.array(NodeIR),\n })\n ),\n otherwise: z.optional(z.array(NodeIR)),\n }),\n z.object({\n ...nodeBase,\n type: z.literal('filter'),\n condition: ConditionIR,\n reason: z.optional(z.string()),\n }),\n z.object({\n ...nodeBase,\n type: z.literal('cohort'),\n key: z.optional(z.string()),\n arms: z.array(z.object({ name: z.string(), weight: z.number(), flow: z.array(NodeIR) })),\n }),\n z.object({ ...nodeBase, type: z.literal('exit'), reason: z.optional(z.string()) }),\n z.object({\n ...nodeBase,\n type: z.literal('send_event'),\n event: z.string(),\n payload: z.record(z.string(), PropValueIR),\n }),\n z.object({\n ...nodeBase,\n type: z.literal('action'),\n action: z.string(),\n args: z.record(z.string(), PropValueIR),\n }),\n ])\n);\n\n/* ---------------------- Top-level definitions and bundle --------------------- */\n\nexport const GoalIR = z.object({\n condition: ConditionIR,\n within: z.optional(DurationIR),\n exitOnMatch: z.boolean(),\n});\nexport type GoalIR = z.infer<typeof GoalIR>;\n\n/**\n * Human-facing metadata: **excluded from contentHash** (see hash.ts).\n * Editing a name or description does not touch execution semantics, so it never\n * invalidates the version in-flight users are pinned to, nor shows up in plan.\n */\nexport const MetaIR = z.object({\n /**\n * Human label. The asset's `id` is its identity — a wire key that routing\n * tables and the entry ledger are built on — so it is not something to\n * rename for readability. This is: it is metadata, dropped before hashing,\n * and the studio shows it in place of the key wherever it is set.\n */\n name: z.optional(z.string()),\n description: z.optional(z.string()),\n loc: z.optional(SourceLocIR),\n});\nexport type MetaIR = z.infer<typeof MetaIR>;\n\nexport const WorkflowIR = z.object({\n irVersion: z.literal(IR_VERSION),\n /** Wire identity (`workflow({ id })`): routing tables, the entry ledger and reporting are keyed on it. */\n id: z.string(),\n contentHash: z.string(),\n meta: z.optional(MetaIR),\n trigger: TriggerIR,\n goal: z.optional(GoalIR),\n exitWhen: z.optional(ConditionIR),\n flow: z.array(NodeIR),\n});\nexport type WorkflowIR = z.infer<typeof WorkflowIR>;\n\nexport const SegmentIR = z.object({\n irVersion: z.literal(IR_VERSION),\n /** Wire identity (`segment({ id })`): segment references and the membership store are keyed on it. */\n id: z.string(),\n contentHash: z.string(),\n /** Provenance plus the human label — MetaIR, same as a workflow's. */\n meta: z.optional(MetaIR),\n condition: ConditionIR,\n});\nexport type SegmentIR = z.infer<typeof SegmentIR>;\n\n/** Template manifest entry: IR records only the reference; the body lives in the template system. */\nexport const TemplateIR = z.object({\n irVersion: z.literal(IR_VERSION),\n key: z.string(),\n channel: ChannelIR,\n});\nexport type TemplateIR = z.infer<typeof TemplateIR>;\n\n/**\n * Action manifest entry: IR records the name; the implementation solidifies\n * into the Worker bundle at deploy time, never into IR (custom code stays out\n * of the data plane by design).\n */\nexport const ActionIR = z.object({\n irVersion: z.literal(IR_VERSION),\n name: z.string(),\n});\nexport type ActionIR = z.infer<typeof ActionIR>;\n\n/**\n * Deployment unit: the complete snapshot one `deploy` produces (terraform\n * apply, same mental model). The server diffs each definition's contentHash to\n * decide publish / skip / retire.\n */\nexport const BundleIR = z.object({\n irVersion: z.literal(IR_VERSION),\n workflows: z.array(WorkflowIR),\n segments: z.array(SegmentIR),\n templates: z.array(TemplateIR),\n actions: z.array(ActionIR),\n});\nexport type BundleIR = z.infer<typeof BundleIR>;\n"],"mappings":"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"}
|
package/dist/plan.cjs
CHANGED
|
@@ -139,48 +139,42 @@ function diffSegments(local, deployed) {
|
|
|
139
139
|
});
|
|
140
140
|
return changes.sort((a, b) => a.id.localeCompare(b.id));
|
|
141
141
|
}
|
|
142
|
+
/**
|
|
143
|
+
* A template's identity is its key *within its channel* — an email and a push
|
|
144
|
+
* of one nudge share a key — so the diff is keyed, and reported, by
|
|
145
|
+
* `channel:key`. Keyed by bare key, two channels sharing one collapsed into a
|
|
146
|
+
* single entry that flipped between `changed` and `unchanged` depending on
|
|
147
|
+
* which came first.
|
|
148
|
+
*/
|
|
142
149
|
function diffTemplates(local, deployed) {
|
|
143
|
-
const
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
status: before.channel === item.channel ? "unchanged" : "changed"
|
|
153
|
-
};
|
|
154
|
-
});
|
|
155
|
-
const localKeys = new Set(local.map((item) => item.key));
|
|
156
|
-
for (const item of deployed) if (!localKeys.has(item.key)) changes.push({
|
|
157
|
-
id: item.key,
|
|
150
|
+
const idOf = (item) => `${item.channel}:${item.key}`;
|
|
151
|
+
const deployedIds = new Set(deployed.map(idOf));
|
|
152
|
+
const changes = local.map((item) => ({
|
|
153
|
+
id: idOf(item),
|
|
154
|
+
status: deployedIds.has(idOf(item)) ? "unchanged" : "added"
|
|
155
|
+
}));
|
|
156
|
+
const localIds = new Set(local.map(idOf));
|
|
157
|
+
for (const item of deployed) if (!localIds.has(idOf(item))) changes.push({
|
|
158
|
+
id: idOf(item),
|
|
158
159
|
status: "removed"
|
|
159
160
|
});
|
|
160
161
|
return changes.sort((a, b) => a.id.localeCompare(b.id));
|
|
161
162
|
}
|
|
162
|
-
/**
|
|
163
|
+
/**
|
|
164
|
+
* Actions diff by name only: the manifest records identity, and the code
|
|
165
|
+
* itself lives in the Worker bundle. A hash of the handler's printed source
|
|
166
|
+
* would depend on the bundler and report changes nobody made.
|
|
167
|
+
*/
|
|
163
168
|
function diffActions(local, deployed) {
|
|
164
|
-
const
|
|
165
|
-
const changes = local.map((item) => {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
status: "added",
|
|
170
|
-
after: item.codeHash
|
|
171
|
-
};
|
|
172
|
-
return {
|
|
173
|
-
id: item.name,
|
|
174
|
-
status: before.codeHash === item.codeHash ? "unchanged" : "changed",
|
|
175
|
-
before: before.codeHash,
|
|
176
|
-
after: item.codeHash
|
|
177
|
-
};
|
|
178
|
-
});
|
|
169
|
+
const deployedNames = new Set(deployed.map((item) => item.name));
|
|
170
|
+
const changes = local.map((item) => ({
|
|
171
|
+
id: item.name,
|
|
172
|
+
status: deployedNames.has(item.name) ? "unchanged" : "added"
|
|
173
|
+
}));
|
|
179
174
|
const localNames = new Set(local.map((item) => item.name));
|
|
180
175
|
for (const item of deployed) if (!localNames.has(item.name)) changes.push({
|
|
181
176
|
id: item.name,
|
|
182
|
-
status: "removed"
|
|
183
|
-
before: item.codeHash
|
|
177
|
+
status: "removed"
|
|
184
178
|
});
|
|
185
179
|
return changes.sort((a, b) => a.id.localeCompare(b.id));
|
|
186
180
|
}
|
package/dist/plan.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"plan.cjs","names":["fullHash","stripMeta","stripProvenance"],"sources":["../src/plan.ts"],"sourcesContent":["import { fullHash, stripMeta, stripProvenance } from './hash';\nimport type { BundleIR, NodeIR, SegmentIR, WorkflowIR } from './ir';\n\n/**\n * Plan — the diff between local definitions and what is deployed (terraform\n * plan, same mental model).\n *\n * This is the core advantage of code-first over a UI canvas: before deploying,\n * a machine can answer \"what changed, and how many in-flight users does it\n * affect\". Pure function, zero IO — CLI, UI and CI share one verdict.\n *\n * Status comes from two levels of comparison:\n * - contentHash differs → `changed` (semantics moved; in-flight users may need\n * migration)\n * - contentHash equal but full content differs → `metadata_only` (description\n * or labels only, nothing about execution) — exactly what keeping metadata\n * out of the hash buys us.\n */\n\nexport type ChangeStatus = 'added' | 'removed' | 'changed' | 'metadata_only' | 'unchanged';\n\n/** Whether a status warrants human attention (metadata-only edits do not). */\nexport function isSemanticChange(status: ChangeStatus): boolean {\n return status === 'added' || status === 'removed' || status === 'changed';\n}\n\nexport interface NodeChange {\n id: string;\n status: 'added' | 'removed' | 'changed';\n /** Node type: taken from the deployed side when removed, local otherwise. */\n type: NodeIR['type'];\n}\n\nexport interface WorkflowChange {\n /** The workflow's id. */\n id: string;\n status: ChangeStatus;\n /** Deployed contentHash (absent when added). */\n before?: string;\n /** Local contentHash (absent when removed). */\n after?: string;\n /** Workflow-level semantic fields that moved (trigger / goal / exitWhen). */\n fields: ('trigger' | 'goal' | 'exitWhen')[];\n /**\n * Node-level detail, matched by structural-path id — so **inserting a node\n * shifts the ids of its later siblings**, and one insert can report as a run\n * of `changed` entries. That is the known trade-off of structural ids (see\n * ir.ts): the verdict stays correct, it is just noisier than ideal.\n */\n nodes: NodeChange[];\n}\n\nexport interface ResourceChange {\n /** The resource's identity: a segment's id, a template's key, an action's name. */\n id: string;\n status: ChangeStatus;\n before?: string;\n after?: string;\n}\n\nexport interface BundlePlan {\n workflows: WorkflowChange[];\n segments: ResourceChange[];\n templates: ResourceChange[];\n actions: ResourceChange[];\n /** Whether any semantic change exists (metadata-only edits do not count). */\n hasChanges: boolean;\n}\n\n/** Walk the node tree into a flat id → node map (branch arms, cohort arms and timeout flows included). */\nfunction flatten(nodes: readonly NodeIR[], into: Map<string, NodeIR>): Map<string, NodeIR> {\n for (const node of nodes) {\n into.set(node.id, node);\n switch (node.type) {\n case 'branch':\n for (const branchCase of node.cases) flatten(branchCase.flow, into);\n if (node.otherwise) flatten(node.otherwise, into);\n break;\n case 'cohort':\n for (const arm of node.arms) flatten(arm.flow, into);\n break;\n case 'wait_until':\n if (Array.isArray(node.onTimeout)) flatten(node.onTimeout, into);\n break;\n default:\n break;\n }\n }\n return into;\n}\n\n/**\n * A node's semantic fingerprint: metadata stripped, child flows excluded (they\n * diff on their own ids). Everything else a compound node owns — branch case\n * conditions, cohort arm names and weights, *having* a default arm or a\n * timeout flow — is this node's semantics and stays in the fingerprint, so\n * editing a branch condition or reweighting an A/B shows up on the node itself.\n */\nfunction nodeFingerprint(node: NodeIR): string {\n const bare: Record<string, unknown> = { ...node };\n if (node.type === 'branch') {\n bare.cases = node.cases.map((branchCase) => ({ condition: branchCase.condition }));\n delete bare.otherwise;\n if (node.otherwise !== undefined) bare.otherwise = '<flow>';\n }\n if (node.type === 'cohort') {\n bare.arms = node.arms.map((arm) => ({ name: arm.name, weight: arm.weight }));\n }\n if (node.type === 'wait_until' && Array.isArray(node.onTimeout)) {\n bare.onTimeout = '<flow>';\n }\n return fullHash(stripMeta(bare));\n}\n\nfunction diffNodes(local: WorkflowIR, deployed: WorkflowIR): NodeChange[] {\n const localNodes = flatten(local.flow, new Map());\n const deployedNodes = flatten(deployed.flow, new Map());\n const changes: NodeChange[] = [];\n\n for (const [id, node] of localNodes) {\n const before = deployedNodes.get(id);\n if (!before) {\n changes.push({ id, status: 'added', type: node.type });\n } else if (nodeFingerprint(node) !== nodeFingerprint(before)) {\n changes.push({ id, status: 'changed', type: node.type });\n }\n }\n for (const [id, node] of deployedNodes) {\n if (!localNodes.has(id)) changes.push({ id, status: 'removed', type: node.type });\n }\n\n return changes.sort((a, b) => a.id.localeCompare(b.id));\n}\n\nfunction diffFields(local: WorkflowIR, deployed: WorkflowIR): WorkflowChange['fields'] {\n const fields: WorkflowChange['fields'] = [];\n for (const field of ['trigger', 'goal', 'exitWhen'] as const) {\n if (fullHash(stripMeta(local[field])) !== fullHash(stripMeta(deployed[field]))) {\n fields.push(field);\n }\n }\n return fields;\n}\n\nfunction statusOf(local: { contentHash: string }, deployed: { contentHash: string }): ChangeStatus {\n if (local.contentHash !== deployed.contentHash) return 'changed';\n /*\n * Provenance is deliberately not part of the comparison: a recorded loc is a\n * source map, and its path depends on the machine and the directory the\n * compile ran from ('packages/workflow/src/wf.ts' from the workspace root,\n * 'src/wf.ts' from the package). Comparing it turned every CI run into a\n * wall of metadata_only against a locally compiled deploy. Descriptions,\n * labels and reasons still count — those are edits somebody made.\n */\n return fullHash(stripProvenance(local)) === fullHash(stripProvenance(deployed))\n ? 'unchanged'\n : 'metadata_only';\n}\n\nfunction diffWorkflows(\n local: readonly WorkflowIR[],\n deployed: readonly WorkflowIR[]\n): WorkflowChange[] {\n const deployedByName = new Map(deployed.map((item) => [item.id, item]));\n const changes: WorkflowChange[] = [];\n\n for (const item of local) {\n const before = deployedByName.get(item.id);\n if (!before) {\n changes.push({\n id: item.id,\n status: 'added',\n after: item.contentHash,\n fields: [],\n nodes: [],\n });\n continue;\n }\n const status = statusOf(item, before);\n changes.push({\n id: item.id,\n status,\n before: before.contentHash,\n after: item.contentHash,\n fields: status === 'changed' ? diffFields(item, before) : [],\n nodes: status === 'changed' ? diffNodes(item, before) : [],\n });\n }\n\n const localNames = new Set(local.map((item) => item.id));\n for (const item of deployed) {\n if (!localNames.has(item.id)) {\n changes.push({\n id: item.id,\n status: 'removed',\n before: item.contentHash,\n fields: [],\n nodes: [],\n });\n }\n }\n\n return changes.sort((a, b) => a.id.localeCompare(b.id));\n}\n\nfunction diffSegments(\n local: readonly SegmentIR[],\n deployed: readonly SegmentIR[]\n): ResourceChange[] {\n const deployedByName = new Map(deployed.map((item) => [item.id, item]));\n const changes: ResourceChange[] = local.map((item) => {\n const before = deployedByName.get(item.id);\n if (!before) return { id: item.id, status: 'added' as const, after: item.contentHash };\n return {\n id: item.id,\n status: statusOf(item, before),\n before: before.contentHash,\n after: item.contentHash,\n };\n });\n\n const localNames = new Set(local.map((item) => item.id));\n for (const item of deployed) {\n if (!localNames.has(item.id)) {\n changes.push({ id: item.id, status: 'removed', before: item.contentHash });\n }\n }\n return changes.sort((a, b) => a.id.localeCompare(b.id));\n}\n\nfunction diffTemplates(local: BundleIR['templates'], deployed: BundleIR['templates']) {\n const deployedByKey = new Map(deployed.map((item) => [item.key, item]));\n const changes: ResourceChange[] = local.map((item) => {\n const before = deployedByKey.get(item.key);\n if (!before) return { id: item.key, status: 'added' as const };\n return { id: item.key, status: before.channel === item.channel ? 'unchanged' : 'changed' };\n });\n\n const localKeys = new Set(local.map((item) => item.key));\n for (const item of deployed) {\n if (!localKeys.has(item.key)) changes.push({ id: item.key, status: 'removed' });\n }\n return changes.sort((a, b) => a.id.localeCompare(b.id));\n}\n\n/** Actions diff on codeHash — the manifest's only semantic content (the code itself lives in the Worker bundle). */\nfunction diffActions(local: BundleIR['actions'], deployed: BundleIR['actions']) {\n const deployedByName = new Map(deployed.map((item) => [item.name, item]));\n const changes: ResourceChange[] = local.map((item) => {\n const before = deployedByName.get(item.name);\n if (!before) return { id: item.name, status: 'added' as const, after: item.codeHash };\n return {\n id: item.name,\n status: before.codeHash === item.codeHash ? ('unchanged' as const) : ('changed' as const),\n before: before.codeHash,\n after: item.codeHash,\n };\n });\n\n const localNames = new Set(local.map((item) => item.name));\n for (const item of deployed) {\n if (!localNames.has(item.name)) {\n changes.push({ id: item.name, status: 'removed', before: item.codeHash });\n }\n }\n return changes.sort((a, b) => a.id.localeCompare(b.id));\n}\n\n/** Empty bundle: the default `deployed` on a first deploy, so everything reports as added. */\nconst EMPTY: Pick<BundleIR, 'workflows' | 'segments' | 'templates' | 'actions'> = {\n workflows: [],\n segments: [],\n templates: [],\n actions: [],\n};\n\n/**\n * Compute a deployment plan. Omitting `deployed` means a first deploy.\n *\n * const changes = plan(compileBundle({ workflows }), await fetchDeployed());\n * if (changes.hasChanges) { ... }\n */\nexport function plan(local: BundleIR, deployed?: BundleIR): BundlePlan {\n const base = deployed ?? EMPTY;\n const workflows = diffWorkflows(local.workflows, base.workflows);\n const segments = diffSegments(local.segments, base.segments);\n const templates = diffTemplates(local.templates, base.templates);\n const actions = diffActions(local.actions, base.actions);\n\n const hasChanges = [...workflows, ...segments, ...templates, ...actions].some((change) =>\n isSemanticChange(change.status)\n );\n\n return { workflows, segments, templates, actions, hasChanges };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"plan.cjs","names":["fullHash","stripMeta","stripProvenance"],"sources":["../src/plan.ts"],"sourcesContent":["import { fullHash, stripMeta, stripProvenance } from './hash';\nimport type { BundleIR, NodeIR, SegmentIR, WorkflowIR } from './ir';\n\n/**\n * Plan — the diff between local definitions and what is deployed (terraform\n * plan, same mental model).\n *\n * This is the core advantage of code-first over a UI canvas: before deploying,\n * a machine can answer \"what changed, and how many in-flight users does it\n * affect\". Pure function, zero IO — CLI, UI and CI share one verdict.\n *\n * Status comes from two levels of comparison:\n * - contentHash differs → `changed` (semantics moved; in-flight users may need\n * migration)\n * - contentHash equal but full content differs → `metadata_only` (description\n * or labels only, nothing about execution) — exactly what keeping metadata\n * out of the hash buys us.\n */\n\nexport type ChangeStatus = 'added' | 'removed' | 'changed' | 'metadata_only' | 'unchanged';\n\n/** Whether a status warrants human attention (metadata-only edits do not). */\nexport function isSemanticChange(status: ChangeStatus): boolean {\n return status === 'added' || status === 'removed' || status === 'changed';\n}\n\nexport interface NodeChange {\n id: string;\n status: 'added' | 'removed' | 'changed';\n /** Node type: taken from the deployed side when removed, local otherwise. */\n type: NodeIR['type'];\n}\n\nexport interface WorkflowChange {\n /** The workflow's id. */\n id: string;\n status: ChangeStatus;\n /** Deployed contentHash (absent when added). */\n before?: string;\n /** Local contentHash (absent when removed). */\n after?: string;\n /** Workflow-level semantic fields that moved (trigger / goal / exitWhen). */\n fields: ('trigger' | 'goal' | 'exitWhen')[];\n /**\n * Node-level detail, matched by structural-path id — so **inserting a node\n * shifts the ids of its later siblings**, and one insert can report as a run\n * of `changed` entries. That is the known trade-off of structural ids (see\n * ir.ts): the verdict stays correct, it is just noisier than ideal.\n */\n nodes: NodeChange[];\n}\n\nexport interface ResourceChange {\n /** The resource's identity: a segment's id, a template's `channel:key`, an action's name. */\n id: string;\n status: ChangeStatus;\n before?: string;\n after?: string;\n}\n\nexport interface BundlePlan {\n workflows: WorkflowChange[];\n segments: ResourceChange[];\n templates: ResourceChange[];\n actions: ResourceChange[];\n /** Whether any semantic change exists (metadata-only edits do not count). */\n hasChanges: boolean;\n}\n\n/** Walk the node tree into a flat id → node map (branch arms, cohort arms and timeout flows included). */\nfunction flatten(nodes: readonly NodeIR[], into: Map<string, NodeIR>): Map<string, NodeIR> {\n for (const node of nodes) {\n into.set(node.id, node);\n switch (node.type) {\n case 'branch':\n for (const branchCase of node.cases) flatten(branchCase.flow, into);\n if (node.otherwise) flatten(node.otherwise, into);\n break;\n case 'cohort':\n for (const arm of node.arms) flatten(arm.flow, into);\n break;\n case 'wait_until':\n if (Array.isArray(node.onTimeout)) flatten(node.onTimeout, into);\n break;\n default:\n break;\n }\n }\n return into;\n}\n\n/**\n * A node's semantic fingerprint: metadata stripped, child flows excluded (they\n * diff on their own ids). Everything else a compound node owns — branch case\n * conditions, cohort arm names and weights, *having* a default arm or a\n * timeout flow — is this node's semantics and stays in the fingerprint, so\n * editing a branch condition or reweighting an A/B shows up on the node itself.\n */\nfunction nodeFingerprint(node: NodeIR): string {\n const bare: Record<string, unknown> = { ...node };\n if (node.type === 'branch') {\n bare.cases = node.cases.map((branchCase) => ({ condition: branchCase.condition }));\n delete bare.otherwise;\n if (node.otherwise !== undefined) bare.otherwise = '<flow>';\n }\n if (node.type === 'cohort') {\n bare.arms = node.arms.map((arm) => ({ name: arm.name, weight: arm.weight }));\n }\n if (node.type === 'wait_until' && Array.isArray(node.onTimeout)) {\n bare.onTimeout = '<flow>';\n }\n return fullHash(stripMeta(bare));\n}\n\nfunction diffNodes(local: WorkflowIR, deployed: WorkflowIR): NodeChange[] {\n const localNodes = flatten(local.flow, new Map());\n const deployedNodes = flatten(deployed.flow, new Map());\n const changes: NodeChange[] = [];\n\n for (const [id, node] of localNodes) {\n const before = deployedNodes.get(id);\n if (!before) {\n changes.push({ id, status: 'added', type: node.type });\n } else if (nodeFingerprint(node) !== nodeFingerprint(before)) {\n changes.push({ id, status: 'changed', type: node.type });\n }\n }\n for (const [id, node] of deployedNodes) {\n if (!localNodes.has(id)) changes.push({ id, status: 'removed', type: node.type });\n }\n\n return changes.sort((a, b) => a.id.localeCompare(b.id));\n}\n\nfunction diffFields(local: WorkflowIR, deployed: WorkflowIR): WorkflowChange['fields'] {\n const fields: WorkflowChange['fields'] = [];\n for (const field of ['trigger', 'goal', 'exitWhen'] as const) {\n if (fullHash(stripMeta(local[field])) !== fullHash(stripMeta(deployed[field]))) {\n fields.push(field);\n }\n }\n return fields;\n}\n\nfunction statusOf(local: { contentHash: string }, deployed: { contentHash: string }): ChangeStatus {\n if (local.contentHash !== deployed.contentHash) return 'changed';\n /*\n * Provenance is deliberately not part of the comparison: a recorded loc is a\n * source map, and its path depends on the machine and the directory the\n * compile ran from ('packages/workflow/src/wf.ts' from the workspace root,\n * 'src/wf.ts' from the package). Comparing it turned every CI run into a\n * wall of metadata_only against a locally compiled deploy. Descriptions,\n * labels and reasons still count — those are edits somebody made.\n */\n return fullHash(stripProvenance(local)) === fullHash(stripProvenance(deployed))\n ? 'unchanged'\n : 'metadata_only';\n}\n\nfunction diffWorkflows(\n local: readonly WorkflowIR[],\n deployed: readonly WorkflowIR[]\n): WorkflowChange[] {\n const deployedByName = new Map(deployed.map((item) => [item.id, item]));\n const changes: WorkflowChange[] = [];\n\n for (const item of local) {\n const before = deployedByName.get(item.id);\n if (!before) {\n changes.push({\n id: item.id,\n status: 'added',\n after: item.contentHash,\n fields: [],\n nodes: [],\n });\n continue;\n }\n const status = statusOf(item, before);\n changes.push({\n id: item.id,\n status,\n before: before.contentHash,\n after: item.contentHash,\n fields: status === 'changed' ? diffFields(item, before) : [],\n nodes: status === 'changed' ? diffNodes(item, before) : [],\n });\n }\n\n const localNames = new Set(local.map((item) => item.id));\n for (const item of deployed) {\n if (!localNames.has(item.id)) {\n changes.push({\n id: item.id,\n status: 'removed',\n before: item.contentHash,\n fields: [],\n nodes: [],\n });\n }\n }\n\n return changes.sort((a, b) => a.id.localeCompare(b.id));\n}\n\nfunction diffSegments(\n local: readonly SegmentIR[],\n deployed: readonly SegmentIR[]\n): ResourceChange[] {\n const deployedByName = new Map(deployed.map((item) => [item.id, item]));\n const changes: ResourceChange[] = local.map((item) => {\n const before = deployedByName.get(item.id);\n if (!before) return { id: item.id, status: 'added' as const, after: item.contentHash };\n return {\n id: item.id,\n status: statusOf(item, before),\n before: before.contentHash,\n after: item.contentHash,\n };\n });\n\n const localNames = new Set(local.map((item) => item.id));\n for (const item of deployed) {\n if (!localNames.has(item.id)) {\n changes.push({ id: item.id, status: 'removed', before: item.contentHash });\n }\n }\n return changes.sort((a, b) => a.id.localeCompare(b.id));\n}\n\n/**\n * A template's identity is its key *within its channel* — an email and a push\n * of one nudge share a key — so the diff is keyed, and reported, by\n * `channel:key`. Keyed by bare key, two channels sharing one collapsed into a\n * single entry that flipped between `changed` and `unchanged` depending on\n * which came first.\n */\nfunction diffTemplates(local: BundleIR['templates'], deployed: BundleIR['templates']) {\n const idOf = (item: BundleIR['templates'][number]) => `${item.channel}:${item.key}`;\n const deployedIds = new Set(deployed.map(idOf));\n const changes: ResourceChange[] = local.map((item) => ({\n id: idOf(item),\n status: deployedIds.has(idOf(item)) ? ('unchanged' as const) : ('added' as const),\n }));\n\n const localIds = new Set(local.map(idOf));\n for (const item of deployed) {\n if (!localIds.has(idOf(item))) changes.push({ id: idOf(item), status: 'removed' });\n }\n return changes.sort((a, b) => a.id.localeCompare(b.id));\n}\n\n/**\n * Actions diff by name only: the manifest records identity, and the code\n * itself lives in the Worker bundle. A hash of the handler's printed source\n * would depend on the bundler and report changes nobody made.\n */\nfunction diffActions(local: BundleIR['actions'], deployed: BundleIR['actions']) {\n const deployedNames = new Set(deployed.map((item) => item.name));\n const changes: ResourceChange[] = local.map((item) => ({\n id: item.name,\n status: deployedNames.has(item.name) ? ('unchanged' as const) : ('added' as const),\n }));\n\n const localNames = new Set(local.map((item) => item.name));\n for (const item of deployed) {\n if (!localNames.has(item.name)) changes.push({ id: item.name, status: 'removed' });\n }\n return changes.sort((a, b) => a.id.localeCompare(b.id));\n}\n\n/** Empty bundle: the default `deployed` on a first deploy, so everything reports as added. */\nconst EMPTY: Pick<BundleIR, 'workflows' | 'segments' | 'templates' | 'actions'> = {\n workflows: [],\n segments: [],\n templates: [],\n actions: [],\n};\n\n/**\n * Compute a deployment plan. Omitting `deployed` means a first deploy.\n *\n * const changes = plan(compileBundle({ workflows }), await fetchDeployed());\n * if (changes.hasChanges) { ... }\n */\nexport function plan(local: BundleIR, deployed?: BundleIR): BundlePlan {\n const base = deployed ?? EMPTY;\n const workflows = diffWorkflows(local.workflows, base.workflows);\n const segments = diffSegments(local.segments, base.segments);\n const templates = diffTemplates(local.templates, base.templates);\n const actions = diffActions(local.actions, base.actions);\n\n const hasChanges = [...workflows, ...segments, ...templates, ...actions].some((change) =>\n isSemanticChange(change.status)\n );\n\n return { workflows, segments, templates, actions, hasChanges };\n}\n"],"mappings":"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"}
|
package/dist/plan.d.cts
CHANGED
|
@@ -43,7 +43,7 @@ export interface WorkflowChange {
|
|
|
43
43
|
nodes: NodeChange[];
|
|
44
44
|
}
|
|
45
45
|
export interface ResourceChange {
|
|
46
|
-
/** The resource's identity: a segment's id, a template's key
|
|
46
|
+
/** The resource's identity: a segment's id, a template's `channel:key`, an action's name. */
|
|
47
47
|
id: string;
|
|
48
48
|
status: ChangeStatus;
|
|
49
49
|
before?: string;
|
package/dist/plan.d.cts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"plan.d.cts","names":[],"sources":["../src/plan.ts"],"mappings":";;;;;;;;;;;;;;;;;YAmBY;;wBAGI,iBAAiB,QAAQ;iBAIxB;EACf;EACA;;EAEA,MAAM;;iBAGS;;EAEf;EACA,QAAQ;;EAER;;EAEA;;EAEA;;;;;;;EAOA,OAAO;;iBAGQ;;EAEf;EACA,QAAQ;EACR;EACA;;iBAGe;EACf,WAAW;EACX,UAAU;EACV,WAAW;EACX,SAAS;;EAET;;;;;;;;
|
|
1
|
+
{"version":3,"file":"plan.d.cts","names":[],"sources":["../src/plan.ts"],"mappings":";;;;;;;;;;;;;;;;;YAmBY;;wBAGI,iBAAiB,QAAQ;iBAIxB;EACf;EACA;;EAEA,MAAM;;iBAGS;;EAEf;EACA,QAAQ;;EAER;;EAEA;;EAEA;;;;;;;EAOA,OAAO;;iBAGQ;;EAEf;EACA,QAAQ;EACR;EACA;;iBAGe;EACf,WAAW;EACX,UAAU;EACV,WAAW;EACX,SAAS;;EAET;;;;;;;;wBA2Nc,KAAK,OAAO,UAAU,WAAW,WAAW"}
|
package/dist/plan.d.mts
CHANGED
|
@@ -43,7 +43,7 @@ export interface WorkflowChange {
|
|
|
43
43
|
nodes: NodeChange[];
|
|
44
44
|
}
|
|
45
45
|
export interface ResourceChange {
|
|
46
|
-
/** The resource's identity: a segment's id, a template's key
|
|
46
|
+
/** The resource's identity: a segment's id, a template's `channel:key`, an action's name. */
|
|
47
47
|
id: string;
|
|
48
48
|
status: ChangeStatus;
|
|
49
49
|
before?: string;
|
package/dist/plan.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"plan.d.mts","names":[],"sources":["../src/plan.ts"],"mappings":";;;;;;;;;;;;;;;;;YAmBY;;wBAGI,iBAAiB,QAAQ;iBAIxB;EACf;EACA;;EAEA,MAAM;;iBAGS;;EAEf;EACA,QAAQ;;EAER;;EAEA;;EAEA;;;;;;;EAOA,OAAO;;iBAGQ;;EAEf;EACA,QAAQ;EACR;EACA;;iBAGe;EACf,WAAW;EACX,UAAU;EACV,WAAW;EACX,SAAS;;EAET;;;;;;;;
|
|
1
|
+
{"version":3,"file":"plan.d.mts","names":[],"sources":["../src/plan.ts"],"mappings":";;;;;;;;;;;;;;;;;YAmBY;;wBAGI,iBAAiB,QAAQ;iBAIxB;EACf;EACA;;EAEA,MAAM;;iBAGS;;EAEf;EACA,QAAQ;;EAER;;EAEA;;EAEA;;;;;;;EAOA,OAAO;;iBAGQ;;EAEf;EACA,QAAQ;EACR;EACA;;iBAGe;EACf,WAAW;EACX,UAAU;EACV,WAAW;EACX,SAAS;;EAET;;;;;;;;wBA2Nc,KAAK,OAAO,UAAU,WAAW,WAAW"}
|
package/dist/plan.mjs
CHANGED
|
@@ -138,48 +138,42 @@ function diffSegments(local, deployed) {
|
|
|
138
138
|
});
|
|
139
139
|
return changes.sort((a, b) => a.id.localeCompare(b.id));
|
|
140
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* A template's identity is its key *within its channel* — an email and a push
|
|
143
|
+
* of one nudge share a key — so the diff is keyed, and reported, by
|
|
144
|
+
* `channel:key`. Keyed by bare key, two channels sharing one collapsed into a
|
|
145
|
+
* single entry that flipped between `changed` and `unchanged` depending on
|
|
146
|
+
* which came first.
|
|
147
|
+
*/
|
|
141
148
|
function diffTemplates(local, deployed) {
|
|
142
|
-
const
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
status: before.channel === item.channel ? "unchanged" : "changed"
|
|
152
|
-
};
|
|
153
|
-
});
|
|
154
|
-
const localKeys = new Set(local.map((item) => item.key));
|
|
155
|
-
for (const item of deployed) if (!localKeys.has(item.key)) changes.push({
|
|
156
|
-
id: item.key,
|
|
149
|
+
const idOf = (item) => `${item.channel}:${item.key}`;
|
|
150
|
+
const deployedIds = new Set(deployed.map(idOf));
|
|
151
|
+
const changes = local.map((item) => ({
|
|
152
|
+
id: idOf(item),
|
|
153
|
+
status: deployedIds.has(idOf(item)) ? "unchanged" : "added"
|
|
154
|
+
}));
|
|
155
|
+
const localIds = new Set(local.map(idOf));
|
|
156
|
+
for (const item of deployed) if (!localIds.has(idOf(item))) changes.push({
|
|
157
|
+
id: idOf(item),
|
|
157
158
|
status: "removed"
|
|
158
159
|
});
|
|
159
160
|
return changes.sort((a, b) => a.id.localeCompare(b.id));
|
|
160
161
|
}
|
|
161
|
-
/**
|
|
162
|
+
/**
|
|
163
|
+
* Actions diff by name only: the manifest records identity, and the code
|
|
164
|
+
* itself lives in the Worker bundle. A hash of the handler's printed source
|
|
165
|
+
* would depend on the bundler and report changes nobody made.
|
|
166
|
+
*/
|
|
162
167
|
function diffActions(local, deployed) {
|
|
163
|
-
const
|
|
164
|
-
const changes = local.map((item) => {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
status: "added",
|
|
169
|
-
after: item.codeHash
|
|
170
|
-
};
|
|
171
|
-
return {
|
|
172
|
-
id: item.name,
|
|
173
|
-
status: before.codeHash === item.codeHash ? "unchanged" : "changed",
|
|
174
|
-
before: before.codeHash,
|
|
175
|
-
after: item.codeHash
|
|
176
|
-
};
|
|
177
|
-
});
|
|
168
|
+
const deployedNames = new Set(deployed.map((item) => item.name));
|
|
169
|
+
const changes = local.map((item) => ({
|
|
170
|
+
id: item.name,
|
|
171
|
+
status: deployedNames.has(item.name) ? "unchanged" : "added"
|
|
172
|
+
}));
|
|
178
173
|
const localNames = new Set(local.map((item) => item.name));
|
|
179
174
|
for (const item of deployed) if (!localNames.has(item.name)) changes.push({
|
|
180
175
|
id: item.name,
|
|
181
|
-
status: "removed"
|
|
182
|
-
before: item.codeHash
|
|
176
|
+
status: "removed"
|
|
183
177
|
});
|
|
184
178
|
return changes.sort((a, b) => a.id.localeCompare(b.id));
|
|
185
179
|
}
|
package/dist/plan.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"plan.mjs","names":[],"sources":["../src/plan.ts"],"sourcesContent":["import { fullHash, stripMeta, stripProvenance } from './hash';\nimport type { BundleIR, NodeIR, SegmentIR, WorkflowIR } from './ir';\n\n/**\n * Plan — the diff between local definitions and what is deployed (terraform\n * plan, same mental model).\n *\n * This is the core advantage of code-first over a UI canvas: before deploying,\n * a machine can answer \"what changed, and how many in-flight users does it\n * affect\". Pure function, zero IO — CLI, UI and CI share one verdict.\n *\n * Status comes from two levels of comparison:\n * - contentHash differs → `changed` (semantics moved; in-flight users may need\n * migration)\n * - contentHash equal but full content differs → `metadata_only` (description\n * or labels only, nothing about execution) — exactly what keeping metadata\n * out of the hash buys us.\n */\n\nexport type ChangeStatus = 'added' | 'removed' | 'changed' | 'metadata_only' | 'unchanged';\n\n/** Whether a status warrants human attention (metadata-only edits do not). */\nexport function isSemanticChange(status: ChangeStatus): boolean {\n return status === 'added' || status === 'removed' || status === 'changed';\n}\n\nexport interface NodeChange {\n id: string;\n status: 'added' | 'removed' | 'changed';\n /** Node type: taken from the deployed side when removed, local otherwise. */\n type: NodeIR['type'];\n}\n\nexport interface WorkflowChange {\n /** The workflow's id. */\n id: string;\n status: ChangeStatus;\n /** Deployed contentHash (absent when added). */\n before?: string;\n /** Local contentHash (absent when removed). */\n after?: string;\n /** Workflow-level semantic fields that moved (trigger / goal / exitWhen). */\n fields: ('trigger' | 'goal' | 'exitWhen')[];\n /**\n * Node-level detail, matched by structural-path id — so **inserting a node\n * shifts the ids of its later siblings**, and one insert can report as a run\n * of `changed` entries. That is the known trade-off of structural ids (see\n * ir.ts): the verdict stays correct, it is just noisier than ideal.\n */\n nodes: NodeChange[];\n}\n\nexport interface ResourceChange {\n /** The resource's identity: a segment's id, a template's key, an action's name. */\n id: string;\n status: ChangeStatus;\n before?: string;\n after?: string;\n}\n\nexport interface BundlePlan {\n workflows: WorkflowChange[];\n segments: ResourceChange[];\n templates: ResourceChange[];\n actions: ResourceChange[];\n /** Whether any semantic change exists (metadata-only edits do not count). */\n hasChanges: boolean;\n}\n\n/** Walk the node tree into a flat id → node map (branch arms, cohort arms and timeout flows included). */\nfunction flatten(nodes: readonly NodeIR[], into: Map<string, NodeIR>): Map<string, NodeIR> {\n for (const node of nodes) {\n into.set(node.id, node);\n switch (node.type) {\n case 'branch':\n for (const branchCase of node.cases) flatten(branchCase.flow, into);\n if (node.otherwise) flatten(node.otherwise, into);\n break;\n case 'cohort':\n for (const arm of node.arms) flatten(arm.flow, into);\n break;\n case 'wait_until':\n if (Array.isArray(node.onTimeout)) flatten(node.onTimeout, into);\n break;\n default:\n break;\n }\n }\n return into;\n}\n\n/**\n * A node's semantic fingerprint: metadata stripped, child flows excluded (they\n * diff on their own ids). Everything else a compound node owns — branch case\n * conditions, cohort arm names and weights, *having* a default arm or a\n * timeout flow — is this node's semantics and stays in the fingerprint, so\n * editing a branch condition or reweighting an A/B shows up on the node itself.\n */\nfunction nodeFingerprint(node: NodeIR): string {\n const bare: Record<string, unknown> = { ...node };\n if (node.type === 'branch') {\n bare.cases = node.cases.map((branchCase) => ({ condition: branchCase.condition }));\n delete bare.otherwise;\n if (node.otherwise !== undefined) bare.otherwise = '<flow>';\n }\n if (node.type === 'cohort') {\n bare.arms = node.arms.map((arm) => ({ name: arm.name, weight: arm.weight }));\n }\n if (node.type === 'wait_until' && Array.isArray(node.onTimeout)) {\n bare.onTimeout = '<flow>';\n }\n return fullHash(stripMeta(bare));\n}\n\nfunction diffNodes(local: WorkflowIR, deployed: WorkflowIR): NodeChange[] {\n const localNodes = flatten(local.flow, new Map());\n const deployedNodes = flatten(deployed.flow, new Map());\n const changes: NodeChange[] = [];\n\n for (const [id, node] of localNodes) {\n const before = deployedNodes.get(id);\n if (!before) {\n changes.push({ id, status: 'added', type: node.type });\n } else if (nodeFingerprint(node) !== nodeFingerprint(before)) {\n changes.push({ id, status: 'changed', type: node.type });\n }\n }\n for (const [id, node] of deployedNodes) {\n if (!localNodes.has(id)) changes.push({ id, status: 'removed', type: node.type });\n }\n\n return changes.sort((a, b) => a.id.localeCompare(b.id));\n}\n\nfunction diffFields(local: WorkflowIR, deployed: WorkflowIR): WorkflowChange['fields'] {\n const fields: WorkflowChange['fields'] = [];\n for (const field of ['trigger', 'goal', 'exitWhen'] as const) {\n if (fullHash(stripMeta(local[field])) !== fullHash(stripMeta(deployed[field]))) {\n fields.push(field);\n }\n }\n return fields;\n}\n\nfunction statusOf(local: { contentHash: string }, deployed: { contentHash: string }): ChangeStatus {\n if (local.contentHash !== deployed.contentHash) return 'changed';\n /*\n * Provenance is deliberately not part of the comparison: a recorded loc is a\n * source map, and its path depends on the machine and the directory the\n * compile ran from ('packages/workflow/src/wf.ts' from the workspace root,\n * 'src/wf.ts' from the package). Comparing it turned every CI run into a\n * wall of metadata_only against a locally compiled deploy. Descriptions,\n * labels and reasons still count — those are edits somebody made.\n */\n return fullHash(stripProvenance(local)) === fullHash(stripProvenance(deployed))\n ? 'unchanged'\n : 'metadata_only';\n}\n\nfunction diffWorkflows(\n local: readonly WorkflowIR[],\n deployed: readonly WorkflowIR[]\n): WorkflowChange[] {\n const deployedByName = new Map(deployed.map((item) => [item.id, item]));\n const changes: WorkflowChange[] = [];\n\n for (const item of local) {\n const before = deployedByName.get(item.id);\n if (!before) {\n changes.push({\n id: item.id,\n status: 'added',\n after: item.contentHash,\n fields: [],\n nodes: [],\n });\n continue;\n }\n const status = statusOf(item, before);\n changes.push({\n id: item.id,\n status,\n before: before.contentHash,\n after: item.contentHash,\n fields: status === 'changed' ? diffFields(item, before) : [],\n nodes: status === 'changed' ? diffNodes(item, before) : [],\n });\n }\n\n const localNames = new Set(local.map((item) => item.id));\n for (const item of deployed) {\n if (!localNames.has(item.id)) {\n changes.push({\n id: item.id,\n status: 'removed',\n before: item.contentHash,\n fields: [],\n nodes: [],\n });\n }\n }\n\n return changes.sort((a, b) => a.id.localeCompare(b.id));\n}\n\nfunction diffSegments(\n local: readonly SegmentIR[],\n deployed: readonly SegmentIR[]\n): ResourceChange[] {\n const deployedByName = new Map(deployed.map((item) => [item.id, item]));\n const changes: ResourceChange[] = local.map((item) => {\n const before = deployedByName.get(item.id);\n if (!before) return { id: item.id, status: 'added' as const, after: item.contentHash };\n return {\n id: item.id,\n status: statusOf(item, before),\n before: before.contentHash,\n after: item.contentHash,\n };\n });\n\n const localNames = new Set(local.map((item) => item.id));\n for (const item of deployed) {\n if (!localNames.has(item.id)) {\n changes.push({ id: item.id, status: 'removed', before: item.contentHash });\n }\n }\n return changes.sort((a, b) => a.id.localeCompare(b.id));\n}\n\nfunction diffTemplates(local: BundleIR['templates'], deployed: BundleIR['templates']) {\n const deployedByKey = new Map(deployed.map((item) => [item.key, item]));\n const changes: ResourceChange[] = local.map((item) => {\n const before = deployedByKey.get(item.key);\n if (!before) return { id: item.key, status: 'added' as const };\n return { id: item.key, status: before.channel === item.channel ? 'unchanged' : 'changed' };\n });\n\n const localKeys = new Set(local.map((item) => item.key));\n for (const item of deployed) {\n if (!localKeys.has(item.key)) changes.push({ id: item.key, status: 'removed' });\n }\n return changes.sort((a, b) => a.id.localeCompare(b.id));\n}\n\n/** Actions diff on codeHash — the manifest's only semantic content (the code itself lives in the Worker bundle). */\nfunction diffActions(local: BundleIR['actions'], deployed: BundleIR['actions']) {\n const deployedByName = new Map(deployed.map((item) => [item.name, item]));\n const changes: ResourceChange[] = local.map((item) => {\n const before = deployedByName.get(item.name);\n if (!before) return { id: item.name, status: 'added' as const, after: item.codeHash };\n return {\n id: item.name,\n status: before.codeHash === item.codeHash ? ('unchanged' as const) : ('changed' as const),\n before: before.codeHash,\n after: item.codeHash,\n };\n });\n\n const localNames = new Set(local.map((item) => item.name));\n for (const item of deployed) {\n if (!localNames.has(item.name)) {\n changes.push({ id: item.name, status: 'removed', before: item.codeHash });\n }\n }\n return changes.sort((a, b) => a.id.localeCompare(b.id));\n}\n\n/** Empty bundle: the default `deployed` on a first deploy, so everything reports as added. */\nconst EMPTY: Pick<BundleIR, 'workflows' | 'segments' | 'templates' | 'actions'> = {\n workflows: [],\n segments: [],\n templates: [],\n actions: [],\n};\n\n/**\n * Compute a deployment plan. Omitting `deployed` means a first deploy.\n *\n * const changes = plan(compileBundle({ workflows }), await fetchDeployed());\n * if (changes.hasChanges) { ... }\n */\nexport function plan(local: BundleIR, deployed?: BundleIR): BundlePlan {\n const base = deployed ?? EMPTY;\n const workflows = diffWorkflows(local.workflows, base.workflows);\n const segments = diffSegments(local.segments, base.segments);\n const templates = diffTemplates(local.templates, base.templates);\n const actions = diffActions(local.actions, base.actions);\n\n const hasChanges = [...workflows, ...segments, ...templates, ...actions].some((change) =>\n isSemanticChange(change.status)\n );\n\n return { workflows, segments, templates, actions, hasChanges };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"plan.mjs","names":[],"sources":["../src/plan.ts"],"sourcesContent":["import { fullHash, stripMeta, stripProvenance } from './hash';\nimport type { BundleIR, NodeIR, SegmentIR, WorkflowIR } from './ir';\n\n/**\n * Plan — the diff between local definitions and what is deployed (terraform\n * plan, same mental model).\n *\n * This is the core advantage of code-first over a UI canvas: before deploying,\n * a machine can answer \"what changed, and how many in-flight users does it\n * affect\". Pure function, zero IO — CLI, UI and CI share one verdict.\n *\n * Status comes from two levels of comparison:\n * - contentHash differs → `changed` (semantics moved; in-flight users may need\n * migration)\n * - contentHash equal but full content differs → `metadata_only` (description\n * or labels only, nothing about execution) — exactly what keeping metadata\n * out of the hash buys us.\n */\n\nexport type ChangeStatus = 'added' | 'removed' | 'changed' | 'metadata_only' | 'unchanged';\n\n/** Whether a status warrants human attention (metadata-only edits do not). */\nexport function isSemanticChange(status: ChangeStatus): boolean {\n return status === 'added' || status === 'removed' || status === 'changed';\n}\n\nexport interface NodeChange {\n id: string;\n status: 'added' | 'removed' | 'changed';\n /** Node type: taken from the deployed side when removed, local otherwise. */\n type: NodeIR['type'];\n}\n\nexport interface WorkflowChange {\n /** The workflow's id. */\n id: string;\n status: ChangeStatus;\n /** Deployed contentHash (absent when added). */\n before?: string;\n /** Local contentHash (absent when removed). */\n after?: string;\n /** Workflow-level semantic fields that moved (trigger / goal / exitWhen). */\n fields: ('trigger' | 'goal' | 'exitWhen')[];\n /**\n * Node-level detail, matched by structural-path id — so **inserting a node\n * shifts the ids of its later siblings**, and one insert can report as a run\n * of `changed` entries. That is the known trade-off of structural ids (see\n * ir.ts): the verdict stays correct, it is just noisier than ideal.\n */\n nodes: NodeChange[];\n}\n\nexport interface ResourceChange {\n /** The resource's identity: a segment's id, a template's `channel:key`, an action's name. */\n id: string;\n status: ChangeStatus;\n before?: string;\n after?: string;\n}\n\nexport interface BundlePlan {\n workflows: WorkflowChange[];\n segments: ResourceChange[];\n templates: ResourceChange[];\n actions: ResourceChange[];\n /** Whether any semantic change exists (metadata-only edits do not count). */\n hasChanges: boolean;\n}\n\n/** Walk the node tree into a flat id → node map (branch arms, cohort arms and timeout flows included). */\nfunction flatten(nodes: readonly NodeIR[], into: Map<string, NodeIR>): Map<string, NodeIR> {\n for (const node of nodes) {\n into.set(node.id, node);\n switch (node.type) {\n case 'branch':\n for (const branchCase of node.cases) flatten(branchCase.flow, into);\n if (node.otherwise) flatten(node.otherwise, into);\n break;\n case 'cohort':\n for (const arm of node.arms) flatten(arm.flow, into);\n break;\n case 'wait_until':\n if (Array.isArray(node.onTimeout)) flatten(node.onTimeout, into);\n break;\n default:\n break;\n }\n }\n return into;\n}\n\n/**\n * A node's semantic fingerprint: metadata stripped, child flows excluded (they\n * diff on their own ids). Everything else a compound node owns — branch case\n * conditions, cohort arm names and weights, *having* a default arm or a\n * timeout flow — is this node's semantics and stays in the fingerprint, so\n * editing a branch condition or reweighting an A/B shows up on the node itself.\n */\nfunction nodeFingerprint(node: NodeIR): string {\n const bare: Record<string, unknown> = { ...node };\n if (node.type === 'branch') {\n bare.cases = node.cases.map((branchCase) => ({ condition: branchCase.condition }));\n delete bare.otherwise;\n if (node.otherwise !== undefined) bare.otherwise = '<flow>';\n }\n if (node.type === 'cohort') {\n bare.arms = node.arms.map((arm) => ({ name: arm.name, weight: arm.weight }));\n }\n if (node.type === 'wait_until' && Array.isArray(node.onTimeout)) {\n bare.onTimeout = '<flow>';\n }\n return fullHash(stripMeta(bare));\n}\n\nfunction diffNodes(local: WorkflowIR, deployed: WorkflowIR): NodeChange[] {\n const localNodes = flatten(local.flow, new Map());\n const deployedNodes = flatten(deployed.flow, new Map());\n const changes: NodeChange[] = [];\n\n for (const [id, node] of localNodes) {\n const before = deployedNodes.get(id);\n if (!before) {\n changes.push({ id, status: 'added', type: node.type });\n } else if (nodeFingerprint(node) !== nodeFingerprint(before)) {\n changes.push({ id, status: 'changed', type: node.type });\n }\n }\n for (const [id, node] of deployedNodes) {\n if (!localNodes.has(id)) changes.push({ id, status: 'removed', type: node.type });\n }\n\n return changes.sort((a, b) => a.id.localeCompare(b.id));\n}\n\nfunction diffFields(local: WorkflowIR, deployed: WorkflowIR): WorkflowChange['fields'] {\n const fields: WorkflowChange['fields'] = [];\n for (const field of ['trigger', 'goal', 'exitWhen'] as const) {\n if (fullHash(stripMeta(local[field])) !== fullHash(stripMeta(deployed[field]))) {\n fields.push(field);\n }\n }\n return fields;\n}\n\nfunction statusOf(local: { contentHash: string }, deployed: { contentHash: string }): ChangeStatus {\n if (local.contentHash !== deployed.contentHash) return 'changed';\n /*\n * Provenance is deliberately not part of the comparison: a recorded loc is a\n * source map, and its path depends on the machine and the directory the\n * compile ran from ('packages/workflow/src/wf.ts' from the workspace root,\n * 'src/wf.ts' from the package). Comparing it turned every CI run into a\n * wall of metadata_only against a locally compiled deploy. Descriptions,\n * labels and reasons still count — those are edits somebody made.\n */\n return fullHash(stripProvenance(local)) === fullHash(stripProvenance(deployed))\n ? 'unchanged'\n : 'metadata_only';\n}\n\nfunction diffWorkflows(\n local: readonly WorkflowIR[],\n deployed: readonly WorkflowIR[]\n): WorkflowChange[] {\n const deployedByName = new Map(deployed.map((item) => [item.id, item]));\n const changes: WorkflowChange[] = [];\n\n for (const item of local) {\n const before = deployedByName.get(item.id);\n if (!before) {\n changes.push({\n id: item.id,\n status: 'added',\n after: item.contentHash,\n fields: [],\n nodes: [],\n });\n continue;\n }\n const status = statusOf(item, before);\n changes.push({\n id: item.id,\n status,\n before: before.contentHash,\n after: item.contentHash,\n fields: status === 'changed' ? diffFields(item, before) : [],\n nodes: status === 'changed' ? diffNodes(item, before) : [],\n });\n }\n\n const localNames = new Set(local.map((item) => item.id));\n for (const item of deployed) {\n if (!localNames.has(item.id)) {\n changes.push({\n id: item.id,\n status: 'removed',\n before: item.contentHash,\n fields: [],\n nodes: [],\n });\n }\n }\n\n return changes.sort((a, b) => a.id.localeCompare(b.id));\n}\n\nfunction diffSegments(\n local: readonly SegmentIR[],\n deployed: readonly SegmentIR[]\n): ResourceChange[] {\n const deployedByName = new Map(deployed.map((item) => [item.id, item]));\n const changes: ResourceChange[] = local.map((item) => {\n const before = deployedByName.get(item.id);\n if (!before) return { id: item.id, status: 'added' as const, after: item.contentHash };\n return {\n id: item.id,\n status: statusOf(item, before),\n before: before.contentHash,\n after: item.contentHash,\n };\n });\n\n const localNames = new Set(local.map((item) => item.id));\n for (const item of deployed) {\n if (!localNames.has(item.id)) {\n changes.push({ id: item.id, status: 'removed', before: item.contentHash });\n }\n }\n return changes.sort((a, b) => a.id.localeCompare(b.id));\n}\n\n/**\n * A template's identity is its key *within its channel* — an email and a push\n * of one nudge share a key — so the diff is keyed, and reported, by\n * `channel:key`. Keyed by bare key, two channels sharing one collapsed into a\n * single entry that flipped between `changed` and `unchanged` depending on\n * which came first.\n */\nfunction diffTemplates(local: BundleIR['templates'], deployed: BundleIR['templates']) {\n const idOf = (item: BundleIR['templates'][number]) => `${item.channel}:${item.key}`;\n const deployedIds = new Set(deployed.map(idOf));\n const changes: ResourceChange[] = local.map((item) => ({\n id: idOf(item),\n status: deployedIds.has(idOf(item)) ? ('unchanged' as const) : ('added' as const),\n }));\n\n const localIds = new Set(local.map(idOf));\n for (const item of deployed) {\n if (!localIds.has(idOf(item))) changes.push({ id: idOf(item), status: 'removed' });\n }\n return changes.sort((a, b) => a.id.localeCompare(b.id));\n}\n\n/**\n * Actions diff by name only: the manifest records identity, and the code\n * itself lives in the Worker bundle. A hash of the handler's printed source\n * would depend on the bundler and report changes nobody made.\n */\nfunction diffActions(local: BundleIR['actions'], deployed: BundleIR['actions']) {\n const deployedNames = new Set(deployed.map((item) => item.name));\n const changes: ResourceChange[] = local.map((item) => ({\n id: item.name,\n status: deployedNames.has(item.name) ? ('unchanged' as const) : ('added' as const),\n }));\n\n const localNames = new Set(local.map((item) => item.name));\n for (const item of deployed) {\n if (!localNames.has(item.name)) changes.push({ id: item.name, status: 'removed' });\n }\n return changes.sort((a, b) => a.id.localeCompare(b.id));\n}\n\n/** Empty bundle: the default `deployed` on a first deploy, so everything reports as added. */\nconst EMPTY: Pick<BundleIR, 'workflows' | 'segments' | 'templates' | 'actions'> = {\n workflows: [],\n segments: [],\n templates: [],\n actions: [],\n};\n\n/**\n * Compute a deployment plan. Omitting `deployed` means a first deploy.\n *\n * const changes = plan(compileBundle({ workflows }), await fetchDeployed());\n * if (changes.hasChanges) { ... }\n */\nexport function plan(local: BundleIR, deployed?: BundleIR): BundlePlan {\n const base = deployed ?? EMPTY;\n const workflows = diffWorkflows(local.workflows, base.workflows);\n const segments = diffSegments(local.segments, base.segments);\n const templates = diffTemplates(local.templates, base.templates);\n const actions = diffActions(local.actions, base.actions);\n\n const hasChanges = [...workflows, ...segments, ...templates, ...actions].some((change) =>\n isSemanticChange(change.status)\n );\n\n return { workflows, segments, templates, actions, hasChanges };\n}\n"],"mappings":"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"}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
-
const require_email = require("../email.cjs");
|
|
3
2
|
const require_reporting_classify = require("./classify.cjs");
|
|
4
3
|
//#region src/reporting/tracking.ts
|
|
5
4
|
const GIF = Uint8Array.from(atob("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"), (char) => char.charCodeAt(0));
|
|
@@ -31,10 +30,10 @@ async function handleTracking(request, reporting, options = {}) {
|
|
|
31
30
|
"cache-control": "no-store, max-age=0"
|
|
32
31
|
} });
|
|
33
32
|
}
|
|
34
|
-
if ((kind === "l" || kind === "u"
|
|
33
|
+
if ((kind === "l" || kind === "u") && position !== void 0 && read) {
|
|
35
34
|
const target = await recordClick(reporting, id, Number(position), who, options);
|
|
36
35
|
if (target === void 0) return page(options, "This link has expired.", 404);
|
|
37
|
-
if (kind === "u"
|
|
36
|
+
if (kind === "u") return confirmUnsubscribe(options, id);
|
|
38
37
|
return Response.redirect(target, 302);
|
|
39
38
|
}
|
|
40
39
|
if (kind === "u" && position === void 0 && read) return confirmUnsubscribe(options, id);
|
|
@@ -66,7 +65,18 @@ async function handleTracking(request, reporting, options = {}) {
|
|
|
66
65
|
return new Response(null, { status: known ? 204 : 404 });
|
|
67
66
|
}
|
|
68
67
|
if (kind === "u" && who.method === "POST") {
|
|
69
|
-
|
|
68
|
+
const at = Date.now();
|
|
69
|
+
const unsubscribed = await reporting.recordUnsubscribe(id, at);
|
|
70
|
+
if (unsubscribed === void 0) return page(options, "You won't get these emails anymore.");
|
|
71
|
+
if (options.onUnsubscribed !== void 0) try {
|
|
72
|
+
await options.onUnsubscribed({
|
|
73
|
+
messageId: id,
|
|
74
|
+
userId: unsubscribed.userId,
|
|
75
|
+
at
|
|
76
|
+
});
|
|
77
|
+
} catch (error) {
|
|
78
|
+
console.error(`[workflow] acting on the unsubscribe of ${id} failed`, error);
|
|
79
|
+
}
|
|
70
80
|
return page(options, options.product === void 0 ? "You won't get these emails anymore." : `You won't get these emails from ${escape(options.product)} anymore.`);
|
|
71
81
|
}
|
|
72
82
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tracking.cjs","names":["isUnsubscribeLink","classifyOpen","BURST_WINDOW_MS","classifyClick"],"sources":["../../src/reporting/tracking.ts"],"sourcesContent":["import { isUnsubscribeLink } from '../email';\nimport type { JourneyReporting } from '../store';\nimport { BURST_WINDOW_MS, classifyClick, classifyOpen } from './classify';\n\n/**\n * The endpoints a journey email's tracking points at (see ./observer.ts), served by journeyWorker\n * without a bearer — recipients open them:\n *\n * GET /t/o/<message> the open pixel: records the open, answers a 1×1 GIF\n * GET /t/l/<message>/<position> a link: records the click, redirects to the recorded URL\n * GET /t/u/<message>/<position> the unsubscribe link: records the click, asks to confirm\n * GET /t/u/<message> the List-Unsubscribe header's URL, opened: asks to confirm\n * POST /t/u/<message> the unsubscribe confirmation, and the header's one-click\n * unsubscribe (RFC 8058: a mail client's button posts here)\n * GET /t/c/<message>/<position> a link of an email sent before links carried their purpose\n * POST /t/d/<message> a push the device received (iOS's notification service\n * extension, a browser's service worker)\n * POST /t/p/<message> a push opened, reported by a browser's service worker\n *\n * Every open and click is recorded with its verdict (./classify.ts), and the message keeps its\n * first open and click of each kind. The redirect goes only to the URL recorded for that position,\n * never to one the request carries, so the endpoint cannot bounce visitors anywhere else. An\n * email's unsubscribe link is a tracked link too, and which link it is was fixed when the email was\n * sent (its route): following it shows a confirmation page, and only the confirmation — a POST,\n * which no link scanner sends — counts. Changing a template's or the config's unsubscribe URL, or\n * removing the template, changes nothing for emails already sent.\n *\n * Emails sent before that carry `/t/c/` links, which say nothing of their purpose: one is the\n * unsubscribe link when its recorded URL is `legacyUnsubscribeUrl` (the global unsubscribe URL\n * those emails were all sent with), as it was then.\n */\n\nexport interface TrackingOptions {\n /**\n * The unsubscribe URL emails sent with `/t/c/` links carried (the config's `email.unsubscribeUrl`\n * by default): following such a link to it asks for confirmation instead of redirecting.\n */\n legacyUnsubscribeUrl?: string | undefined;\n /** The name the confirmation pages show (the config's `title`). */\n product?: string | undefined;\n /** `email.tracking.quickClickMs`: how soon after delivery an open or click is a weak machine signal. */\n quickMs?: number | undefined;\n /** A browser reported a push tapped (`/t/p`): the host records it as an event too (`webPush.openedEvent`). */\n pushOpened?:\n | ((open: { messageId: string; userId: string; at: number }) => Promise<void>)\n | undefined;\n}\n\nconst GIF = Uint8Array.from(\n atob('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'),\n (char) => char.charCodeAt(0)\n);\nconst UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n\ninterface Requester {\n method: string;\n userAgent: string | null;\n asn: number | null;\n organization: string | null;\n country: string | null;\n}\n\nfunction requester(request: Request): Requester {\n const cf = (\n request as Request & { cf?: { asn?: number; asOrganization?: string; country?: string } }\n ).cf;\n return {\n method: request.method,\n userAgent: request.headers.get('user-agent'),\n asn: cf?.asn ?? null,\n organization: cf?.asOrganization ?? null,\n country: cf?.country ?? null,\n };\n}\n\n/** A `/t/...` request's response, or undefined when the path is not a tracking route. */\nexport async function handleTracking(\n request: Request,\n reporting: JourneyReporting,\n options: TrackingOptions = {}\n): Promise<Response | undefined> {\n const url = new URL(request.url);\n const path = url.pathname.split('/');\n const root = path.at(1);\n const kind = path.at(2);\n const id = path.at(3);\n const position = path.at(4);\n if (root !== 't' || id === undefined || !UUID.test(id)) return undefined;\n const who = requester(request);\n const read = who.method === 'GET' || who.method === 'HEAD';\n if (kind === 'o' && read) {\n await recordOpen(reporting, id, who, options);\n return new Response(who.method === 'HEAD' ? null : GIF, {\n headers: { 'content-type': 'image/gif', 'cache-control': 'no-store, max-age=0' },\n });\n }\n if ((kind === 'l' || kind === 'u' || kind === 'c') && position !== undefined && read) {\n const target = await recordClick(reporting, id, Number(position), who, options);\n if (target === undefined) return page(options, 'This link has expired.', 404);\n const unsubscribe =\n kind === 'u' || (kind === 'c' && isUnsubscribeLink(target, options.legacyUnsubscribeUrl));\n if (unsubscribe) return confirmUnsubscribe(options, id);\n return Response.redirect(target, 302);\n }\n // The List-Unsubscribe URL opened in a browser (a client without one-click): the same question\n if (kind === 'u' && position === undefined && read) return confirmUnsubscribe(options, id);\n if (kind === 'd' && who.method === 'POST') {\n const known = await reporting.recordDeviceDelivered(id, Date.now());\n return new Response(null, { status: known ? 204 : 404 });\n }\n if (kind === 'p' && who.method === 'POST') {\n // A notification tap: a person, as the message id is what proves it is theirs\n const at = Date.now();\n let userId: string | undefined;\n const known = await reporting.recordOpen(id, (message) => {\n userId = message.userId;\n return { at, machine: false, reason: null, ...network(who) };\n });\n if (known && userId !== undefined && options.pushOpened !== undefined) {\n try {\n await options.pushOpened({ messageId: id, userId, at });\n } catch (error) {\n // The open is recorded; the event is the host's extra, never a reason to fail the beacon\n console.error(`[workflow] recording push open ${id} as an event failed`, error);\n }\n }\n return new Response(null, { status: known ? 204 : 404 });\n }\n if (kind === 'u' && who.method === 'POST') {\n const known = await reporting.recordUnsubscribe(id, Date.now());\n // A mail client's one-click POST must succeed: for a copy no longer on record (its user\n // erased) there is nothing left to send to, so the answer is the same\n if (!known) return page(options, \"You won't get these emails anymore.\");\n return page(\n options,\n options.product === undefined\n ? \"You won't get these emails anymore.\"\n : `You won't get these emails from ${escape(options.product)} anymore.`\n );\n }\n return undefined;\n}\n\n/** The question an unsubscribe link, or the List-Unsubscribe URL opened in a browser, asks. */\nfunction confirmUnsubscribe(options: TrackingOptions, id: string): Response {\n return page(\n options,\n `Unsubscribe from ${escape(options.product ?? 'these')} emails?`,\n 200,\n `<form method=\"post\" action=\"/t/u/${id}\"><button type=\"submit\">Unsubscribe</button></form>`\n );\n}\n\n/**\n * A journey push tapped open, as the app reports it (`JourneyClient.messageOpened`): an open on\n * the copy — only the recipient's own, and never a machine, since no proxy or scanner taps a\n * notification. False when the message is unknown or not the user's.\n */\nexport async function recordPushOpen(\n reporting: JourneyReporting,\n userId: string,\n messageId: string,\n at = Date.now()\n): Promise<boolean> {\n if (!UUID.test(messageId)) return false;\n let recorded = false;\n await reporting.recordOpen(messageId, (message) => {\n if (message.userId !== userId) return undefined;\n recorded = true;\n return {\n at,\n machine: false,\n reason: null,\n userAgent: null,\n asn: null,\n organization: null,\n country: null,\n };\n });\n return recorded;\n}\n\nasync function recordOpen(\n reporting: JourneyReporting,\n id: string,\n who: Requester,\n options: TrackingOptions\n) {\n const at = Date.now();\n await reporting.recordOpen(id, (message) => ({\n at,\n ...classifyOpen(\n { ...who, sinceDeliveredMs: at - message.deliveredAt },\n { quickMs: options.quickMs }\n ),\n ...network(who),\n }));\n}\n\nconst network = (who: Requester) => ({\n userAgent: who.userAgent,\n asn: who.asn,\n organization: who.organization,\n country: who.country,\n});\n\n/** Records the click and answers the URL to send the visitor to, or undefined for an unknown link. */\nasync function recordClick(\n reporting: JourneyReporting,\n id: string,\n position: number,\n who: Requester,\n options: TrackingOptions\n): Promise<string | undefined> {\n if (!Number.isInteger(position) || position < 0) return undefined;\n const at = Date.now();\n const recorded = await reporting.recordClick(id, position, at, BURST_WINDOW_MS, (link) => {\n const linksInBurst = new Set([position, ...link.recent.map((click) => click.position)]).size;\n const verdict = classifyClick(\n { ...who, sinceDeliveredMs: at - link.deliveredAt, linksInBurst, linkCount: link.linkCount },\n { quickMs: options.quickMs }\n );\n return {\n engagement: { at, ...verdict, ...network(who) },\n backdate: verdict.reason === 'link_burst' || verdict.reason === 'quick+link_pair',\n };\n });\n return recorded?.url;\n}\n\nfunction escape(text: string): string {\n return text.replace(/[&<>\"']/g, (c) => `&#${c.charCodeAt(0)};`);\n}\n\nfunction page(options: TrackingOptions, message: string, status = 200, extra = ''): Response {\n const title = escape(options.product ?? 'Email');\n const html = `<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>${title}</title><style>body{font:16px system-ui;margin:0;display:grid;place-items:center;min-height:100vh;background:#fafafa;color:#18181b}main{text-align:center;padding:24px}button{font:inherit;padding:10px 20px;border-radius:9999px;border:0;background:#18181b;color:#fff;cursor:pointer}</style></head><body><main><p>${message}</p>${extra}</main></body></html>`;\n return new Response(html, { status, headers: { 'content-type': 'text/html; charset=utf-8' } });\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"tracking.cjs","names":["classifyOpen","BURST_WINDOW_MS","classifyClick"],"sources":["../../src/reporting/tracking.ts"],"sourcesContent":["import type { JourneyReporting } from '../store';\nimport { BURST_WINDOW_MS, classifyClick, classifyOpen } from './classify';\n\n/**\n * The endpoints a journey email's tracking points at (see ./observer.ts), served by journeyWorker\n * without a bearer — recipients open them:\n *\n * GET /t/o/<message> the open pixel: records the open, answers a 1×1 GIF\n * GET /t/l/<message>/<position> a link: records the click, redirects to the recorded URL\n * GET /t/u/<message>/<position> the unsubscribe link: records the click, asks to confirm\n * GET /t/u/<message> the List-Unsubscribe header's URL, opened: asks to confirm\n * POST /t/u/<message> the unsubscribe confirmation, and the header's one-click\n * unsubscribe (RFC 8058: a mail client's button posts here)\n * POST /t/d/<message> a push the device received (iOS's notification service\n * extension, a browser's service worker)\n * POST /t/p/<message> a push opened, reported by a browser's service worker\n *\n * Every open and click is recorded with its verdict (./classify.ts), and the message keeps its\n * first open and click of each kind. The redirect goes only to the URL recorded for that position,\n * never to one the request carries, so the endpoint cannot bounce visitors anywhere else. An\n * email's unsubscribe link is a tracked link too, and which link it is was fixed when the email was\n * sent (its route): following it shows a confirmation page, and only the confirmation — a POST,\n * which no link scanner sends — counts. Changing a template's or the config's unsubscribe URL, or\n * removing the template, changes nothing for emails already sent.\n */\n\nexport interface TrackingOptions {\n /** The name the confirmation pages show (the config's `title`). */\n product?: string | undefined;\n /** `email.tracking.quickClickMs`: how soon after delivery an open or click is a weak machine signal. */\n quickMs?: number | undefined;\n /** A browser reported a push tapped (`/t/p`): the host records it as an event too (`webPush.openedEvent`). */\n pushOpened?:\n | ((open: { messageId: string; userId: string; at: number }) => Promise<void>)\n | undefined;\n /**\n * An unsubscribe was recorded (`POST /t/u`): the host wakes the user's waits that read email\n * reachability, so an `exitWhen: not(reachable('email'))` takes effect at once. Its failure is\n * logged, never a reason to fail the unsubscribe.\n */\n onUnsubscribed?:\n | ((unsubscribe: { messageId: string; userId: string; at: number }) => Promise<void>)\n | undefined;\n}\n\nconst GIF = Uint8Array.from(\n atob('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'),\n (char) => char.charCodeAt(0)\n);\nconst UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n\ninterface Requester {\n method: string;\n userAgent: string | null;\n asn: number | null;\n organization: string | null;\n country: string | null;\n}\n\nfunction requester(request: Request): Requester {\n const cf = (\n request as Request & { cf?: { asn?: number; asOrganization?: string; country?: string } }\n ).cf;\n return {\n method: request.method,\n userAgent: request.headers.get('user-agent'),\n asn: cf?.asn ?? null,\n organization: cf?.asOrganization ?? null,\n country: cf?.country ?? null,\n };\n}\n\n/** A `/t/...` request's response, or undefined when the path is not a tracking route. */\nexport async function handleTracking(\n request: Request,\n reporting: JourneyReporting,\n options: TrackingOptions = {}\n): Promise<Response | undefined> {\n const url = new URL(request.url);\n const path = url.pathname.split('/');\n const root = path.at(1);\n const kind = path.at(2);\n const id = path.at(3);\n const position = path.at(4);\n if (root !== 't' || id === undefined || !UUID.test(id)) return undefined;\n const who = requester(request);\n const read = who.method === 'GET' || who.method === 'HEAD';\n if (kind === 'o' && read) {\n await recordOpen(reporting, id, who, options);\n return new Response(who.method === 'HEAD' ? null : GIF, {\n headers: { 'content-type': 'image/gif', 'cache-control': 'no-store, max-age=0' },\n });\n }\n if ((kind === 'l' || kind === 'u') && position !== undefined && read) {\n const target = await recordClick(reporting, id, Number(position), who, options);\n if (target === undefined) return page(options, 'This link has expired.', 404);\n if (kind === 'u') return confirmUnsubscribe(options, id);\n return Response.redirect(target, 302);\n }\n // The List-Unsubscribe URL opened in a browser (a client without one-click): the same question\n if (kind === 'u' && position === undefined && read) return confirmUnsubscribe(options, id);\n if (kind === 'd' && who.method === 'POST') {\n const known = await reporting.recordDeviceDelivered(id, Date.now());\n return new Response(null, { status: known ? 204 : 404 });\n }\n if (kind === 'p' && who.method === 'POST') {\n // A notification tap: a person, as the message id is what proves it is theirs\n const at = Date.now();\n let userId: string | undefined;\n const known = await reporting.recordOpen(id, (message) => {\n userId = message.userId;\n return { at, machine: false, reason: null, ...network(who) };\n });\n if (known && userId !== undefined && options.pushOpened !== undefined) {\n try {\n await options.pushOpened({ messageId: id, userId, at });\n } catch (error) {\n // The open is recorded; the event is the host's extra, never a reason to fail the beacon\n console.error(`[workflow] recording push open ${id} as an event failed`, error);\n }\n }\n return new Response(null, { status: known ? 204 : 404 });\n }\n if (kind === 'u' && who.method === 'POST') {\n const at = Date.now();\n const unsubscribed = await reporting.recordUnsubscribe(id, at);\n // A mail client's one-click POST must succeed: for a copy no longer on record (its user\n // erased) there is nothing left to send to, so the answer is the same\n if (unsubscribed === undefined) return page(options, \"You won't get these emails anymore.\");\n if (options.onUnsubscribed !== undefined) {\n try {\n await options.onUnsubscribed({ messageId: id, userId: unsubscribed.userId, at });\n } catch (error) {\n console.error(`[workflow] acting on the unsubscribe of ${id} failed`, error);\n }\n }\n return page(\n options,\n options.product === undefined\n ? \"You won't get these emails anymore.\"\n : `You won't get these emails from ${escape(options.product)} anymore.`\n );\n }\n return undefined;\n}\n\n/** The question an unsubscribe link, or the List-Unsubscribe URL opened in a browser, asks. */\nfunction confirmUnsubscribe(options: TrackingOptions, id: string): Response {\n return page(\n options,\n `Unsubscribe from ${escape(options.product ?? 'these')} emails?`,\n 200,\n `<form method=\"post\" action=\"/t/u/${id}\"><button type=\"submit\">Unsubscribe</button></form>`\n );\n}\n\n/**\n * A journey push tapped open, as the app reports it (`JourneyClient.messageOpened`): an open on\n * the copy — only the recipient's own, and never a machine, since no proxy or scanner taps a\n * notification. False when the message is unknown or not the user's.\n */\nexport async function recordPushOpen(\n reporting: JourneyReporting,\n userId: string,\n messageId: string,\n at = Date.now()\n): Promise<boolean> {\n if (!UUID.test(messageId)) return false;\n let recorded = false;\n await reporting.recordOpen(messageId, (message) => {\n if (message.userId !== userId) return undefined;\n recorded = true;\n return {\n at,\n machine: false,\n reason: null,\n userAgent: null,\n asn: null,\n organization: null,\n country: null,\n };\n });\n return recorded;\n}\n\nasync function recordOpen(\n reporting: JourneyReporting,\n id: string,\n who: Requester,\n options: TrackingOptions\n) {\n const at = Date.now();\n await reporting.recordOpen(id, (message) => ({\n at,\n ...classifyOpen(\n { ...who, sinceDeliveredMs: at - message.deliveredAt },\n { quickMs: options.quickMs }\n ),\n ...network(who),\n }));\n}\n\nconst network = (who: Requester) => ({\n userAgent: who.userAgent,\n asn: who.asn,\n organization: who.organization,\n country: who.country,\n});\n\n/** Records the click and answers the URL to send the visitor to, or undefined for an unknown link. */\nasync function recordClick(\n reporting: JourneyReporting,\n id: string,\n position: number,\n who: Requester,\n options: TrackingOptions\n): Promise<string | undefined> {\n if (!Number.isInteger(position) || position < 0) return undefined;\n const at = Date.now();\n const recorded = await reporting.recordClick(id, position, at, BURST_WINDOW_MS, (link) => {\n const linksInBurst = new Set([position, ...link.recent.map((click) => click.position)]).size;\n const verdict = classifyClick(\n { ...who, sinceDeliveredMs: at - link.deliveredAt, linksInBurst, linkCount: link.linkCount },\n { quickMs: options.quickMs }\n );\n return {\n engagement: { at, ...verdict, ...network(who) },\n backdate: verdict.reason === 'link_burst' || verdict.reason === 'quick+link_pair',\n };\n });\n return recorded?.url;\n}\n\nfunction escape(text: string): string {\n return text.replace(/[&<>\"']/g, (c) => `&#${c.charCodeAt(0)};`);\n}\n\nfunction page(options: TrackingOptions, message: string, status = 200, extra = ''): Response {\n const title = escape(options.product ?? 'Email');\n const html = `<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>${title}</title><style>body{font:16px system-ui;margin:0;display:grid;place-items:center;min-height:100vh;background:#fafafa;color:#18181b}main{text-align:center;padding:24px}button{font:inherit;padding:10px 20px;border-radius:9999px;border:0;background:#18181b;color:#fff;cursor:pointer}</style></head><body><main><p>${message}</p>${extra}</main></body></html>`;\n return new Response(html, { status, headers: { 'content-type': 'text/html; charset=utf-8' } });\n}\n"],"mappings":"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"}
|