@shware/workflow 6.1.1 → 8.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +132 -35
- package/dist/aws/engine-step.cjs +4 -1
- package/dist/aws/engine-step.cjs.map +1 -1
- package/dist/aws/engine-step.d.cts.map +1 -1
- package/dist/aws/engine-step.d.mts.map +1 -1
- package/dist/aws/engine-step.mjs +4 -1
- package/dist/aws/engine-step.mjs.map +1 -1
- package/dist/aws/index.cjs +3 -2
- package/dist/aws/index.cjs.map +1 -1
- package/dist/aws/index.mjs +3 -2
- package/dist/aws/index.mjs.map +1 -1
- package/dist/client.cjs +66 -41
- package/dist/client.cjs.map +1 -1
- package/dist/client.d.cts +72 -22
- package/dist/client.d.cts.map +1 -1
- package/dist/client.d.mts +72 -22
- package/dist/client.d.mts.map +1 -1
- package/dist/client.mjs +66 -42
- package/dist/client.mjs.map +1 -1
- package/dist/cloudflare/index.d.cts +4 -3
- package/dist/cloudflare/index.d.mts +4 -3
- package/dist/cloudflare/runner.cjs +3 -1
- package/dist/cloudflare/runner.cjs.map +1 -1
- package/dist/cloudflare/runner.d.cts.map +1 -1
- package/dist/cloudflare/runner.d.mts.map +1 -1
- package/dist/cloudflare/runner.mjs +3 -1
- package/dist/cloudflare/runner.mjs.map +1 -1
- package/dist/cloudflare/worker.cjs +13 -2
- package/dist/cloudflare/worker.cjs.map +1 -1
- package/dist/cloudflare/worker.d.cts +9 -1
- package/dist/cloudflare/worker.d.cts.map +1 -1
- package/dist/cloudflare/worker.d.mts +9 -1
- package/dist/cloudflare/worker.d.mts.map +1 -1
- package/dist/cloudflare/worker.mjs +13 -2
- package/dist/cloudflare/worker.mjs.map +1 -1
- package/dist/contract.cjs.map +1 -1
- package/dist/contract.d.cts +4 -4
- package/dist/contract.d.mts +4 -4
- package/dist/contract.mjs.map +1 -1
- package/dist/dsl/trigger.cjs +2 -1
- package/dist/dsl/trigger.cjs.map +1 -1
- package/dist/dsl/trigger.d.cts +6 -0
- package/dist/dsl/trigger.d.cts.map +1 -1
- package/dist/dsl/trigger.d.mts +6 -0
- package/dist/dsl/trigger.d.mts.map +1 -1
- package/dist/dsl/trigger.mjs +2 -1
- package/dist/dsl/trigger.mjs.map +1 -1
- package/dist/email-sender.cjs +8 -1
- package/dist/email-sender.cjs.map +1 -1
- package/dist/email-sender.d.cts +2 -2
- package/dist/email-sender.d.cts.map +1 -1
- package/dist/email-sender.d.mts +2 -2
- package/dist/email-sender.d.mts.map +1 -1
- package/dist/email-sender.mjs +9 -2
- package/dist/email-sender.mjs.map +1 -1
- package/dist/email.cjs +5 -0
- package/dist/email.cjs.map +1 -1
- package/dist/email.d.cts +2 -0
- package/dist/email.d.cts.map +1 -1
- package/dist/email.d.mts +2 -0
- package/dist/email.d.mts.map +1 -1
- package/dist/email.mjs +5 -1
- package/dist/email.mjs.map +1 -1
- package/dist/engine/condition.cjs +7 -5
- package/dist/engine/condition.cjs.map +1 -1
- package/dist/engine/condition.d.cts +3 -3
- package/dist/engine/condition.d.cts.map +1 -1
- package/dist/engine/condition.d.mts +3 -3
- package/dist/engine/condition.d.mts.map +1 -1
- package/dist/engine/condition.mjs +7 -5
- package/dist/engine/condition.mjs.map +1 -1
- package/dist/engine/interpreter.cjs +11 -6
- 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 +11 -6
- package/dist/engine/interpreter.mjs.map +1 -1
- package/dist/engine/ports.d.cts +21 -3
- package/dist/engine/ports.d.cts.map +1 -1
- package/dist/engine/ports.d.mts +21 -3
- package/dist/engine/ports.d.mts.map +1 -1
- package/dist/host/batch.cjs +172 -0
- package/dist/host/batch.cjs.map +1 -0
- package/dist/host/batch.d.cts +88 -0
- package/dist/host/batch.d.cts.map +1 -0
- package/dist/host/batch.d.mts +88 -0
- package/dist/host/batch.d.mts.map +1 -0
- package/dist/host/batch.mjs +163 -0
- package/dist/host/batch.mjs.map +1 -0
- package/dist/host/execute.cjs +10 -6
- package/dist/host/execute.cjs.map +1 -1
- package/dist/host/execute.mjs +10 -6
- package/dist/host/execute.mjs.map +1 -1
- package/dist/host/http.cjs +31 -90
- package/dist/host/http.cjs.map +1 -1
- package/dist/host/http.d.cts +4 -2
- package/dist/host/http.d.cts.map +1 -1
- package/dist/host/http.d.mts +4 -2
- package/dist/host/http.d.mts.map +1 -1
- package/dist/host/http.mjs +25 -84
- package/dist/host/http.mjs.map +1 -1
- package/dist/host/ingest.cjs +164 -25
- package/dist/host/ingest.cjs.map +1 -1
- package/dist/host/ingest.d.cts +38 -8
- package/dist/host/ingest.d.cts.map +1 -1
- package/dist/host/ingest.d.mts +38 -8
- package/dist/host/ingest.d.mts.map +1 -1
- package/dist/host/ingest.mjs +161 -26
- package/dist/host/ingest.mjs.map +1 -1
- package/dist/host/journey-host.cjs +8 -1
- package/dist/host/journey-host.cjs.map +1 -1
- package/dist/host/journey-host.d.cts +10 -4
- package/dist/host/journey-host.d.cts.map +1 -1
- package/dist/host/journey-host.d.mts +10 -4
- package/dist/host/journey-host.d.mts.map +1 -1
- package/dist/host/journey-host.mjs +8 -1
- package/dist/host/journey-host.mjs.map +1 -1
- package/dist/host/platform.cjs.map +1 -1
- package/dist/host/platform.d.cts +2 -0
- package/dist/host/platform.d.cts.map +1 -1
- package/dist/host/platform.d.mts +2 -0
- package/dist/host/platform.d.mts.map +1 -1
- package/dist/host/platform.mjs.map +1 -1
- package/dist/index.cjs +1 -0
- package/dist/index.d.cts +2 -2
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +2 -2
- package/dist/ir.cjs +2 -1
- package/dist/ir.cjs.map +1 -1
- package/dist/ir.d.cts +3 -0
- package/dist/ir.d.cts.map +1 -1
- package/dist/ir.d.mts +3 -0
- package/dist/ir.d.mts.map +1 -1
- package/dist/ir.mjs +2 -1
- package/dist/ir.mjs.map +1 -1
- package/dist/reporting/observer.cjs +1 -0
- package/dist/reporting/observer.cjs.map +1 -1
- package/dist/reporting/observer.mjs +1 -0
- package/dist/reporting/observer.mjs.map +1 -1
- package/dist/store/facts.cjs +29 -9
- package/dist/store/facts.cjs.map +1 -1
- package/dist/store/facts.d.cts +7 -2
- package/dist/store/facts.d.cts.map +1 -1
- package/dist/store/facts.d.mts +7 -2
- package/dist/store/facts.d.mts.map +1 -1
- package/dist/store/facts.mjs +29 -9
- package/dist/store/facts.mjs.map +1 -1
- package/dist/store/identity.cjs +34 -0
- package/dist/store/identity.cjs.map +1 -0
- package/dist/store/identity.d.cts +13 -0
- package/dist/store/identity.d.cts.map +1 -0
- package/dist/store/identity.d.mts +13 -0
- package/dist/store/identity.d.mts.map +1 -0
- package/dist/store/identity.mjs +30 -0
- package/dist/store/identity.mjs.map +1 -0
- package/dist/store/index.cjs +5 -0
- package/dist/store/index.cjs.map +1 -1
- package/dist/store/index.d.cts +119 -25
- package/dist/store/index.d.cts.map +1 -1
- package/dist/store/index.d.mts +119 -25
- 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 +4 -0
- package/dist/store/postgres-reporting.cjs.map +1 -1
- package/dist/store/postgres-reporting.d.cts +1 -0
- package/dist/store/postgres-reporting.d.cts.map +1 -1
- package/dist/store/postgres-reporting.d.mts +1 -0
- package/dist/store/postgres-reporting.d.mts.map +1 -1
- package/dist/store/postgres-reporting.mjs +4 -0
- package/dist/store/postgres-reporting.mjs.map +1 -1
- package/dist/store/postgres.cjs +271 -55
- package/dist/store/postgres.cjs.map +1 -1
- package/dist/store/postgres.d.cts +29 -7
- package/dist/store/postgres.d.cts.map +1 -1
- package/dist/store/postgres.d.mts +29 -7
- package/dist/store/postgres.d.mts.map +1 -1
- package/dist/store/postgres.mjs +272 -56
- package/dist/store/postgres.mjs.map +1 -1
- package/dist/store/tables.cjs +28 -9
- package/dist/store/tables.cjs.map +1 -1
- package/dist/store/tables.d.cts +1 -0
- package/dist/store/tables.d.cts.map +1 -1
- package/dist/store/tables.d.mts +1 -0
- package/dist/store/tables.d.mts.map +1 -1
- package/dist/store/tables.mjs +28 -9
- package/dist/store/tables.mjs.map +1 -1
- package/dist/webpush.cjs.map +1 -1
- package/dist/webpush.d.cts +1 -1
- package/dist/webpush.d.mts +1 -1
- package/dist/webpush.mjs.map +1 -1
- package/package.json +1 -1
|
@@ -69,7 +69,7 @@ function journeyWorker(options) {
|
|
|
69
69
|
}
|
|
70
70
|
const { store, close } = host.lease(env);
|
|
71
71
|
try {
|
|
72
|
-
return await host.handle(request, env, store);
|
|
72
|
+
return await host.handle(request, env, store, ownVersion(env));
|
|
73
73
|
} finally {
|
|
74
74
|
if (ctx === void 0) await close();
|
|
75
75
|
else ctx.waitUntil(close());
|
|
@@ -81,11 +81,22 @@ function journeyWorker(options) {
|
|
|
81
81
|
await host.applyOwnBundle(env, ownVersion(env));
|
|
82
82
|
await host.sweep(env);
|
|
83
83
|
};
|
|
84
|
+
const queue = async (batch, rawEnv) => {
|
|
85
|
+
const env = hostEnv(rawEnv);
|
|
86
|
+
await host.applyOwnBundle(env, ownVersion(env));
|
|
87
|
+
const { store, close } = host.lease(env);
|
|
88
|
+
try {
|
|
89
|
+
await host.consume(batch.messages, env, store);
|
|
90
|
+
} finally {
|
|
91
|
+
await close();
|
|
92
|
+
}
|
|
93
|
+
};
|
|
84
94
|
return {
|
|
85
95
|
Runner,
|
|
86
96
|
fetch,
|
|
87
97
|
sweep,
|
|
88
|
-
scheduled
|
|
98
|
+
scheduled,
|
|
99
|
+
queue
|
|
89
100
|
};
|
|
90
101
|
}
|
|
91
102
|
export { EnvValidationError, journeyWorker };
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"worker.mjs","names":[],"sources":["../../src/cloudflare/worker.ts"],"sourcesContent":["import type { JourneyRunParts } from '../host/execute';\nimport type { SweepResult } from '../host/ingest';\nimport {\n EnvValidationError,\n type JourneyHostOptions,\n createJourneyHost,\n} from '../host/journey-host';\nimport type { JourneyEnv, JourneyParams } from '../host/platform';\nimport type { BundleVersion, JourneyStore } from '../store/index';\nimport { cloudflareJourneyBinding } from './bindings';\nimport { JourneyRunner } from './runner';\n\nexport { EnvValidationError } from '../host/journey-host';\n\n/** The fetch handler's execution context (the subset used). */\nexport interface ExecutionContextLike {\n waitUntil(promise: Promise<unknown>): void;\n}\n\n/**\n * `journeyWorker`'s options: the host's (config, store, email transport, observer, ...). The\n * email transport is the host's choice — `cloudflareEmailTransport({ binding: env.EMAIL })` for\n * Cloudflare Email, `sesEmailTransport(...)` for SES.\n */\nexport type JourneyWorkerOptions<Env extends JourneyEnv> = JourneyHostOptions<Env>;\n\nexport interface JourneyWorker<Env extends JourneyEnv> {\n /** Export this under the `class_name` of the wrangler `workflows` binding. */\n Runner: new (...args: never[]) => JourneyRunner;\n /** The Worker's fetch handler: `/health`, `/
|
|
1
|
+
{"version":3,"file":"worker.mjs","names":[],"sources":["../../src/cloudflare/worker.ts"],"sourcesContent":["import type { QueueMessageLike } from '../host/batch';\nimport type { JourneyRunParts } from '../host/execute';\nimport type { SweepResult } from '../host/ingest';\nimport {\n EnvValidationError,\n type JourneyHostOptions,\n createJourneyHost,\n} from '../host/journey-host';\nimport type { JourneyEnv, JourneyParams } from '../host/platform';\nimport type { BundleVersion, JourneyStore } from '../store/index';\nimport { cloudflareJourneyBinding } from './bindings';\nimport { JourneyRunner } from './runner';\n\nexport { EnvValidationError } from '../host/journey-host';\n\n/** The fetch handler's execution context (the subset used). */\nexport interface ExecutionContextLike {\n waitUntil(promise: Promise<unknown>): void;\n}\n\n/**\n * `journeyWorker`'s options: the host's (config, store, email transport, observer, ...). The\n * email transport is the host's choice — `cloudflareEmailTransport({ binding: env.EMAIL })` for\n * Cloudflare Email, `sesEmailTransport(...)` for SES.\n */\nexport type JourneyWorkerOptions<Env extends JourneyEnv> = JourneyHostOptions<Env>;\n\nexport interface JourneyWorker<Env extends JourneyEnv> {\n /** Export this under the `class_name` of the wrangler `workflows` binding. */\n Runner: new (...args: never[]) => JourneyRunner;\n /** The Worker's fetch handler: `/health`, `/ingest`, `/devices`, `/messages/opened`, `/segments/sweep`, `/t/*` (email tracking), `/deploy`, `/bundle`, `/bundle/deploy` and `/preview/*`. */\n fetch: (request: Request, env: Env, ctx?: ExecutionContextLike) => Promise<Response>;\n /**\n * The segment sweep (`sweepSegments`): membership of every deployed segment for every known\n * user, the journeys of segments entered by the clock alone (`not(performed(e.login, { within:\n * '7 days' }))` has no event to ride on), and each segment's size. Also served as\n * `POST /segments/sweep`.\n */\n sweep: (env: Env) => Promise<SweepResult>;\n /** The cron trigger's handler: runs the sweep. Export it beside `fetch` with a `triggers.crons` entry. */\n scheduled: (controller: unknown, env: Env, ctx: ExecutionContextLike) => Promise<void>;\n /**\n * The queue consumer for `JourneyQueue`: export it beside `fetch` with a `queues.consumers`\n * entry. A batch that fails is retried with a backoff, then goes to the dead letter queue.\n */\n queue: (batch: { messages: readonly QueueMessageLike[] }, env: Env) => Promise<void>;\n}\n\n/**\n * A complete journey host in one call: the runner class with the app's store,\n * outlets and runtime plugged in, and the fetch handler that serves the ingest\n * API around them.\n *\n * const journeys = journeyWorker<Env>({\n * config, // workflow.config.ts (defineConfig)\n * store: (env, database) => postgresJsStore(env.HYPERDRIVE.connectionString, database),\n * });\n * export const MyRunner = journeys.Runner;\n * export default { fetch: journeys.fetch };\n */\nexport function journeyWorker<Env extends JourneyEnv>(\n options: JourneyWorkerOptions<Env>\n): JourneyWorker<Env> {\n const host = createJourneyHost(options);\n\n class Runner extends JourneyRunner {\n private readonly leases = new WeakMap<JourneyStore, () => Promise<void>>();\n\n protected override createStore(): JourneyStore {\n // A run fails at its start on an invalid environment, not at the send that needs it\n const { store, close } = host.lease(host.checkEnv(this.env as Env));\n this.leases.set(store, close);\n return store;\n }\n\n protected override async closeStore(store: JourneyStore): Promise<void> {\n await this.leases.get(store)?.();\n }\n\n protected override createRunParts(store: JourneyStore, params: JourneyParams): JourneyRunParts {\n return host.runParts(host.checkEnv(this.hostEnv() as Env), store, params);\n }\n }\n\n /**\n * The validated environment, its Workflow binding behind the host's port (an unknown instance\n * answers `InstanceNotFoundError`). Everything below reads this one, never the raw env.\n */\n const hostEnv = (rawEnv: Env): Env => {\n const env = host.checkEnv(rawEnv);\n return { ...env, JOURNEY: cloudflareJourneyBinding(env.JOURNEY) };\n };\n\n /** CF's version metadata: the bundle compiled into each new version goes live by itself. */\n const ownVersion = (env: Env): BundleVersion | undefined => {\n const metadata = env.CF_VERSION_METADATA;\n if (metadata === undefined) return undefined;\n return { id: metadata.id, createdAt: Date.parse(metadata.timestamp) };\n };\n\n const fetch = async (request: Request, rawEnv: Env, ctx?: ExecutionContextLike) => {\n let env: Env;\n try {\n env = hostEnv(rawEnv);\n } catch (error) {\n if (!(error instanceof EnvValidationError)) throw error;\n console.error(`[workflow] ${error.message}`);\n return new Response(JSON.stringify({ error: error.message }), {\n status: 500,\n headers: { 'content-type': 'application/json' },\n });\n }\n const pending = host.applyOwnBundle(env, ownVersion(env));\n // In the background where the runtime has waitUntil; inline otherwise (tests)\n if (pending !== undefined) {\n if (ctx === undefined) await pending;\n else ctx.waitUntil(pending);\n }\n const { store, close } = host.lease(env);\n try {\n return await host.handle(request, env, store, ownVersion(env));\n } finally {\n // After the response when the runtime offers waitUntil, inline otherwise (tests).\n if (ctx === undefined) await close();\n else ctx.waitUntil(close());\n }\n };\n\n const sweep = (rawEnv: Env) => host.sweep(hostEnv(rawEnv));\n\n const scheduled = async (_controller: unknown, rawEnv: Env, _ctx: ExecutionContextLike) => {\n // Awaited, not handed to waitUntil: the runtime keeps a scheduled handler alive for as long\n // as its promise runs, while a sweep only parked in waitUntil can be cut off by the\n // handler returning, and its failure would surface nowhere. A version with no traffic yet\n // goes live here; the sweep then reads its segments.\n const env = hostEnv(rawEnv);\n await host.applyOwnBundle(env, ownVersion(env));\n await host.sweep(env);\n };\n\n const queue = async (batch: { messages: readonly QueueMessageLike[] }, rawEnv: Env) => {\n // Awaited for the same reason as the sweep; a version with no traffic yet goes live here, so\n // the batch is routed by the journeys of the code consuming it.\n const env = hostEnv(rawEnv);\n await host.applyOwnBundle(env, ownVersion(env));\n const { store, close } = host.lease(env);\n try {\n await host.consume(batch.messages, env, store);\n } finally {\n await close();\n }\n };\n\n return { Runner, fetch, sweep, scheduled, queue };\n}\n"],"mappings":";;;;;;;;;;;;;;;;AA4DA,SAAgB,cACd,SACoB;CACpB,MAAM,OAAO,kBAAkB,OAAO;CAEtC,MAAM,eAAe,cAAc;EACjC,yBAA0B,IAAI,QAA2C;EAEzE,cAA+C;GAE7C,MAAM,EAAE,OAAO,UAAU,KAAK,MAAM,KAAK,SAAS,KAAK,GAAU,CAAC;GAClE,KAAK,OAAO,IAAI,OAAO,KAAK;GAC5B,OAAO;EACT;EAEA,MAAyB,WAAW,OAAoC;GACtE,MAAM,KAAK,OAAO,IAAI,KAAK,CAAC,GAAG;EACjC;EAEA,eAAkC,OAAqB,QAAwC;GAC7F,OAAO,KAAK,SAAS,KAAK,SAAS,KAAK,QAAQ,CAAQ,GAAG,OAAO,MAAM;EAC1E;CACF;;;;;CAMA,MAAM,WAAW,WAAqB;EACpC,MAAM,MAAM,KAAK,SAAS,MAAM;EAChC,OAAO;GAAE,GAAG;GAAK,SAAS,yBAAyB,IAAI,OAAO;EAAE;CAClE;;CAGA,MAAM,cAAc,QAAwC;EAC1D,MAAM,WAAW,IAAI;EACrB,IAAI,aAAa,KAAA,GAAW,OAAO,KAAA;EACnC,OAAO;GAAE,IAAI,SAAS;GAAI,WAAW,KAAK,MAAM,SAAS,SAAS;EAAE;CACtE;CAEA,MAAM,QAAQ,OAAO,SAAkB,QAAa,QAA+B;EACjF,IAAI;EACJ,IAAI;GACF,MAAM,QAAQ,MAAM;EACtB,SAAS,OAAO;GACd,IAAI,EAAE,iBAAiB,qBAAqB,MAAM;GAClD,QAAQ,MAAM,cAAc,MAAM,SAAS;GAC3C,OAAO,IAAI,SAAS,KAAK,UAAU,EAAE,OAAO,MAAM,QAAQ,CAAC,GAAG;IAC5D,QAAQ;IACR,SAAS,EAAE,gBAAgB,mBAAmB;GAChD,CAAC;EACH;EACA,MAAM,UAAU,KAAK,eAAe,KAAK,WAAW,GAAG,CAAC;EAExD,IAAI,YAAY,KAAA,GAAW;GACzB,IAAI,QAAQ,KAAA,GAAW,MAAM;QACxB,IAAI,UAAU,OAAO;EAC5B;EACA,MAAM,EAAE,OAAO,UAAU,KAAK,MAAM,GAAG;EACvC,IAAI;GACF,OAAO,MAAM,KAAK,OAAO,SAAS,KAAK,OAAO,WAAW,GAAG,CAAC;EAC/D,UAAU;GAER,IAAI,QAAQ,KAAA,GAAW,MAAM,MAAM;QAC9B,IAAI,UAAU,MAAM,CAAC;EAC5B;CACF;CAEA,MAAM,SAAS,WAAgB,KAAK,MAAM,QAAQ,MAAM,CAAC;CAEzD,MAAM,YAAY,OAAO,aAAsB,QAAa,SAA+B;EAKzF,MAAM,MAAM,QAAQ,MAAM;EAC1B,MAAM,KAAK,eAAe,KAAK,WAAW,GAAG,CAAC;EAC9C,MAAM,KAAK,MAAM,GAAG;CACtB;CAEA,MAAM,QAAQ,OAAO,OAAkD,WAAgB;EAGrF,MAAM,MAAM,QAAQ,MAAM;EAC1B,MAAM,KAAK,eAAe,KAAK,WAAW,GAAG,CAAC;EAC9C,MAAM,EAAE,OAAO,UAAU,KAAK,MAAM,GAAG;EACvC,IAAI;GACF,MAAM,KAAK,QAAQ,MAAM,UAAU,KAAK,KAAK;EAC/C,UAAU;GACR,MAAM,MAAM;EACd;CACF;CAEA,OAAO;EAAE;EAAQ;EAAO;EAAO;EAAW;CAAM;AAClD"}
|
package/dist/contract.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"contract.cjs","names":["event","user","z"],"sources":["../src/contract.ts"],"sourcesContent":["import * as z from 'zod/mini';\nimport type * as core from 'zod/v4/core';\nimport { type EventRefs, type UserRefs, event, user } from './dsl';\n\n/**\n * `defineContract({ events, profile, subscriptions? })` — the producer/engine contract as Zod\n * schemas, written once in a package both sides import. From it:\n *\n * - `contract.events` / `contract.user` are the DSL's typed reference tables\n * (what `event<E>()` / `user<U>()` used to be, now derived from the schemas);\n * - `contract.topics` are the subscription topics: every email and push template names one (or\n * is transactional), a user opts out of one at a time per channel, and the compile refuses a\n * topic the contract does not declare;\n * - `JourneyClient({ contract })` types `track` / `identify` by event name and\n * checks payloads before sending, and `getSubscriptions` / `setSubscriptions` by topic;\n * - `journeyWorker({ contract })` re-validates every inbound `/events`,\n * `/identify` and `/subscriptions` — never trusting the caller used the SDK — and refuses a bad\n * one with a 4xx before anything is stored, woken or started.\n *\n * Supported schemas (v1), synchronous and JSON-shaped: event payloads are\n * object schemas (`z.looseObject` keeps a producer's extra analytics fields,\n * `z.strictObject` refuses them) and may nest objects and arrays; the profile\n * is a flat object of string / number / boolean / enum / literal fields. Input\n * and output must mean the same thing, so defaults, transforms, coercion,\n * custom refinements and async checks are refused when the contract is\n * defined. The Zod instances stay in the host; nothing here changes the IR.\n */\n\ntype ObjectSchema = core.$ZodObject;\n\n/** The declared fields of an object schema — without a loose object's `[key: string]: unknown`, so the DSL only offers fields that were written down. */\nexport type DeclaredOutput<S> =\n S extends core.$ZodObject<infer Shape>\n ? core.$InferObjectOutput<Shape, core.$strict['out']>\n : never;\n\n/**\n * The subscription topics a producer and the engine agree on: content categories ('offers',\n * 'tips'), not channels — an email or push template declares one (`emailTemplate({ topic })`,\n * `pushTemplate({ topic })`), and a user opts out of a topic per channel (the preference page is\n * topic × channel). Ids only — display names belong to the app's i18n. A plain array literal\n * is enough: `defineContract` keeps the literals (no `as const` needed), and the union they form\n * types the templates, the config and `JourneyClient.setSubscriptions`.\n */\nexport interface SubscriptionsDefinition<T extends string> {\n topics: readonly T[];\n}\n\nexport interface ContractDefinition<\n E extends Record<string, ObjectSchema>,\n P extends ObjectSchema,\n T extends string = never,\n> {\n events: E;\n profile: P;\n /** The topics, e.g. `{ topics: ['promotions', 'newsletter'] }`; none without it (transactional email and push only). */\n subscriptions?: SubscriptionsDefinition<T>;\n}\n\n/** One rejected field, with the dotted path the producer can fix. */\nexport interface ContractIssue {\n path: string;\n message: string;\n}\n\nexport type ContractResult<T> = { ok: true; data: T } | { ok: false; issues: ContractIssue[] };\n\nexport interface Contract<\n E extends Record<string, ObjectSchema> = Record<string, ObjectSchema>,\n P extends ObjectSchema = ObjectSchema,\n T extends string = string,\n> {\n readonly kind: 'journey-contract';\n /** The declared subscription topics (`subscriptions.topics`), empty when none were declared. */\n readonly topics: readonly T[];\n isTopic(topic: string): topic is T;\n /** The DSL's event reference table: `e.purchase` — payload refs from the declared fields only. */\n readonly events: EventRefs<{ [K in keyof E]: DeclaredOutput<E[K]> }>;\n /** The DSL's user-property reference table: `u.name`, plus the system `u.userId`. */\n readonly user: UserRefs<DeclaredOutput<P> & { userId: string }>;\n /** The raw schemas, for hosts that need them (validation, docs). */\n readonly schemas: { readonly events: E; readonly profile: P };\n /** Every event name the contract declares — what a producer forwards. */\n readonly eventNames: readonly (keyof E & string)[];\n isEvent(name: string): name is keyof E & string;\n /** Validate one event's payload; the parsed value is what may be stored. */\n parseEvent<K extends keyof E & string>(\n name: K,\n payload: unknown\n ): ContractResult<core.output<E[K]>>;\n /**\n * Validate an `/identify` merge patch: omitted fields stay as they are,\n * `null` removes a field, every provided value is checked against its field\n * schema, and an undeclared field is refused — a patch, never a full parse\n * that would fill defaults over fields the caller did not send.\n */\n parseProfilePatch(patch: unknown): ContractResult<ProfilePatch<P>>;\n}\n\n/** An `/identify` patch over the profile: any subset of fields, `null` to remove one. */\nexport type ProfilePatch<P> = { [K in keyof DeclaredOutput<P>]?: DeclaredOutput<P>[K] | null };\n\n/** Payload type of one event, for producers: `EventPayload<typeof contract, 'purchase'>`. */\nexport type EventPayload<C, K extends string> =\n C extends Contract<infer E> ? (K extends keyof E ? core.input<E[K]> : never) : never;\n\n/** The topics a contract declares, as a union: `Topic<typeof contract>`; `never` without any. */\nexport type Topic<C> =\n C extends Contract<Record<string, ObjectSchema>, ObjectSchema, infer T> ? T : never;\n\n/** A topic id: what goes in a URL, a JSON key and a database column, so no spaces or specials. */\nconst TOPIC_ID = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;\n\nexport function defineContract<\n E extends Record<string, ObjectSchema>,\n P extends ObjectSchema,\n const T extends string = never,\n>(definition: ContractDefinition<E, P, T>): Contract<E, P, T> {\n const topics = [...(definition.subscriptions?.topics ?? [])];\n const seen = new Set<string>();\n for (const topic of topics) {\n if (typeof topic !== 'string' || !TOPIC_ID.test(topic)) {\n throw new TypeError(\n `contract subscriptions.topics: '${topic as string}' is not a topic id (letters, digits, '_', '-' and '.', starting with a letter or digit)`\n );\n }\n if (seen.has(topic))\n throw new TypeError(`contract subscriptions.topics lists '${topic}' twice`);\n seen.add(topic);\n }\n for (const [name, schema] of Object.entries(definition.events)) {\n if (name.startsWith('$')) {\n throw new TypeError(`contract event '${name}': names starting with $ are reserved`);\n }\n assertJsonSchema(schema, `event '${name}'`, true);\n }\n assertJsonSchema(definition.profile, 'profile', true);\n const profileShape = (definition.profile._zod.def as core.$ZodObjectDef).shape;\n for (const [field, schema] of Object.entries(profileShape)) {\n assertFlatScalar(schema, `profile.${field}`);\n }\n const strictProfile = definition.profile._zod.def.catchall?._zod.def.type === 'never';\n\n const names = Object.keys(definition.events) as (keyof E & string)[];\n return {\n kind: 'journey-contract',\n events: event() as never,\n user: user() as never,\n schemas: { events: definition.events, profile: definition.profile },\n eventNames: names,\n topics,\n isTopic: (topic): topic is T => seen.has(topic),\n isEvent: (name): name is keyof E & string => Object.hasOwn(definition.events, name),\n parseEvent(name, payload) {\n if (!Object.hasOwn(definition.events, name)) {\n return { ok: false, issues: [{ path: '', message: `unknown event '${name}'` }] };\n }\n return toResult(z.safeParse(definition.events[name], payload));\n },\n parseProfilePatch(patch) {\n if (typeof patch !== 'object' || patch === null || Array.isArray(patch)) {\n return { ok: false, issues: [{ path: '', message: 'props must be an object' }] };\n }\n const issues: ContractIssue[] = [];\n const data: Record<string, unknown> = {};\n for (const [field, value] of Object.entries(patch)) {\n if (!Object.hasOwn(profileShape, field)) {\n // userId is the request's identity, not a profile field\n if (strictProfile || field === 'userId') {\n issues.push({ path: field, message: 'not a declared profile field' });\n } else {\n data[field] = value;\n }\n continue;\n }\n if (value === null) {\n data[field] = null;\n continue;\n }\n const result = z.safeParse(profileShape[field], value);\n if (result.success) data[field] = result.data;\n else issues.push(...prefixed(field, result.error));\n }\n return issues.length > 0 ? { ok: false, issues } : { ok: true, data: data as never };\n },\n };\n}\n\n/**\n * An event the contract refuses — raised for events a journey emits itself\n * (`.sendEvent()`), which never pass the HTTP ingest: they go through the same\n * check, and a refusal is deterministic, so it is not retried.\n */\nexport class ContractValidationError extends Error {\n override readonly name = 'ContractValidationError';\n constructor(\n readonly event: string,\n readonly issues: ContractIssue[]\n ) {\n super(\n `event '${event}' refused by the contract: ${issues\n .map((i) => `${i.path || '(root)'} ${i.message}`)\n .join('; ')}`\n );\n }\n}\n\n/** The one check every event passes, from HTTP or from a journey: declared, and its payload parses. */\nexport function checkEvent(\n contract: Contract,\n name: string,\n payload: unknown\n): ContractResult<Record<string, unknown>> {\n if (!contract.isEvent(name)) {\n return {\n ok: false,\n issues: [{ path: '', message: `'${String(name)}' is not in the contract` }],\n };\n }\n return contract.parseEvent(name, payload);\n}\n\nexport function isContract(value: unknown): value is Contract {\n return (\n typeof value === 'object' &&\n value !== null &&\n (value as { kind?: unknown }).kind === 'journey-contract'\n );\n}\n\nfunction toResult<T>(result: core.util.SafeParseResult<T>): ContractResult<T> {\n return result.success\n ? { ok: true, data: result.data }\n : { ok: false, issues: prefixed('', result.error) };\n}\n\nfunction prefixed(prefix: string, error: core.$ZodError): ContractIssue[] {\n return error.issues.map((issue) => ({\n path: [prefix, ...issue.path.map(String)].filter((p) => p !== '').join('.'),\n message: issue.message,\n }));\n}\n\n/* ---------------------------- supported schemas ---------------------------- */\n\n/**\n * The schema types a contract may use — JSON shapes that parse synchronously\n * and mean the same before and after parsing. Anything not listed (default,\n * transform, pipe, lazy, promise, custom, date, bigint, …) is refused, and\n * every container is walked so nothing unsupported hides inside one.\n */\nconst ALLOWED = new Set([\n 'object',\n 'array',\n 'tuple',\n 'union',\n 'intersection',\n 'record',\n 'optional',\n 'nullable',\n 'readonly',\n 'nonoptional',\n 'string',\n 'number',\n 'boolean',\n 'null',\n 'enum',\n 'literal',\n 'unknown',\n 'any',\n 'never',\n]);\n\n/** Checks that change the value (`overwrite`: `.trim()`, `.toLowerCase()`, …) or run arbitrary code (`custom`: `.refine()`, async or not). */\nconst REFUSED_CHECKS = new Set(['custom', 'overwrite']);\n\nfunction assertJsonSchema(schema: unknown, where: string, top = false): void {\n const def = (schema as { _zod?: { def?: core.$ZodTypeDef } } | null | undefined)?._zod?.def;\n if (def === undefined) throw new TypeError(`contract ${where}: not a Zod schema`);\n if (top && def.type !== 'object') {\n throw new TypeError(\n `contract ${where}: must be an object schema (z.looseObject / z.strictObject)`\n );\n }\n if (!ALLOWED.has(def.type)) {\n throw new TypeError(\n `contract ${where}: ${def.type} schemas are not supported (input and output must mean the same JSON, parsed synchronously)`\n );\n }\n if ((def as { coerce?: boolean }).coerce === true) {\n throw new TypeError(`contract ${where}: coercion (z.coerce) is not supported`);\n }\n for (const check of def.checks ?? []) {\n if (REFUSED_CHECKS.has(check._zod.def.check)) {\n throw new TypeError(\n `contract ${where}: ${check._zod.def.check === 'overwrite' ? 'value-changing checks (.overwrite, .trim, …)' : 'custom refinements'} are not supported`\n );\n }\n }\n const children: [string, unknown][] = [];\n switch (def.type) {\n case 'object': {\n const object = def as core.$ZodObjectDef;\n for (const [k, v] of Object.entries(object.shape)) children.push([`${where}.${k}`, v]);\n if (object.catchall !== undefined) children.push([`${where}.*`, object.catchall]);\n break;\n }\n case 'optional':\n case 'nullable':\n case 'readonly':\n case 'nonoptional':\n children.push([where, (def as unknown as { innerType: unknown }).innerType]);\n break;\n case 'array':\n children.push([`${where}[]`, (def as core.$ZodArrayDef).element]);\n break;\n case 'tuple': {\n const tuple = def as core.$ZodTupleDef;\n tuple.items.forEach((item, index) => children.push([`${where}[${index}]`, item]));\n if (tuple.rest !== null) children.push([`${where}[…]`, tuple.rest]);\n break;\n }\n case 'union':\n for (const option of (def as core.$ZodUnionDef).options) children.push([where, option]);\n break;\n case 'intersection': {\n const intersection = def as core.$ZodIntersectionDef;\n children.push([where, intersection.left], [where, intersection.right]);\n break;\n }\n case 'record': {\n const record = def as core.$ZodRecordDef;\n children.push([`${where}{key}`, record.keyType], [`${where}{}`, record.valueType]);\n break;\n }\n default:\n break;\n }\n for (const [path, child] of children) assertJsonSchema(child, path);\n}\n\nconst SCALARS = new Set(['string', 'number', 'boolean', 'enum', 'literal']);\n\nfunction assertFlatScalar(schema: unknown, where: string): void {\n let def = (schema as core.$ZodType)._zod.def;\n if (def.type === 'optional') def = (def as core.$ZodOptionalDef).innerType._zod.def;\n if (!SCALARS.has(def.type)) {\n throw new TypeError(\n `contract ${where}: profile fields must be flat scalars (string, number, boolean, enum, literal), got ${def.type}`\n );\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"contract.cjs","names":["event","user","z"],"sources":["../src/contract.ts"],"sourcesContent":["import * as z from 'zod/mini';\nimport type * as core from 'zod/v4/core';\nimport { type EventRefs, type UserRefs, event, user } from './dsl';\n\n/**\n * `defineContract({ events, profile, subscriptions? })` — the producer/engine contract as Zod\n * schemas, written once in a package both sides import. From it:\n *\n * - `contract.events` / `contract.user` are the DSL's typed reference tables\n * (what `event<E>()` / `user<U>()` used to be, now derived from the schemas);\n * - `contract.topics` are the subscription topics: every email and push template names one (or\n * is transactional), a user opts out of one at a time per channel, and the compile refuses a\n * topic the contract does not declare;\n * - `JourneyClient({ contract })` types `track` / `identify` by event name and\n * checks payloads before sending, and `getSubscriptions` / `setSubscriptions` by topic;\n * - `journeyWorker({ contract })` re-validates every inbound `/ingest`\n * and `/subscriptions` — never trusting the caller used the SDK — and refuses a bad\n * one with a 4xx before anything is stored, woken or started.\n *\n * Supported schemas (v1), synchronous and JSON-shaped: event payloads are\n * object schemas (`z.looseObject` keeps a producer's extra analytics fields,\n * `z.strictObject` refuses them) and may nest objects and arrays; the profile\n * is a flat object of string / number / boolean / enum / literal fields. Input\n * and output must mean the same thing, so defaults, transforms, coercion,\n * custom refinements and async checks are refused when the contract is\n * defined. The Zod instances stay in the host; nothing here changes the IR.\n */\n\ntype ObjectSchema = core.$ZodObject;\n\n/** The declared fields of an object schema — without a loose object's `[key: string]: unknown`, so the DSL only offers fields that were written down. */\nexport type DeclaredOutput<S> =\n S extends core.$ZodObject<infer Shape>\n ? core.$InferObjectOutput<Shape, core.$strict['out']>\n : never;\n\n/**\n * The subscription topics a producer and the engine agree on: content categories ('offers',\n * 'tips'), not channels — an email or push template declares one (`emailTemplate({ topic })`,\n * `pushTemplate({ topic })`), and a user opts out of a topic per channel (the preference page is\n * topic × channel). Ids only — display names belong to the app's i18n. A plain array literal\n * is enough: `defineContract` keeps the literals (no `as const` needed), and the union they form\n * types the templates, the config and `JourneyClient.setSubscriptions`.\n */\nexport interface SubscriptionsDefinition<T extends string> {\n topics: readonly T[];\n}\n\nexport interface ContractDefinition<\n E extends Record<string, ObjectSchema>,\n P extends ObjectSchema,\n T extends string = never,\n> {\n events: E;\n profile: P;\n /** The topics, e.g. `{ topics: ['promotions', 'newsletter'] }`; none without it (transactional email and push only). */\n subscriptions?: SubscriptionsDefinition<T>;\n}\n\n/** One rejected field, with the dotted path the producer can fix. */\nexport interface ContractIssue {\n path: string;\n message: string;\n}\n\nexport type ContractResult<T> = { ok: true; data: T } | { ok: false; issues: ContractIssue[] };\n\nexport interface Contract<\n E extends Record<string, ObjectSchema> = Record<string, ObjectSchema>,\n P extends ObjectSchema = ObjectSchema,\n T extends string = string,\n> {\n readonly kind: 'journey-contract';\n /** The declared subscription topics (`subscriptions.topics`), empty when none were declared. */\n readonly topics: readonly T[];\n isTopic(topic: string): topic is T;\n /** The DSL's event reference table: `e.purchase` — payload refs from the declared fields only. */\n readonly events: EventRefs<{ [K in keyof E]: DeclaredOutput<E[K]> }>;\n /** The DSL's user-property reference table: `u.name`, plus the system `u.userId`. */\n readonly user: UserRefs<DeclaredOutput<P> & { userId: string }>;\n /** The raw schemas, for hosts that need them (validation, docs). */\n readonly schemas: { readonly events: E; readonly profile: P };\n /** Every event name the contract declares — what a producer forwards. */\n readonly eventNames: readonly (keyof E & string)[];\n isEvent(name: string): name is keyof E & string;\n /** Validate one event's payload; the parsed value is what may be stored. */\n parseEvent<K extends keyof E & string>(\n name: K,\n payload: unknown\n ): ContractResult<core.output<E[K]>>;\n /**\n * Validate a profile merge patch: omitted fields stay as they are,\n * `null` removes a field, every provided value is checked against its field\n * schema, and an undeclared field is refused — a patch, never a full parse\n * that would fill defaults over fields the caller did not send.\n */\n parseProfilePatch(patch: unknown): ContractResult<ProfilePatch<P>>;\n}\n\n/** A patch over the profile: any subset of fields, `null` to remove one. */\nexport type ProfilePatch<P> = { [K in keyof DeclaredOutput<P>]?: DeclaredOutput<P>[K] | null };\n\n/** Payload type of one event, for producers: `EventPayload<typeof contract, 'purchase'>`. */\nexport type EventPayload<C, K extends string> =\n C extends Contract<infer E> ? (K extends keyof E ? core.input<E[K]> : never) : never;\n\n/** The topics a contract declares, as a union: `Topic<typeof contract>`; `never` without any. */\nexport type Topic<C> =\n C extends Contract<Record<string, ObjectSchema>, ObjectSchema, infer T> ? T : never;\n\n/** A topic id: what goes in a URL, a JSON key and a database column, so no spaces or specials. */\nconst TOPIC_ID = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;\n\nexport function defineContract<\n E extends Record<string, ObjectSchema>,\n P extends ObjectSchema,\n const T extends string = never,\n>(definition: ContractDefinition<E, P, T>): Contract<E, P, T> {\n const topics = [...(definition.subscriptions?.topics ?? [])];\n const seen = new Set<string>();\n for (const topic of topics) {\n if (typeof topic !== 'string' || !TOPIC_ID.test(topic)) {\n throw new TypeError(\n `contract subscriptions.topics: '${topic as string}' is not a topic id (letters, digits, '_', '-' and '.', starting with a letter or digit)`\n );\n }\n if (seen.has(topic))\n throw new TypeError(`contract subscriptions.topics lists '${topic}' twice`);\n seen.add(topic);\n }\n for (const [name, schema] of Object.entries(definition.events)) {\n if (name.startsWith('$')) {\n throw new TypeError(`contract event '${name}': names starting with $ are reserved`);\n }\n assertJsonSchema(schema, `event '${name}'`, true);\n }\n assertJsonSchema(definition.profile, 'profile', true);\n const profileShape = (definition.profile._zod.def as core.$ZodObjectDef).shape;\n for (const [field, schema] of Object.entries(profileShape)) {\n assertFlatScalar(schema, `profile.${field}`);\n }\n const strictProfile = definition.profile._zod.def.catchall?._zod.def.type === 'never';\n\n const names = Object.keys(definition.events) as (keyof E & string)[];\n return {\n kind: 'journey-contract',\n events: event() as never,\n user: user() as never,\n schemas: { events: definition.events, profile: definition.profile },\n eventNames: names,\n topics,\n isTopic: (topic): topic is T => seen.has(topic),\n isEvent: (name): name is keyof E & string => Object.hasOwn(definition.events, name),\n parseEvent(name, payload) {\n if (!Object.hasOwn(definition.events, name)) {\n return { ok: false, issues: [{ path: '', message: `unknown event '${name}'` }] };\n }\n return toResult(z.safeParse(definition.events[name], payload));\n },\n parseProfilePatch(patch) {\n if (typeof patch !== 'object' || patch === null || Array.isArray(patch)) {\n return { ok: false, issues: [{ path: '', message: 'props must be an object' }] };\n }\n const issues: ContractIssue[] = [];\n const data: Record<string, unknown> = {};\n for (const [field, value] of Object.entries(patch)) {\n if (!Object.hasOwn(profileShape, field)) {\n // userId is the request's identity, not a profile field\n if (strictProfile || field === 'userId') {\n issues.push({ path: field, message: 'not a declared profile field' });\n } else {\n data[field] = value;\n }\n continue;\n }\n if (value === null) {\n data[field] = null;\n continue;\n }\n const result = z.safeParse(profileShape[field], value);\n if (result.success) data[field] = result.data;\n else issues.push(...prefixed(field, result.error));\n }\n return issues.length > 0 ? { ok: false, issues } : { ok: true, data: data as never };\n },\n };\n}\n\n/**\n * An event the contract refuses — raised for events a journey emits itself\n * (`.sendEvent()`), which never pass the HTTP ingest: they go through the same\n * check, and a refusal is deterministic, so it is not retried.\n */\nexport class ContractValidationError extends Error {\n override readonly name = 'ContractValidationError';\n constructor(\n readonly event: string,\n readonly issues: ContractIssue[]\n ) {\n super(\n `event '${event}' refused by the contract: ${issues\n .map((i) => `${i.path || '(root)'} ${i.message}`)\n .join('; ')}`\n );\n }\n}\n\n/** The one check every event passes, from HTTP or from a journey: declared, and its payload parses. */\nexport function checkEvent(\n contract: Contract,\n name: string,\n payload: unknown\n): ContractResult<Record<string, unknown>> {\n if (!contract.isEvent(name)) {\n return {\n ok: false,\n issues: [{ path: '', message: `'${String(name)}' is not in the contract` }],\n };\n }\n return contract.parseEvent(name, payload);\n}\n\nexport function isContract(value: unknown): value is Contract {\n return (\n typeof value === 'object' &&\n value !== null &&\n (value as { kind?: unknown }).kind === 'journey-contract'\n );\n}\n\nfunction toResult<T>(result: core.util.SafeParseResult<T>): ContractResult<T> {\n return result.success\n ? { ok: true, data: result.data }\n : { ok: false, issues: prefixed('', result.error) };\n}\n\nfunction prefixed(prefix: string, error: core.$ZodError): ContractIssue[] {\n return error.issues.map((issue) => ({\n path: [prefix, ...issue.path.map(String)].filter((p) => p !== '').join('.'),\n message: issue.message,\n }));\n}\n\n/* ---------------------------- supported schemas ---------------------------- */\n\n/**\n * The schema types a contract may use — JSON shapes that parse synchronously\n * and mean the same before and after parsing. Anything not listed (default,\n * transform, pipe, lazy, promise, custom, date, bigint, …) is refused, and\n * every container is walked so nothing unsupported hides inside one.\n */\nconst ALLOWED = new Set([\n 'object',\n 'array',\n 'tuple',\n 'union',\n 'intersection',\n 'record',\n 'optional',\n 'nullable',\n 'readonly',\n 'nonoptional',\n 'string',\n 'number',\n 'boolean',\n 'null',\n 'enum',\n 'literal',\n 'unknown',\n 'any',\n 'never',\n]);\n\n/** Checks that change the value (`overwrite`: `.trim()`, `.toLowerCase()`, …) or run arbitrary code (`custom`: `.refine()`, async or not). */\nconst REFUSED_CHECKS = new Set(['custom', 'overwrite']);\n\nfunction assertJsonSchema(schema: unknown, where: string, top = false): void {\n const def = (schema as { _zod?: { def?: core.$ZodTypeDef } } | null | undefined)?._zod?.def;\n if (def === undefined) throw new TypeError(`contract ${where}: not a Zod schema`);\n if (top && def.type !== 'object') {\n throw new TypeError(\n `contract ${where}: must be an object schema (z.looseObject / z.strictObject)`\n );\n }\n if (!ALLOWED.has(def.type)) {\n throw new TypeError(\n `contract ${where}: ${def.type} schemas are not supported (input and output must mean the same JSON, parsed synchronously)`\n );\n }\n if ((def as { coerce?: boolean }).coerce === true) {\n throw new TypeError(`contract ${where}: coercion (z.coerce) is not supported`);\n }\n for (const check of def.checks ?? []) {\n if (REFUSED_CHECKS.has(check._zod.def.check)) {\n throw new TypeError(\n `contract ${where}: ${check._zod.def.check === 'overwrite' ? 'value-changing checks (.overwrite, .trim, …)' : 'custom refinements'} are not supported`\n );\n }\n }\n const children: [string, unknown][] = [];\n switch (def.type) {\n case 'object': {\n const object = def as core.$ZodObjectDef;\n for (const [k, v] of Object.entries(object.shape)) children.push([`${where}.${k}`, v]);\n if (object.catchall !== undefined) children.push([`${where}.*`, object.catchall]);\n break;\n }\n case 'optional':\n case 'nullable':\n case 'readonly':\n case 'nonoptional':\n children.push([where, (def as unknown as { innerType: unknown }).innerType]);\n break;\n case 'array':\n children.push([`${where}[]`, (def as core.$ZodArrayDef).element]);\n break;\n case 'tuple': {\n const tuple = def as core.$ZodTupleDef;\n tuple.items.forEach((item, index) => children.push([`${where}[${index}]`, item]));\n if (tuple.rest !== null) children.push([`${where}[…]`, tuple.rest]);\n break;\n }\n case 'union':\n for (const option of (def as core.$ZodUnionDef).options) children.push([where, option]);\n break;\n case 'intersection': {\n const intersection = def as core.$ZodIntersectionDef;\n children.push([where, intersection.left], [where, intersection.right]);\n break;\n }\n case 'record': {\n const record = def as core.$ZodRecordDef;\n children.push([`${where}{key}`, record.keyType], [`${where}{}`, record.valueType]);\n break;\n }\n default:\n break;\n }\n for (const [path, child] of children) assertJsonSchema(child, path);\n}\n\nconst SCALARS = new Set(['string', 'number', 'boolean', 'enum', 'literal']);\n\nfunction assertFlatScalar(schema: unknown, where: string): void {\n let def = (schema as core.$ZodType)._zod.def;\n if (def.type === 'optional') def = (def as core.$ZodOptionalDef).innerType._zod.def;\n if (!SCALARS.has(def.type)) {\n throw new TypeError(\n `contract ${where}: profile fields must be flat scalars (string, number, boolean, enum, literal), got ${def.type}`\n );\n }\n}\n"],"mappings":"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"}
|
package/dist/contract.d.cts
CHANGED
|
@@ -12,8 +12,8 @@ import * as core from "zod/v4/core";
|
|
|
12
12
|
* topic the contract does not declare;
|
|
13
13
|
* - `JourneyClient({ contract })` types `track` / `identify` by event name and
|
|
14
14
|
* checks payloads before sending, and `getSubscriptions` / `setSubscriptions` by topic;
|
|
15
|
-
* - `journeyWorker({ contract })` re-validates every inbound `/
|
|
16
|
-
*
|
|
15
|
+
* - `journeyWorker({ contract })` re-validates every inbound `/ingest`
|
|
16
|
+
* and `/subscriptions` — never trusting the caller used the SDK — and refuses a bad
|
|
17
17
|
* one with a 4xx before anything is stored, woken or started.
|
|
18
18
|
*
|
|
19
19
|
* Supported schemas (v1), synchronous and JSON-shaped: event payloads are
|
|
@@ -78,14 +78,14 @@ export interface Contract<E extends Record<string, ObjectSchema> = Record<string
|
|
|
78
78
|
/** Validate one event's payload; the parsed value is what may be stored. */
|
|
79
79
|
parseEvent<K extends keyof E & string>(name: K, payload: unknown): ContractResult<core.output<E[K]>>;
|
|
80
80
|
/**
|
|
81
|
-
* Validate
|
|
81
|
+
* Validate a profile merge patch: omitted fields stay as they are,
|
|
82
82
|
* `null` removes a field, every provided value is checked against its field
|
|
83
83
|
* schema, and an undeclared field is refused — a patch, never a full parse
|
|
84
84
|
* that would fill defaults over fields the caller did not send.
|
|
85
85
|
*/
|
|
86
86
|
parseProfilePatch(patch: unknown): ContractResult<ProfilePatch<P>>;
|
|
87
87
|
}
|
|
88
|
-
/**
|
|
88
|
+
/** A patch over the profile: any subset of fields, `null` to remove one. */
|
|
89
89
|
export type ProfilePatch<P> = { [K in keyof DeclaredOutput<P>]?: DeclaredOutput<P>[K] | null; };
|
|
90
90
|
/** Payload type of one event, for producers: `EventPayload<typeof contract, 'purchase'>`. */
|
|
91
91
|
export type EventPayload<C, K extends string> = C extends Contract<infer E> ? (K extends keyof E ? core.input<E[K]> : never) : never;
|
package/dist/contract.d.mts
CHANGED
|
@@ -12,8 +12,8 @@ import * as core from "zod/v4/core";
|
|
|
12
12
|
* topic the contract does not declare;
|
|
13
13
|
* - `JourneyClient({ contract })` types `track` / `identify` by event name and
|
|
14
14
|
* checks payloads before sending, and `getSubscriptions` / `setSubscriptions` by topic;
|
|
15
|
-
* - `journeyWorker({ contract })` re-validates every inbound `/
|
|
16
|
-
*
|
|
15
|
+
* - `journeyWorker({ contract })` re-validates every inbound `/ingest`
|
|
16
|
+
* and `/subscriptions` — never trusting the caller used the SDK — and refuses a bad
|
|
17
17
|
* one with a 4xx before anything is stored, woken or started.
|
|
18
18
|
*
|
|
19
19
|
* Supported schemas (v1), synchronous and JSON-shaped: event payloads are
|
|
@@ -78,14 +78,14 @@ export interface Contract<E extends Record<string, ObjectSchema> = Record<string
|
|
|
78
78
|
/** Validate one event's payload; the parsed value is what may be stored. */
|
|
79
79
|
parseEvent<K extends keyof E & string>(name: K, payload: unknown): ContractResult<core.output<E[K]>>;
|
|
80
80
|
/**
|
|
81
|
-
* Validate
|
|
81
|
+
* Validate a profile merge patch: omitted fields stay as they are,
|
|
82
82
|
* `null` removes a field, every provided value is checked against its field
|
|
83
83
|
* schema, and an undeclared field is refused — a patch, never a full parse
|
|
84
84
|
* that would fill defaults over fields the caller did not send.
|
|
85
85
|
*/
|
|
86
86
|
parseProfilePatch(patch: unknown): ContractResult<ProfilePatch<P>>;
|
|
87
87
|
}
|
|
88
|
-
/**
|
|
88
|
+
/** A patch over the profile: any subset of fields, `null` to remove one. */
|
|
89
89
|
export type ProfilePatch<P> = { [K in keyof DeclaredOutput<P>]?: DeclaredOutput<P>[K] | null; };
|
|
90
90
|
/** Payload type of one event, for producers: `EventPayload<typeof contract, 'purchase'>`. */
|
|
91
91
|
export type EventPayload<C, K extends string> = C extends Contract<infer E> ? (K extends keyof E ? core.input<E[K]> : never) : never;
|
package/dist/contract.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"contract.mjs","names":[],"sources":["../src/contract.ts"],"sourcesContent":["import * as z from 'zod/mini';\nimport type * as core from 'zod/v4/core';\nimport { type EventRefs, type UserRefs, event, user } from './dsl';\n\n/**\n * `defineContract({ events, profile, subscriptions? })` — the producer/engine contract as Zod\n * schemas, written once in a package both sides import. From it:\n *\n * - `contract.events` / `contract.user` are the DSL's typed reference tables\n * (what `event<E>()` / `user<U>()` used to be, now derived from the schemas);\n * - `contract.topics` are the subscription topics: every email and push template names one (or\n * is transactional), a user opts out of one at a time per channel, and the compile refuses a\n * topic the contract does not declare;\n * - `JourneyClient({ contract })` types `track` / `identify` by event name and\n * checks payloads before sending, and `getSubscriptions` / `setSubscriptions` by topic;\n * - `journeyWorker({ contract })` re-validates every inbound `/events`,\n * `/identify` and `/subscriptions` — never trusting the caller used the SDK — and refuses a bad\n * one with a 4xx before anything is stored, woken or started.\n *\n * Supported schemas (v1), synchronous and JSON-shaped: event payloads are\n * object schemas (`z.looseObject` keeps a producer's extra analytics fields,\n * `z.strictObject` refuses them) and may nest objects and arrays; the profile\n * is a flat object of string / number / boolean / enum / literal fields. Input\n * and output must mean the same thing, so defaults, transforms, coercion,\n * custom refinements and async checks are refused when the contract is\n * defined. The Zod instances stay in the host; nothing here changes the IR.\n */\n\ntype ObjectSchema = core.$ZodObject;\n\n/** The declared fields of an object schema — without a loose object's `[key: string]: unknown`, so the DSL only offers fields that were written down. */\nexport type DeclaredOutput<S> =\n S extends core.$ZodObject<infer Shape>\n ? core.$InferObjectOutput<Shape, core.$strict['out']>\n : never;\n\n/**\n * The subscription topics a producer and the engine agree on: content categories ('offers',\n * 'tips'), not channels — an email or push template declares one (`emailTemplate({ topic })`,\n * `pushTemplate({ topic })`), and a user opts out of a topic per channel (the preference page is\n * topic × channel). Ids only — display names belong to the app's i18n. A plain array literal\n * is enough: `defineContract` keeps the literals (no `as const` needed), and the union they form\n * types the templates, the config and `JourneyClient.setSubscriptions`.\n */\nexport interface SubscriptionsDefinition<T extends string> {\n topics: readonly T[];\n}\n\nexport interface ContractDefinition<\n E extends Record<string, ObjectSchema>,\n P extends ObjectSchema,\n T extends string = never,\n> {\n events: E;\n profile: P;\n /** The topics, e.g. `{ topics: ['promotions', 'newsletter'] }`; none without it (transactional email and push only). */\n subscriptions?: SubscriptionsDefinition<T>;\n}\n\n/** One rejected field, with the dotted path the producer can fix. */\nexport interface ContractIssue {\n path: string;\n message: string;\n}\n\nexport type ContractResult<T> = { ok: true; data: T } | { ok: false; issues: ContractIssue[] };\n\nexport interface Contract<\n E extends Record<string, ObjectSchema> = Record<string, ObjectSchema>,\n P extends ObjectSchema = ObjectSchema,\n T extends string = string,\n> {\n readonly kind: 'journey-contract';\n /** The declared subscription topics (`subscriptions.topics`), empty when none were declared. */\n readonly topics: readonly T[];\n isTopic(topic: string): topic is T;\n /** The DSL's event reference table: `e.purchase` — payload refs from the declared fields only. */\n readonly events: EventRefs<{ [K in keyof E]: DeclaredOutput<E[K]> }>;\n /** The DSL's user-property reference table: `u.name`, plus the system `u.userId`. */\n readonly user: UserRefs<DeclaredOutput<P> & { userId: string }>;\n /** The raw schemas, for hosts that need them (validation, docs). */\n readonly schemas: { readonly events: E; readonly profile: P };\n /** Every event name the contract declares — what a producer forwards. */\n readonly eventNames: readonly (keyof E & string)[];\n isEvent(name: string): name is keyof E & string;\n /** Validate one event's payload; the parsed value is what may be stored. */\n parseEvent<K extends keyof E & string>(\n name: K,\n payload: unknown\n ): ContractResult<core.output<E[K]>>;\n /**\n * Validate an `/identify` merge patch: omitted fields stay as they are,\n * `null` removes a field, every provided value is checked against its field\n * schema, and an undeclared field is refused — a patch, never a full parse\n * that would fill defaults over fields the caller did not send.\n */\n parseProfilePatch(patch: unknown): ContractResult<ProfilePatch<P>>;\n}\n\n/** An `/identify` patch over the profile: any subset of fields, `null` to remove one. */\nexport type ProfilePatch<P> = { [K in keyof DeclaredOutput<P>]?: DeclaredOutput<P>[K] | null };\n\n/** Payload type of one event, for producers: `EventPayload<typeof contract, 'purchase'>`. */\nexport type EventPayload<C, K extends string> =\n C extends Contract<infer E> ? (K extends keyof E ? core.input<E[K]> : never) : never;\n\n/** The topics a contract declares, as a union: `Topic<typeof contract>`; `never` without any. */\nexport type Topic<C> =\n C extends Contract<Record<string, ObjectSchema>, ObjectSchema, infer T> ? T : never;\n\n/** A topic id: what goes in a URL, a JSON key and a database column, so no spaces or specials. */\nconst TOPIC_ID = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;\n\nexport function defineContract<\n E extends Record<string, ObjectSchema>,\n P extends ObjectSchema,\n const T extends string = never,\n>(definition: ContractDefinition<E, P, T>): Contract<E, P, T> {\n const topics = [...(definition.subscriptions?.topics ?? [])];\n const seen = new Set<string>();\n for (const topic of topics) {\n if (typeof topic !== 'string' || !TOPIC_ID.test(topic)) {\n throw new TypeError(\n `contract subscriptions.topics: '${topic as string}' is not a topic id (letters, digits, '_', '-' and '.', starting with a letter or digit)`\n );\n }\n if (seen.has(topic))\n throw new TypeError(`contract subscriptions.topics lists '${topic}' twice`);\n seen.add(topic);\n }\n for (const [name, schema] of Object.entries(definition.events)) {\n if (name.startsWith('$')) {\n throw new TypeError(`contract event '${name}': names starting with $ are reserved`);\n }\n assertJsonSchema(schema, `event '${name}'`, true);\n }\n assertJsonSchema(definition.profile, 'profile', true);\n const profileShape = (definition.profile._zod.def as core.$ZodObjectDef).shape;\n for (const [field, schema] of Object.entries(profileShape)) {\n assertFlatScalar(schema, `profile.${field}`);\n }\n const strictProfile = definition.profile._zod.def.catchall?._zod.def.type === 'never';\n\n const names = Object.keys(definition.events) as (keyof E & string)[];\n return {\n kind: 'journey-contract',\n events: event() as never,\n user: user() as never,\n schemas: { events: definition.events, profile: definition.profile },\n eventNames: names,\n topics,\n isTopic: (topic): topic is T => seen.has(topic),\n isEvent: (name): name is keyof E & string => Object.hasOwn(definition.events, name),\n parseEvent(name, payload) {\n if (!Object.hasOwn(definition.events, name)) {\n return { ok: false, issues: [{ path: '', message: `unknown event '${name}'` }] };\n }\n return toResult(z.safeParse(definition.events[name], payload));\n },\n parseProfilePatch(patch) {\n if (typeof patch !== 'object' || patch === null || Array.isArray(patch)) {\n return { ok: false, issues: [{ path: '', message: 'props must be an object' }] };\n }\n const issues: ContractIssue[] = [];\n const data: Record<string, unknown> = {};\n for (const [field, value] of Object.entries(patch)) {\n if (!Object.hasOwn(profileShape, field)) {\n // userId is the request's identity, not a profile field\n if (strictProfile || field === 'userId') {\n issues.push({ path: field, message: 'not a declared profile field' });\n } else {\n data[field] = value;\n }\n continue;\n }\n if (value === null) {\n data[field] = null;\n continue;\n }\n const result = z.safeParse(profileShape[field], value);\n if (result.success) data[field] = result.data;\n else issues.push(...prefixed(field, result.error));\n }\n return issues.length > 0 ? { ok: false, issues } : { ok: true, data: data as never };\n },\n };\n}\n\n/**\n * An event the contract refuses — raised for events a journey emits itself\n * (`.sendEvent()`), which never pass the HTTP ingest: they go through the same\n * check, and a refusal is deterministic, so it is not retried.\n */\nexport class ContractValidationError extends Error {\n override readonly name = 'ContractValidationError';\n constructor(\n readonly event: string,\n readonly issues: ContractIssue[]\n ) {\n super(\n `event '${event}' refused by the contract: ${issues\n .map((i) => `${i.path || '(root)'} ${i.message}`)\n .join('; ')}`\n );\n }\n}\n\n/** The one check every event passes, from HTTP or from a journey: declared, and its payload parses. */\nexport function checkEvent(\n contract: Contract,\n name: string,\n payload: unknown\n): ContractResult<Record<string, unknown>> {\n if (!contract.isEvent(name)) {\n return {\n ok: false,\n issues: [{ path: '', message: `'${String(name)}' is not in the contract` }],\n };\n }\n return contract.parseEvent(name, payload);\n}\n\nexport function isContract(value: unknown): value is Contract {\n return (\n typeof value === 'object' &&\n value !== null &&\n (value as { kind?: unknown }).kind === 'journey-contract'\n );\n}\n\nfunction toResult<T>(result: core.util.SafeParseResult<T>): ContractResult<T> {\n return result.success\n ? { ok: true, data: result.data }\n : { ok: false, issues: prefixed('', result.error) };\n}\n\nfunction prefixed(prefix: string, error: core.$ZodError): ContractIssue[] {\n return error.issues.map((issue) => ({\n path: [prefix, ...issue.path.map(String)].filter((p) => p !== '').join('.'),\n message: issue.message,\n }));\n}\n\n/* ---------------------------- supported schemas ---------------------------- */\n\n/**\n * The schema types a contract may use — JSON shapes that parse synchronously\n * and mean the same before and after parsing. Anything not listed (default,\n * transform, pipe, lazy, promise, custom, date, bigint, …) is refused, and\n * every container is walked so nothing unsupported hides inside one.\n */\nconst ALLOWED = new Set([\n 'object',\n 'array',\n 'tuple',\n 'union',\n 'intersection',\n 'record',\n 'optional',\n 'nullable',\n 'readonly',\n 'nonoptional',\n 'string',\n 'number',\n 'boolean',\n 'null',\n 'enum',\n 'literal',\n 'unknown',\n 'any',\n 'never',\n]);\n\n/** Checks that change the value (`overwrite`: `.trim()`, `.toLowerCase()`, …) or run arbitrary code (`custom`: `.refine()`, async or not). */\nconst REFUSED_CHECKS = new Set(['custom', 'overwrite']);\n\nfunction assertJsonSchema(schema: unknown, where: string, top = false): void {\n const def = (schema as { _zod?: { def?: core.$ZodTypeDef } } | null | undefined)?._zod?.def;\n if (def === undefined) throw new TypeError(`contract ${where}: not a Zod schema`);\n if (top && def.type !== 'object') {\n throw new TypeError(\n `contract ${where}: must be an object schema (z.looseObject / z.strictObject)`\n );\n }\n if (!ALLOWED.has(def.type)) {\n throw new TypeError(\n `contract ${where}: ${def.type} schemas are not supported (input and output must mean the same JSON, parsed synchronously)`\n );\n }\n if ((def as { coerce?: boolean }).coerce === true) {\n throw new TypeError(`contract ${where}: coercion (z.coerce) is not supported`);\n }\n for (const check of def.checks ?? []) {\n if (REFUSED_CHECKS.has(check._zod.def.check)) {\n throw new TypeError(\n `contract ${where}: ${check._zod.def.check === 'overwrite' ? 'value-changing checks (.overwrite, .trim, …)' : 'custom refinements'} are not supported`\n );\n }\n }\n const children: [string, unknown][] = [];\n switch (def.type) {\n case 'object': {\n const object = def as core.$ZodObjectDef;\n for (const [k, v] of Object.entries(object.shape)) children.push([`${where}.${k}`, v]);\n if (object.catchall !== undefined) children.push([`${where}.*`, object.catchall]);\n break;\n }\n case 'optional':\n case 'nullable':\n case 'readonly':\n case 'nonoptional':\n children.push([where, (def as unknown as { innerType: unknown }).innerType]);\n break;\n case 'array':\n children.push([`${where}[]`, (def as core.$ZodArrayDef).element]);\n break;\n case 'tuple': {\n const tuple = def as core.$ZodTupleDef;\n tuple.items.forEach((item, index) => children.push([`${where}[${index}]`, item]));\n if (tuple.rest !== null) children.push([`${where}[…]`, tuple.rest]);\n break;\n }\n case 'union':\n for (const option of (def as core.$ZodUnionDef).options) children.push([where, option]);\n break;\n case 'intersection': {\n const intersection = def as core.$ZodIntersectionDef;\n children.push([where, intersection.left], [where, intersection.right]);\n break;\n }\n case 'record': {\n const record = def as core.$ZodRecordDef;\n children.push([`${where}{key}`, record.keyType], [`${where}{}`, record.valueType]);\n break;\n }\n default:\n break;\n }\n for (const [path, child] of children) assertJsonSchema(child, path);\n}\n\nconst SCALARS = new Set(['string', 'number', 'boolean', 'enum', 'literal']);\n\nfunction assertFlatScalar(schema: unknown, where: string): void {\n let def = (schema as core.$ZodType)._zod.def;\n if (def.type === 'optional') def = (def as core.$ZodOptionalDef).innerType._zod.def;\n if (!SCALARS.has(def.type)) {\n throw new TypeError(\n `contract ${where}: profile fields must be flat scalars (string, number, boolean, enum, literal), got ${def.type}`\n );\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"contract.mjs","names":[],"sources":["../src/contract.ts"],"sourcesContent":["import * as z from 'zod/mini';\nimport type * as core from 'zod/v4/core';\nimport { type EventRefs, type UserRefs, event, user } from './dsl';\n\n/**\n * `defineContract({ events, profile, subscriptions? })` — the producer/engine contract as Zod\n * schemas, written once in a package both sides import. From it:\n *\n * - `contract.events` / `contract.user` are the DSL's typed reference tables\n * (what `event<E>()` / `user<U>()` used to be, now derived from the schemas);\n * - `contract.topics` are the subscription topics: every email and push template names one (or\n * is transactional), a user opts out of one at a time per channel, and the compile refuses a\n * topic the contract does not declare;\n * - `JourneyClient({ contract })` types `track` / `identify` by event name and\n * checks payloads before sending, and `getSubscriptions` / `setSubscriptions` by topic;\n * - `journeyWorker({ contract })` re-validates every inbound `/ingest`\n * and `/subscriptions` — never trusting the caller used the SDK — and refuses a bad\n * one with a 4xx before anything is stored, woken or started.\n *\n * Supported schemas (v1), synchronous and JSON-shaped: event payloads are\n * object schemas (`z.looseObject` keeps a producer's extra analytics fields,\n * `z.strictObject` refuses them) and may nest objects and arrays; the profile\n * is a flat object of string / number / boolean / enum / literal fields. Input\n * and output must mean the same thing, so defaults, transforms, coercion,\n * custom refinements and async checks are refused when the contract is\n * defined. The Zod instances stay in the host; nothing here changes the IR.\n */\n\ntype ObjectSchema = core.$ZodObject;\n\n/** The declared fields of an object schema — without a loose object's `[key: string]: unknown`, so the DSL only offers fields that were written down. */\nexport type DeclaredOutput<S> =\n S extends core.$ZodObject<infer Shape>\n ? core.$InferObjectOutput<Shape, core.$strict['out']>\n : never;\n\n/**\n * The subscription topics a producer and the engine agree on: content categories ('offers',\n * 'tips'), not channels — an email or push template declares one (`emailTemplate({ topic })`,\n * `pushTemplate({ topic })`), and a user opts out of a topic per channel (the preference page is\n * topic × channel). Ids only — display names belong to the app's i18n. A plain array literal\n * is enough: `defineContract` keeps the literals (no `as const` needed), and the union they form\n * types the templates, the config and `JourneyClient.setSubscriptions`.\n */\nexport interface SubscriptionsDefinition<T extends string> {\n topics: readonly T[];\n}\n\nexport interface ContractDefinition<\n E extends Record<string, ObjectSchema>,\n P extends ObjectSchema,\n T extends string = never,\n> {\n events: E;\n profile: P;\n /** The topics, e.g. `{ topics: ['promotions', 'newsletter'] }`; none without it (transactional email and push only). */\n subscriptions?: SubscriptionsDefinition<T>;\n}\n\n/** One rejected field, with the dotted path the producer can fix. */\nexport interface ContractIssue {\n path: string;\n message: string;\n}\n\nexport type ContractResult<T> = { ok: true; data: T } | { ok: false; issues: ContractIssue[] };\n\nexport interface Contract<\n E extends Record<string, ObjectSchema> = Record<string, ObjectSchema>,\n P extends ObjectSchema = ObjectSchema,\n T extends string = string,\n> {\n readonly kind: 'journey-contract';\n /** The declared subscription topics (`subscriptions.topics`), empty when none were declared. */\n readonly topics: readonly T[];\n isTopic(topic: string): topic is T;\n /** The DSL's event reference table: `e.purchase` — payload refs from the declared fields only. */\n readonly events: EventRefs<{ [K in keyof E]: DeclaredOutput<E[K]> }>;\n /** The DSL's user-property reference table: `u.name`, plus the system `u.userId`. */\n readonly user: UserRefs<DeclaredOutput<P> & { userId: string }>;\n /** The raw schemas, for hosts that need them (validation, docs). */\n readonly schemas: { readonly events: E; readonly profile: P };\n /** Every event name the contract declares — what a producer forwards. */\n readonly eventNames: readonly (keyof E & string)[];\n isEvent(name: string): name is keyof E & string;\n /** Validate one event's payload; the parsed value is what may be stored. */\n parseEvent<K extends keyof E & string>(\n name: K,\n payload: unknown\n ): ContractResult<core.output<E[K]>>;\n /**\n * Validate a profile merge patch: omitted fields stay as they are,\n * `null` removes a field, every provided value is checked against its field\n * schema, and an undeclared field is refused — a patch, never a full parse\n * that would fill defaults over fields the caller did not send.\n */\n parseProfilePatch(patch: unknown): ContractResult<ProfilePatch<P>>;\n}\n\n/** A patch over the profile: any subset of fields, `null` to remove one. */\nexport type ProfilePatch<P> = { [K in keyof DeclaredOutput<P>]?: DeclaredOutput<P>[K] | null };\n\n/** Payload type of one event, for producers: `EventPayload<typeof contract, 'purchase'>`. */\nexport type EventPayload<C, K extends string> =\n C extends Contract<infer E> ? (K extends keyof E ? core.input<E[K]> : never) : never;\n\n/** The topics a contract declares, as a union: `Topic<typeof contract>`; `never` without any. */\nexport type Topic<C> =\n C extends Contract<Record<string, ObjectSchema>, ObjectSchema, infer T> ? T : never;\n\n/** A topic id: what goes in a URL, a JSON key and a database column, so no spaces or specials. */\nconst TOPIC_ID = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;\n\nexport function defineContract<\n E extends Record<string, ObjectSchema>,\n P extends ObjectSchema,\n const T extends string = never,\n>(definition: ContractDefinition<E, P, T>): Contract<E, P, T> {\n const topics = [...(definition.subscriptions?.topics ?? [])];\n const seen = new Set<string>();\n for (const topic of topics) {\n if (typeof topic !== 'string' || !TOPIC_ID.test(topic)) {\n throw new TypeError(\n `contract subscriptions.topics: '${topic as string}' is not a topic id (letters, digits, '_', '-' and '.', starting with a letter or digit)`\n );\n }\n if (seen.has(topic))\n throw new TypeError(`contract subscriptions.topics lists '${topic}' twice`);\n seen.add(topic);\n }\n for (const [name, schema] of Object.entries(definition.events)) {\n if (name.startsWith('$')) {\n throw new TypeError(`contract event '${name}': names starting with $ are reserved`);\n }\n assertJsonSchema(schema, `event '${name}'`, true);\n }\n assertJsonSchema(definition.profile, 'profile', true);\n const profileShape = (definition.profile._zod.def as core.$ZodObjectDef).shape;\n for (const [field, schema] of Object.entries(profileShape)) {\n assertFlatScalar(schema, `profile.${field}`);\n }\n const strictProfile = definition.profile._zod.def.catchall?._zod.def.type === 'never';\n\n const names = Object.keys(definition.events) as (keyof E & string)[];\n return {\n kind: 'journey-contract',\n events: event() as never,\n user: user() as never,\n schemas: { events: definition.events, profile: definition.profile },\n eventNames: names,\n topics,\n isTopic: (topic): topic is T => seen.has(topic),\n isEvent: (name): name is keyof E & string => Object.hasOwn(definition.events, name),\n parseEvent(name, payload) {\n if (!Object.hasOwn(definition.events, name)) {\n return { ok: false, issues: [{ path: '', message: `unknown event '${name}'` }] };\n }\n return toResult(z.safeParse(definition.events[name], payload));\n },\n parseProfilePatch(patch) {\n if (typeof patch !== 'object' || patch === null || Array.isArray(patch)) {\n return { ok: false, issues: [{ path: '', message: 'props must be an object' }] };\n }\n const issues: ContractIssue[] = [];\n const data: Record<string, unknown> = {};\n for (const [field, value] of Object.entries(patch)) {\n if (!Object.hasOwn(profileShape, field)) {\n // userId is the request's identity, not a profile field\n if (strictProfile || field === 'userId') {\n issues.push({ path: field, message: 'not a declared profile field' });\n } else {\n data[field] = value;\n }\n continue;\n }\n if (value === null) {\n data[field] = null;\n continue;\n }\n const result = z.safeParse(profileShape[field], value);\n if (result.success) data[field] = result.data;\n else issues.push(...prefixed(field, result.error));\n }\n return issues.length > 0 ? { ok: false, issues } : { ok: true, data: data as never };\n },\n };\n}\n\n/**\n * An event the contract refuses — raised for events a journey emits itself\n * (`.sendEvent()`), which never pass the HTTP ingest: they go through the same\n * check, and a refusal is deterministic, so it is not retried.\n */\nexport class ContractValidationError extends Error {\n override readonly name = 'ContractValidationError';\n constructor(\n readonly event: string,\n readonly issues: ContractIssue[]\n ) {\n super(\n `event '${event}' refused by the contract: ${issues\n .map((i) => `${i.path || '(root)'} ${i.message}`)\n .join('; ')}`\n );\n }\n}\n\n/** The one check every event passes, from HTTP or from a journey: declared, and its payload parses. */\nexport function checkEvent(\n contract: Contract,\n name: string,\n payload: unknown\n): ContractResult<Record<string, unknown>> {\n if (!contract.isEvent(name)) {\n return {\n ok: false,\n issues: [{ path: '', message: `'${String(name)}' is not in the contract` }],\n };\n }\n return contract.parseEvent(name, payload);\n}\n\nexport function isContract(value: unknown): value is Contract {\n return (\n typeof value === 'object' &&\n value !== null &&\n (value as { kind?: unknown }).kind === 'journey-contract'\n );\n}\n\nfunction toResult<T>(result: core.util.SafeParseResult<T>): ContractResult<T> {\n return result.success\n ? { ok: true, data: result.data }\n : { ok: false, issues: prefixed('', result.error) };\n}\n\nfunction prefixed(prefix: string, error: core.$ZodError): ContractIssue[] {\n return error.issues.map((issue) => ({\n path: [prefix, ...issue.path.map(String)].filter((p) => p !== '').join('.'),\n message: issue.message,\n }));\n}\n\n/* ---------------------------- supported schemas ---------------------------- */\n\n/**\n * The schema types a contract may use — JSON shapes that parse synchronously\n * and mean the same before and after parsing. Anything not listed (default,\n * transform, pipe, lazy, promise, custom, date, bigint, …) is refused, and\n * every container is walked so nothing unsupported hides inside one.\n */\nconst ALLOWED = new Set([\n 'object',\n 'array',\n 'tuple',\n 'union',\n 'intersection',\n 'record',\n 'optional',\n 'nullable',\n 'readonly',\n 'nonoptional',\n 'string',\n 'number',\n 'boolean',\n 'null',\n 'enum',\n 'literal',\n 'unknown',\n 'any',\n 'never',\n]);\n\n/** Checks that change the value (`overwrite`: `.trim()`, `.toLowerCase()`, …) or run arbitrary code (`custom`: `.refine()`, async or not). */\nconst REFUSED_CHECKS = new Set(['custom', 'overwrite']);\n\nfunction assertJsonSchema(schema: unknown, where: string, top = false): void {\n const def = (schema as { _zod?: { def?: core.$ZodTypeDef } } | null | undefined)?._zod?.def;\n if (def === undefined) throw new TypeError(`contract ${where}: not a Zod schema`);\n if (top && def.type !== 'object') {\n throw new TypeError(\n `contract ${where}: must be an object schema (z.looseObject / z.strictObject)`\n );\n }\n if (!ALLOWED.has(def.type)) {\n throw new TypeError(\n `contract ${where}: ${def.type} schemas are not supported (input and output must mean the same JSON, parsed synchronously)`\n );\n }\n if ((def as { coerce?: boolean }).coerce === true) {\n throw new TypeError(`contract ${where}: coercion (z.coerce) is not supported`);\n }\n for (const check of def.checks ?? []) {\n if (REFUSED_CHECKS.has(check._zod.def.check)) {\n throw new TypeError(\n `contract ${where}: ${check._zod.def.check === 'overwrite' ? 'value-changing checks (.overwrite, .trim, …)' : 'custom refinements'} are not supported`\n );\n }\n }\n const children: [string, unknown][] = [];\n switch (def.type) {\n case 'object': {\n const object = def as core.$ZodObjectDef;\n for (const [k, v] of Object.entries(object.shape)) children.push([`${where}.${k}`, v]);\n if (object.catchall !== undefined) children.push([`${where}.*`, object.catchall]);\n break;\n }\n case 'optional':\n case 'nullable':\n case 'readonly':\n case 'nonoptional':\n children.push([where, (def as unknown as { innerType: unknown }).innerType]);\n break;\n case 'array':\n children.push([`${where}[]`, (def as core.$ZodArrayDef).element]);\n break;\n case 'tuple': {\n const tuple = def as core.$ZodTupleDef;\n tuple.items.forEach((item, index) => children.push([`${where}[${index}]`, item]));\n if (tuple.rest !== null) children.push([`${where}[…]`, tuple.rest]);\n break;\n }\n case 'union':\n for (const option of (def as core.$ZodUnionDef).options) children.push([where, option]);\n break;\n case 'intersection': {\n const intersection = def as core.$ZodIntersectionDef;\n children.push([where, intersection.left], [where, intersection.right]);\n break;\n }\n case 'record': {\n const record = def as core.$ZodRecordDef;\n children.push([`${where}{key}`, record.keyType], [`${where}{}`, record.valueType]);\n break;\n }\n default:\n break;\n }\n for (const [path, child] of children) assertJsonSchema(child, path);\n}\n\nconst SCALARS = new Set(['string', 'number', 'boolean', 'enum', 'literal']);\n\nfunction assertFlatScalar(schema: unknown, where: string): void {\n let def = (schema as core.$ZodType)._zod.def;\n if (def.type === 'optional') def = (def as core.$ZodOptionalDef).innerType._zod.def;\n if (!SCALARS.has(def.type)) {\n throw new TypeError(\n `contract ${where}: profile fields must be flat scalars (string, number, boolean, enum, literal), got ${def.type}`\n );\n }\n}\n"],"mappings":"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"}
|
package/dist/dsl/trigger.cjs
CHANGED
|
@@ -21,7 +21,8 @@ const trigger = {
|
|
|
21
21
|
type: "event",
|
|
22
22
|
event: ev.name,
|
|
23
23
|
...where !== void 0 ? { where: require_dsl_condition.condIR(where(require_dsl_refs.payloadRefs())) } : {},
|
|
24
|
-
...opts?.filter !== void 0 ? { filter: require_dsl_condition.condIR(opts.filter) } : {}
|
|
24
|
+
...opts?.filter !== void 0 ? { filter: require_dsl_condition.condIR(opts.filter) } : {},
|
|
25
|
+
...opts?.visitors === true ? { visitors: true } : {}
|
|
25
26
|
});
|
|
26
27
|
},
|
|
27
28
|
segment: (seg) => makeTrigger({
|
package/dist/dsl/trigger.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"trigger.cjs","names":["condIR","payloadRefs"],"sources":["../../src/dsl/trigger.ts"],"sourcesContent":["import type { TriggerIR } from '../ir';\nimport { type Condition, type PayloadWhere, type SegmentRef, condIR } from './condition';\nimport { type EventRef, payloadRefs } from './refs';\n\n/**\n * Triggers — the four ways into a workflow, mirroring customer.io's campaign\n * trigger types. A trigger is a plain reusable object; the event name and\n * payload types flow in from an e.xxx reference, so the factory itself needs\n * no generics.\n */\n\n/** 'YYYY-MM-DD HH:mm:ss'. TODO: validate at runtime — template literals cannot express leading zeros. */\nexport type DateTime = string;\n\n/** Trigger reference: what trigger.xxx() returns, reusable across workflows. */\nexport interface TriggerRef {\n readonly type: 'event' | 'segment' | 'date' | 'webhook';\n}\n\n/** @internal the compiled shape behind a TriggerRef — workflow.toIR reads .ir off it */\nexport interface TriggerInternal extends TriggerRef {\n readonly ir: TriggerIR;\n}\n\nconst makeTrigger = (ir: TriggerIR): TriggerRef => {\n const impl: TriggerInternal = { type: ir.type, ir };\n return impl;\n};\n\nexport interface EventTriggerOptions {\n filter?: Condition;\n}\n\n/** Trigger factory: four ways in, mirroring customer.io's campaign trigger types. */\nexport interface TriggerFactory {\n /**\n * Event trigger: the event name comes from an e.xxx reference (types flow in\n * from it, so the factory itself needs no generics). Two independent gates:\n * - the optional second argument is a payload where clause (typed refs, the\n * ordinary predicates — compiles to IR data the UI renders):\n * trigger.event(e.login, (p) => eq(p.platform, 'web'))\n * - `filter` tests the user's profile (customer.io's trigger Filters).\n * The user enters only if the event fired *and* both gates hold.\n */\n event<P>(event: EventRef<P>, opts?: EventTriggerOptions): TriggerRef;\n event<P>(event: EventRef<P>, where: PayloadWhere<P>, opts?: EventTriggerOptions): TriggerRef;\n\n /**\n * Segment-entry trigger: the user enters the moment they go from not\n * matching to matching. Only a named segment is accepted — entry semantics\n * need a membership table, which an anonymous expression cannot provide.\n * Runtime note: membership is maintained lazily (re-evaluated on the user's\n * ingest/identify activity), so purely time-driven drift — e.g. an\n * inactivity segment becoming true by clock alone — is only observed on the\n * user's next activity. TODO: a cron sweep for time-driven segments.\n */\n segment(segment: SegmentRef): TriggerRef;\n\n /**\n * Scheduled trigger (one-shot, e.g. '2026-12-25 09:00:00', interpreted as\n * UTC). TODO: not routed by the runtime yet — deployBundle reports it in\n * `unrouted`; the implementation goes through cron + Queues (instance\n * creation is rate-limited, a date trigger fans out to the whole audience at\n * once). TODO: cron/recurring form.\n */\n date(at: DateTime): TriggerRef;\n\n /** Webhook trigger: third-party or external code fires it via a URL; the runtime allocates the endpoint. */\n webhook(): TriggerRef;\n}\n\n/**\n * Trigger definition: a plain object — event names come from e.xxx references.\n *\n * const login = trigger.event(e.login, { filter: newUsers7d });\n */\nexport const trigger: TriggerFactory = {\n event: <P>(\n ev: EventRef<P>,\n whereOrOpts?: PayloadWhere<P> | EventTriggerOptions,\n maybeOpts?: EventTriggerOptions\n ) => {\n const where = typeof whereOrOpts === 'function' ? whereOrOpts : undefined;\n const opts = typeof whereOrOpts === 'function' ? maybeOpts : whereOrOpts;\n return makeTrigger({\n type: 'event',\n event: ev.name,\n ...(where !== undefined ? { where: condIR(where(payloadRefs<P>())) } : {}),\n ...(opts?.filter !== undefined ? { filter: condIR(opts.filter) } : {}),\n });\n },\n segment: (seg) => makeTrigger({ type: 'segment', segment: seg.id }),\n date: (at) => makeTrigger({ type: 'date', at }),\n webhook: () => makeTrigger({ type: 'webhook' }),\n};\n"],"mappings":";;;;AAwBA,MAAM,eAAe,OAA8B;CAEjD,OAAO;EADyB,MAAM,GAAG;EAAM;CACrC;AACZ;;;;;;
|
|
1
|
+
{"version":3,"file":"trigger.cjs","names":["condIR","payloadRefs"],"sources":["../../src/dsl/trigger.ts"],"sourcesContent":["import type { TriggerIR } from '../ir';\nimport { type Condition, type PayloadWhere, type SegmentRef, condIR } from './condition';\nimport { type EventRef, payloadRefs } from './refs';\n\n/**\n * Triggers — the four ways into a workflow, mirroring customer.io's campaign\n * trigger types. A trigger is a plain reusable object; the event name and\n * payload types flow in from an e.xxx reference, so the factory itself needs\n * no generics.\n */\n\n/** 'YYYY-MM-DD HH:mm:ss'. TODO: validate at runtime — template literals cannot express leading zeros. */\nexport type DateTime = string;\n\n/** Trigger reference: what trigger.xxx() returns, reusable across workflows. */\nexport interface TriggerRef {\n readonly type: 'event' | 'segment' | 'date' | 'webhook';\n}\n\n/** @internal the compiled shape behind a TriggerRef — workflow.toIR reads .ir off it */\nexport interface TriggerInternal extends TriggerRef {\n readonly ir: TriggerIR;\n}\n\nconst makeTrigger = (ir: TriggerIR): TriggerRef => {\n const impl: TriggerInternal = { type: ir.type, ir };\n return impl;\n};\n\nexport interface EventTriggerOptions {\n filter?: Condition;\n /**\n * Let a visitor who has not signed in enter: such a journey reaches them by push, having no\n * address to email. Off by default, because a user enters a workflow once: an entry made as a\n * visitor is still theirs after signing in.\n */\n visitors?: boolean;\n}\n\n/** Trigger factory: four ways in, mirroring customer.io's campaign trigger types. */\nexport interface TriggerFactory {\n /**\n * Event trigger: the event name comes from an e.xxx reference (types flow in\n * from it, so the factory itself needs no generics). Two independent gates:\n * - the optional second argument is a payload where clause (typed refs, the\n * ordinary predicates — compiles to IR data the UI renders):\n * trigger.event(e.login, (p) => eq(p.platform, 'web'))\n * - `filter` tests the user's profile (customer.io's trigger Filters).\n * The user enters only if the event fired *and* both gates hold.\n */\n event<P>(event: EventRef<P>, opts?: EventTriggerOptions): TriggerRef;\n event<P>(event: EventRef<P>, where: PayloadWhere<P>, opts?: EventTriggerOptions): TriggerRef;\n\n /**\n * Segment-entry trigger: the user enters the moment they go from not\n * matching to matching. Only a named segment is accepted — entry semantics\n * need a membership table, which an anonymous expression cannot provide.\n * Runtime note: membership is maintained lazily (re-evaluated on the user's\n * ingest/identify activity), so purely time-driven drift — e.g. an\n * inactivity segment becoming true by clock alone — is only observed on the\n * user's next activity. TODO: a cron sweep for time-driven segments.\n */\n segment(segment: SegmentRef): TriggerRef;\n\n /**\n * Scheduled trigger (one-shot, e.g. '2026-12-25 09:00:00', interpreted as\n * UTC). TODO: not routed by the runtime yet — deployBundle reports it in\n * `unrouted`; the implementation goes through cron + Queues (instance\n * creation is rate-limited, a date trigger fans out to the whole audience at\n * once). TODO: cron/recurring form.\n */\n date(at: DateTime): TriggerRef;\n\n /** Webhook trigger: third-party or external code fires it via a URL; the runtime allocates the endpoint. */\n webhook(): TriggerRef;\n}\n\n/**\n * Trigger definition: a plain object — event names come from e.xxx references.\n *\n * const login = trigger.event(e.login, { filter: newUsers7d });\n */\nexport const trigger: TriggerFactory = {\n event: <P>(\n ev: EventRef<P>,\n whereOrOpts?: PayloadWhere<P> | EventTriggerOptions,\n maybeOpts?: EventTriggerOptions\n ) => {\n const where = typeof whereOrOpts === 'function' ? whereOrOpts : undefined;\n const opts = typeof whereOrOpts === 'function' ? maybeOpts : whereOrOpts;\n return makeTrigger({\n type: 'event',\n event: ev.name,\n ...(where !== undefined ? { where: condIR(where(payloadRefs<P>())) } : {}),\n ...(opts?.filter !== undefined ? { filter: condIR(opts.filter) } : {}),\n ...(opts?.visitors === true ? { visitors: true } : {}),\n });\n },\n segment: (seg) => makeTrigger({ type: 'segment', segment: seg.id }),\n date: (at) => makeTrigger({ type: 'date', at }),\n webhook: () => makeTrigger({ type: 'webhook' }),\n};\n"],"mappings":";;;;AAwBA,MAAM,eAAe,OAA8B;CAEjD,OAAO;EADyB,MAAM,GAAG;EAAM;CACrC;AACZ;;;;;;AAuDA,MAAa,UAA0B;CACrC,QACE,IACA,aACA,cACG;EACH,MAAM,QAAQ,OAAO,gBAAgB,aAAa,cAAc,KAAA;EAChE,MAAM,OAAO,OAAO,gBAAgB,aAAa,YAAY;EAC7D,OAAO,YAAY;GACjB,MAAM;GACN,OAAO,GAAG;GACV,GAAI,UAAU,KAAA,IAAY,EAAE,OAAOA,sBAAAA,OAAO,MAAMC,iBAAAA,YAAe,CAAC,CAAC,EAAE,IAAI,CAAC;GACxE,GAAI,MAAM,WAAW,KAAA,IAAY,EAAE,QAAQD,sBAAAA,OAAO,KAAK,MAAM,EAAE,IAAI,CAAC;GACpE,GAAI,MAAM,aAAa,OAAO,EAAE,UAAU,KAAK,IAAI,CAAC;EACtD,CAAC;CACH;CACA,UAAU,QAAQ,YAAY;EAAE,MAAM;EAAW,SAAS,IAAI;CAAG,CAAC;CAClE,OAAO,OAAO,YAAY;EAAE,MAAM;EAAQ;CAAG,CAAC;CAC9C,eAAe,YAAY,EAAE,MAAM,UAAU,CAAC;AAChD"}
|
package/dist/dsl/trigger.d.cts
CHANGED
|
@@ -19,6 +19,12 @@ export interface TriggerInternal extends TriggerRef {
|
|
|
19
19
|
}
|
|
20
20
|
export interface EventTriggerOptions {
|
|
21
21
|
filter?: Condition;
|
|
22
|
+
/**
|
|
23
|
+
* Let a visitor who has not signed in enter: such a journey reaches them by push, having no
|
|
24
|
+
* address to email. Off by default, because a user enters a workflow once: an entry made as a
|
|
25
|
+
* visitor is still theirs after signing in.
|
|
26
|
+
*/
|
|
27
|
+
visitors?: boolean;
|
|
22
28
|
}
|
|
23
29
|
/** Trigger factory: four ways in, mirroring customer.io's campaign trigger types. */
|
|
24
30
|
export interface TriggerFactory {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"trigger.d.cts","names":[],"sources":["../../src/dsl/trigger.ts"],"mappings":";;;;;;;;;;YAYY;;iBAGK;WACN;;;iBAIM,wBAAwB;WAC9B,IAAI;;iBAQE;EACf,SAAS;;;
|
|
1
|
+
{"version":3,"file":"trigger.d.cts","names":[],"sources":["../../src/dsl/trigger.ts"],"mappings":";;;;;;;;;;YAYY;;iBAGK;WACN;;;iBAIM,wBAAwB;WAC9B,IAAI;;iBAQE;EACf,SAAS;;;;;;EAMT;;;iBAIe;;;;;;;;;;EAUf,MAAM,GAAG,OAAO,SAAS,IAAI,OAAO,sBAAsB;EAC1D,MAAM,GAAG,OAAO,SAAS,IAAI,OAAO,aAAa,IAAI,OAAO,sBAAsB;;;;;;;;;;EAWlF,QAAQ,SAAS,aAAa;;;;;;;;EAS9B,KAAK,IAAI,WAAW;;EAGpB,WAAW;;;;;;;qBAQA,SAAS"}
|
package/dist/dsl/trigger.d.mts
CHANGED
|
@@ -19,6 +19,12 @@ export interface TriggerInternal extends TriggerRef {
|
|
|
19
19
|
}
|
|
20
20
|
export interface EventTriggerOptions {
|
|
21
21
|
filter?: Condition;
|
|
22
|
+
/**
|
|
23
|
+
* Let a visitor who has not signed in enter: such a journey reaches them by push, having no
|
|
24
|
+
* address to email. Off by default, because a user enters a workflow once: an entry made as a
|
|
25
|
+
* visitor is still theirs after signing in.
|
|
26
|
+
*/
|
|
27
|
+
visitors?: boolean;
|
|
22
28
|
}
|
|
23
29
|
/** Trigger factory: four ways in, mirroring customer.io's campaign trigger types. */
|
|
24
30
|
export interface TriggerFactory {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"trigger.d.mts","names":[],"sources":["../../src/dsl/trigger.ts"],"mappings":";;;;;;;;;;YAYY;;iBAGK;WACN;;;iBAIM,wBAAwB;WAC9B,IAAI;;iBAQE;EACf,SAAS;;;
|
|
1
|
+
{"version":3,"file":"trigger.d.mts","names":[],"sources":["../../src/dsl/trigger.ts"],"mappings":";;;;;;;;;;YAYY;;iBAGK;WACN;;;iBAIM,wBAAwB;WAC9B,IAAI;;iBAQE;EACf,SAAS;;;;;;EAMT;;;iBAIe;;;;;;;;;;EAUf,MAAM,GAAG,OAAO,SAAS,IAAI,OAAO,sBAAsB;EAC1D,MAAM,GAAG,OAAO,SAAS,IAAI,OAAO,aAAa,IAAI,OAAO,sBAAsB;;;;;;;;;;EAWlF,QAAQ,SAAS,aAAa;;;;;;;;EAS9B,KAAK,IAAI,WAAW;;EAGpB,WAAW;;;;;;;qBAQA,SAAS"}
|
package/dist/dsl/trigger.mjs
CHANGED
|
@@ -20,7 +20,8 @@ const trigger = {
|
|
|
20
20
|
type: "event",
|
|
21
21
|
event: ev.name,
|
|
22
22
|
...where !== void 0 ? { where: condIR(where(payloadRefs())) } : {},
|
|
23
|
-
...opts?.filter !== void 0 ? { filter: condIR(opts.filter) } : {}
|
|
23
|
+
...opts?.filter !== void 0 ? { filter: condIR(opts.filter) } : {},
|
|
24
|
+
...opts?.visitors === true ? { visitors: true } : {}
|
|
24
25
|
});
|
|
25
26
|
},
|
|
26
27
|
segment: (seg) => makeTrigger({
|
package/dist/dsl/trigger.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"trigger.mjs","names":[],"sources":["../../src/dsl/trigger.ts"],"sourcesContent":["import type { TriggerIR } from '../ir';\nimport { type Condition, type PayloadWhere, type SegmentRef, condIR } from './condition';\nimport { type EventRef, payloadRefs } from './refs';\n\n/**\n * Triggers — the four ways into a workflow, mirroring customer.io's campaign\n * trigger types. A trigger is a plain reusable object; the event name and\n * payload types flow in from an e.xxx reference, so the factory itself needs\n * no generics.\n */\n\n/** 'YYYY-MM-DD HH:mm:ss'. TODO: validate at runtime — template literals cannot express leading zeros. */\nexport type DateTime = string;\n\n/** Trigger reference: what trigger.xxx() returns, reusable across workflows. */\nexport interface TriggerRef {\n readonly type: 'event' | 'segment' | 'date' | 'webhook';\n}\n\n/** @internal the compiled shape behind a TriggerRef — workflow.toIR reads .ir off it */\nexport interface TriggerInternal extends TriggerRef {\n readonly ir: TriggerIR;\n}\n\nconst makeTrigger = (ir: TriggerIR): TriggerRef => {\n const impl: TriggerInternal = { type: ir.type, ir };\n return impl;\n};\n\nexport interface EventTriggerOptions {\n filter?: Condition;\n}\n\n/** Trigger factory: four ways in, mirroring customer.io's campaign trigger types. */\nexport interface TriggerFactory {\n /**\n * Event trigger: the event name comes from an e.xxx reference (types flow in\n * from it, so the factory itself needs no generics). Two independent gates:\n * - the optional second argument is a payload where clause (typed refs, the\n * ordinary predicates — compiles to IR data the UI renders):\n * trigger.event(e.login, (p) => eq(p.platform, 'web'))\n * - `filter` tests the user's profile (customer.io's trigger Filters).\n * The user enters only if the event fired *and* both gates hold.\n */\n event<P>(event: EventRef<P>, opts?: EventTriggerOptions): TriggerRef;\n event<P>(event: EventRef<P>, where: PayloadWhere<P>, opts?: EventTriggerOptions): TriggerRef;\n\n /**\n * Segment-entry trigger: the user enters the moment they go from not\n * matching to matching. Only a named segment is accepted — entry semantics\n * need a membership table, which an anonymous expression cannot provide.\n * Runtime note: membership is maintained lazily (re-evaluated on the user's\n * ingest/identify activity), so purely time-driven drift — e.g. an\n * inactivity segment becoming true by clock alone — is only observed on the\n * user's next activity. TODO: a cron sweep for time-driven segments.\n */\n segment(segment: SegmentRef): TriggerRef;\n\n /**\n * Scheduled trigger (one-shot, e.g. '2026-12-25 09:00:00', interpreted as\n * UTC). TODO: not routed by the runtime yet — deployBundle reports it in\n * `unrouted`; the implementation goes through cron + Queues (instance\n * creation is rate-limited, a date trigger fans out to the whole audience at\n * once). TODO: cron/recurring form.\n */\n date(at: DateTime): TriggerRef;\n\n /** Webhook trigger: third-party or external code fires it via a URL; the runtime allocates the endpoint. */\n webhook(): TriggerRef;\n}\n\n/**\n * Trigger definition: a plain object — event names come from e.xxx references.\n *\n * const login = trigger.event(e.login, { filter: newUsers7d });\n */\nexport const trigger: TriggerFactory = {\n event: <P>(\n ev: EventRef<P>,\n whereOrOpts?: PayloadWhere<P> | EventTriggerOptions,\n maybeOpts?: EventTriggerOptions\n ) => {\n const where = typeof whereOrOpts === 'function' ? whereOrOpts : undefined;\n const opts = typeof whereOrOpts === 'function' ? maybeOpts : whereOrOpts;\n return makeTrigger({\n type: 'event',\n event: ev.name,\n ...(where !== undefined ? { where: condIR(where(payloadRefs<P>())) } : {}),\n ...(opts?.filter !== undefined ? { filter: condIR(opts.filter) } : {}),\n });\n },\n segment: (seg) => makeTrigger({ type: 'segment', segment: seg.id }),\n date: (at) => makeTrigger({ type: 'date', at }),\n webhook: () => makeTrigger({ type: 'webhook' }),\n};\n"],"mappings":";;;AAwBA,MAAM,eAAe,OAA8B;CAEjD,OAAO;EADyB,MAAM,GAAG;EAAM;CACrC;AACZ;;;;;;
|
|
1
|
+
{"version":3,"file":"trigger.mjs","names":[],"sources":["../../src/dsl/trigger.ts"],"sourcesContent":["import type { TriggerIR } from '../ir';\nimport { type Condition, type PayloadWhere, type SegmentRef, condIR } from './condition';\nimport { type EventRef, payloadRefs } from './refs';\n\n/**\n * Triggers — the four ways into a workflow, mirroring customer.io's campaign\n * trigger types. A trigger is a plain reusable object; the event name and\n * payload types flow in from an e.xxx reference, so the factory itself needs\n * no generics.\n */\n\n/** 'YYYY-MM-DD HH:mm:ss'. TODO: validate at runtime — template literals cannot express leading zeros. */\nexport type DateTime = string;\n\n/** Trigger reference: what trigger.xxx() returns, reusable across workflows. */\nexport interface TriggerRef {\n readonly type: 'event' | 'segment' | 'date' | 'webhook';\n}\n\n/** @internal the compiled shape behind a TriggerRef — workflow.toIR reads .ir off it */\nexport interface TriggerInternal extends TriggerRef {\n readonly ir: TriggerIR;\n}\n\nconst makeTrigger = (ir: TriggerIR): TriggerRef => {\n const impl: TriggerInternal = { type: ir.type, ir };\n return impl;\n};\n\nexport interface EventTriggerOptions {\n filter?: Condition;\n /**\n * Let a visitor who has not signed in enter: such a journey reaches them by push, having no\n * address to email. Off by default, because a user enters a workflow once: an entry made as a\n * visitor is still theirs after signing in.\n */\n visitors?: boolean;\n}\n\n/** Trigger factory: four ways in, mirroring customer.io's campaign trigger types. */\nexport interface TriggerFactory {\n /**\n * Event trigger: the event name comes from an e.xxx reference (types flow in\n * from it, so the factory itself needs no generics). Two independent gates:\n * - the optional second argument is a payload where clause (typed refs, the\n * ordinary predicates — compiles to IR data the UI renders):\n * trigger.event(e.login, (p) => eq(p.platform, 'web'))\n * - `filter` tests the user's profile (customer.io's trigger Filters).\n * The user enters only if the event fired *and* both gates hold.\n */\n event<P>(event: EventRef<P>, opts?: EventTriggerOptions): TriggerRef;\n event<P>(event: EventRef<P>, where: PayloadWhere<P>, opts?: EventTriggerOptions): TriggerRef;\n\n /**\n * Segment-entry trigger: the user enters the moment they go from not\n * matching to matching. Only a named segment is accepted — entry semantics\n * need a membership table, which an anonymous expression cannot provide.\n * Runtime note: membership is maintained lazily (re-evaluated on the user's\n * ingest/identify activity), so purely time-driven drift — e.g. an\n * inactivity segment becoming true by clock alone — is only observed on the\n * user's next activity. TODO: a cron sweep for time-driven segments.\n */\n segment(segment: SegmentRef): TriggerRef;\n\n /**\n * Scheduled trigger (one-shot, e.g. '2026-12-25 09:00:00', interpreted as\n * UTC). TODO: not routed by the runtime yet — deployBundle reports it in\n * `unrouted`; the implementation goes through cron + Queues (instance\n * creation is rate-limited, a date trigger fans out to the whole audience at\n * once). TODO: cron/recurring form.\n */\n date(at: DateTime): TriggerRef;\n\n /** Webhook trigger: third-party or external code fires it via a URL; the runtime allocates the endpoint. */\n webhook(): TriggerRef;\n}\n\n/**\n * Trigger definition: a plain object — event names come from e.xxx references.\n *\n * const login = trigger.event(e.login, { filter: newUsers7d });\n */\nexport const trigger: TriggerFactory = {\n event: <P>(\n ev: EventRef<P>,\n whereOrOpts?: PayloadWhere<P> | EventTriggerOptions,\n maybeOpts?: EventTriggerOptions\n ) => {\n const where = typeof whereOrOpts === 'function' ? whereOrOpts : undefined;\n const opts = typeof whereOrOpts === 'function' ? maybeOpts : whereOrOpts;\n return makeTrigger({\n type: 'event',\n event: ev.name,\n ...(where !== undefined ? { where: condIR(where(payloadRefs<P>())) } : {}),\n ...(opts?.filter !== undefined ? { filter: condIR(opts.filter) } : {}),\n ...(opts?.visitors === true ? { visitors: true } : {}),\n });\n },\n segment: (seg) => makeTrigger({ type: 'segment', segment: seg.id }),\n date: (at) => makeTrigger({ type: 'date', at }),\n webhook: () => makeTrigger({ type: 'webhook' }),\n};\n"],"mappings":";;;AAwBA,MAAM,eAAe,OAA8B;CAEjD,OAAO;EADyB,MAAM,GAAG;EAAM;CACrC;AACZ;;;;;;AAuDA,MAAa,UAA0B;CACrC,QACE,IACA,aACA,cACG;EACH,MAAM,QAAQ,OAAO,gBAAgB,aAAa,cAAc,KAAA;EAChE,MAAM,OAAO,OAAO,gBAAgB,aAAa,YAAY;EAC7D,OAAO,YAAY;GACjB,MAAM;GACN,OAAO,GAAG;GACV,GAAI,UAAU,KAAA,IAAY,EAAE,OAAO,OAAO,MAAM,YAAe,CAAC,CAAC,EAAE,IAAI,CAAC;GACxE,GAAI,MAAM,WAAW,KAAA,IAAY,EAAE,QAAQ,OAAO,KAAK,MAAM,EAAE,IAAI,CAAC;GACpE,GAAI,MAAM,aAAa,OAAO,EAAE,UAAU,KAAK,IAAI,CAAC;EACtD,CAAC;CACH;CACA,UAAU,QAAQ,YAAY;EAAE,MAAM;EAAW,SAAS,IAAI;CAAG,CAAC;CAClE,OAAO,OAAO,YAAY;EAAE,MAAM;EAAQ;CAAG,CAAC;CAC9C,eAAe,YAAY,EAAE,MAAM,UAAU,CAAC;AAChD"}
|
package/dist/email-sender.cjs
CHANGED
|
@@ -13,7 +13,14 @@ var EmailSender = class {
|
|
|
13
13
|
}
|
|
14
14
|
async send(message) {
|
|
15
15
|
if (message.channel !== "email") throw new Error(`EmailSender: unsupported channel '${message.channel}'`);
|
|
16
|
-
if (message.recipient === void 0)
|
|
16
|
+
if (message.recipient === void 0) return {
|
|
17
|
+
delivered: false,
|
|
18
|
+
reason: "no_recipient"
|
|
19
|
+
};
|
|
20
|
+
if (!require_email.isEmailAddress(message.recipient)) return {
|
|
21
|
+
delivered: false,
|
|
22
|
+
reason: "invalid_recipient"
|
|
23
|
+
};
|
|
17
24
|
const messageId = message.tracking?.messageId;
|
|
18
25
|
const { subject, html, from, replyTo, unsubscribeUrl } = await this.options.render(message.template, message.props, messageId === void 0 ? {} : { messageId });
|
|
19
26
|
const tracked = message.tracking === void 0 ? html : trackEmail(html, message.tracking, unsubscribeUrl);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"email-sender.cjs","names":["isUnsubscribeLink"],"sources":["../src/email-sender.ts"],"sourcesContent":["import {\n type EmailAddress,\n type EmailRenderOptions,\n type ResolvedEmail,\n isUnsubscribeLink,\n} from './email';\nimport type { MessageSender, MessageTracking, OutboundMessage } from './engine/ports';\nimport type { ScalarIR } from './ir';\n\n/**\n * The email channel, split where the runtime stops mattering: `EmailSender` renders a copy,\n * applies its tracking and commits the tracked links — the same on every host — and hands the\n * finished email to an `EmailTransport`, the only part that knows a provider (SES, Cloudflare\n * Email, ...). Nothing here imports a platform module, so a Worker and a Lambda share it.\n */\n\n/** A rendered email: the template's resolved metadata (`resolveEmail`), its subject and its HTML. */\nexport interface RenderedEmail extends ResolvedEmail {\n subject: string;\n html: string;\n}\n\n/**\n * Template renderer, injected by the app — this package's core stays free of\n * any react-email dependency (`@shware/workflow/react-email` ships one over a\n * registry). The subject comes back final: it is sent exactly as written, from\n * the template's own sender, and the unsubscribe link is the one the HTML was\n * rendered with. `options` carries the copy's message id when reporting records one, for the\n * preferences link (`resolveEmail`).\n */\nexport type EmailRenderer = (\n template: string,\n props: Record<string, ScalarIR | undefined>,\n options?: EmailRenderOptions\n) => Promise<RenderedEmail>;\n\n/** A finished email, as a transport takes it: rendered, tracked, addressed. */\nexport interface EmailMessage {\n from: EmailAddress;\n to: string;\n replyTo?: EmailAddress;\n subject: string;\n html: string;\n /**\n * Extra headers, already formatted: `List-Unsubscribe` and `List-Unsubscribe-Post` for an\n * email that has an unsubscribe link (see `unsubscribeHeaders`).\n */\n headers?: Readonly<Record<string, string>>;\n /**\n * `${instanceId}:${nodeId}`: the copy's identity, stable across retries and replays. A provider\n * that de-duplicates on a key receives it; one that cannot (SES) still sends at least once —\n * the window between the provider accepting the email and the step's checkpoint stays open.\n */\n idempotencyKey: string;\n}\n\n/**\n * One email provider. Throwing hands the retry to the durable step; resolving means the provider\n * accepted the email (not that it was delivered).\n */\nexport interface EmailTransport {\n send(message: EmailMessage): Promise<{ providerMessageId?: string }>;\n}\n\nexport interface EmailSenderOptions {\n transport: EmailTransport;\n render: EmailRenderer;\n}\n\n/**\n * The email channel over any transport: render → tracking applied to the HTML → the tracked\n * links committed → the transport. The links resolve before the email exists anywhere a\n * scanner could follow them.\n */\nexport class EmailSender implements MessageSender {\n constructor(private readonly options: EmailSenderOptions) {}\n\n async send(message: OutboundMessage): Promise<void> {\n if (message.channel !== 'email') {\n throw new Error(`EmailSender: unsupported channel '${message.channel}'`);\n }\n if (message.recipient === undefined) {\n
|
|
1
|
+
{"version":3,"file":"email-sender.cjs","names":["isEmailAddress","isUnsubscribeLink"],"sources":["../src/email-sender.ts"],"sourcesContent":["import {\n type EmailAddress,\n type EmailRenderOptions,\n type ResolvedEmail,\n isEmailAddress,\n isUnsubscribeLink,\n} from './email';\nimport type { MessageSender, MessageTracking, OutboundMessage, SendResult } from './engine/ports';\nimport type { ScalarIR } from './ir';\n\n/**\n * The email channel, split where the runtime stops mattering: `EmailSender` renders a copy,\n * applies its tracking and commits the tracked links — the same on every host — and hands the\n * finished email to an `EmailTransport`, the only part that knows a provider (SES, Cloudflare\n * Email, ...). Nothing here imports a platform module, so a Worker and a Lambda share it.\n */\n\n/** A rendered email: the template's resolved metadata (`resolveEmail`), its subject and its HTML. */\nexport interface RenderedEmail extends ResolvedEmail {\n subject: string;\n html: string;\n}\n\n/**\n * Template renderer, injected by the app — this package's core stays free of\n * any react-email dependency (`@shware/workflow/react-email` ships one over a\n * registry). The subject comes back final: it is sent exactly as written, from\n * the template's own sender, and the unsubscribe link is the one the HTML was\n * rendered with. `options` carries the copy's message id when reporting records one, for the\n * preferences link (`resolveEmail`).\n */\nexport type EmailRenderer = (\n template: string,\n props: Record<string, ScalarIR | undefined>,\n options?: EmailRenderOptions\n) => Promise<RenderedEmail>;\n\n/** A finished email, as a transport takes it: rendered, tracked, addressed. */\nexport interface EmailMessage {\n from: EmailAddress;\n to: string;\n replyTo?: EmailAddress;\n subject: string;\n html: string;\n /**\n * Extra headers, already formatted: `List-Unsubscribe` and `List-Unsubscribe-Post` for an\n * email that has an unsubscribe link (see `unsubscribeHeaders`).\n */\n headers?: Readonly<Record<string, string>>;\n /**\n * `${instanceId}:${nodeId}`: the copy's identity, stable across retries and replays. A provider\n * that de-duplicates on a key receives it; one that cannot (SES) still sends at least once —\n * the window between the provider accepting the email and the step's checkpoint stays open.\n */\n idempotencyKey: string;\n}\n\n/**\n * One email provider. Throwing hands the retry to the durable step; resolving means the provider\n * accepted the email (not that it was delivered).\n */\nexport interface EmailTransport {\n send(message: EmailMessage): Promise<{ providerMessageId?: string }>;\n}\n\nexport interface EmailSenderOptions {\n transport: EmailTransport;\n render: EmailRenderer;\n}\n\n/**\n * The email channel over any transport: render → tracking applied to the HTML → the tracked\n * links committed → the transport. The links resolve before the email exists anywhere a\n * scanner could follow them.\n */\nexport class EmailSender implements MessageSender {\n constructor(private readonly options: EmailSenderOptions) {}\n\n async send(message: OutboundMessage): Promise<void | SendResult> {\n if (message.channel !== 'email') {\n throw new Error(`EmailSender: unsupported channel '${message.channel}'`);\n }\n // A user with no usable address is a fact about that user, not a fault worth retrying: the\n // copy settles undelivered and the journey goes on, the way a push with no device is skipped.\n // Throwing here burned the step's retries and then failed the whole instance.\n if (message.recipient === undefined) return { delivered: false, reason: 'no_recipient' };\n if (!isEmailAddress(message.recipient)) {\n return { delivered: false, reason: 'invalid_recipient' };\n }\n const messageId = message.tracking?.messageId;\n const { subject, html, from, replyTo, unsubscribeUrl } = await this.options.render(\n message.template,\n message.props,\n messageId === undefined ? {} : { messageId }\n );\n const tracked =\n message.tracking === undefined ? html : trackEmail(html, message.tracking, unsubscribeUrl);\n await message.tracking?.commit?.();\n const headers = unsubscribeHeaders(unsubscribeUrl, message.tracking?.unsubscribe);\n await this.options.transport.send({\n from,\n to: message.recipient,\n ...(replyTo === undefined ? {} : { replyTo }),\n subject,\n html: tracked,\n ...(headers === undefined ? {} : { headers }),\n idempotencyKey: message.idempotencyKey,\n });\n }\n}\n\n/**\n * The list-unsubscribe headers of an email with an unsubscribe link (RFC 2369, RFC 8058), so a\n * mail client's own unsubscribe button works — Gmail and Yahoo require one-click for bulk mail:\n *\n * - With a one-click endpoint (`MessageTracking.unsubscribe`, which reporting provides): the\n * header names it, and `List-Unsubscribe-Post` says a POST there unsubscribes at once.\n * - Without one: the header names the email's own unsubscribe link, with no one-click claim —\n * whether that page takes a POST is the app's.\n * - An email without an unsubscribe link (a transactional one) gets neither.\n *\n * Only an https URL is named: mail clients and Cloudflare Email refuse any other.\n */\nexport function unsubscribeHeaders(\n unsubscribeUrl: string | undefined,\n oneClick: string | undefined\n): Record<string, string> | undefined {\n if (unsubscribeUrl === undefined) return undefined;\n const https = (url: string | undefined) => url?.startsWith('https://') === true;\n if (https(oneClick)) {\n return {\n 'List-Unsubscribe': `<${oneClick}>`,\n 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',\n };\n }\n if (https(unsubscribeUrl)) return { 'List-Unsubscribe': `<${unsubscribeUrl}>` };\n return undefined;\n}\n\nconst LINK = /(<a\\b[^>]*?\\shref\\s*=\\s*)([\"'])(https?:\\/\\/[^\"']+)\\2/gi;\n\n/**\n * An email's tracking applied to its rendered HTML: every `http(s)` anchor routed through\n * `link` (the original URL decoded from its attribute escaping, the tracking URL escaped back) —\n * as the unsubscribe link when it is the one the email was rendered with, so what a click on it\n * means is fixed when the email is sent — and the open pixel appended before `</body>`, or at the\n * end when there is none.\n */\nexport function trackEmail(\n html: string,\n tracking: MessageTracking,\n unsubscribeUrl?: string\n): string {\n const { link, pixel } = tracking;\n let out = html;\n if (link !== undefined) {\n out = out.replace(LINK, (_, start: string, quote: string, attribute: string) => {\n const url = unescapeAttribute(attribute);\n const purpose = isUnsubscribeLink(url, unsubscribeUrl) ? 'unsubscribe' : 'link';\n return `${start}${quote}${escapeAttribute(link(url, purpose))}${quote}`;\n });\n }\n if (pixel !== undefined) {\n const image = `<img src=\"${escapeAttribute(pixel)}\" width=\"1\" height=\"1\" alt=\"\" style=\"display:block;border:0;width:1px;height:1px\" />`;\n out = /<\\/body>/i.test(out) ? out.replace(/<\\/body>/i, `${image}</body>`) : `${out}${image}`;\n }\n return out;\n}\n\nfunction unescapeAttribute(value: string): string {\n return value.replaceAll('&', '&').replaceAll('"', '\"').replaceAll(''', \"'\");\n}\n\nfunction escapeAttribute(value: string): string {\n return value.replaceAll('&', '&').replaceAll('\"', '"');\n}\n"],"mappings":";;;;;;;;AA2EA,IAAa,cAAb,MAAkD;CACnB;CAA7B,YAAY,SAA8C;EAA7B,KAAA,UAAA;CAA8B;CAE3D,MAAM,KAAK,SAAsD;EAC/D,IAAI,QAAQ,YAAY,SACtB,MAAM,IAAI,MAAM,qCAAqC,QAAQ,QAAQ,EAAE;EAKzE,IAAI,QAAQ,cAAc,KAAA,GAAW,OAAO;GAAE,WAAW;GAAO,QAAQ;EAAe;EACvF,IAAI,CAACA,cAAAA,eAAe,QAAQ,SAAS,GACnC,OAAO;GAAE,WAAW;GAAO,QAAQ;EAAoB;EAEzD,MAAM,YAAY,QAAQ,UAAU;EACpC,MAAM,EAAE,SAAS,MAAM,MAAM,SAAS,mBAAmB,MAAM,KAAK,QAAQ,OAC1E,QAAQ,UACR,QAAQ,OACR,cAAc,KAAA,IAAY,CAAC,IAAI,EAAE,UAAU,CAC7C;EACA,MAAM,UACJ,QAAQ,aAAa,KAAA,IAAY,OAAO,WAAW,MAAM,QAAQ,UAAU,cAAc;EAC3F,MAAM,QAAQ,UAAU,SAAS;EACjC,MAAM,UAAU,mBAAmB,gBAAgB,QAAQ,UAAU,WAAW;EAChF,MAAM,KAAK,QAAQ,UAAU,KAAK;GAChC;GACA,IAAI,QAAQ;GACZ,GAAI,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,QAAQ;GAC3C;GACA,MAAM;GACN,GAAI,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,QAAQ;GAC3C,gBAAgB,QAAQ;EAC1B,CAAC;CACH;AACF;;;;;;;;;;;;;AAcA,SAAgB,mBACd,gBACA,UACoC;CACpC,IAAI,mBAAmB,KAAA,GAAW,OAAO,KAAA;CACzC,MAAM,SAAS,QAA4B,KAAK,WAAW,UAAU,MAAM;CAC3E,IAAI,MAAM,QAAQ,GAChB,OAAO;EACL,oBAAoB,IAAI,SAAS;EACjC,yBAAyB;CAC3B;CAEF,IAAI,MAAM,cAAc,GAAG,OAAO,EAAE,oBAAoB,IAAI,eAAe,GAAG;AAEhF;AAEA,MAAM,OAAO;;;;;;;;AASb,SAAgB,WACd,MACA,UACA,gBACQ;CACR,MAAM,EAAE,MAAM,UAAU;CACxB,IAAI,MAAM;CACV,IAAI,SAAS,KAAA,GACX,MAAM,IAAI,QAAQ,OAAO,GAAG,OAAe,OAAe,cAAsB;EAC9E,MAAM,MAAM,kBAAkB,SAAS;EACvC,MAAM,UAAUC,cAAAA,kBAAkB,KAAK,cAAc,IAAI,gBAAgB;EACzE,OAAO,GAAG,QAAQ,QAAQ,gBAAgB,KAAK,KAAK,OAAO,CAAC,IAAI;CAClE,CAAC;CAEH,IAAI,UAAU,KAAA,GAAW;EACvB,MAAM,QAAQ,aAAa,gBAAgB,KAAK,EAAE;EAClD,MAAM,YAAY,KAAK,GAAG,IAAI,IAAI,QAAQ,aAAa,GAAG,MAAM,QAAQ,IAAI,GAAG,MAAM;CACvF;CACA,OAAO;AACT;AAEA,SAAS,kBAAkB,OAAuB;CAChD,OAAO,MAAM,WAAW,SAAS,GAAG,CAAC,CAAC,WAAW,UAAU,IAAG,CAAC,CAAC,WAAW,UAAU,GAAG;AAC1F;AAEA,SAAS,gBAAgB,OAAuB;CAC9C,OAAO,MAAM,WAAW,KAAK,OAAO,CAAC,CAAC,WAAW,MAAK,QAAQ;AAChE"}
|
package/dist/email-sender.d.cts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ScalarIR } from "./ir.cjs";
|
|
2
|
-
import { MessageSender, MessageTracking, OutboundMessage } from "./engine/ports.cjs";
|
|
2
|
+
import { MessageSender, MessageTracking, OutboundMessage, SendResult } from "./engine/ports.cjs";
|
|
3
3
|
import { EmailAddress, EmailRenderOptions, ResolvedEmail } from "./email.cjs";
|
|
4
4
|
/**
|
|
5
5
|
* The email channel, split where the runtime stops mattering: `EmailSender` renders a copy,
|
|
@@ -61,7 +61,7 @@ export interface EmailSenderOptions {
|
|
|
61
61
|
export declare class EmailSender implements MessageSender {
|
|
62
62
|
private readonly options;
|
|
63
63
|
constructor(options: EmailSenderOptions);
|
|
64
|
-
send(message: OutboundMessage): Promise<void>;
|
|
64
|
+
send(message: OutboundMessage): Promise<void | SendResult>;
|
|
65
65
|
}
|
|
66
66
|
/**
|
|
67
67
|
* The list-unsubscribe headers of an email with an unsubscribe link (RFC 2369, RFC 8058), so a
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"email-sender.d.cts","names":[],"sources":["../src/email-sender.ts"],"mappings":";;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"email-sender.d.cts","names":[],"sources":["../src/email-sender.ts"],"mappings":";;;;;;;;;;iBAkBiB,sBAAsB;EACrC;EACA;;;;;;;;;;YAWU,iBACV,kBACA,OAAO,eAAe,uBACtB,UAAU,uBACP,QAAQ;;iBAGI;EACf,MAAM;EACN;EACA,UAAU;EACV;EACA;;;;;EAKA,UAAU,SAAS;;;;;;EAMnB;;;;;;iBAOe;EACf,KAAK,SAAS,eAAe;IAAU;;;iBAGxB;EACf,WAAW;EACX,QAAQ;;;;;;;qBAQG,uBAAuB;mBACL;EAA7B,YAA6B,SAAS;EAEhC,KAAK,SAAS,kBAAkB,eAAe;;;;;;;;;;;;;;wBA6CvC,mBACd,oCACA,+BACC;;;;;;;;wBAsBa,WACd,cACA,UAAU,iBACV"}
|
package/dist/email-sender.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ScalarIR } from "./ir.mjs";
|
|
2
|
-
import { MessageSender, MessageTracking, OutboundMessage } from "./engine/ports.mjs";
|
|
2
|
+
import { MessageSender, MessageTracking, OutboundMessage, SendResult } from "./engine/ports.mjs";
|
|
3
3
|
import { EmailAddress, EmailRenderOptions, ResolvedEmail } from "./email.mjs";
|
|
4
4
|
/**
|
|
5
5
|
* The email channel, split where the runtime stops mattering: `EmailSender` renders a copy,
|
|
@@ -61,7 +61,7 @@ export interface EmailSenderOptions {
|
|
|
61
61
|
export declare class EmailSender implements MessageSender {
|
|
62
62
|
private readonly options;
|
|
63
63
|
constructor(options: EmailSenderOptions);
|
|
64
|
-
send(message: OutboundMessage): Promise<void>;
|
|
64
|
+
send(message: OutboundMessage): Promise<void | SendResult>;
|
|
65
65
|
}
|
|
66
66
|
/**
|
|
67
67
|
* The list-unsubscribe headers of an email with an unsubscribe link (RFC 2369, RFC 8058), so a
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"email-sender.d.mts","names":[],"sources":["../src/email-sender.ts"],"mappings":";;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"email-sender.d.mts","names":[],"sources":["../src/email-sender.ts"],"mappings":";;;;;;;;;;iBAkBiB,sBAAsB;EACrC;EACA;;;;;;;;;;YAWU,iBACV,kBACA,OAAO,eAAe,uBACtB,UAAU,uBACP,QAAQ;;iBAGI;EACf,MAAM;EACN;EACA,UAAU;EACV;EACA;;;;;EAKA,UAAU,SAAS;;;;;;EAMnB;;;;;;iBAOe;EACf,KAAK,SAAS,eAAe;IAAU;;;iBAGxB;EACf,WAAW;EACX,QAAQ;;;;;;;qBAQG,uBAAuB;mBACL;EAA7B,YAA6B,SAAS;EAEhC,KAAK,SAAS,kBAAkB,eAAe;;;;;;;;;;;;;;wBA6CvC,mBACd,oCACA,+BACC;;;;;;;;wBAsBa,WACd,cACA,UAAU,iBACV"}
|
package/dist/email-sender.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isUnsubscribeLink } from "./email.mjs";
|
|
1
|
+
import { isEmailAddress, isUnsubscribeLink } from "./email.mjs";
|
|
2
2
|
// src/email-sender.ts
|
|
3
3
|
/**
|
|
4
4
|
* The email channel over any transport: render → tracking applied to the HTML → the tracked
|
|
@@ -12,7 +12,14 @@ var EmailSender = class {
|
|
|
12
12
|
}
|
|
13
13
|
async send(message) {
|
|
14
14
|
if (message.channel !== "email") throw new Error(`EmailSender: unsupported channel '${message.channel}'`);
|
|
15
|
-
if (message.recipient === void 0)
|
|
15
|
+
if (message.recipient === void 0) return {
|
|
16
|
+
delivered: false,
|
|
17
|
+
reason: "no_recipient"
|
|
18
|
+
};
|
|
19
|
+
if (!isEmailAddress(message.recipient)) return {
|
|
20
|
+
delivered: false,
|
|
21
|
+
reason: "invalid_recipient"
|
|
22
|
+
};
|
|
16
23
|
const messageId = message.tracking?.messageId;
|
|
17
24
|
const { subject, html, from, replyTo, unsubscribeUrl } = await this.options.render(message.template, message.props, messageId === void 0 ? {} : { messageId });
|
|
18
25
|
const tracked = message.tracking === void 0 ? html : trackEmail(html, message.tracking, unsubscribeUrl);
|