@shware/workflow 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +688 -10
  2. package/dist/cloudflare/bindings.cjs.map +1 -1
  3. package/dist/cloudflare/bindings.d.cts +11 -0
  4. package/dist/cloudflare/bindings.d.cts.map +1 -1
  5. package/dist/cloudflare/bindings.d.mts +11 -0
  6. package/dist/cloudflare/bindings.d.mts.map +1 -1
  7. package/dist/cloudflare/bindings.mjs.map +1 -1
  8. package/dist/cloudflare/index.cjs +1 -0
  9. package/dist/cloudflare/index.d.cts +3 -3
  10. package/dist/cloudflare/index.d.mts +3 -3
  11. package/dist/cloudflare/index.mjs +2 -2
  12. package/dist/cloudflare/router.cjs +21 -4
  13. package/dist/cloudflare/router.cjs.map +1 -1
  14. package/dist/cloudflare/router.d.cts +18 -3
  15. package/dist/cloudflare/router.d.cts.map +1 -1
  16. package/dist/cloudflare/router.d.mts +18 -3
  17. package/dist/cloudflare/router.d.mts.map +1 -1
  18. package/dist/cloudflare/router.mjs +21 -5
  19. package/dist/cloudflare/router.mjs.map +1 -1
  20. package/dist/cloudflare/worker.cjs +37 -1
  21. package/dist/cloudflare/worker.cjs.map +1 -1
  22. package/dist/cloudflare/worker.d.cts.map +1 -1
  23. package/dist/cloudflare/worker.d.mts.map +1 -1
  24. package/dist/cloudflare/worker.mjs +37 -1
  25. package/dist/cloudflare/worker.mjs.map +1 -1
  26. package/dist/dsl/condition.cjs +2 -0
  27. package/dist/dsl/condition.cjs.map +1 -1
  28. package/dist/dsl/condition.d.cts +2 -0
  29. package/dist/dsl/condition.d.cts.map +1 -1
  30. package/dist/dsl/condition.d.mts +2 -0
  31. package/dist/dsl/condition.d.mts.map +1 -1
  32. package/dist/dsl/condition.mjs +2 -0
  33. package/dist/dsl/condition.mjs.map +1 -1
  34. package/dist/dsl/flow.cjs +52 -8
  35. package/dist/dsl/flow.cjs.map +1 -1
  36. package/dist/dsl/flow.d.cts +39 -5
  37. package/dist/dsl/flow.d.cts.map +1 -1
  38. package/dist/dsl/flow.d.mts +39 -5
  39. package/dist/dsl/flow.d.mts.map +1 -1
  40. package/dist/dsl/flow.mjs +52 -8
  41. package/dist/dsl/flow.mjs.map +1 -1
  42. package/dist/dsl/index.d.cts +2 -2
  43. package/dist/dsl/index.d.mts +2 -2
  44. package/dist/index.d.cts +2 -2
  45. package/dist/index.d.mts +2 -2
  46. package/dist/store/index.cjs.map +1 -1
  47. package/dist/store/index.d.cts +21 -0
  48. package/dist/store/index.d.cts.map +1 -1
  49. package/dist/store/index.d.mts +21 -0
  50. package/dist/store/index.d.mts.map +1 -1
  51. package/dist/store/index.mjs.map +1 -1
  52. package/dist/store/postgres.cjs +45 -20
  53. package/dist/store/postgres.cjs.map +1 -1
  54. package/dist/store/postgres.d.cts +4 -1
  55. package/dist/store/postgres.d.cts.map +1 -1
  56. package/dist/store/postgres.d.mts +4 -1
  57. package/dist/store/postgres.d.mts.map +1 -1
  58. package/dist/store/postgres.mjs +45 -20
  59. package/dist/store/postgres.mjs.map +1 -1
  60. package/dist/store/tables.cjs +11 -0
  61. package/dist/store/tables.cjs.map +1 -1
  62. package/dist/store/tables.d.cts +1 -0
  63. package/dist/store/tables.d.cts.map +1 -1
  64. package/dist/store/tables.d.mts +1 -0
  65. package/dist/store/tables.d.mts.map +1 -1
  66. package/dist/store/tables.mjs +11 -0
  67. package/dist/store/tables.mjs.map +1 -1
  68. package/package.json +1 -1
package/README.md CHANGED
@@ -2,18 +2,231 @@
2
2
 
3
3
  Code-first customer journeys. Workflows are written as TypeScript, compiled to a
4
4
  versioned IR, and executed by a single generic runner: the IR is data, so one
5
- deployed Worker runs every journey.
5
+ deployed Worker runs every journey. Events and profile fields are typed by a
6
+ shared Zod contract, messages are typed templates (React email, push, Slack,
7
+ Discord), and the host is a Cloudflare Worker on Cloudflare Workflows, with its
8
+ data in D1 or in your own Postgres.
6
9
 
7
10
  ```ts
8
11
  const welcome = workflow({ id: 'welcome', trigger: trigger.event(e.sign_up) })
9
- .email(gettingStarted)
12
+ .email(t.email.getting_started, { name: u.name })
10
13
  .delay('3 days')
11
14
  .waitUntil(performed(e.activated), {
12
15
  timeout: '7 days',
13
- onTimeout: (w) => w.email(nudge),
16
+ onTimeout: (w) => w.email(t.email.nudge, { name: u.name }),
14
17
  });
15
18
  ```
16
19
 
