@shware/workflow 3.3.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +91 -29
- package/dist/apns.cjs +113 -26
- package/dist/apns.cjs.map +1 -1
- package/dist/apns.d.cts +52 -6
- package/dist/apns.d.cts.map +1 -1
- package/dist/apns.d.mts +52 -6
- package/dist/apns.d.mts.map +1 -1
- package/dist/apns.mjs +111 -27
- package/dist/apns.mjs.map +1 -1
- package/dist/aws/engine-step.cjs +5 -5
- package/dist/aws/engine-step.cjs.map +1 -1
- package/dist/aws/engine-step.d.cts +8 -2
- package/dist/aws/engine-step.d.cts.map +1 -1
- package/dist/aws/engine-step.d.mts +8 -2
- package/dist/aws/engine-step.d.mts.map +1 -1
- package/dist/aws/engine-step.mjs +5 -5
- package/dist/aws/engine-step.mjs.map +1 -1
- package/dist/aws/index.cjs +47 -6
- package/dist/aws/index.cjs.map +1 -1
- package/dist/aws/index.d.cts +2 -2
- package/dist/aws/index.d.cts.map +1 -1
- package/dist/aws/index.d.mts +2 -2
- package/dist/aws/index.d.mts.map +1 -1
- package/dist/aws/index.mjs +47 -6
- package/dist/aws/index.mjs.map +1 -1
- package/dist/aws/instances.cjs +29 -6
- package/dist/aws/instances.cjs.map +1 -1
- package/dist/aws/instances.d.cts +11 -1
- package/dist/aws/instances.d.cts.map +1 -1
- package/dist/aws/instances.d.mts +11 -1
- package/dist/aws/instances.d.mts.map +1 -1
- package/dist/aws/instances.mjs +27 -4
- package/dist/aws/instances.mjs.map +1 -1
- package/dist/client.cjs +4 -1
- package/dist/client.cjs.map +1 -1
- package/dist/client.d.cts +2 -2
- package/dist/client.d.cts.map +1 -1
- package/dist/client.d.mts +2 -2
- package/dist/client.d.mts.map +1 -1
- package/dist/client.mjs +4 -1
- package/dist/client.mjs.map +1 -1
- package/dist/cloudflare/bindings.cjs +28 -25
- package/dist/cloudflare/bindings.cjs.map +1 -1
- package/dist/cloudflare/bindings.d.cts +15 -129
- package/dist/cloudflare/bindings.d.cts.map +1 -1
- package/dist/cloudflare/bindings.d.mts +15 -129
- package/dist/cloudflare/bindings.d.mts.map +1 -1
- package/dist/cloudflare/bindings.mjs +28 -23
- package/dist/cloudflare/bindings.mjs.map +1 -1
- package/dist/cloudflare/d1-env.cjs +6 -6
- package/dist/cloudflare/d1-env.cjs.map +1 -1
- package/dist/cloudflare/d1-env.d.cts +5 -5
- package/dist/cloudflare/d1-env.d.mts +5 -5
- package/dist/cloudflare/d1-env.mjs +6 -6
- package/dist/cloudflare/d1-env.mjs.map +1 -1
- package/dist/cloudflare/index.cjs +28 -18
- package/dist/cloudflare/index.d.cts +8 -4
- package/dist/cloudflare/index.d.mts +8 -4
- package/dist/cloudflare/index.mjs +9 -5
- package/dist/cloudflare/runner.cjs +29 -14
- package/dist/cloudflare/runner.cjs.map +1 -1
- package/dist/cloudflare/runner.d.cts +9 -2
- package/dist/cloudflare/runner.d.cts.map +1 -1
- package/dist/cloudflare/runner.d.mts +9 -2
- package/dist/cloudflare/runner.d.mts.map +1 -1
- package/dist/cloudflare/runner.mjs +28 -13
- package/dist/cloudflare/runner.mjs.map +1 -1
- package/dist/cloudflare/schema.sql +27 -6
- package/dist/cloudflare/worker.cjs +19 -9
- package/dist/cloudflare/worker.cjs.map +1 -1
- package/dist/cloudflare/worker.d.cts +2 -2
- package/dist/cloudflare/worker.d.cts.map +1 -1
- package/dist/cloudflare/worker.d.mts +2 -2
- package/dist/cloudflare/worker.d.mts.map +1 -1
- package/dist/cloudflare/worker.mjs +19 -9
- package/dist/cloudflare/worker.mjs.map +1 -1
- package/dist/config.cjs +21 -5
- package/dist/config.cjs.map +1 -1
- package/dist/config.mjs +21 -5
- package/dist/config.mjs.map +1 -1
- package/dist/dsl/action.cjs +3 -6
- package/dist/dsl/action.cjs.map +1 -1
- package/dist/dsl/action.d.cts +12 -11
- package/dist/dsl/action.d.cts.map +1 -1
- package/dist/dsl/action.d.mts +12 -11
- package/dist/dsl/action.d.mts.map +1 -1
- package/dist/dsl/action.mjs +3 -6
- package/dist/dsl/action.mjs.map +1 -1
- package/dist/dsl/bundle.cjs +35 -31
- package/dist/dsl/bundle.cjs.map +1 -1
- package/dist/dsl/bundle.d.cts.map +1 -1
- package/dist/dsl/bundle.d.mts.map +1 -1
- package/dist/dsl/bundle.mjs +35 -31
- package/dist/dsl/bundle.mjs.map +1 -1
- package/dist/dsl/condition.cjs +13 -1
- package/dist/dsl/condition.cjs.map +1 -1
- package/dist/dsl/condition.d.cts +9 -1
- package/dist/dsl/condition.d.cts.map +1 -1
- package/dist/dsl/condition.d.mts +9 -1
- package/dist/dsl/condition.d.mts.map +1 -1
- package/dist/dsl/condition.mjs +13 -2
- package/dist/dsl/condition.mjs.map +1 -1
- package/dist/dsl/flow.cjs +33 -11
- package/dist/dsl/flow.cjs.map +1 -1
- package/dist/dsl/flow.d.cts +9 -1
- package/dist/dsl/flow.d.cts.map +1 -1
- package/dist/dsl/flow.d.mts +9 -1
- package/dist/dsl/flow.d.mts.map +1 -1
- package/dist/dsl/flow.mjs +34 -13
- package/dist/dsl/flow.mjs.map +1 -1
- package/dist/dsl/workflow.cjs +2 -3
- package/dist/dsl/workflow.cjs.map +1 -1
- package/dist/dsl/workflow.d.cts.map +1 -1
- package/dist/dsl/workflow.d.mts.map +1 -1
- package/dist/dsl/workflow.mjs +3 -4
- package/dist/dsl/workflow.mjs.map +1 -1
- package/dist/engine/actions.cjs +1 -8
- package/dist/engine/actions.cjs.map +1 -1
- package/dist/engine/actions.d.cts +1 -12
- package/dist/engine/actions.d.cts.map +1 -1
- package/dist/engine/actions.d.mts +1 -12
- package/dist/engine/actions.d.mts.map +1 -1
- package/dist/engine/actions.mjs +1 -8
- package/dist/engine/actions.mjs.map +1 -1
- package/dist/engine/condition.cjs +25 -5
- package/dist/engine/condition.cjs.map +1 -1
- package/dist/engine/condition.d.cts +12 -3
- package/dist/engine/condition.d.cts.map +1 -1
- package/dist/engine/condition.d.mts +12 -3
- package/dist/engine/condition.d.mts.map +1 -1
- package/dist/engine/condition.mjs +25 -6
- package/dist/engine/condition.mjs.map +1 -1
- package/dist/engine/index.cjs +3 -0
- package/dist/engine/index.d.cts +5 -5
- package/dist/engine/index.d.mts +5 -5
- package/dist/engine/index.mjs +4 -4
- package/dist/engine/interpreter.cjs +27 -20
- package/dist/engine/interpreter.cjs.map +1 -1
- package/dist/engine/interpreter.d.cts.map +1 -1
- package/dist/engine/interpreter.d.mts.map +1 -1
- package/dist/engine/interpreter.mjs +27 -20
- package/dist/engine/interpreter.mjs.map +1 -1
- package/dist/engine/ports.d.cts +15 -6
- package/dist/engine/ports.d.cts.map +1 -1
- package/dist/engine/ports.d.mts +15 -6
- package/dist/engine/ports.d.mts.map +1 -1
- package/dist/engine/retry.cjs +13 -0
- package/dist/engine/retry.cjs.map +1 -1
- package/dist/engine/retry.d.cts +12 -0
- package/dist/engine/retry.d.cts.map +1 -1
- package/dist/engine/retry.d.mts +12 -0
- package/dist/engine/retry.d.mts.map +1 -1
- package/dist/engine/retry.mjs +13 -1
- package/dist/engine/retry.mjs.map +1 -1
- package/dist/engine/time-scale.cjs +90 -15
- package/dist/engine/time-scale.cjs.map +1 -1
- package/dist/engine/time-scale.d.cts +9 -6
- package/dist/engine/time-scale.d.cts.map +1 -1
- package/dist/engine/time-scale.d.mts +9 -6
- package/dist/engine/time-scale.d.mts.map +1 -1
- package/dist/engine/time-scale.mjs +90 -16
- package/dist/engine/time-scale.mjs.map +1 -1
- package/dist/fcm.cjs +111 -38
- package/dist/fcm.cjs.map +1 -1
- package/dist/fcm.d.cts +45 -5
- package/dist/fcm.d.cts.map +1 -1
- package/dist/fcm.d.mts +45 -5
- package/dist/fcm.d.mts.map +1 -1
- package/dist/fcm.mjs +110 -39
- package/dist/fcm.mjs.map +1 -1
- package/dist/hash.cjs +6 -6
- package/dist/hash.cjs.map +1 -1
- package/dist/hash.mjs +6 -6
- package/dist/hash.mjs.map +1 -1
- package/dist/host/auth.cjs +49 -0
- package/dist/host/auth.cjs.map +1 -0
- package/dist/host/auth.d.cts +21 -0
- package/dist/host/auth.d.cts.map +1 -0
- package/dist/host/auth.d.mts +21 -0
- package/dist/host/auth.d.mts.map +1 -0
- package/dist/host/auth.mjs +47 -0
- package/dist/host/auth.mjs.map +1 -0
- package/dist/host/channels.cjs +118 -40
- package/dist/host/channels.cjs.map +1 -1
- package/dist/host/channels.d.cts +27 -1
- package/dist/host/channels.d.cts.map +1 -1
- package/dist/host/channels.d.mts +27 -1
- package/dist/host/channels.d.mts.map +1 -1
- package/dist/host/channels.mjs +121 -44
- package/dist/host/channels.mjs.map +1 -1
- package/dist/host/execute.cjs +14 -2
- package/dist/host/execute.cjs.map +1 -1
- package/dist/host/execute.d.cts +4 -4
- package/dist/host/execute.d.cts.map +1 -1
- package/dist/host/execute.d.mts +4 -4
- package/dist/host/execute.d.mts.map +1 -1
- package/dist/host/execute.mjs +13 -1
- package/dist/host/execute.mjs.map +1 -1
- package/dist/host/fresh-read.cjs +30 -0
- package/dist/host/fresh-read.cjs.map +1 -0
- package/dist/host/fresh-read.d.cts +18 -0
- package/dist/host/fresh-read.d.cts.map +1 -0
- package/dist/host/fresh-read.d.mts +18 -0
- package/dist/host/fresh-read.d.mts.map +1 -0
- package/dist/host/fresh-read.mjs +29 -0
- package/dist/host/fresh-read.mjs.map +1 -0
- package/dist/host/http.cjs +321 -0
- package/dist/host/http.cjs.map +1 -0
- package/dist/host/http.d.cts +54 -0
- package/dist/host/http.d.cts.map +1 -0
- package/dist/host/http.d.mts +54 -0
- package/dist/host/http.d.mts.map +1 -0
- package/dist/host/http.mjs +317 -0
- package/dist/host/http.mjs.map +1 -0
- package/dist/host/{router.cjs → ingest.cjs} +147 -175
- package/dist/host/ingest.cjs.map +1 -0
- package/dist/host/{router.d.cts → ingest.d.cts} +48 -30
- package/dist/host/ingest.d.cts.map +1 -0
- package/dist/host/{router.d.mts → ingest.d.mts} +48 -30
- package/dist/host/ingest.d.mts.map +1 -0
- package/dist/host/{router.mjs → ingest.mjs} +144 -173
- package/dist/host/ingest.mjs.map +1 -0
- package/dist/host/journey-host.cjs +65 -93
- package/dist/host/journey-host.cjs.map +1 -1
- package/dist/host/journey-host.d.cts +19 -26
- package/dist/host/journey-host.d.cts.map +1 -1
- package/dist/host/journey-host.d.mts +19 -26
- package/dist/host/journey-host.d.mts.map +1 -1
- package/dist/host/journey-host.mjs +65 -93
- package/dist/host/journey-host.mjs.map +1 -1
- package/dist/host/platform.cjs +21 -0
- package/dist/host/platform.cjs.map +1 -0
- package/dist/host/platform.d.cts +126 -0
- package/dist/host/platform.d.cts.map +1 -0
- package/dist/host/platform.d.mts +126 -0
- package/dist/host/platform.d.mts.map +1 -0
- package/dist/host/platform.mjs +19 -0
- package/dist/host/platform.mjs.map +1 -0
- package/dist/host/senders.cjs +18 -9
- package/dist/host/senders.cjs.map +1 -1
- package/dist/host/senders.d.cts +9 -4
- package/dist/host/senders.d.cts.map +1 -1
- package/dist/host/senders.d.mts +9 -4
- package/dist/host/senders.d.mts.map +1 -1
- package/dist/host/senders.mjs +18 -9
- package/dist/host/senders.mjs.map +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.mjs +1 -1
- package/dist/ir.cjs +5 -7
- package/dist/ir.cjs.map +1 -1
- package/dist/ir.d.cts +3 -12
- package/dist/ir.d.cts.map +1 -1
- package/dist/ir.d.mts +3 -12
- package/dist/ir.d.mts.map +1 -1
- package/dist/ir.mjs +5 -7
- package/dist/ir.mjs.map +1 -1
- package/dist/plan.cjs +27 -33
- package/dist/plan.cjs.map +1 -1
- package/dist/plan.d.cts +1 -1
- package/dist/plan.d.cts.map +1 -1
- package/dist/plan.d.mts +1 -1
- package/dist/plan.d.mts.map +1 -1
- package/dist/plan.mjs +27 -33
- package/dist/plan.mjs.map +1 -1
- package/dist/reporting/tracking.cjs +14 -4
- package/dist/reporting/tracking.cjs.map +1 -1
- package/dist/reporting/tracking.d.cts +10 -10
- package/dist/reporting/tracking.d.cts.map +1 -1
- package/dist/reporting/tracking.d.mts +10 -10
- package/dist/reporting/tracking.d.mts.map +1 -1
- package/dist/reporting/tracking.mjs +14 -4
- package/dist/reporting/tracking.mjs.map +1 -1
- package/dist/store/d1.cjs +88 -29
- package/dist/store/d1.cjs.map +1 -1
- package/dist/store/d1.d.cts +29 -11
- package/dist/store/d1.d.cts.map +1 -1
- package/dist/store/d1.d.mts +29 -11
- package/dist/store/d1.d.mts.map +1 -1
- package/dist/store/d1.mjs +88 -29
- package/dist/store/d1.mjs.map +1 -1
- package/dist/store/erasure.cjs +22 -0
- package/dist/store/erasure.cjs.map +1 -0
- package/dist/store/erasure.d.cts +18 -0
- package/dist/store/erasure.d.cts.map +1 -0
- package/dist/store/erasure.d.mts +18 -0
- package/dist/store/erasure.d.mts.map +1 -0
- package/dist/store/erasure.mjs +21 -0
- package/dist/store/erasure.mjs.map +1 -0
- package/dist/store/index.cjs +2 -0
- package/dist/store/index.cjs.map +1 -1
- package/dist/store/index.d.cts +67 -13
- package/dist/store/index.d.cts.map +1 -1
- package/dist/store/index.d.mts +67 -13
- package/dist/store/index.d.mts.map +1 -1
- package/dist/store/index.mjs +2 -1
- package/dist/store/index.mjs.map +1 -1
- package/dist/store/postgres-reporting.cjs +125 -62
- package/dist/store/postgres-reporting.cjs.map +1 -1
- package/dist/store/postgres-reporting.d.cts +30 -2
- package/dist/store/postgres-reporting.d.cts.map +1 -1
- package/dist/store/postgres-reporting.d.mts +30 -2
- package/dist/store/postgres-reporting.d.mts.map +1 -1
- package/dist/store/postgres-reporting.mjs +125 -62
- package/dist/store/postgres-reporting.mjs.map +1 -1
- package/dist/store/postgres.cjs +179 -60
- package/dist/store/postgres.cjs.map +1 -1
- package/dist/store/postgres.d.cts +44 -7
- package/dist/store/postgres.d.cts.map +1 -1
- package/dist/store/postgres.d.mts +44 -7
- package/dist/store/postgres.d.mts.map +1 -1
- package/dist/store/postgres.mjs +179 -60
- package/dist/store/postgres.mjs.map +1 -1
- package/dist/store/tables.cjs +7 -5
- package/dist/store/tables.cjs.map +1 -1
- package/dist/store/tables.d.cts.map +1 -1
- package/dist/store/tables.d.mts.map +1 -1
- package/dist/store/tables.mjs +7 -5
- package/dist/store/tables.mjs.map +1 -1
- package/dist/store/user-lock.cjs +43 -0
- package/dist/store/user-lock.cjs.map +1 -0
- package/dist/store/user-lock.d.cts +32 -0
- package/dist/store/user-lock.d.cts.map +1 -0
- package/dist/store/user-lock.d.mts +32 -0
- package/dist/store/user-lock.d.mts.map +1 -0
- package/dist/store/user-lock.mjs +40 -0
- package/dist/store/user-lock.mjs.map +1 -0
- package/dist/transports/cloudflare-email.cjs +1 -1
- package/dist/transports/cloudflare-email.mjs +1 -1
- package/dist/transports/ses.cjs +47 -6
- package/dist/transports/ses.cjs.map +1 -1
- package/dist/transports/ses.d.cts +9 -1
- package/dist/transports/ses.d.cts.map +1 -1
- package/dist/transports/ses.d.mts +9 -1
- package/dist/transports/ses.d.mts.map +1 -1
- package/dist/transports/ses.mjs +47 -7
- package/dist/transports/ses.mjs.map +1 -1
- package/dist/webpush.cjs +133 -47
- package/dist/webpush.cjs.map +1 -1
- package/dist/webpush.d.cts +49 -8
- package/dist/webpush.d.cts.map +1 -1
- package/dist/webpush.d.mts +49 -8
- package/dist/webpush.d.mts.map +1 -1
- package/dist/webpush.mjs +130 -47
- package/dist/webpush.mjs.map +1 -1
- package/package.json +1 -1
- package/src/cloudflare/schema.sql +27 -6
- package/dist/host/router.cjs.map +0 -1
- package/dist/host/router.d.cts.map +0 -1
- package/dist/host/router.d.mts.map +0 -1
- package/dist/host/router.mjs.map +0 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { EnvValidationError, createJourneyHost } from "../host/journey-host.mjs";
|
|
2
|
+
import { cloudflareJourneyBinding } from "./bindings.mjs";
|
|
2
3
|
import { JourneyRunner } from "./runner.mjs";
|
|
3
4
|
//#region src/cloudflare/worker.ts
|
|
4
5
|
/**
|
|
@@ -26,9 +27,20 @@ function journeyWorker(options) {
|
|
|
26
27
|
await this.leases.get(store)?.();
|
|
27
28
|
}
|
|
28
29
|
createRunParts(store, params) {
|
|
29
|
-
return host.runParts(host.checkEnv(this.
|
|
30
|
+
return host.runParts(host.checkEnv(this.hostEnv()), store, params);
|
|
30
31
|
}
|
|
31
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* The validated environment, its Workflow binding behind the host's port (an unknown instance
|
|
35
|
+
* answers `InstanceNotFoundError`). Everything below reads this one, never the raw env.
|
|
36
|
+
*/
|
|
37
|
+
const hostEnv = (rawEnv) => {
|
|
38
|
+
const env = host.checkEnv(rawEnv);
|
|
39
|
+
return {
|
|
40
|
+
...env,
|
|
41
|
+
JOURNEY: cloudflareJourneyBinding(env.JOURNEY)
|
|
42
|
+
};
|
|
43
|
+
};
|
|
32
44
|
/** CF's version metadata: the bundle compiled into each new version goes live by itself. */
|
|
33
45
|
const ownVersion = (env) => {
|
|
34
46
|
const metadata = env.CF_VERSION_METADATA;
|
|
@@ -41,7 +53,7 @@ function journeyWorker(options) {
|
|
|
41
53
|
const fetch = async (request, rawEnv, ctx) => {
|
|
42
54
|
let env;
|
|
43
55
|
try {
|
|
44
|
-
env =
|
|
56
|
+
env = hostEnv(rawEnv);
|
|
45
57
|
} catch (error) {
|
|
46
58
|
if (!(error instanceof EnvValidationError)) throw error;
|
|
47
59
|
console.error(`[workflow] ${error.message}`);
|
|
@@ -63,13 +75,11 @@ function journeyWorker(options) {
|
|
|
63
75
|
else ctx.waitUntil(close());
|
|
64
76
|
}
|
|
65
77
|
};
|
|
66
|
-
const sweep = (rawEnv) => host.sweep(
|
|
67
|
-
const scheduled = async (_controller,
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
await sweep(env);
|
|
72
|
-
})());
|
|
78
|
+
const sweep = (rawEnv) => host.sweep(hostEnv(rawEnv));
|
|
79
|
+
const scheduled = async (_controller, rawEnv, _ctx) => {
|
|
80
|
+
const env = hostEnv(rawEnv);
|
|
81
|
+
await host.applyOwnBundle(env, ownVersion(env));
|
|
82
|
+
await host.sweep(env);
|
|
73
83
|
};
|
|
74
84
|
return {
|
|
75
85
|
Runner,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"worker.mjs","names":[],"sources":["../../src/cloudflare/worker.ts"],"sourcesContent":["import type { JourneyRunParts } from '../host/execute';\nimport {\n EnvValidationError,\n type JourneyHostOptions,\n createJourneyHost,\n} from '../host/journey-host';\nimport type {
|
|
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`, `/events`, `/identify`, `/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\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);\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 return { Runner, fetch, sweep, scheduled };\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAsDA,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,KAAK;EAC9C,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,OAAO;EAAE;EAAQ;EAAO;EAAO;CAAU;AAC3C"}
|
package/dist/config.cjs
CHANGED
|
@@ -145,21 +145,37 @@ function checkMessage(workflow, node, index, config) {
|
|
|
145
145
|
if (problems.length > 0) throw new ConfigCompileError(`${where}: ${node.channel} '${node.template}': ${problems.join("; ")}`);
|
|
146
146
|
}
|
|
147
147
|
/**
|
|
148
|
+
* Whether an object schema refuses keys it does not declare: `z.strictObject`
|
|
149
|
+
* sets its catchall to `never`; `z.object` strips unknown keys and
|
|
150
|
+
* `z.looseObject` passes them through, and neither refuses.
|
|
151
|
+
*/
|
|
152
|
+
function refusesUnknownKeys(schema) {
|
|
153
|
+
return schema._zod.def.catchall?._zod.def.type === "never";
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
148
156
|
* An emitted event against the contract, as far as compile time can see: the
|
|
149
|
-
* event is declared, literal payload values parse against their field,
|
|
150
|
-
* required field is missing
|
|
151
|
-
*
|
|
157
|
+
* event is declared, literal payload values parse against their field, no
|
|
158
|
+
* required field is missing, and — when the event's schema is strict — no
|
|
159
|
+
* literal field is undeclared (the Worker would refuse the whole event at
|
|
160
|
+
* emit time, failing the journey where the compile could have failed the
|
|
161
|
+
* build). Values read from the profile are checked when the event is emitted,
|
|
162
|
+
* by the same contract.
|
|
152
163
|
*/
|
|
153
164
|
function checkEmit(workflow, node, config) {
|
|
154
165
|
const contract = config.contract;
|
|
155
166
|
if (contract === void 0) return;
|
|
156
167
|
const where = `workflow '${workflow.id}' node ${node.id}`;
|
|
157
168
|
if (!contract.isEvent(node.event)) throw new ConfigCompileError(`${where}: emits '${String(node.event)}', which is not in the contract`);
|
|
158
|
-
const
|
|
169
|
+
const schema = contract.schemas.events[node.event];
|
|
170
|
+
const shape = schema._zod.def.shape;
|
|
171
|
+
const strict = refusesUnknownKeys(schema);
|
|
159
172
|
const problems = [];
|
|
160
173
|
for (const [field, value] of Object.entries(node.payload)) {
|
|
161
174
|
if (typeof value === "object" && "type" in value) continue;
|
|
162
|
-
if (!Object.hasOwn(shape, field))
|
|
175
|
+
if (!Object.hasOwn(shape, field)) {
|
|
176
|
+
if (strict) problems.push(`'${field}' is not a field the contract declares for the event`);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
163
179
|
if (!zod_mini.safeParse(shape[field], value).success) problems.push(`'${field}' = ${JSON.stringify(value)} is refused by the contract`);
|
|
164
180
|
}
|
|
165
181
|
for (const [field, fieldSchema] of Object.entries(shape)) if (!canBeOmitted(fieldSchema) && !Object.hasOwn(node.payload, field)) problems.push(`required field '${field}' is missing`);
|
package/dist/config.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.cjs","names":["indexTemplates","compileBundle","templateUses","CHANNEL_OF","z"],"sources":["../src/config.ts"],"sourcesContent":["import * as z from 'zod/mini';\nimport type * as core from 'zod/v4/core';\nimport type { Contract } from './contract';\nimport { type ActionRef, type SegmentRef, type WorkflowBuilder, compileBundle } from './dsl';\nimport { templateUses } from './dsl/flow';\nimport { type EmailConfig, assertEmailConfig } from './email';\nimport type { BundleIR, ChannelIR, NodeIR, WorkflowIR } from './ir';\nimport type { JourneyMode, JourneyRuntimeConfig } from './runtime';\nimport {\n type AnyTemplate,\n CHANNEL_OF,\n type TemplateChannel,\n type TemplateRegistry,\n indexTemplates,\n} from './templates';\n\n/**\n * `workflow.config.ts` — the project's journey configuration, typed, flat and\n * committed with the code: what it deploys (workflows, segments, actions),\n * the template registries behind them, the contract every inbound event is\n * checked against, what the email templates share, and how the engine runs per\n * environment (time scale, APNs, FCM). The compiler (`compileConfig`), the\n * Worker (`journeyWorker({ config })`) and the Workflow Studio all read this\n * one object, so everything is registered exactly once:\n *\n * export default defineConfig({\n * title: 'Workflow Studio · Acme',\n * contract,\n * workflows, // [onboarding, notificationTest]\n * templates, // { email, push }: the definitions the workflows send\n * email: { unsubscribeUrl: 'https://acme.test/unsubscribe' },\n * runtime: { development: { timeScale: 600, logMessages: true } },\n * apns: { topic: 'io.acme.app', keyId: 'ABC123DEFG', teamId: 'TEAM123456' },\n * });\n *\n * Secrets are the one thing that stay out: a `.p8`, a service account, the\n * ingest bearer. The host reads them from its environment under fixed names\n * (see `JourneyEnv`), so the config says *which* key without carrying it. The\n * config is a source object (schemas, components, functions); only its\n * compiled bundle and preview-safe template data may cross to a browser.\n */\n\n/** Token-based APNs, the `.p8` auth key; the key itself is `APNS_PRIVATE_KEY_BASE64` in the environment. */\nexport interface ApnsConfig {\n /** The app's bundle id (`apns-topic`). */\n topic: string;\n /** The key's 10-character id. */\n keyId: string;\n /** The Apple Developer team id. */\n teamId: string;\n /**\n * Which APNs host each mode's device tokens belong to: `sandbox` for builds\n * signed with `aps-environment: development` (Xcode, the simulator),\n * `production` for App Store and TestFlight builds. Unlisted modes default\n * to `production` for the production mode and `sandbox` for every other.\n */\n environment?: Partial<Record<JourneyMode, 'sandbox' | 'production'>>;\n}\n\n/**\n * Standard Web Push for browsers (VAPID); the key pair is `VAPID_PUBLIC_KEY` /\n * `VAPID_PRIVATE_KEY` in the environment, and the site subscribes with the same public key.\n */\nexport interface WebPushConfig {\n /** Who push services may contact about this sender: `mailto:` or an `https:` URL. */\n subject: string;\n /**\n * The contract event a notification tap the browser reports (`opened_url`) is also recorded\n * as, with `{ message_id }`: the app reports its own taps as an event, and a service worker has\n * no session to do so — without this, browser opens reach the reports but not the conditions\n * (a `performed(e.push_opened)` segment). One event per copy, however often it is tapped.\n */\n openedEvent?: string;\n}\n\n/** FCM HTTP v1 for Android; the service account is `GOOGLE_APPLICATION_CREDENTIALS_BASE64` in the environment. */\nexport interface FcmConfig {\n /** Android notification channel the pushes land in (`android.notification.channel_id`). */\n androidChannelId?: string;\n}\n\n/**\n * The engine's tables live in a Postgres schema of their own, so their short names (`event`,\n * `profile`, `message`, …) never meet the app's. `workflow-ui generate` writes them as one drizzle\n * file on this schema; the host migrates it with its own tooling.\n */\nexport interface DatabaseConfig {\n /** The Postgres schema; `workflow` by default. */\n schema?: string;\n}\n\nexport interface JourneyConfigDefinition {\n /** Studio title (the browser tab names the project). */\n title?: string;\n /** The shared `defineContract(...)` the Worker validates ingest against. */\n contract?: Contract;\n workflows: readonly WorkflowBuilder[];\n segments?: readonly SegmentRef[];\n actions?: readonly ActionRef[];\n /** The template definitions, per channel; each is identified by its own `id`. */\n templates?: TemplateRegistry;\n /** What every email template shares: the default unsubscribe link, how opens and clicks are judged. */\n email?: EmailConfig;\n /** How the engine behaves per mode (`development`, `production`, …): time scale, message logging. */\n runtime?: JourneyRuntimeConfig;\n apns?: ApnsConfig;\n fcm?: FcmConfig;\n webPush?: WebPushConfig;\n /** Where a Postgres store keeps the engine's tables (the D1 store ignores it). */\n database?: DatabaseConfig;\n}\n\nexport interface JourneyConfig extends JourneyConfigDefinition {\n readonly kind: 'journey-config';\n}\n\nexport function defineConfig(definition: JourneyConfigDefinition): JourneyConfig {\n return { kind: 'journey-config', ...definition };\n}\n\nexport function isConfig(value: unknown): value is JourneyConfig {\n return (\n typeof value === 'object' &&\n value !== null &&\n (value as { kind?: unknown }).kind === 'journey-config'\n );\n}\n\n/** The APNs environment for `mode`: the configured one, else production only for production. */\nexport function apnsEnvironment(config: ApnsConfig, mode: JourneyMode): 'sandbox' | 'production' {\n return config.environment?.[mode] ?? (mode === 'production' ? 'production' : 'sandbox');\n}\n\n/** A message node the config's registries cannot serve, or whose literal props its template refuses. */\nexport class ConfigCompileError extends Error {\n override readonly name = 'ConfigCompileError';\n}\n\n/**\n * Compile the config: the bundle `compileBundle` would produce, with the\n * template manifest generated from the registry (every registered template,\n * by id, not a hand-kept list). The registry is checked first, used or not:\n * every entry is a template of its channel with an id, an id is registered\n * once per channel, and every email template's metadata is valid, as is the\n * config's `email` (which replaced `emails`: a config still carrying it fails).\n * Then every message node is checked against it — an\n * unregistered or cross-channel template fails with the workflow, node and\n * channel named, and so does a literal prop the template's schema refuses (a\n * missing required prop, an undeclared one, a wrong literal) — and every\n * definition a workflow sends must be the very object registered under its id,\n * not merely one with the same id. User-property references cannot be parsed\n * here (their values exist only at send time) — they are checked by name only,\n * and parsed before sending.\n */\nexport function compileConfig(config: JourneyConfig): BundleIR {\n let index: RegistryIndex;\n try {\n const email = indexTemplates('email', config.templates?.email);\n assertEmailConfig(config, email.values());\n index = {\n email,\n push: indexTemplates('push', config.templates?.push),\n discord: indexTemplates('discord', config.templates?.discord),\n slack: indexTemplates('slack', config.templates?.slack),\n };\n } catch (error) {\n throw new ConfigCompileError((error as Error).message);\n }\n const bundle = compileBundle({\n workflows: config.workflows,\n ...(config.segments === undefined ? {} : { segments: config.segments }),\n ...(config.actions === undefined ? {} : { actions: config.actions }),\n templates: TEMPLATE_CHANNELS.flatMap((channel) => [...index[channel].values()]),\n });\n bundle.workflows.forEach((workflow, i) => {\n checkMessages(workflow, workflow.flow, index, config);\n checkUses(workflow, config.workflows[i], index, config);\n });\n return bundle;\n}\n\nconst TEMPLATE_CHANNELS = ['email', 'push', 'discord', 'slack'] as const;\n\n/** The registry by id, per channel. */\ntype RegistryIndex = Record<TemplateChannel, ReadonlyMap<string, AnyTemplate>>;\n\n/** The channel's templates by id; undefined for a channel templates cannot be declared for. */\nfunction indexOf(\n index: RegistryIndex,\n channel: ChannelIR\n): ReadonlyMap<string, AnyTemplate> | undefined {\n return (TEMPLATE_CHANNELS as readonly ChannelIR[]).includes(channel)\n ? index[channel as TemplateChannel]\n : undefined;\n}\n\n/**\n * The definitions a workflow sends against the registered ones: same id is not enough — a\n * workflow typed by one definition and a registry holding another under that id would render\n * with a schema the call was never checked against.\n */\nfunction checkUses(\n workflow: WorkflowIR,\n builder: WorkflowBuilder,\n index: RegistryIndex,\n config: JourneyConfig\n): void {\n for (const { template, loc } of templateUses(builder)) {\n const channel = CHANNEL_OF[template.kind];\n const registered = index[channel].get(template.id);\n if (registered === template) continue;\n const at = loc === undefined ? '' : ` (${loc.file}:${loc.line})`;\n const where = `workflow '${workflow.id}'${at}: ${channel} template '${template.id}'`;\n if (registered === undefined) throw new ConfigCompileError(`${where} is not registered`);\n const name = Object.entries(config.templates?.[channel] ?? {}).find(\n ([, entry]) => entry === registered\n )?.[0];\n throw new ConfigCompileError(\n `${where} is not the definition registered under that id (templates.${channel}.${name}) — two definitions share the id`\n );\n }\n}\n\n/** A field that may be left out: optional, or with a default the schema fills in. */\nfunction canBeOmitted(schema: core.$ZodType): boolean {\n const type = schema._zod.def.type;\n return type === 'optional' || type === 'default' || type === 'prefault';\n}\n\nfunction checkMessages(\n workflow: WorkflowIR,\n nodes: readonly NodeIR[],\n index: RegistryIndex,\n config: JourneyConfig\n): void {\n for (const node of nodes) {\n switch (node.type) {\n case 'message':\n checkMessage(workflow, node, index, config);\n break;\n case 'send_event':\n checkEmit(workflow, node, config);\n break;\n case 'branch':\n for (const c of node.cases) checkMessages(workflow, c.flow, index, config);\n if (node.otherwise) checkMessages(workflow, node.otherwise, index, config);\n break;\n case 'cohort':\n for (const arm of node.arms) checkMessages(workflow, arm.flow, index, config);\n break;\n case 'wait_until':\n if (Array.isArray(node.onTimeout)) checkMessages(workflow, node.onTimeout, index, config);\n break;\n default:\n break;\n }\n }\n}\n\nfunction checkMessage(\n workflow: WorkflowIR,\n node: Extract<NodeIR, { type: 'message' }>,\n index: RegistryIndex,\n config: JourneyConfig\n): void {\n const where = `workflow '${workflow.id}' node ${node.id}`;\n const registry = indexOf(index, node.channel);\n // Channels without template definitions (sms, …) are rendered by app-supplied senders\n if (registry === undefined) return;\n const entry = registry.get(node.template);\n if (entry === undefined) {\n const other = TEMPLATE_CHANNELS.find(\n (channel) => channel !== node.channel && index[channel].has(node.template)\n );\n const hint = other === undefined ? '' : ` (it is registered as a ${other} template)`;\n throw new ConfigCompileError(\n `${where}: ${node.channel} template '${node.template}' is not registered${hint}`\n );\n }\n const shape = (entry.props._zod.def as core.$ZodObjectDef).shape;\n const problems: string[] = [];\n for (const [field, value] of Object.entries(node.props)) {\n if (!Object.hasOwn(shape, field)) {\n problems.push(`'${field}' is not a prop of the template`);\n continue;\n }\n if (typeof value === 'object' && 'type' in value) {\n // A user ref's value exists only at send time (parsed then); here its name can be checked\n const profile = config.contract?.schemas.profile._zod.def;\n if (\n profile !== undefined &&\n value.path !== 'userId' &&\n !Object.hasOwn(profile.shape, value.path)\n ) {\n problems.push(\n `'${field}' reads u.${value.path}, which the contract's profile does not declare`\n );\n }\n continue;\n }\n const result = z.safeParse(shape[field], value);\n if (!result.success)\n problems.push(`'${field}' = ${JSON.stringify(value)} is refused by the schema`);\n }\n for (const [field, schema] of Object.entries(shape)) {\n const optional = canBeOmitted(schema);\n if (!optional && !Object.hasOwn(node.props, field))\n problems.push(`required prop '${field}' is missing`);\n }\n if (problems.length > 0) {\n throw new ConfigCompileError(\n `${where}: ${node.channel} '${node.template}': ${problems.join('; ')}`\n );\n }\n}\n\n/**\n * An emitted event against the contract, as far as compile time can see: the\n * event is declared, literal payload values parse against their field, and no\n * required field is missing. Values read from the profile are checked when the\n * event is emitted, by the same contract.\n */\nfunction checkEmit(\n workflow: WorkflowIR,\n node: Extract<NodeIR, { type: 'send_event' }>,\n config: JourneyConfig\n): void {\n const contract = config.contract;\n if (contract === undefined) return;\n const where = `workflow '${workflow.id}' node ${node.id}`;\n if (!contract.isEvent(node.event)) {\n throw new ConfigCompileError(\n `${where}: emits '${String(node.event)}', which is not in the contract`\n );\n }\n const schema = contract.schemas.events[node.event];\n const shape = (schema._zod.def as core.$ZodObjectDef).shape;\n const problems: string[] = [];\n for (const [field, value] of Object.entries(node.payload)) {\n if (typeof value === 'object' && 'type' in value) continue; // read at emit time\n if (!Object.hasOwn(shape, field)) continue; // a loose payload may carry extra fields\n const result = z.safeParse(shape[field], value);\n if (!result.success)\n problems.push(`'${field}' = ${JSON.stringify(value)} is refused by the contract`);\n }\n for (const [field, fieldSchema] of Object.entries(shape)) {\n const optional = canBeOmitted(fieldSchema);\n if (!optional && !Object.hasOwn(node.payload, field))\n problems.push(`required field '${field}' is missing`);\n }\n if (problems.length > 0) {\n throw new ConfigCompileError(`${where}: event '${node.event}': ${problems.join('; ')}`);\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"config.cjs","names":["indexTemplates","compileBundle","templateUses","CHANNEL_OF","z"],"sources":["../src/config.ts"],"sourcesContent":["import * as z from 'zod/mini';\nimport type * as core from 'zod/v4/core';\nimport type { Contract } from './contract';\nimport { type ActionRef, type SegmentRef, type WorkflowBuilder, compileBundle } from './dsl';\nimport { templateUses } from './dsl/flow';\nimport { type EmailConfig, assertEmailConfig } from './email';\nimport type { BundleIR, ChannelIR, NodeIR, WorkflowIR } from './ir';\nimport type { JourneyMode, JourneyRuntimeConfig } from './runtime';\nimport {\n type AnyTemplate,\n CHANNEL_OF,\n type TemplateChannel,\n type TemplateRegistry,\n indexTemplates,\n} from './templates';\n\n/**\n * `workflow.config.ts` — the project's journey configuration, typed, flat and\n * committed with the code: what it deploys (workflows, segments, actions),\n * the template registries behind them, the contract every inbound event is\n * checked against, what the email templates share, and how the engine runs per\n * environment (time scale, APNs, FCM). The compiler (`compileConfig`), the\n * Worker (`journeyWorker({ config })`) and the Workflow Studio all read this\n * one object, so everything is registered exactly once:\n *\n * export default defineConfig({\n * title: 'Workflow Studio · Acme',\n * contract,\n * workflows, // [onboarding, notificationTest]\n * templates, // { email, push }: the definitions the workflows send\n * email: { unsubscribeUrl: 'https://acme.test/unsubscribe' },\n * runtime: { development: { timeScale: 600, logMessages: true } },\n * apns: { topic: 'io.acme.app', keyId: 'ABC123DEFG', teamId: 'TEAM123456' },\n * });\n *\n * Secrets are the one thing that stay out: a `.p8`, a service account, the\n * ingest bearer. The host reads them from its environment under fixed names\n * (see `JourneyEnv`), so the config says *which* key without carrying it. The\n * config is a source object (schemas, components, functions); only its\n * compiled bundle and preview-safe template data may cross to a browser.\n */\n\n/** Token-based APNs, the `.p8` auth key; the key itself is `APNS_PRIVATE_KEY_BASE64` in the environment. */\nexport interface ApnsConfig {\n /** The app's bundle id (`apns-topic`). */\n topic: string;\n /** The key's 10-character id. */\n keyId: string;\n /** The Apple Developer team id. */\n teamId: string;\n /**\n * Which APNs host each mode's device tokens belong to: `sandbox` for builds\n * signed with `aps-environment: development` (Xcode, the simulator),\n * `production` for App Store and TestFlight builds. Unlisted modes default\n * to `production` for the production mode and `sandbox` for every other.\n */\n environment?: Partial<Record<JourneyMode, 'sandbox' | 'production'>>;\n}\n\n/**\n * Standard Web Push for browsers (VAPID); the key pair is `VAPID_PUBLIC_KEY` /\n * `VAPID_PRIVATE_KEY` in the environment, and the site subscribes with the same public key.\n */\nexport interface WebPushConfig {\n /** Who push services may contact about this sender: `mailto:` or an `https:` URL. */\n subject: string;\n /**\n * The contract event a notification tap the browser reports (`opened_url`) is also recorded\n * as, with `{ message_id }`: the app reports its own taps as an event, and a service worker has\n * no session to do so — without this, browser opens reach the reports but not the conditions\n * (a `performed(e.push_opened)` segment). One event per copy, however often it is tapped.\n */\n openedEvent?: string;\n}\n\n/** FCM HTTP v1 for Android; the service account is `GOOGLE_APPLICATION_CREDENTIALS_BASE64` in the environment. */\nexport interface FcmConfig {\n /** Android notification channel the pushes land in (`android.notification.channel_id`). */\n androidChannelId?: string;\n}\n\n/**\n * The engine's tables live in a Postgres schema of their own, so their short names (`event`,\n * `profile`, `message`, …) never meet the app's. `workflow-ui generate` writes them as one drizzle\n * file on this schema; the host migrates it with its own tooling.\n */\nexport interface DatabaseConfig {\n /** The Postgres schema; `workflow` by default. */\n schema?: string;\n}\n\nexport interface JourneyConfigDefinition {\n /** Studio title (the browser tab names the project). */\n title?: string;\n /** The shared `defineContract(...)` the Worker validates ingest against. */\n contract?: Contract;\n workflows: readonly WorkflowBuilder[];\n segments?: readonly SegmentRef[];\n actions?: readonly ActionRef[];\n /** The template definitions, per channel; each is identified by its own `id`. */\n templates?: TemplateRegistry;\n /** What every email template shares: the default unsubscribe link, how opens and clicks are judged. */\n email?: EmailConfig;\n /** How the engine behaves per mode (`development`, `production`, …): time scale, message logging. */\n runtime?: JourneyRuntimeConfig;\n apns?: ApnsConfig;\n fcm?: FcmConfig;\n webPush?: WebPushConfig;\n /** Where a Postgres store keeps the engine's tables (the D1 store ignores it). */\n database?: DatabaseConfig;\n}\n\nexport interface JourneyConfig extends JourneyConfigDefinition {\n readonly kind: 'journey-config';\n}\n\nexport function defineConfig(definition: JourneyConfigDefinition): JourneyConfig {\n return { kind: 'journey-config', ...definition };\n}\n\nexport function isConfig(value: unknown): value is JourneyConfig {\n return (\n typeof value === 'object' &&\n value !== null &&\n (value as { kind?: unknown }).kind === 'journey-config'\n );\n}\n\n/** The APNs environment for `mode`: the configured one, else production only for production. */\nexport function apnsEnvironment(config: ApnsConfig, mode: JourneyMode): 'sandbox' | 'production' {\n return config.environment?.[mode] ?? (mode === 'production' ? 'production' : 'sandbox');\n}\n\n/** A message node the config's registries cannot serve, or whose literal props its template refuses. */\nexport class ConfigCompileError extends Error {\n override readonly name = 'ConfigCompileError';\n}\n\n/**\n * Compile the config: the bundle `compileBundle` would produce, with the\n * template manifest generated from the registry (every registered template,\n * by id, not a hand-kept list). The registry is checked first, used or not:\n * every entry is a template of its channel with an id, an id is registered\n * once per channel, and every email template's metadata is valid, as is the\n * config's `email` (which replaced `emails`: a config still carrying it fails).\n * Then every message node is checked against it — an\n * unregistered or cross-channel template fails with the workflow, node and\n * channel named, and so does a literal prop the template's schema refuses (a\n * missing required prop, an undeclared one, a wrong literal) — and every\n * definition a workflow sends must be the very object registered under its id,\n * not merely one with the same id. User-property references cannot be parsed\n * here (their values exist only at send time) — they are checked by name only,\n * and parsed before sending.\n */\nexport function compileConfig(config: JourneyConfig): BundleIR {\n let index: RegistryIndex;\n try {\n const email = indexTemplates('email', config.templates?.email);\n assertEmailConfig(config, email.values());\n index = {\n email,\n push: indexTemplates('push', config.templates?.push),\n discord: indexTemplates('discord', config.templates?.discord),\n slack: indexTemplates('slack', config.templates?.slack),\n };\n } catch (error) {\n throw new ConfigCompileError((error as Error).message);\n }\n const bundle = compileBundle({\n workflows: config.workflows,\n ...(config.segments === undefined ? {} : { segments: config.segments }),\n ...(config.actions === undefined ? {} : { actions: config.actions }),\n templates: TEMPLATE_CHANNELS.flatMap((channel) => [...index[channel].values()]),\n });\n bundle.workflows.forEach((workflow, i) => {\n checkMessages(workflow, workflow.flow, index, config);\n checkUses(workflow, config.workflows[i], index, config);\n });\n return bundle;\n}\n\nconst TEMPLATE_CHANNELS = ['email', 'push', 'discord', 'slack'] as const;\n\n/** The registry by id, per channel. */\ntype RegistryIndex = Record<TemplateChannel, ReadonlyMap<string, AnyTemplate>>;\n\n/** The channel's templates by id; undefined for a channel templates cannot be declared for. */\nfunction indexOf(\n index: RegistryIndex,\n channel: ChannelIR\n): ReadonlyMap<string, AnyTemplate> | undefined {\n return (TEMPLATE_CHANNELS as readonly ChannelIR[]).includes(channel)\n ? index[channel as TemplateChannel]\n : undefined;\n}\n\n/**\n * The definitions a workflow sends against the registered ones: same id is not enough — a\n * workflow typed by one definition and a registry holding another under that id would render\n * with a schema the call was never checked against.\n */\nfunction checkUses(\n workflow: WorkflowIR,\n builder: WorkflowBuilder,\n index: RegistryIndex,\n config: JourneyConfig\n): void {\n for (const { template, loc } of templateUses(builder)) {\n const channel = CHANNEL_OF[template.kind];\n const registered = index[channel].get(template.id);\n if (registered === template) continue;\n const at = loc === undefined ? '' : ` (${loc.file}:${loc.line})`;\n const where = `workflow '${workflow.id}'${at}: ${channel} template '${template.id}'`;\n if (registered === undefined) throw new ConfigCompileError(`${where} is not registered`);\n const name = Object.entries(config.templates?.[channel] ?? {}).find(\n ([, entry]) => entry === registered\n )?.[0];\n throw new ConfigCompileError(\n `${where} is not the definition registered under that id (templates.${channel}.${name}) — two definitions share the id`\n );\n }\n}\n\n/** A field that may be left out: optional, or with a default the schema fills in. */\nfunction canBeOmitted(schema: core.$ZodType): boolean {\n const type = schema._zod.def.type;\n return type === 'optional' || type === 'default' || type === 'prefault';\n}\n\nfunction checkMessages(\n workflow: WorkflowIR,\n nodes: readonly NodeIR[],\n index: RegistryIndex,\n config: JourneyConfig\n): void {\n for (const node of nodes) {\n switch (node.type) {\n case 'message':\n checkMessage(workflow, node, index, config);\n break;\n case 'send_event':\n checkEmit(workflow, node, config);\n break;\n case 'branch':\n for (const c of node.cases) checkMessages(workflow, c.flow, index, config);\n if (node.otherwise) checkMessages(workflow, node.otherwise, index, config);\n break;\n case 'cohort':\n for (const arm of node.arms) checkMessages(workflow, arm.flow, index, config);\n break;\n case 'wait_until':\n if (Array.isArray(node.onTimeout)) checkMessages(workflow, node.onTimeout, index, config);\n break;\n default:\n break;\n }\n }\n}\n\nfunction checkMessage(\n workflow: WorkflowIR,\n node: Extract<NodeIR, { type: 'message' }>,\n index: RegistryIndex,\n config: JourneyConfig\n): void {\n const where = `workflow '${workflow.id}' node ${node.id}`;\n const registry = indexOf(index, node.channel);\n // Channels without template definitions (sms, …) are rendered by app-supplied senders\n if (registry === undefined) return;\n const entry = registry.get(node.template);\n if (entry === undefined) {\n const other = TEMPLATE_CHANNELS.find(\n (channel) => channel !== node.channel && index[channel].has(node.template)\n );\n const hint = other === undefined ? '' : ` (it is registered as a ${other} template)`;\n throw new ConfigCompileError(\n `${where}: ${node.channel} template '${node.template}' is not registered${hint}`\n );\n }\n const shape = (entry.props._zod.def as core.$ZodObjectDef).shape;\n const problems: string[] = [];\n for (const [field, value] of Object.entries(node.props)) {\n if (!Object.hasOwn(shape, field)) {\n problems.push(`'${field}' is not a prop of the template`);\n continue;\n }\n if (typeof value === 'object' && 'type' in value) {\n // A user ref's value exists only at send time (parsed then); here its name can be checked\n const profile = config.contract?.schemas.profile._zod.def;\n if (\n profile !== undefined &&\n value.path !== 'userId' &&\n !Object.hasOwn(profile.shape, value.path)\n ) {\n problems.push(\n `'${field}' reads u.${value.path}, which the contract's profile does not declare`\n );\n }\n continue;\n }\n const result = z.safeParse(shape[field], value);\n if (!result.success)\n problems.push(`'${field}' = ${JSON.stringify(value)} is refused by the schema`);\n }\n for (const [field, schema] of Object.entries(shape)) {\n const optional = canBeOmitted(schema);\n if (!optional && !Object.hasOwn(node.props, field))\n problems.push(`required prop '${field}' is missing`);\n }\n if (problems.length > 0) {\n throw new ConfigCompileError(\n `${where}: ${node.channel} '${node.template}': ${problems.join('; ')}`\n );\n }\n}\n\n/**\n * Whether an object schema refuses keys it does not declare: `z.strictObject`\n * sets its catchall to `never`; `z.object` strips unknown keys and\n * `z.looseObject` passes them through, and neither refuses.\n */\nfunction refusesUnknownKeys(schema: core.$ZodType): boolean {\n return (schema._zod.def as core.$ZodObjectDef).catchall?._zod.def.type === 'never';\n}\n\n/**\n * An emitted event against the contract, as far as compile time can see: the\n * event is declared, literal payload values parse against their field, no\n * required field is missing, and — when the event's schema is strict — no\n * literal field is undeclared (the Worker would refuse the whole event at\n * emit time, failing the journey where the compile could have failed the\n * build). Values read from the profile are checked when the event is emitted,\n * by the same contract.\n */\nfunction checkEmit(\n workflow: WorkflowIR,\n node: Extract<NodeIR, { type: 'send_event' }>,\n config: JourneyConfig\n): void {\n const contract = config.contract;\n if (contract === undefined) return;\n const where = `workflow '${workflow.id}' node ${node.id}`;\n if (!contract.isEvent(node.event)) {\n throw new ConfigCompileError(\n `${where}: emits '${String(node.event)}', which is not in the contract`\n );\n }\n const schema = contract.schemas.events[node.event];\n const shape = (schema._zod.def as core.$ZodObjectDef).shape;\n const strict = refusesUnknownKeys(schema);\n const problems: string[] = [];\n for (const [field, value] of Object.entries(node.payload)) {\n if (typeof value === 'object' && 'type' in value) continue; // read at emit time\n if (!Object.hasOwn(shape, field)) {\n // A loose or stripping payload may carry extra fields; a strict one refuses them\n if (strict) problems.push(`'${field}' is not a field the contract declares for the event`);\n continue;\n }\n const result = z.safeParse(shape[field], value);\n if (!result.success)\n problems.push(`'${field}' = ${JSON.stringify(value)} is refused by the contract`);\n }\n for (const [field, fieldSchema] of Object.entries(shape)) {\n const optional = canBeOmitted(fieldSchema);\n if (!optional && !Object.hasOwn(node.payload, field))\n problems.push(`required field '${field}' is missing`);\n }\n if (problems.length > 0) {\n throw new ConfigCompileError(`${where}: event '${node.event}': ${problems.join('; ')}`);\n }\n}\n"],"mappings":"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"}
|
package/dist/config.mjs
CHANGED
|
@@ -142,21 +142,37 @@ function checkMessage(workflow, node, index, config) {
|
|
|
142
142
|
if (problems.length > 0) throw new ConfigCompileError(`${where}: ${node.channel} '${node.template}': ${problems.join("; ")}`);
|
|
143
143
|
}
|
|
144
144
|
/**
|
|
145
|
+
* Whether an object schema refuses keys it does not declare: `z.strictObject`
|
|
146
|
+
* sets its catchall to `never`; `z.object` strips unknown keys and
|
|
147
|
+
* `z.looseObject` passes them through, and neither refuses.
|
|
148
|
+
*/
|
|
149
|
+
function refusesUnknownKeys(schema) {
|
|
150
|
+
return schema._zod.def.catchall?._zod.def.type === "never";
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
145
153
|
* An emitted event against the contract, as far as compile time can see: the
|
|
146
|
-
* event is declared, literal payload values parse against their field,
|
|
147
|
-
* required field is missing
|
|
148
|
-
*
|
|
154
|
+
* event is declared, literal payload values parse against their field, no
|
|
155
|
+
* required field is missing, and — when the event's schema is strict — no
|
|
156
|
+
* literal field is undeclared (the Worker would refuse the whole event at
|
|
157
|
+
* emit time, failing the journey where the compile could have failed the
|
|
158
|
+
* build). Values read from the profile are checked when the event is emitted,
|
|
159
|
+
* by the same contract.
|
|
149
160
|
*/
|
|
150
161
|
function checkEmit(workflow, node, config) {
|
|
151
162
|
const contract = config.contract;
|
|
152
163
|
if (contract === void 0) return;
|
|
153
164
|
const where = `workflow '${workflow.id}' node ${node.id}`;
|
|
154
165
|
if (!contract.isEvent(node.event)) throw new ConfigCompileError(`${where}: emits '${String(node.event)}', which is not in the contract`);
|
|
155
|
-
const
|
|
166
|
+
const schema = contract.schemas.events[node.event];
|
|
167
|
+
const shape = schema._zod.def.shape;
|
|
168
|
+
const strict = refusesUnknownKeys(schema);
|
|
156
169
|
const problems = [];
|
|
157
170
|
for (const [field, value] of Object.entries(node.payload)) {
|
|
158
171
|
if (typeof value === "object" && "type" in value) continue;
|
|
159
|
-
if (!Object.hasOwn(shape, field))
|
|
172
|
+
if (!Object.hasOwn(shape, field)) {
|
|
173
|
+
if (strict) problems.push(`'${field}' is not a field the contract declares for the event`);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
160
176
|
if (!z.safeParse(shape[field], value).success) problems.push(`'${field}' = ${JSON.stringify(value)} is refused by the contract`);
|
|
161
177
|
}
|
|
162
178
|
for (const [field, fieldSchema] of Object.entries(shape)) if (!canBeOmitted(fieldSchema) && !Object.hasOwn(node.payload, field)) problems.push(`required field '${field}' is missing`);
|
package/dist/config.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.mjs","names":[],"sources":["../src/config.ts"],"sourcesContent":["import * as z from 'zod/mini';\nimport type * as core from 'zod/v4/core';\nimport type { Contract } from './contract';\nimport { type ActionRef, type SegmentRef, type WorkflowBuilder, compileBundle } from './dsl';\nimport { templateUses } from './dsl/flow';\nimport { type EmailConfig, assertEmailConfig } from './email';\nimport type { BundleIR, ChannelIR, NodeIR, WorkflowIR } from './ir';\nimport type { JourneyMode, JourneyRuntimeConfig } from './runtime';\nimport {\n type AnyTemplate,\n CHANNEL_OF,\n type TemplateChannel,\n type TemplateRegistry,\n indexTemplates,\n} from './templates';\n\n/**\n * `workflow.config.ts` — the project's journey configuration, typed, flat and\n * committed with the code: what it deploys (workflows, segments, actions),\n * the template registries behind them, the contract every inbound event is\n * checked against, what the email templates share, and how the engine runs per\n * environment (time scale, APNs, FCM). The compiler (`compileConfig`), the\n * Worker (`journeyWorker({ config })`) and the Workflow Studio all read this\n * one object, so everything is registered exactly once:\n *\n * export default defineConfig({\n * title: 'Workflow Studio · Acme',\n * contract,\n * workflows, // [onboarding, notificationTest]\n * templates, // { email, push }: the definitions the workflows send\n * email: { unsubscribeUrl: 'https://acme.test/unsubscribe' },\n * runtime: { development: { timeScale: 600, logMessages: true } },\n * apns: { topic: 'io.acme.app', keyId: 'ABC123DEFG', teamId: 'TEAM123456' },\n * });\n *\n * Secrets are the one thing that stay out: a `.p8`, a service account, the\n * ingest bearer. The host reads them from its environment under fixed names\n * (see `JourneyEnv`), so the config says *which* key without carrying it. The\n * config is a source object (schemas, components, functions); only its\n * compiled bundle and preview-safe template data may cross to a browser.\n */\n\n/** Token-based APNs, the `.p8` auth key; the key itself is `APNS_PRIVATE_KEY_BASE64` in the environment. */\nexport interface ApnsConfig {\n /** The app's bundle id (`apns-topic`). */\n topic: string;\n /** The key's 10-character id. */\n keyId: string;\n /** The Apple Developer team id. */\n teamId: string;\n /**\n * Which APNs host each mode's device tokens belong to: `sandbox` for builds\n * signed with `aps-environment: development` (Xcode, the simulator),\n * `production` for App Store and TestFlight builds. Unlisted modes default\n * to `production` for the production mode and `sandbox` for every other.\n */\n environment?: Partial<Record<JourneyMode, 'sandbox' | 'production'>>;\n}\n\n/**\n * Standard Web Push for browsers (VAPID); the key pair is `VAPID_PUBLIC_KEY` /\n * `VAPID_PRIVATE_KEY` in the environment, and the site subscribes with the same public key.\n */\nexport interface WebPushConfig {\n /** Who push services may contact about this sender: `mailto:` or an `https:` URL. */\n subject: string;\n /**\n * The contract event a notification tap the browser reports (`opened_url`) is also recorded\n * as, with `{ message_id }`: the app reports its own taps as an event, and a service worker has\n * no session to do so — without this, browser opens reach the reports but not the conditions\n * (a `performed(e.push_opened)` segment). One event per copy, however often it is tapped.\n */\n openedEvent?: string;\n}\n\n/** FCM HTTP v1 for Android; the service account is `GOOGLE_APPLICATION_CREDENTIALS_BASE64` in the environment. */\nexport interface FcmConfig {\n /** Android notification channel the pushes land in (`android.notification.channel_id`). */\n androidChannelId?: string;\n}\n\n/**\n * The engine's tables live in a Postgres schema of their own, so their short names (`event`,\n * `profile`, `message`, …) never meet the app's. `workflow-ui generate` writes them as one drizzle\n * file on this schema; the host migrates it with its own tooling.\n */\nexport interface DatabaseConfig {\n /** The Postgres schema; `workflow` by default. */\n schema?: string;\n}\n\nexport interface JourneyConfigDefinition {\n /** Studio title (the browser tab names the project). */\n title?: string;\n /** The shared `defineContract(...)` the Worker validates ingest against. */\n contract?: Contract;\n workflows: readonly WorkflowBuilder[];\n segments?: readonly SegmentRef[];\n actions?: readonly ActionRef[];\n /** The template definitions, per channel; each is identified by its own `id`. */\n templates?: TemplateRegistry;\n /** What every email template shares: the default unsubscribe link, how opens and clicks are judged. */\n email?: EmailConfig;\n /** How the engine behaves per mode (`development`, `production`, …): time scale, message logging. */\n runtime?: JourneyRuntimeConfig;\n apns?: ApnsConfig;\n fcm?: FcmConfig;\n webPush?: WebPushConfig;\n /** Where a Postgres store keeps the engine's tables (the D1 store ignores it). */\n database?: DatabaseConfig;\n}\n\nexport interface JourneyConfig extends JourneyConfigDefinition {\n readonly kind: 'journey-config';\n}\n\nexport function defineConfig(definition: JourneyConfigDefinition): JourneyConfig {\n return { kind: 'journey-config', ...definition };\n}\n\nexport function isConfig(value: unknown): value is JourneyConfig {\n return (\n typeof value === 'object' &&\n value !== null &&\n (value as { kind?: unknown }).kind === 'journey-config'\n );\n}\n\n/** The APNs environment for `mode`: the configured one, else production only for production. */\nexport function apnsEnvironment(config: ApnsConfig, mode: JourneyMode): 'sandbox' | 'production' {\n return config.environment?.[mode] ?? (mode === 'production' ? 'production' : 'sandbox');\n}\n\n/** A message node the config's registries cannot serve, or whose literal props its template refuses. */\nexport class ConfigCompileError extends Error {\n override readonly name = 'ConfigCompileError';\n}\n\n/**\n * Compile the config: the bundle `compileBundle` would produce, with the\n * template manifest generated from the registry (every registered template,\n * by id, not a hand-kept list). The registry is checked first, used or not:\n * every entry is a template of its channel with an id, an id is registered\n * once per channel, and every email template's metadata is valid, as is the\n * config's `email` (which replaced `emails`: a config still carrying it fails).\n * Then every message node is checked against it — an\n * unregistered or cross-channel template fails with the workflow, node and\n * channel named, and so does a literal prop the template's schema refuses (a\n * missing required prop, an undeclared one, a wrong literal) — and every\n * definition a workflow sends must be the very object registered under its id,\n * not merely one with the same id. User-property references cannot be parsed\n * here (their values exist only at send time) — they are checked by name only,\n * and parsed before sending.\n */\nexport function compileConfig(config: JourneyConfig): BundleIR {\n let index: RegistryIndex;\n try {\n const email = indexTemplates('email', config.templates?.email);\n assertEmailConfig(config, email.values());\n index = {\n email,\n push: indexTemplates('push', config.templates?.push),\n discord: indexTemplates('discord', config.templates?.discord),\n slack: indexTemplates('slack', config.templates?.slack),\n };\n } catch (error) {\n throw new ConfigCompileError((error as Error).message);\n }\n const bundle = compileBundle({\n workflows: config.workflows,\n ...(config.segments === undefined ? {} : { segments: config.segments }),\n ...(config.actions === undefined ? {} : { actions: config.actions }),\n templates: TEMPLATE_CHANNELS.flatMap((channel) => [...index[channel].values()]),\n });\n bundle.workflows.forEach((workflow, i) => {\n checkMessages(workflow, workflow.flow, index, config);\n checkUses(workflow, config.workflows[i], index, config);\n });\n return bundle;\n}\n\nconst TEMPLATE_CHANNELS = ['email', 'push', 'discord', 'slack'] as const;\n\n/** The registry by id, per channel. */\ntype RegistryIndex = Record<TemplateChannel, ReadonlyMap<string, AnyTemplate>>;\n\n/** The channel's templates by id; undefined for a channel templates cannot be declared for. */\nfunction indexOf(\n index: RegistryIndex,\n channel: ChannelIR\n): ReadonlyMap<string, AnyTemplate> | undefined {\n return (TEMPLATE_CHANNELS as readonly ChannelIR[]).includes(channel)\n ? index[channel as TemplateChannel]\n : undefined;\n}\n\n/**\n * The definitions a workflow sends against the registered ones: same id is not enough — a\n * workflow typed by one definition and a registry holding another under that id would render\n * with a schema the call was never checked against.\n */\nfunction checkUses(\n workflow: WorkflowIR,\n builder: WorkflowBuilder,\n index: RegistryIndex,\n config: JourneyConfig\n): void {\n for (const { template, loc } of templateUses(builder)) {\n const channel = CHANNEL_OF[template.kind];\n const registered = index[channel].get(template.id);\n if (registered === template) continue;\n const at = loc === undefined ? '' : ` (${loc.file}:${loc.line})`;\n const where = `workflow '${workflow.id}'${at}: ${channel} template '${template.id}'`;\n if (registered === undefined) throw new ConfigCompileError(`${where} is not registered`);\n const name = Object.entries(config.templates?.[channel] ?? {}).find(\n ([, entry]) => entry === registered\n )?.[0];\n throw new ConfigCompileError(\n `${where} is not the definition registered under that id (templates.${channel}.${name}) — two definitions share the id`\n );\n }\n}\n\n/** A field that may be left out: optional, or with a default the schema fills in. */\nfunction canBeOmitted(schema: core.$ZodType): boolean {\n const type = schema._zod.def.type;\n return type === 'optional' || type === 'default' || type === 'prefault';\n}\n\nfunction checkMessages(\n workflow: WorkflowIR,\n nodes: readonly NodeIR[],\n index: RegistryIndex,\n config: JourneyConfig\n): void {\n for (const node of nodes) {\n switch (node.type) {\n case 'message':\n checkMessage(workflow, node, index, config);\n break;\n case 'send_event':\n checkEmit(workflow, node, config);\n break;\n case 'branch':\n for (const c of node.cases) checkMessages(workflow, c.flow, index, config);\n if (node.otherwise) checkMessages(workflow, node.otherwise, index, config);\n break;\n case 'cohort':\n for (const arm of node.arms) checkMessages(workflow, arm.flow, index, config);\n break;\n case 'wait_until':\n if (Array.isArray(node.onTimeout)) checkMessages(workflow, node.onTimeout, index, config);\n break;\n default:\n break;\n }\n }\n}\n\nfunction checkMessage(\n workflow: WorkflowIR,\n node: Extract<NodeIR, { type: 'message' }>,\n index: RegistryIndex,\n config: JourneyConfig\n): void {\n const where = `workflow '${workflow.id}' node ${node.id}`;\n const registry = indexOf(index, node.channel);\n // Channels without template definitions (sms, …) are rendered by app-supplied senders\n if (registry === undefined) return;\n const entry = registry.get(node.template);\n if (entry === undefined) {\n const other = TEMPLATE_CHANNELS.find(\n (channel) => channel !== node.channel && index[channel].has(node.template)\n );\n const hint = other === undefined ? '' : ` (it is registered as a ${other} template)`;\n throw new ConfigCompileError(\n `${where}: ${node.channel} template '${node.template}' is not registered${hint}`\n );\n }\n const shape = (entry.props._zod.def as core.$ZodObjectDef).shape;\n const problems: string[] = [];\n for (const [field, value] of Object.entries(node.props)) {\n if (!Object.hasOwn(shape, field)) {\n problems.push(`'${field}' is not a prop of the template`);\n continue;\n }\n if (typeof value === 'object' && 'type' in value) {\n // A user ref's value exists only at send time (parsed then); here its name can be checked\n const profile = config.contract?.schemas.profile._zod.def;\n if (\n profile !== undefined &&\n value.path !== 'userId' &&\n !Object.hasOwn(profile.shape, value.path)\n ) {\n problems.push(\n `'${field}' reads u.${value.path}, which the contract's profile does not declare`\n );\n }\n continue;\n }\n const result = z.safeParse(shape[field], value);\n if (!result.success)\n problems.push(`'${field}' = ${JSON.stringify(value)} is refused by the schema`);\n }\n for (const [field, schema] of Object.entries(shape)) {\n const optional = canBeOmitted(schema);\n if (!optional && !Object.hasOwn(node.props, field))\n problems.push(`required prop '${field}' is missing`);\n }\n if (problems.length > 0) {\n throw new ConfigCompileError(\n `${where}: ${node.channel} '${node.template}': ${problems.join('; ')}`\n );\n }\n}\n\n/**\n * An emitted event against the contract, as far as compile time can see: the\n * event is declared, literal payload values parse against their field, and no\n * required field is missing. Values read from the profile are checked when the\n * event is emitted, by the same contract.\n */\nfunction checkEmit(\n workflow: WorkflowIR,\n node: Extract<NodeIR, { type: 'send_event' }>,\n config: JourneyConfig\n): void {\n const contract = config.contract;\n if (contract === undefined) return;\n const where = `workflow '${workflow.id}' node ${node.id}`;\n if (!contract.isEvent(node.event)) {\n throw new ConfigCompileError(\n `${where}: emits '${String(node.event)}', which is not in the contract`\n );\n }\n const schema = contract.schemas.events[node.event];\n const shape = (schema._zod.def as core.$ZodObjectDef).shape;\n const problems: string[] = [];\n for (const [field, value] of Object.entries(node.payload)) {\n if (typeof value === 'object' && 'type' in value) continue; // read at emit time\n if (!Object.hasOwn(shape, field)) continue; // a loose payload may carry extra fields\n const result = z.safeParse(shape[field], value);\n if (!result.success)\n problems.push(`'${field}' = ${JSON.stringify(value)} is refused by the contract`);\n }\n for (const [field, fieldSchema] of Object.entries(shape)) {\n const optional = canBeOmitted(fieldSchema);\n if (!optional && !Object.hasOwn(node.payload, field))\n problems.push(`required field '${field}' is missing`);\n }\n if (problems.length > 0) {\n throw new ConfigCompileError(`${where}: event '${node.event}': ${problems.join('; ')}`);\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"config.mjs","names":[],"sources":["../src/config.ts"],"sourcesContent":["import * as z from 'zod/mini';\nimport type * as core from 'zod/v4/core';\nimport type { Contract } from './contract';\nimport { type ActionRef, type SegmentRef, type WorkflowBuilder, compileBundle } from './dsl';\nimport { templateUses } from './dsl/flow';\nimport { type EmailConfig, assertEmailConfig } from './email';\nimport type { BundleIR, ChannelIR, NodeIR, WorkflowIR } from './ir';\nimport type { JourneyMode, JourneyRuntimeConfig } from './runtime';\nimport {\n type AnyTemplate,\n CHANNEL_OF,\n type TemplateChannel,\n type TemplateRegistry,\n indexTemplates,\n} from './templates';\n\n/**\n * `workflow.config.ts` — the project's journey configuration, typed, flat and\n * committed with the code: what it deploys (workflows, segments, actions),\n * the template registries behind them, the contract every inbound event is\n * checked against, what the email templates share, and how the engine runs per\n * environment (time scale, APNs, FCM). The compiler (`compileConfig`), the\n * Worker (`journeyWorker({ config })`) and the Workflow Studio all read this\n * one object, so everything is registered exactly once:\n *\n * export default defineConfig({\n * title: 'Workflow Studio · Acme',\n * contract,\n * workflows, // [onboarding, notificationTest]\n * templates, // { email, push }: the definitions the workflows send\n * email: { unsubscribeUrl: 'https://acme.test/unsubscribe' },\n * runtime: { development: { timeScale: 600, logMessages: true } },\n * apns: { topic: 'io.acme.app', keyId: 'ABC123DEFG', teamId: 'TEAM123456' },\n * });\n *\n * Secrets are the one thing that stay out: a `.p8`, a service account, the\n * ingest bearer. The host reads them from its environment under fixed names\n * (see `JourneyEnv`), so the config says *which* key without carrying it. The\n * config is a source object (schemas, components, functions); only its\n * compiled bundle and preview-safe template data may cross to a browser.\n */\n\n/** Token-based APNs, the `.p8` auth key; the key itself is `APNS_PRIVATE_KEY_BASE64` in the environment. */\nexport interface ApnsConfig {\n /** The app's bundle id (`apns-topic`). */\n topic: string;\n /** The key's 10-character id. */\n keyId: string;\n /** The Apple Developer team id. */\n teamId: string;\n /**\n * Which APNs host each mode's device tokens belong to: `sandbox` for builds\n * signed with `aps-environment: development` (Xcode, the simulator),\n * `production` for App Store and TestFlight builds. Unlisted modes default\n * to `production` for the production mode and `sandbox` for every other.\n */\n environment?: Partial<Record<JourneyMode, 'sandbox' | 'production'>>;\n}\n\n/**\n * Standard Web Push for browsers (VAPID); the key pair is `VAPID_PUBLIC_KEY` /\n * `VAPID_PRIVATE_KEY` in the environment, and the site subscribes with the same public key.\n */\nexport interface WebPushConfig {\n /** Who push services may contact about this sender: `mailto:` or an `https:` URL. */\n subject: string;\n /**\n * The contract event a notification tap the browser reports (`opened_url`) is also recorded\n * as, with `{ message_id }`: the app reports its own taps as an event, and a service worker has\n * no session to do so — without this, browser opens reach the reports but not the conditions\n * (a `performed(e.push_opened)` segment). One event per copy, however often it is tapped.\n */\n openedEvent?: string;\n}\n\n/** FCM HTTP v1 for Android; the service account is `GOOGLE_APPLICATION_CREDENTIALS_BASE64` in the environment. */\nexport interface FcmConfig {\n /** Android notification channel the pushes land in (`android.notification.channel_id`). */\n androidChannelId?: string;\n}\n\n/**\n * The engine's tables live in a Postgres schema of their own, so their short names (`event`,\n * `profile`, `message`, …) never meet the app's. `workflow-ui generate` writes them as one drizzle\n * file on this schema; the host migrates it with its own tooling.\n */\nexport interface DatabaseConfig {\n /** The Postgres schema; `workflow` by default. */\n schema?: string;\n}\n\nexport interface JourneyConfigDefinition {\n /** Studio title (the browser tab names the project). */\n title?: string;\n /** The shared `defineContract(...)` the Worker validates ingest against. */\n contract?: Contract;\n workflows: readonly WorkflowBuilder[];\n segments?: readonly SegmentRef[];\n actions?: readonly ActionRef[];\n /** The template definitions, per channel; each is identified by its own `id`. */\n templates?: TemplateRegistry;\n /** What every email template shares: the default unsubscribe link, how opens and clicks are judged. */\n email?: EmailConfig;\n /** How the engine behaves per mode (`development`, `production`, …): time scale, message logging. */\n runtime?: JourneyRuntimeConfig;\n apns?: ApnsConfig;\n fcm?: FcmConfig;\n webPush?: WebPushConfig;\n /** Where a Postgres store keeps the engine's tables (the D1 store ignores it). */\n database?: DatabaseConfig;\n}\n\nexport interface JourneyConfig extends JourneyConfigDefinition {\n readonly kind: 'journey-config';\n}\n\nexport function defineConfig(definition: JourneyConfigDefinition): JourneyConfig {\n return { kind: 'journey-config', ...definition };\n}\n\nexport function isConfig(value: unknown): value is JourneyConfig {\n return (\n typeof value === 'object' &&\n value !== null &&\n (value as { kind?: unknown }).kind === 'journey-config'\n );\n}\n\n/** The APNs environment for `mode`: the configured one, else production only for production. */\nexport function apnsEnvironment(config: ApnsConfig, mode: JourneyMode): 'sandbox' | 'production' {\n return config.environment?.[mode] ?? (mode === 'production' ? 'production' : 'sandbox');\n}\n\n/** A message node the config's registries cannot serve, or whose literal props its template refuses. */\nexport class ConfigCompileError extends Error {\n override readonly name = 'ConfigCompileError';\n}\n\n/**\n * Compile the config: the bundle `compileBundle` would produce, with the\n * template manifest generated from the registry (every registered template,\n * by id, not a hand-kept list). The registry is checked first, used or not:\n * every entry is a template of its channel with an id, an id is registered\n * once per channel, and every email template's metadata is valid, as is the\n * config's `email` (which replaced `emails`: a config still carrying it fails).\n * Then every message node is checked against it — an\n * unregistered or cross-channel template fails with the workflow, node and\n * channel named, and so does a literal prop the template's schema refuses (a\n * missing required prop, an undeclared one, a wrong literal) — and every\n * definition a workflow sends must be the very object registered under its id,\n * not merely one with the same id. User-property references cannot be parsed\n * here (their values exist only at send time) — they are checked by name only,\n * and parsed before sending.\n */\nexport function compileConfig(config: JourneyConfig): BundleIR {\n let index: RegistryIndex;\n try {\n const email = indexTemplates('email', config.templates?.email);\n assertEmailConfig(config, email.values());\n index = {\n email,\n push: indexTemplates('push', config.templates?.push),\n discord: indexTemplates('discord', config.templates?.discord),\n slack: indexTemplates('slack', config.templates?.slack),\n };\n } catch (error) {\n throw new ConfigCompileError((error as Error).message);\n }\n const bundle = compileBundle({\n workflows: config.workflows,\n ...(config.segments === undefined ? {} : { segments: config.segments }),\n ...(config.actions === undefined ? {} : { actions: config.actions }),\n templates: TEMPLATE_CHANNELS.flatMap((channel) => [...index[channel].values()]),\n });\n bundle.workflows.forEach((workflow, i) => {\n checkMessages(workflow, workflow.flow, index, config);\n checkUses(workflow, config.workflows[i], index, config);\n });\n return bundle;\n}\n\nconst TEMPLATE_CHANNELS = ['email', 'push', 'discord', 'slack'] as const;\n\n/** The registry by id, per channel. */\ntype RegistryIndex = Record<TemplateChannel, ReadonlyMap<string, AnyTemplate>>;\n\n/** The channel's templates by id; undefined for a channel templates cannot be declared for. */\nfunction indexOf(\n index: RegistryIndex,\n channel: ChannelIR\n): ReadonlyMap<string, AnyTemplate> | undefined {\n return (TEMPLATE_CHANNELS as readonly ChannelIR[]).includes(channel)\n ? index[channel as TemplateChannel]\n : undefined;\n}\n\n/**\n * The definitions a workflow sends against the registered ones: same id is not enough — a\n * workflow typed by one definition and a registry holding another under that id would render\n * with a schema the call was never checked against.\n */\nfunction checkUses(\n workflow: WorkflowIR,\n builder: WorkflowBuilder,\n index: RegistryIndex,\n config: JourneyConfig\n): void {\n for (const { template, loc } of templateUses(builder)) {\n const channel = CHANNEL_OF[template.kind];\n const registered = index[channel].get(template.id);\n if (registered === template) continue;\n const at = loc === undefined ? '' : ` (${loc.file}:${loc.line})`;\n const where = `workflow '${workflow.id}'${at}: ${channel} template '${template.id}'`;\n if (registered === undefined) throw new ConfigCompileError(`${where} is not registered`);\n const name = Object.entries(config.templates?.[channel] ?? {}).find(\n ([, entry]) => entry === registered\n )?.[0];\n throw new ConfigCompileError(\n `${where} is not the definition registered under that id (templates.${channel}.${name}) — two definitions share the id`\n );\n }\n}\n\n/** A field that may be left out: optional, or with a default the schema fills in. */\nfunction canBeOmitted(schema: core.$ZodType): boolean {\n const type = schema._zod.def.type;\n return type === 'optional' || type === 'default' || type === 'prefault';\n}\n\nfunction checkMessages(\n workflow: WorkflowIR,\n nodes: readonly NodeIR[],\n index: RegistryIndex,\n config: JourneyConfig\n): void {\n for (const node of nodes) {\n switch (node.type) {\n case 'message':\n checkMessage(workflow, node, index, config);\n break;\n case 'send_event':\n checkEmit(workflow, node, config);\n break;\n case 'branch':\n for (const c of node.cases) checkMessages(workflow, c.flow, index, config);\n if (node.otherwise) checkMessages(workflow, node.otherwise, index, config);\n break;\n case 'cohort':\n for (const arm of node.arms) checkMessages(workflow, arm.flow, index, config);\n break;\n case 'wait_until':\n if (Array.isArray(node.onTimeout)) checkMessages(workflow, node.onTimeout, index, config);\n break;\n default:\n break;\n }\n }\n}\n\nfunction checkMessage(\n workflow: WorkflowIR,\n node: Extract<NodeIR, { type: 'message' }>,\n index: RegistryIndex,\n config: JourneyConfig\n): void {\n const where = `workflow '${workflow.id}' node ${node.id}`;\n const registry = indexOf(index, node.channel);\n // Channels without template definitions (sms, …) are rendered by app-supplied senders\n if (registry === undefined) return;\n const entry = registry.get(node.template);\n if (entry === undefined) {\n const other = TEMPLATE_CHANNELS.find(\n (channel) => channel !== node.channel && index[channel].has(node.template)\n );\n const hint = other === undefined ? '' : ` (it is registered as a ${other} template)`;\n throw new ConfigCompileError(\n `${where}: ${node.channel} template '${node.template}' is not registered${hint}`\n );\n }\n const shape = (entry.props._zod.def as core.$ZodObjectDef).shape;\n const problems: string[] = [];\n for (const [field, value] of Object.entries(node.props)) {\n if (!Object.hasOwn(shape, field)) {\n problems.push(`'${field}' is not a prop of the template`);\n continue;\n }\n if (typeof value === 'object' && 'type' in value) {\n // A user ref's value exists only at send time (parsed then); here its name can be checked\n const profile = config.contract?.schemas.profile._zod.def;\n if (\n profile !== undefined &&\n value.path !== 'userId' &&\n !Object.hasOwn(profile.shape, value.path)\n ) {\n problems.push(\n `'${field}' reads u.${value.path}, which the contract's profile does not declare`\n );\n }\n continue;\n }\n const result = z.safeParse(shape[field], value);\n if (!result.success)\n problems.push(`'${field}' = ${JSON.stringify(value)} is refused by the schema`);\n }\n for (const [field, schema] of Object.entries(shape)) {\n const optional = canBeOmitted(schema);\n if (!optional && !Object.hasOwn(node.props, field))\n problems.push(`required prop '${field}' is missing`);\n }\n if (problems.length > 0) {\n throw new ConfigCompileError(\n `${where}: ${node.channel} '${node.template}': ${problems.join('; ')}`\n );\n }\n}\n\n/**\n * Whether an object schema refuses keys it does not declare: `z.strictObject`\n * sets its catchall to `never`; `z.object` strips unknown keys and\n * `z.looseObject` passes them through, and neither refuses.\n */\nfunction refusesUnknownKeys(schema: core.$ZodType): boolean {\n return (schema._zod.def as core.$ZodObjectDef).catchall?._zod.def.type === 'never';\n}\n\n/**\n * An emitted event against the contract, as far as compile time can see: the\n * event is declared, literal payload values parse against their field, no\n * required field is missing, and — when the event's schema is strict — no\n * literal field is undeclared (the Worker would refuse the whole event at\n * emit time, failing the journey where the compile could have failed the\n * build). Values read from the profile are checked when the event is emitted,\n * by the same contract.\n */\nfunction checkEmit(\n workflow: WorkflowIR,\n node: Extract<NodeIR, { type: 'send_event' }>,\n config: JourneyConfig\n): void {\n const contract = config.contract;\n if (contract === undefined) return;\n const where = `workflow '${workflow.id}' node ${node.id}`;\n if (!contract.isEvent(node.event)) {\n throw new ConfigCompileError(\n `${where}: emits '${String(node.event)}', which is not in the contract`\n );\n }\n const schema = contract.schemas.events[node.event];\n const shape = (schema._zod.def as core.$ZodObjectDef).shape;\n const strict = refusesUnknownKeys(schema);\n const problems: string[] = [];\n for (const [field, value] of Object.entries(node.payload)) {\n if (typeof value === 'object' && 'type' in value) continue; // read at emit time\n if (!Object.hasOwn(shape, field)) {\n // A loose or stripping payload may carry extra fields; a strict one refuses them\n if (strict) problems.push(`'${field}' is not a field the contract declares for the event`);\n continue;\n }\n const result = z.safeParse(shape[field], value);\n if (!result.success)\n problems.push(`'${field}' = ${JSON.stringify(value)} is refused by the contract`);\n }\n for (const [field, fieldSchema] of Object.entries(shape)) {\n const optional = canBeOmitted(fieldSchema);\n if (!optional && !Object.hasOwn(node.payload, field))\n problems.push(`required field '${field}' is missing`);\n }\n if (problems.length > 0) {\n throw new ConfigCompileError(`${where}: event '${node.event}': ${problems.join('; ')}`);\n }\n}\n"],"mappings":"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"}
|
package/dist/dsl/action.cjs
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
-
const require_hash = require("../hash.cjs");
|
|
3
2
|
//#region src/dsl/action.ts
|
|
4
3
|
/**
|
|
5
4
|
* Define a custom action. The generic `A` types the args `.run()` accepts —
|
|
@@ -11,15 +10,13 @@ const require_hash = require("../hash.cjs");
|
|
|
11
10
|
*
|
|
12
11
|
* workflow(...).run(syncCrm, { plan: u.subscription_plan })
|
|
13
12
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* runtime policy on mismatch is warn, not fail.
|
|
13
|
+
* The returned object is the action's identity at compile time: compileBundle
|
|
14
|
+
* checks that the one a workflow runs is the very one listed in `actions`, so
|
|
15
|
+
* two definitions cannot share a name by accident.
|
|
18
16
|
*/
|
|
19
17
|
function action(name, handler) {
|
|
20
18
|
return {
|
|
21
19
|
name,
|
|
22
|
-
codeHash: require_hash.sha256Hex(handler.toString()).slice(0, 32),
|
|
23
20
|
handler
|
|
24
21
|
};
|
|
25
22
|
}
|
package/dist/dsl/action.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"action.cjs","names":[
|
|
1
|
+
{"version":3,"file":"action.cjs","names":[],"sources":["../../src/dsl/action.ts"],"sourcesContent":["/**\n * Custom actions — the escape hatch for side effects the built-in nodes do not\n * cover (CRM sync, coupon issuing, outbound webhooks with custom auth, …).\n *\n * Dual-plane model: the *code* solidifies into the Worker bundle at deploy\n * time (it ships with the app, like any other module), while the *identity*\n * solidifies into IR as `{ type: 'action', action: name, args }`. The runtime\n * resolves the name against a registry (see engine/actions.ts).\n *\n * The IR carries no hash of `handler.toString()`: the printed source depends\n * on the bundler (minification, helper injection, source maps), so every\n * toolchain would produce a different \"version\" of unchanged code and `plan`\n * would report changes nobody made. Code identity is the deployed bundle's,\n * not the IR's.\n *\n * Deliberately NOT supported: custom *conditions*. Branching stays declarative\n * so the canvas can always explain why a user took a path — an action that\n * needs to influence routing writes a property or emits an event, and the\n * decision is expressed as a normal predicate on that data.\n */\n\n/** Runtime context handed to the handler; deliberately small — facts and messaging have their own nodes. */\nexport interface ActionContext {\n userId: string;\n /**\n * `${instanceId}:${nodeId}` — stable across replays and retries of this one\n * action node. A handler with an external side effect (charging, issuing,\n * POSTing) must pass it along so the far side collapses the duplicate: the\n * step body can re-run after the handler already resolved.\n */\n idempotencyKey: string;\n}\n\n/**\n * The handler: plain code, executed inside a durable step (`step.do`), so a\n * throw is retried by the runtime and a success is checkpointed — it should be\n * idempotent under retry, which is what `ctx.idempotencyKey` is for. Args\n * arrive with user_property references already resolved to values.\n */\nexport type ActionHandler<A extends object> = (args: A, ctx: ActionContext) => Promise<void> | void;\n\n/**\n * A registered custom action: the named asset `.run()` refers to. Carries the\n * name the IR records and the implementation for the runtime registry.\n */\nexport interface ActionRef<A extends object = object> {\n readonly name: string;\n /** Method syntax on purpose: bivariant args let refs with different arg shapes share one list (compileBundle, registries). */\n handler(args: A, ctx: ActionContext): Promise<void> | void;\n}\n\n/**\n * Define a custom action. The generic `A` types the args `.run()` accepts —\n * the only type injection point, mirroring `template.sms<P>()`:\n *\n * const syncCrm = action<{ plan: string }>('sync_crm', async ({ plan }, { userId }) => {\n * await fetch('https://crm.example.com/sync', { method: 'POST', body: JSON.stringify({ userId, plan }) });\n * });\n *\n * workflow(...).run(syncCrm, { plan: u.subscription_plan })\n *\n * The returned object is the action's identity at compile time: compileBundle\n * checks that the one a workflow runs is the very one listed in `actions`, so\n * two definitions cannot share a name by accident.\n */\nexport function action<A extends object = Record<string, never>>(\n name: string,\n handler: ActionHandler<A>\n): ActionRef<A> {\n return { name, handler };\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAiEA,SAAgB,OACd,MACA,SACc;CACd,OAAO;EAAE;EAAM;CAAQ;AACzB"}
|
package/dist/dsl/action.d.cts
CHANGED
|
@@ -5,10 +5,14 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Dual-plane model: the *code* solidifies into the Worker bundle at deploy
|
|
7
7
|
* time (it ships with the app, like any other module), while the *identity*
|
|
8
|
-
* solidifies into IR as `{ type: 'action', action: name, args
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* solidifies into IR as `{ type: 'action', action: name, args }`. The runtime
|
|
9
|
+
* resolves the name against a registry (see engine/actions.ts).
|
|
10
|
+
*
|
|
11
|
+
* The IR carries no hash of `handler.toString()`: the printed source depends
|
|
12
|
+
* on the bundler (minification, helper injection, source maps), so every
|
|
13
|
+
* toolchain would produce a different "version" of unchanged code and `plan`
|
|
14
|
+
* would report changes nobody made. Code identity is the deployed bundle's,
|
|
15
|
+
* not the IR's.
|
|
12
16
|
*
|
|
13
17
|
* Deliberately NOT supported: custom *conditions*. Branching stays declarative
|
|
14
18
|
* so the canvas can always explain why a user took a path — an action that
|
|
@@ -35,12 +39,10 @@ export interface ActionContext {
|
|
|
35
39
|
export type ActionHandler<A extends object> = (args: A, ctx: ActionContext) => Promise<void> | void;
|
|
36
40
|
/**
|
|
37
41
|
* A registered custom action: the named asset `.run()` refers to. Carries the
|
|
38
|
-
*
|
|
42
|
+
* name the IR records and the implementation for the runtime registry.
|
|
39
43
|
*/
|
|
40
44
|
export interface ActionRef<A extends object = object> {
|
|
41
45
|
readonly name: string;
|
|
42
|
-
/** Hash of the handler's source text at definition time — the code-identity pin recorded in IR. */
|
|
43
|
-
readonly codeHash: string;
|
|
44
46
|
/** Method syntax on purpose: bivariant args let refs with different arg shapes share one list (compileBundle, registries). */
|
|
45
47
|
handler(args: A, ctx: ActionContext): Promise<void> | void;
|
|
46
48
|
}
|
|
@@ -54,10 +56,9 @@ export interface ActionRef<A extends object = object> {
|
|
|
54
56
|
*
|
|
55
57
|
* workflow(...).run(syncCrm, { plan: u.subscription_plan })
|
|
56
58
|
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* runtime policy on mismatch is warn, not fail.
|
|
59
|
+
* The returned object is the action's identity at compile time: compileBundle
|
|
60
|
+
* checks that the one a workflow runs is the very one listed in `actions`, so
|
|
61
|
+
* two definitions cannot share a name by accident.
|
|
61
62
|
*/
|
|
62
63
|
export declare function action<A extends object = Record<string, never>>(name: string, handler: ActionHandler<A>): ActionRef<A>;
|
|
63
64
|
//#endregion
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"action.d.cts","names":[],"sources":["../../src/dsl/action.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"action.d.cts","names":[],"sources":["../../src/dsl/action.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;iBAsBiB;EACf;;;;;;;EAOA;;;;;;;;YASU,cAAc,qBAAqB,MAAM,GAAG,KAAK,kBAAkB;;;;;iBAM9D,UAAU;WAChB;;EAET,QAAQ,MAAM,GAAG,KAAK,gBAAgB;;;;;;;;;;;;;;;;wBAiBxB,OAAO,mBAAmB,uBACxC,cACA,SAAS,cAAc,KACtB,UAAU"}
|
package/dist/dsl/action.d.mts
CHANGED
|
@@ -5,10 +5,14 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Dual-plane model: the *code* solidifies into the Worker bundle at deploy
|
|
7
7
|
* time (it ships with the app, like any other module), while the *identity*
|
|
8
|
-
* solidifies into IR as `{ type: 'action', action: name, args
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* solidifies into IR as `{ type: 'action', action: name, args }`. The runtime
|
|
9
|
+
* resolves the name against a registry (see engine/actions.ts).
|
|
10
|
+
*
|
|
11
|
+
* The IR carries no hash of `handler.toString()`: the printed source depends
|
|
12
|
+
* on the bundler (minification, helper injection, source maps), so every
|
|
13
|
+
* toolchain would produce a different "version" of unchanged code and `plan`
|
|
14
|
+
* would report changes nobody made. Code identity is the deployed bundle's,
|
|
15
|
+
* not the IR's.
|
|
12
16
|
*
|
|
13
17
|
* Deliberately NOT supported: custom *conditions*. Branching stays declarative
|
|
14
18
|
* so the canvas can always explain why a user took a path — an action that
|
|
@@ -35,12 +39,10 @@ export interface ActionContext {
|
|
|
35
39
|
export type ActionHandler<A extends object> = (args: A, ctx: ActionContext) => Promise<void> | void;
|
|
36
40
|
/**
|
|
37
41
|
* A registered custom action: the named asset `.run()` refers to. Carries the
|
|
38
|
-
*
|
|
42
|
+
* name the IR records and the implementation for the runtime registry.
|
|
39
43
|
*/
|
|
40
44
|
export interface ActionRef<A extends object = object> {
|
|
41
45
|
readonly name: string;
|
|
42
|
-
/** Hash of the handler's source text at definition time — the code-identity pin recorded in IR. */
|
|
43
|
-
readonly codeHash: string;
|
|
44
46
|
/** Method syntax on purpose: bivariant args let refs with different arg shapes share one list (compileBundle, registries). */
|
|
45
47
|
handler(args: A, ctx: ActionContext): Promise<void> | void;
|
|
46
48
|
}
|
|
@@ -54,10 +56,9 @@ export interface ActionRef<A extends object = object> {
|
|
|
54
56
|
*
|
|
55
57
|
* workflow(...).run(syncCrm, { plan: u.subscription_plan })
|
|
56
58
|
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* runtime policy on mismatch is warn, not fail.
|
|
59
|
+
* The returned object is the action's identity at compile time: compileBundle
|
|
60
|
+
* checks that the one a workflow runs is the very one listed in `actions`, so
|
|
61
|
+
* two definitions cannot share a name by accident.
|
|
61
62
|
*/
|
|
62
63
|
export declare function action<A extends object = Record<string, never>>(name: string, handler: ActionHandler<A>): ActionRef<A>;
|
|
63
64
|
//#endregion
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"action.d.mts","names":[],"sources":["../../src/dsl/action.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"action.d.mts","names":[],"sources":["../../src/dsl/action.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;iBAsBiB;EACf;;;;;;;EAOA;;;;;;;;YASU,cAAc,qBAAqB,MAAM,GAAG,KAAK,kBAAkB;;;;;iBAM9D,UAAU;WAChB;;EAET,QAAQ,MAAM,GAAG,KAAK,gBAAgB;;;;;;;;;;;;;;;;wBAiBxB,OAAO,mBAAmB,uBACxC,cACA,SAAS,cAAc,KACtB,UAAU"}
|
package/dist/dsl/action.mjs
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { sha256Hex } from "../hash.mjs";
|
|
2
1
|
//#region src/dsl/action.ts
|
|
3
2
|
/**
|
|
4
3
|
* Define a custom action. The generic `A` types the args `.run()` accepts —
|
|
@@ -10,15 +9,13 @@ import { sha256Hex } from "../hash.mjs";
|
|
|
10
9
|
*
|
|
11
10
|
* workflow(...).run(syncCrm, { plan: u.subscription_plan })
|
|
12
11
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* runtime policy on mismatch is warn, not fail.
|
|
12
|
+
* The returned object is the action's identity at compile time: compileBundle
|
|
13
|
+
* checks that the one a workflow runs is the very one listed in `actions`, so
|
|
14
|
+
* two definitions cannot share a name by accident.
|
|
17
15
|
*/
|
|
18
16
|
function action(name, handler) {
|
|
19
17
|
return {
|
|
20
18
|
name,
|
|
21
|
-
codeHash: sha256Hex(handler.toString()).slice(0, 32),
|
|
22
19
|
handler
|
|
23
20
|
};
|
|
24
21
|
}
|
package/dist/dsl/action.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"action.mjs","names":[],"sources":["../../src/dsl/action.ts"],"sourcesContent":["
|
|
1
|
+
{"version":3,"file":"action.mjs","names":[],"sources":["../../src/dsl/action.ts"],"sourcesContent":["/**\n * Custom actions — the escape hatch for side effects the built-in nodes do not\n * cover (CRM sync, coupon issuing, outbound webhooks with custom auth, …).\n *\n * Dual-plane model: the *code* solidifies into the Worker bundle at deploy\n * time (it ships with the app, like any other module), while the *identity*\n * solidifies into IR as `{ type: 'action', action: name, args }`. The runtime\n * resolves the name against a registry (see engine/actions.ts).\n *\n * The IR carries no hash of `handler.toString()`: the printed source depends\n * on the bundler (minification, helper injection, source maps), so every\n * toolchain would produce a different \"version\" of unchanged code and `plan`\n * would report changes nobody made. Code identity is the deployed bundle's,\n * not the IR's.\n *\n * Deliberately NOT supported: custom *conditions*. Branching stays declarative\n * so the canvas can always explain why a user took a path — an action that\n * needs to influence routing writes a property or emits an event, and the\n * decision is expressed as a normal predicate on that data.\n */\n\n/** Runtime context handed to the handler; deliberately small — facts and messaging have their own nodes. */\nexport interface ActionContext {\n userId: string;\n /**\n * `${instanceId}:${nodeId}` — stable across replays and retries of this one\n * action node. A handler with an external side effect (charging, issuing,\n * POSTing) must pass it along so the far side collapses the duplicate: the\n * step body can re-run after the handler already resolved.\n */\n idempotencyKey: string;\n}\n\n/**\n * The handler: plain code, executed inside a durable step (`step.do`), so a\n * throw is retried by the runtime and a success is checkpointed — it should be\n * idempotent under retry, which is what `ctx.idempotencyKey` is for. Args\n * arrive with user_property references already resolved to values.\n */\nexport type ActionHandler<A extends object> = (args: A, ctx: ActionContext) => Promise<void> | void;\n\n/**\n * A registered custom action: the named asset `.run()` refers to. Carries the\n * name the IR records and the implementation for the runtime registry.\n */\nexport interface ActionRef<A extends object = object> {\n readonly name: string;\n /** Method syntax on purpose: bivariant args let refs with different arg shapes share one list (compileBundle, registries). */\n handler(args: A, ctx: ActionContext): Promise<void> | void;\n}\n\n/**\n * Define a custom action. The generic `A` types the args `.run()` accepts —\n * the only type injection point, mirroring `template.sms<P>()`:\n *\n * const syncCrm = action<{ plan: string }>('sync_crm', async ({ plan }, { userId }) => {\n * await fetch('https://crm.example.com/sync', { method: 'POST', body: JSON.stringify({ userId, plan }) });\n * });\n *\n * workflow(...).run(syncCrm, { plan: u.subscription_plan })\n *\n * The returned object is the action's identity at compile time: compileBundle\n * checks that the one a workflow runs is the very one listed in `actions`, so\n * two definitions cannot share a name by accident.\n */\nexport function action<A extends object = Record<string, never>>(\n name: string,\n handler: ActionHandler<A>\n): ActionRef<A> {\n return { name, handler };\n}\n"],"mappings":";;;;;;;;;;;;;;;AAiEA,SAAgB,OACd,MACA,SACc;CACd,OAAO;EAAE;EAAM;CAAQ;AACzB"}
|