20
+ ## Install
21
+
22
+ ```bash
23
+ pnpm add @shware/workflow zod
24
+ ```
25
+
26
+ The peer dependencies are optional; install the ones the subpaths you use need:
27
+
28
+ | Peer | Needed by |
29
+ | -------------------- | -------------------------------------------------------------------------------- |
30
+ | `react`, `react-dom` | `@shware/workflow/react-email`, and `@shware/workflow/cloudflare`, which uses it |
31
+ | `postgres` | `@shware/workflow/postgres-js` |
32
+
33
+ Email components are usually written with `@react-email/components`, and the
34
+ Worker is deployed with `wrangler`.
35
+
36
+ ## Quick start
37
+
38
+ A project has one contract, a set of templates, the workflows, and one
39
+ `workflow.config.ts` that registers all of it. The compiler, the Worker and the
40
+ Workflow Studio (`@shware/workflow-ui`) all read that one object.
41
+
42
+ ### 1. The contract
43
+
44
+ The events journeys react to and the profile fields they read, as Zod schemas.
45
+ Put it in a module both the producer (your api) and the Worker can import.
46
+
47
+ ```ts
48
+ import { defineContract } from '@shware/workflow';
49
+ import { email, looseObject, number, optional, strictObject, string } from 'zod/mini';
50
+
51
+ export const contract = defineContract({
52
+ events: {
53
+ login: looseObject({ method: string() }),
54
+ purchase: looseObject({ transaction_id: string(), currency: string(), value: number() }),
55
+ },
56
+ profile: strictObject({ email: optional(email()), name: string() }),
57
+ });
58
+
59
+ /** The DSL's reference tables: `e.login`, `u.name`. */
60
+ export const e = contract.events;
61
+ export const u = contract.user;
62
+ ```
63
+
64
+ Event payloads are object schemas (`looseObject` keeps extra analytics fields).
65
+ The profile is a flat object of string, number, boolean, enum and literal
66
+ fields. Defaults, transforms, coercion and custom refinements are refused when
67
+ the contract is defined, because input and output must mean the same JSON.
68
+
69
+ ### 2. Templates
70
+
71
+ One object per template. Its strict Zod `props` schema is the only source of
72
+ the props type: the DSL's arguments, the `preview` and the component are all
73
+ derived from it, and every send parses the props against it first.
74
+
75
+ ```tsx
76
+ // src/email/welcome.tsx
77
+ import { Html, Text } from '@react-email/components';
78
+ import { emailTemplate } from '@shware/workflow';
79
+ import { strictObject, string } from 'zod/mini';
80
+
81
+ export default emailTemplate({
82
+ title: 'Welcome',
83
+ props: strictObject({ name: string() }),
84
+ subject: 'Welcome aboard',
85
+ preview: { name: 'John Doe' },
86
+ component: ({ name }) => (
87
+ <Html>
88
+ <Text>Welcome, {name}! Create your first project; it takes a minute.</Text>
89
+ </Html>
90
+ ),
91
+ });
92
+ ```
93
+
94
+ Register templates once, per channel, and derive the references the workflows
95
+ use from the same registry:
96
+
97
+ ```ts
98
+ // src/templates.ts
99
+ import { templates } from '@shware/workflow';
100
+ import welcome from './email/welcome';
101
+ import tip from './email/tip';
102
+
103
+ export const registry = { email: { welcome, tip } };
104
+ export const t = templates(registry);
105
+ ```
106
+
107
+ `tip.tsx` is written the same way. The registry types the keys: `t.email.typo` does not compile, a push template
108
+ is not accepted where an email is, and `.email(t.email.welcome, props)` checks
109
+ `props` against the template's schema.
110
+
111
+ ### 3. A workflow
112
+
113
+ ```ts
114
+ // src/workflows/welcome.ts
115
+ import { not, performed, trigger, workflow } from '@shware/workflow';
116
+ import { t } from '../templates';
117
+ import { e, u } from '../contract';
118
+
119
+ export const welcomeSeries = workflow({
120
+ id: 'welcome_series',
121
+ name: 'Welcome series',
122
+ trigger: trigger.event(e.login),
123
+ goal: { condition: performed(e.purchase), within: '7 days' },
124
+ })
125
+ .email(t.email.welcome, { name: u.name })
126
+ .delay('2 days')
127
+ .branch([not(performed(e.purchase)), (w) => w.email(t.email.tip, { name: u.name })]);
128
+ ```
129
+
130
+ `id` is wire identity (instances, the entry ledger and stats are keyed on it),
131
+ so rename `name` instead.
132
+
133
+ ### 4. The config
134
+
135
+ ```ts
136
+ // workflow.config.ts
137
+ import { defineConfig } from '@shware/workflow';
138
+ import { contract } from './src/contract';
139
+ import { registry } from './src/templates';
140
+ import { welcomeSeries } from './src/workflows/welcome';
141
+
142
+ export default defineConfig({
143
+ title: 'Workflow Studio · Acme',
144
+ contract,
145
+ workflows: [welcomeSeries],
146
+ templates: registry,
147
+ emails: { from: { email: 'noreply@acme.test', name: 'Acme' } },
148
+ runtime: {
149
+ development: { timeScale: 600, logMessages: true },
150
+ production: { timeScale: 1, logMessages: false },
151
+ },
152
+ });
153
+ ```
154
+
155
+ ### 5. The Worker
156
+
157
+ ```ts
158
+ // src/index.ts
159
+ import { type JourneyEnv, journeyWorker } from '@shware/workflow/cloudflare';
160
+ import { postgresJsStore } from '@shware/workflow/postgres-js';
161
+ import config from '../workflow.config';
162
+
163
+ interface Env extends JourneyEnv {
164
+ HYPERDRIVE: { connectionString: string };
165
+ }
166
+
167
+ const journeys = journeyWorker<Env>({
168
+ config,
169
+ store: (env, database) => postgresJsStore(env.HYPERDRIVE.connectionString, database),
170
+ });
171
+
172
+ /** The wrangler `workflows` binding's class_name. */
173
+ export const AcmeJourneyRunner = journeys.Runner;
174
+
175
+ export default { fetch: journeys.fetch, scheduled: journeys.scheduled };
176
+ ```
177
+
178
+ Leave `store` out to use the default D1 + KV store from the env's bindings (see
179
+ [Cloudflare setup](#cloudflare-setup)). On Postgres, create the engine's tables
180
+ first (see [Postgres](#postgres)).
181
+
182
+ ```jsonc
183
+ // wrangler.jsonc
184
+ {
185
+ "name": "acme-workflow",
186
+ "main": "src/index.ts",
187
+ "compatibility_date": "2026-09-12",
188
+ "compatibility_flags": ["nodejs_compat"],
189
+ "workflows": [
190
+ { "name": "acme-journey", "binding": "JOURNEY", "class_name": "AcmeJourneyRunner" },
191
+ ],
192
+ "hyperdrive": [{ "binding": "HYPERDRIVE", "id": "<hyperdrive id>" }],
193
+ "send_email": [{ "name": "EMAIL" }],
194
+ "vars": { "ENVIRONMENT": "production", "WORKFLOW_ORIGIN": "https://workflow.acme.test" },
195
+ "triggers": { "crons": ["*/15 * * * *"] },
196
+ }
197
+ ```
198
+
199
+ ### 6. Deploy
200
+
201
+ ```bash
202
+ wrangler secret put API_TOKEN
203
+ wrangler deploy
204
+ curl -X POST https://workflow.acme.test/bundle/deploy -H "authorization: Bearer $API_TOKEN"
205
+ ```
206
+
207
+ `POST /bundle/deploy` compiles the config the Worker was built with and swaps
208
+ the routing table. Run it after every deploy that changes a journey.
209
+
210
+ ### 7. Send events
211
+
212
+ From the service that knows what users do:
213
+
214
+ ```ts
215
+ import { JourneyClient } from '@shware/workflow/client';
216
+ import { contract } from '@acme/workflow-contract';
217
+
218
+ export const journeys = new JourneyClient({
219
+ contract,
220
+ origin: 'https://workflow.acme.test',
221
+ token: process.env.WORKFLOW_API_TOKEN,
222
+ });
223
+
224
+ await journeys.identify(user.id, { email: user.email, name: user.name });
225
+ await journeys.track(user.id, 'login', { method: 'google' }, { id: `login-${session.id}` });
226
+ ```
227
+
228
+ Identify the properties a journey reads before the event that starts it.
229
+
17
230
  ## The three planes
18
231
 
19
232
  | Plane | What it is | Where it lives |
@@ -26,11 +239,410 @@ The interpreter never touches a database or a network. Everything it needs
26
239
  arrives through the ports in `engine/ports.ts`, which is what makes a journey
27
240
  testable without deploying one.
28
241
 
242
+ ## Authoring
243
+
244
+ Chained calls and condition expressions build a serializable graph, never
245
+ runtime code. Sub-flow callbacks run exactly once, while the workflow is
246
+ defined, and runtime values are always references (`u.xxx`), not closures.
247
+
248
+ ### Reference tables
249
+
250
+ `contract.events` and `contract.user` are the typed reference tables: `e.purchase`
251
+ is an event reference, `u.name` a profile reference (`u.userId` is always
252
+ there). Without a contract, build them from TypeScript types:
253
+
254
+ ```ts
255
+ import { event, user } from '@shware/workflow';
256
+
257
+ const e = event<{ demo_signup: Record<never, never>; demo_purchase: { value: number } }>();
258
+ const u = user<{ userId: string; email: string; plan: string }>();
259
+ ```
260
+
261
+ ### `workflow(definition)`
262
+
263
+ | Option | Meaning |
264
+ | ------------- | --------------------------------------------------------------------------------- |
265
+ | `id` | Wire identity; never rename it for readability |
266
+ | `trigger` | A `trigger.xxx()` asset |
267
+ | `goal` | A condition, or `{ condition, within?, exitOnMatch? }`; exits on match by default |
268
+ | `exitWhen` | Leave without counting as a conversion (unsubscribed, no longer eligible) |
269
+ | `name` | Studio label; metadata, outside the hash |
270
+ | `description` | One line on what the flow does; metadata, outside the hash |
271
+
272
+ The goal is checked before each node runs. A `performed()` inside a goal counts
273
+ from workflow entry, so earlier history cannot satisfy it.
274
+
275
+ ### Triggers
276
+
277
+ ```ts
278
+ trigger.event(e.login);
279
+ trigger.event(e.purchase_failed, (p) => notInArray(p.code, ['CHECKOUT_CANCELED']));
280
+ trigger.event(e.sign_up, { filter: exists(u.email) });
281
+ trigger.segment(dormant7d);
282
+ trigger.date('2026-12-25 09:00:00');
283
+ trigger.webhook();
284
+ ```
285
+
286
+ - `event` takes an optional payload `where` (typed by the event) and a profile
287
+ `filter`; the user enters only when the event fired and both hold.
288
+ - `segment` starts the journey when a user goes from not matching a named
289
+ segment to matching it. Membership is re-evaluated on the user's events and
290
+ identifies, and by the segment sweep (the cron trigger), which catches
291
+ segments entered by the clock alone.
292
+ - `date` (one-shot, UTC) and `webhook` compile and deploy, but are not routed
293
+ by the runtime yet: deploy lists them in `unrouted`.
294
+
295
+ ### Steps
296
+
297
+ | Step | What it does |
298
+ | --------------------------------------------- | ------------------------------------------------------------------------- |
299
+ | `.email(t.email.k, props)` | Send an email |
300
+ | `.push(t.push.k, props, { devices? })` | Push to every device of the user, or only the `'latest'`; skipped if none |
301
+ | `.slack(t.slack.k, props)` | Post to a Slack incoming webhook |
302
+ | `.discord(t.discord.k, props)` | Post to a Discord webhook |
303
+ | `.sms()`, `.inApp()`, `.survey()` | Message nodes with no built-in sender (see [Channels](#channels)) |
304
+ | `.prefer([first, second], props, { label? })` | One push and one email: the first the user is reachable on |
305
+ | `.delay('1 day')` | Sleep; `{ min, max }` sleeps a random time in between |
306
+ | `.timeWindow({ days?, between, tz? })` | Hold until inside a weekly window |
307
+ | `.waitUntil(condition, opts)` | Wait for a condition, or time out |
308
+ | `.branch(label?, ...arms)` | First-match branch; rejoins the main line afterwards |
309
+ | `.filter(condition, { reason? })` | Exit unless the condition holds |
310
+ | `.cohort(arms, { key? })` | Random A/B split; weights sum to 100 |
311
+ | `.exit(reason?)` | End the journey; inside an arm, do not rejoin |
312
+ | `.sendEvent(e.x, payload?)` | Emit an event, which may trigger another workflow |
313
+ | `.run(action, args)` | Run a custom action inside a durable step |
314
+ | `.use(fragment)` | Splice a `flow(...)` fragment in place |
315
+
316
+ Props may reference the profile (`{ name: u.name }`); references resolve at send
317
+ time and are parsed by the template then. Durations are typed literals such as
318
+ `'30 minutes'`, `'1 day'` or `'2 weeks'`, so a typo does not compile.
319
+
320
+ **prefer** is sugar for the branch it compiles to: the choice is made once when
321
+ the step is reached, and a first choice that fails to send retries as itself.
322
+ Both templates must declare the same props type.
323
+
324
+ ```ts
325
+ workflow({ id: 'welcome_series', trigger: trigger.event(e.login) })
326
+ .email(t.email.welcome, { name: u.name })
327
+ .delay('1 day')
328
+ .prefer([t.push.tip_push, t.email.tip], { name: u.name }, { label: 'tip by channel' });
329
+ ```
330
+
331
+ **timeWindow** takes `between: ['09:00', '17:00']` (24-hour, leading zeros, not
332
+ overnight), `days` (all week by default) and `tz`: `'user'` (the default) reads
333
+ the profile's `timezone` property, an IANA id, falling back to UTC when it is
334
+ invalid; any other string is a fixed IANA zone.
335
+
336
+ **waitUntil** continues on the main line once the condition holds. On timeout it
337
+ does `onTimeout`: `'continue'` (the default), `'exit'`, or a sub-flow. A
338
+ `performed()` inside it counts from the moment the wait began.
339
+
340
+ ```ts
341
+ workflow({ id: 'notification_optin', trigger: trigger.event(e.login) }).waitUntil(
342
+ or(
343
+ reachable('push'),
344
+ performed(e.notification_permission, (p) => eq(p.status, 'granted'))
345
+ ),
346
+ {
347
+ timeout: '1 day',
348
+ onTimeout: (w) => w.email(t.email.enable_notifications, { name: u.name }),
349
+ }
350
+ );
351
+ ```
352
+
353
+ **branch** arms are `[condition, sub-flow]` tuples; a bare sub-flow in last
354
+ position is the default arm. With no match and no default, execution
355
+ continues.
356
+
357
+ ```ts
358
+ workflow({ id: 'nps_followup', trigger: trigger.event(e.nps_sent) }).branch(
359
+ 'by score',
360
+ [
361
+ performed(e.nps_sent, (p) => gte(p.score, 9)),
362
+ (w) => w.delay('1 day').email(t.email.review_ask, { name: u.name }),
363
+ ],
364
+ [
365
+ performed(e.nps_sent, (p) => lte(p.score, 6)),
366
+ (w) => w.slack(t.slack.nps_detractor, { name: u.name }).exit('handed to support'),
367
+ ]
368
+ );
369
+ ```
370
+
371
+ **cohort** arm names are letters, digits, `_` and `-`, not starting with a
372
+ digit, and weights have at most two decimals. Set `key` for any experiment you
373
+ intend to read: bucketing then hashes `userId:key`, so assignments survive
374
+ edits to the workflow.
375
+
376
+ ```ts
377
+ workflow({ id: 'checkout_recovery', trigger: trigger.event(e.begin_checkout) })
378
+ .delay('1 day')
379
+ .cohort(
380
+ {
381
+ discount: { weight: 50, flow: (w) => w.email(t.email.offer_discount, { name: u.name }) },
382
+ social_proof: { weight: 50, flow: (w) => w.email(t.email.offer_reviews, { name: u.name }) },
383
+ },
384
+ { key: 'checkout_offer_v1' }
385
+ );
386
+ ```
387
+
388
+ **sendEvent** carries scalar fields only (literals or `u.xxx`), and the event is
389
+ checked against the contract like any ingested one. Because the entry policy is
390
+ once per user, a self-triggering `sendEvent` does not loop yet.
391
+
392
+ **run** executes a custom action. The IR records only its name, args and a hash
393
+ of its code; the implementation ships in the Worker and is registered through
394
+ the config's `actions`. It is retried on throw, so pass `ctx.idempotencyKey` to
395
+ any external side effect.
396
+
397
+ ```ts
398
+ import { action } from '@shware/workflow';
399
+
400
+ export const issueCoupon = action<{ code: string }>('issue_coupon', async ({ code }, ctx) => {
401
+ await billing.issueCoupon({ code, userId: ctx.userId, idempotencyKey: ctx.idempotencyKey });
402
+ });
403
+
404
+ workflow({ id: 'winback', trigger: trigger.segment(dormant7d) }).run(issueCoupon, {
405
+ code: '15OFF',
406
+ });
407
+ ```
408
+
409
+ **flow** names a reusable fragment, for `.use()` or as a branch arm. Each use
410
+ gets its own copy:
411
+
412
+ ```ts
413
+ const officeHours = flow((w) =>
414
+ w.timeWindow({ days: ['mon', 'tue', 'wed', 'thu', 'fri'], between: ['09:00', '17:00'] })
415
+ );
416
+ ```
417
+
418
+ ### Conditions
419
+
420
+ | Function | Meaning |
421
+ | ------------------------------------------------- | ----------------------------------------------------------------- |
422
+ | `eq`, `ne` | Equal / not equal |
423
+ | `gt`, `gte`, `lt`, `lte` | Ordered comparison (string or number) |
424
+ | `between`, `notBetween` | Inclusive range |
425
+ | `inArray`, `notInArray` | Membership in a list |
426
+ | `exists`, `notExists` | Property present / absent |
427
+ | `contains`, `notContains` | Substring (string) |
428
+ | `performed(e.x, where?, { within?, count? })` | Did the event, optionally matching, in a window, at least N times |
429
+ | `reachable(channel)` | Push: has a registered device; other channels: has the address |
430
+ | `and`, `or`, `not` | Combinators; nest freely |
431
+ | `segment({ id, condition, name?, description? })` | A named condition: reusable, swept, usable as a trigger |
432
+
433
+ Predicates take a profile reference (`eq(u.platform, 'ios')`), or, inside a
434
+ `where`, a payload reference (`(p) => gt(p.value, 100)`). Values are typed by
435
+ the reference, so `eq(u.platform, 'ios ')` against an enum does not compile.
436
+ "Did not do" is `not(performed(...))`.
437
+
438
+ ```ts
439
+ export const dormant7d = segment({
440
+ id: 'dormant_7d',
441
+ name: 'Dormant (7d)',
442
+ condition: and(performed(e.login), not(performed(e.login, { within: '7 days' }))),
443
+ });
444
+ ```
445
+
446
+ Segments a workflow references must be passed in the config's `segments`, or
447
+ the compile fails.
448
+
449
+ ## Channels
450
+
451
+ ### Email
452
+
453
+ `emailTemplate({ title, description?, props, subject, preview, component })`
454
+ declares one. The Worker renders it with `@shware/workflow/react-email` (React's
455
+ streaming renderer, which waits for react-email's `<Tailwind>`) and sends it
456
+ through the `EMAIL` send_email binding with the config's `emails` envelope:
457
+
458
+ ```ts
459
+ emails: {
460
+ from: { email: 'noreply@acme.test', name: 'Acme' },
461
+ replyTo: { email: 'support@acme.test', name: 'Acme Support' },
462
+ unsubscribe: 'https://acme.test/unsubscribe',
463
+ tracking: { quickClickMs: 1_000 },
464
+ },
465
+ ```
466
+
467
+ An email step refuses to send when the profile has no `email`.
468
+ `GET /preview/<key>?props=<JSON>` renders a template, with the subject in the
469
+ `x-subject` header.
470
+
471
+ ### Push
472
+
473
+ `pushTemplate({ title, description?, props, preview, content })`, where
474
+ `content(props)` returns `{ title, body, image?, url? }`: final text, never
475
+ placeholder-expanded. `url` is the page a browser push opens.
476
+
477
+ ```ts
478
+ export default pushTemplate({
479
+ title: 'Onboarding tip (push)',
480
+ props: strictObject({ name: string() }),
481
+ preview: { name: 'John Doe' },
482
+ content: ({ name }) => ({ title: 'A tip for today', body: `Face a window, ${name}.` }),
483
+ });
484
+ ```
485
+
486
+ Pushes go to the devices the engine keeps. The app registers each token with
487
+ `JourneyClient.registerDevice(userId, { token, platform })`, and a token moves
488
+ to the latest user that registers it. Each platform is turned on by the config,
489
+ with its secret in the environment:
490
+
491
+ | Platform | Config | Secret | Transport |
492
+ | --------- | ---------------------------------------------- | --------------------------------------- | -------------------------- |
493
+ | `ios` | `apns: { topic, keyId, teamId, environment? }` | `APNS_PRIVATE_KEY_BASE64` (the `.p8`) | `@shware/workflow/apns` |
494
+ | `android` | `fcm: { androidChannelId? }` | `GOOGLE_APPLICATION_CREDENTIALS_BASE64` | `@shware/workflow/fcm` |
495
+ | `web` | `webPush: { subject, openedEvent? }` | `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY` | `@shware/workflow/webpush` |
496
+
497
+ A secret is only read when something is sent on that platform. Tokens APNs,
498
+ FCM or a push service report as gone are removed, and waits on
499
+ `reachable('push')` wake on every device change.
500
+
501
+ `apns.environment` maps a mode to `sandbox` or `production`; unlisted modes use
502
+ `production` in production and `sandbox` otherwise. Generate a VAPID pair once
503
+ per environment with `generateVapidKeys()` from `@shware/workflow/webpush`.
504
+ `webPush.openedEvent` names the contract event a browser's notification tap is
505
+ also recorded as (`{ message_id }`), so conditions see web opens too.
506
+
507
+ APNs speaks HTTP/2 only, which `wrangler dev` cannot. Locally, run the relay
508
+ next to it and point the Worker at it with `APNS_RELAY_ORIGIN`:
509
+
510
+ ```js
511
+ import { startApnsRelay } from '@shware/workflow/apns-relay';
512
+
513
+ const relay = await startApnsRelay({ port: 8280 });
514
+ // wrangler dev --var APNS_RELAY_ORIGIN:http://localhost:8280
515
+ ```
516
+
517
+ ### Slack and Discord
518
+
519
+ Team-channel messages go through incoming webhooks. The URL is a secret, so the
520
+ template reads it from the Worker's environment when it sends. Pass the
521
+ environment's Zod schema to `journeyWorker({ env })`: it is checked on the
522
+ first request, every problem listed, and the parsed values are what templates
523
+ see.
524
+
525
+ ```ts
526
+ import { slackTemplate } from '@shware/workflow';
527
+ import { optional, strictObject, string } from 'zod/mini';
528
+ import type { Env } from '../env';
529
+
530
+ export default slackTemplate({
531
+ title: 'NPS detractor (support)',
532
+ props: strictObject({ name: string(), email: optional(string()) }),
533
+ preview: { name: 'John Doe', email: 'john@example.com' },
534
+ webhook: (env: Env) => env.WEBHOOK_SLACK_SUPPORT,
535
+ content: ({ name }) => ({ text: `${name} gave us a low NPS score` }),
536
+ });
537
+ ```
538
+
539
+ `discordTemplate` takes the same fields; its `content` returns a Discord webhook
540
+ body (`content`, `embeds`, `allowed_mentions`, …). Register both under
541
+ `templates: { email, push, slack, discord }`. A request the channel refuses for
542
+ good settles the copy as undelivered rather than retrying, so a team-channel
543
+ post never holds up the messages after it.
544
+
545
+ ### Other channels
546
+
547
+ `.sms()`, `.inApp()` and `.survey()` compile, and `template.sms<P>(key)` (also
548
+ `email`, `push`, `inApp`, `slack`, `discord`, `survey`) makes an unregistered,
549
+ hand-typed reference. The config's channels deliver only email, push, Slack and
550
+ Discord; supply anything else with `journeyWorker({ messages })`.
551
+
552
+ ## Configuration
553
+
554
+ `defineConfig` takes:
555
+
556
+ | Field | Holds |
557
+ | ----------- | ------------------------------------------------------------------------------ |
558
+ | `title` | The Studio title |
559
+ | `contract` | The `defineContract(...)` every `/events` and `/identify` is validated against |
560
+ | `workflows` | The workflows to deploy |
561
+ | `segments` | Named segments |
562
+ | `actions` | Custom actions `.run()` uses |
563
+ | `templates` | The registries: `{ email?, push?, discord?, slack? }` |
564
+ | `emails` | `{ from, replyTo?, unsubscribe?, tracking? }` |
565
+ | `runtime` | Per mode (`development`, `production`, …): `{ timeScale?, logMessages? }` |
566
+ | `apns` | `{ topic, keyId, teamId, environment? }` |
567
+ | `fcm` | `{ androidChannelId? }` |
568
+ | `webPush` | `{ subject, openedEvent? }` |
569
+ | `database` | `{ schema? }`: the Postgres schema, `workflow` by default |
570
+
571
+ The `ENVIRONMENT` var picks the `runtime` entry (production when unset).
572
+ `timeScale` fast-forwards every duration: 600 turns "1 day" into 2.4 minutes.
573
+ Secrets never go in the config; the Worker reads them from its environment:
574
+
575
+ | Variable | Purpose |
576
+ | --------------------------------------- | ---------------------------------------------------------- |
577
+ | `API_TOKEN` | Bearer for every endpoint but `/health` and `/t/...` |
578
+ | `WORKFLOW_ORIGIN` | Public origin for email tracking and push delivery reports |
579
+ | `ENVIRONMENT` | Which `runtime` entry and APNs environment apply |
580
+ | `APNS_PRIVATE_KEY_BASE64` | The APNs `.p8` key, base64 |
581
+ | `GOOGLE_APPLICATION_CREDENTIALS_BASE64` | The Firebase service account JSON, base64 |
582
+ | `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY` | The Web Push key pair |
583
+ | `APNS_RELAY_ORIGIN` | Development only: the local APNs relay |
584
+
585
+ `journeyWorker` also takes `observer`, `messages` and `runtime` overrides, and
586
+ `contractMode: 'report'` to log contract refusals instead of answering 400.
587
+
588
+ ## `JourneyClient`
589
+
590
+ `@shware/workflow/client` is the producer side: plain fetch, no dependencies.
591
+ With a `contract`, event names and payloads are typed and checked before
592
+ anything is sent (a refusal throws `JourneyValidationError`); a failed request
593
+ throws `JourneyClientError` with its status and body.
594
+
595
+ ```ts
596
+ const journeys = new JourneyClient({ contract, origin, token });
597
+ ```
598
+
599
+ | Method | Endpoint | Does |
600
+ | --------------------------------------------- | ----------------------- | -------------------------------------------------------- |
601
+ | `identify(userId, props)` | `POST /identify` | Merge profile properties; `null` removes one |
602
+ | `track(userId, event, payload, { id?, ts? })` | `POST /events` | Record an event; starts and wakes journeys |
603
+ | `registerDevice(userId, { token, platform })` | `POST /devices` | Register a push token (`ios`, `android` or `web`) |
604
+ | `unbindDevice(token)` | `POST /devices/unbind` | Signed out: the token stays, bound to nobody |
605
+ | `removeDevice(token)` | `POST /devices/remove` | Forget a token for good |
606
+ | `messageOpened(userId, messageId)` | `POST /messages/opened` | A journey push tapped open (the `message_id` it carried) |
607
+ | `forgetUser(userId)` | `POST /users/forget` | Stop the user's journeys and erase their data |
608
+ | `deploy(bundle)` | `POST /deploy` | Deploy a compiled bundle |
609
+
610
+ Pass an `id` to `track` whenever a caller may retry; see
611
+ [Give each event an `id`](#give-each-event-an-id).
612
+
613
+ ## Compiling and planning
614
+
615
+ `compileConfig(config)` returns the `BundleIR` the Worker deploys. It is
616
+ `compileBundle` plus a template manifest generated from the registries, and it
617
+ checks every message node: an unregistered or cross-channel template, a missing
618
+ or undeclared prop, a literal the schema refuses, or a `u.xxx` the contract's
619
+ profile does not declare fails with the workflow and node named
620
+ (`ConfigCompileError`).
621
+
622
+ `compileBundle({ workflows, segments?, templates?, actions? })` is the lower
623
+ level: it compiles each workflow, rejects duplicate ids, segment references
624
+ missing from the bundle, payload predicates outside a `where`, and actions that
625
+ are not passed in.
626
+
627
+ ```ts
628
+ import { compileConfig, plan } from '@shware/workflow';
629
+ import config from './workflow.config';
630
+
631
+ const bundle = compileConfig(config);
632
+ const changes = plan(bundle, deployedBundle); // deployed: `GET /bundle` of the running Worker
633
+ if (changes.hasChanges) console.log(changes.workflows.filter((w) => w.status !== 'unchanged'));
634
+ ```
635
+
636
+ `plan(local, deployed?)` diffs workflows, segments, templates and actions.
637
+ Each change is `added`, `removed`, `changed`, `metadata_only` or `unchanged`,
638
+ and a workflow change lists the fields (`trigger`, `goal`, `exitWhen`) and
639
+ nodes that moved. Omitting `deployed` plans a first deploy.
640
+
29
641
  ## Versioning
30
642
 
31
643
  Every workflow carries a `contentHash` over its own IR, with metadata excluded,
32
644
  so a reworded description is not a new version but a changed delay is. The hash
33
- is the KV address the IR is stored under, and a journey is pinned to the hash it
645
+ is the address the IR is stored under, and a journey is pinned to the hash it
34
646
  entered on. An in-flight journey therefore finishes on the version it started,
35
647
  however many times you deploy underneath it.
36
648
 
@@ -38,9 +650,13 @@ however many times you deploy underneath it.
38
650
  workflows actually changed. Deploy recomputes every declared hash and refuses a
39
651
  bundle whose hashes do not describe its contents.
40
652
 
653
+ Node ids are structural paths, so inserting a node shifts the ids of the nodes
654
+ after it. Template keys are stable identities too: a key in flight takes only
655
+ backward-compatible changes, so a new required prop means a new key.
656
+
41
657
  ## Cloudflare setup
42
658
 
43
- The adapter needs three bindings, named as in `JourneyEnv`:
659
+ The default store needs three bindings, named as in `JourneyEnv`:
44
660
 
45
661
  | Binding | Type | Holds |
46
662
  | ------------- | ------------ | ----------------------------------------- |
@@ -48,6 +664,10 @@ The adapter needs three bindings, named as in `JourneyEnv`:
48
664
  | `DB` | D1 database | Events, profiles, routing, entries, wakes |
49
665
  | `JOURNEY` | Workflow | The `JourneyRunner` entrypoint |
50
666
 
667
+ A Worker on Postgres needs only `JOURNEY`. Add the `EMAIL` send_email binding
668
+ when the config has `emails`, and a `triggers.crons` entry with `scheduled`
669
+ exported to run the segment sweep.
670
+
51
671
  Set `API_TOKEN` in production. Without it every mutating endpoint is open, and
52
672
  `/deploy` rewrites the whole routing table.
53
673
 
@@ -89,11 +709,35 @@ Run `--check` in CI to catch an SDK upgrade that changed them. Without drizzle,
89
709
  alters them at runtime: `POST /bundle/deploy` checks the live database first
90
710
  and answers 503 with every missing table or column.
91
711
 
712
+ ### Deploying is `wrangler deploy`
713
+
714
+ The journeys are compiled into the Worker, so a new version carries its own
715
+ bundle. Add Cloudflare's version metadata binding and each new version makes
716
+ that bundle live by itself, with nothing to call after `wrangler deploy`:
717
+
718
+ ```jsonc
719
+ // wrangler.jsonc
720
+ "version_metadata": { "binding": "CF_VERSION_METADATA" }
721
+ ```
722
+
723
+ The first request or cron run an isolate serves reads which version's bundle
724
+ is live (one row, `workflow.deployment`) and applies its own only over an older
725
+ version, in the same transaction that records it. Isolates of the old version
726
+ keep serving for a few seconds after a deploy; they can never put their routes
727
+ back. Until the new version first runs, events still follow the old routes.
728
+ The check runs once per isolate, in the background; a failure (migrations not
729
+ applied, say) is logged and retried a minute later.
730
+
731
+ With a gradual deployment the newest version's bundle goes live as soon as
732
+ that version serves its first request, whatever share of traffic it has.
733
+
734
+ `wrangler rollback` restores an older version, which does not apply itself over
735
+ the newer one. Call `POST /bundle/deploy` after a rollback: it applies the
736
+ running version's bundle whatever is live. The D1 store keeps no version, so
737
+ on D1 every deploy is that call.
738
+
92
739
  On Postgres the engine also keeps:
93
740
 
94
- - **Push devices.** The app registers each token with
95
- `JourneyClient.registerDevice(userId, { token, platform })` and forgets one
96
- with `removeDevice(token)`. Tokens APNs / FCM report as gone are dropped.
97
741
  - **Reporting.** Instances, every message copy, opens and clicks, recorded
98
742
  by the default observer. With `WORKFLOW_ORIGIN` set, email links and an open
99
743
  pixel are tracked through the Worker's `/t/...` routes. Each open and click
@@ -108,7 +752,7 @@ On Postgres the engine also keeps:
108
752
 
109
753
  ## HTTP surface
110
754
 
111
- `handleRequest` serves four endpoints.
755
+ `handleRequest` serves the ingest API:
112
756
 
113
757
  - `GET /health` is the only unauthenticated one.
114
758
  - `POST /events` takes `{ userId, event, payload?, ts?, id? }` and answers with
@@ -117,8 +761,18 @@ On Postgres the engine also keeps:
117
761
  - `POST /identify` takes `{ userId, props }` and merges the properties into the
118
762
  profile. A property set to `null` is removed, which is the only way to unset
119
763
  one.
764
+ - `POST /devices` takes `{ userId, token, platform, ts? }`;
765
+ `POST /devices/unbind` and `POST /devices/remove` take `{ token }`.
766
+ - `POST /messages/opened` takes `{ userId, messageId }`.
767
+ - `POST /users/forget` takes `{ userId }`.
120
768
  - `POST /deploy` takes a compiled bundle and swaps the routing table atomically.
121
769
 
770
+ `journeyWorker` adds `GET /bundle` (the compiled config), `POST /bundle/deploy`
771
+ (deploy it), `GET /preview/<key>`, `POST /segments/sweep`, and the `/t/...`
772
+ tracking routes recipients open, which need no bearer. With a contract, an
773
+ event or identify it refuses is answered with a 400 and the field paths, before
774
+ anything is stored, woken or started.
775
+
122
776
  ### Give each event an `id`
123
777
 
124
778
  `id` is the occurrence's identity, and sending the same one twice records it
@@ -137,7 +791,7 @@ you can reproduce and the retry costs nothing.
137
791
 
138
792
  Use something that identifies the occurrence, not the event name: a row id from
139
793
  your own system, an upstream webhook's delivery id, or a message id from your
140
- queue. Workflows emitting events through `send_event` already do this, passing
794
+ queue. Workflows emitting events through `.sendEvent()` already do this, passing
141
795
  the node's identity within the running instance.
142
796
 
143
797
  ## Entry policy
@@ -148,8 +802,32 @@ exception is an entry whose instance died: that row records a failure, not a
148
802
  journey the user received, so the next trigger reclaims it. Re-entry beyond that
149
803
  is a design question this package does not yet answer.
150
804
 
805
+ ## Exports
806
+
807
+ | Subpath | Contents |
808
+ | ------------------------------ | -------------------------------------------------------------------------------------------------- |
809
+ | `@shware/workflow` | The DSL, `defineContract`, `defineConfig`, `compileConfig`, `compileBundle`, `plan`, templates, IR |
810
+ | `@shware/workflow/engine` | The interpreter (`runJourney`) and its ports, condition evaluation, bucketing |
811
+ | `@shware/workflow/cloudflare` | `journeyWorker`, `JourneyRunner`, `handleRequest`, the router functions, senders, stores |
812
+ | `@shware/workflow/store` | The `JourneyStore` port, D1 and Postgres stores, `renderPostgresDdl`, `renderDrizzleSchema` |
813
+ | `@shware/workflow/client` | `JourneyClient`, the producer side of the HTTP API |
814
+ | `@shware/workflow/postgres-js` | `postgresJsStore`, the Postgres store over postgres.js (peer `postgres`) |
815
+ | `@shware/workflow/react-email` | `registryRenderer`, the email renderer over the registry (peers `react`, `react-dom`) |
816
+ | `@shware/workflow/push` | `registryPushRenderer`, push content from the registry |
817
+ | `@shware/workflow/apns` | `ApnsPushSender`, token-based APNs over HTTP/2 |
818
+ | `@shware/workflow/apns-relay` | `startApnsRelay`, the Node HTTP/1.1 to HTTP/2 relay for `wrangler dev` |
819
+ | `@shware/workflow/fcm` | `FcmPushSender`, FCM HTTP v1 for Android |
820
+ | `@shware/workflow/webpush` | `WebPushSender` and `generateVapidKeys`, standard Web Push with VAPID |
821
+ | `@shware/workflow/webhooks` | `SlackWebhookSender` and `DiscordWebhookSender` |
822
+ | `@shware/workflow/schema.sql` | The D1 schema file |
823
+
151
824
  ## Testing
152
825
 
153
826
  See [TESTING.md](./TESTING.md). The interpreter runs against in-memory ports and
154
827
  the Cloudflare adapter against fakes that dispatch on the exact SQL strings the
155
828
  real code sends, so changing a query means updating the fake, deliberately.
829
+
830
+ In an app, `compileConfig(config)` makes the compiled IR snapshot-testable, and
831
+ `runJourney` from `@shware/workflow/engine` runs a journey against an in-memory
832
+ host, with `registryRenderer` and `registryPushRenderer` rendering what it
833
+ sends.