@shware/workflow 0.2.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.
- package/README.md +688 -10
- package/dist/cloudflare/bindings.cjs.map +1 -1
- package/dist/cloudflare/bindings.d.cts +11 -0
- package/dist/cloudflare/bindings.d.cts.map +1 -1
- package/dist/cloudflare/bindings.d.mts +11 -0
- package/dist/cloudflare/bindings.d.mts.map +1 -1
- package/dist/cloudflare/bindings.mjs.map +1 -1
- package/dist/cloudflare/index.cjs +1 -0
- package/dist/cloudflare/index.d.cts +3 -3
- package/dist/cloudflare/index.d.mts +3 -3
- package/dist/cloudflare/index.mjs +2 -2
- package/dist/cloudflare/router.cjs +21 -4
- package/dist/cloudflare/router.cjs.map +1 -1
- package/dist/cloudflare/router.d.cts +18 -3
- package/dist/cloudflare/router.d.cts.map +1 -1
- package/dist/cloudflare/router.d.mts +18 -3
- package/dist/cloudflare/router.d.mts.map +1 -1
- package/dist/cloudflare/router.mjs +21 -5
- package/dist/cloudflare/router.mjs.map +1 -1
- package/dist/cloudflare/worker.cjs +37 -1
- package/dist/cloudflare/worker.cjs.map +1 -1
- package/dist/cloudflare/worker.d.cts.map +1 -1
- package/dist/cloudflare/worker.d.mts.map +1 -1
- package/dist/cloudflare/worker.mjs +37 -1
- package/dist/cloudflare/worker.mjs.map +1 -1
- package/dist/store/index.cjs.map +1 -1
- package/dist/store/index.d.cts +21 -0
- package/dist/store/index.d.cts.map +1 -1
- package/dist/store/index.d.mts +21 -0
- package/dist/store/index.d.mts.map +1 -1
- package/dist/store/index.mjs.map +1 -1
- package/dist/store/postgres.cjs +45 -20
- package/dist/store/postgres.cjs.map +1 -1
- package/dist/store/postgres.d.cts +4 -1
- package/dist/store/postgres.d.cts.map +1 -1
- package/dist/store/postgres.d.mts +4 -1
- package/dist/store/postgres.d.mts.map +1 -1
- package/dist/store/postgres.mjs +45 -20
- package/dist/store/postgres.mjs.map +1 -1
- package/dist/store/tables.cjs +11 -0
- package/dist/store/tables.cjs.map +1 -1
- package/dist/store/tables.d.cts +1 -0
- package/dist/store/tables.d.cts.map +1 -1
- package/dist/store/tables.d.mts +1 -0
- package/dist/store/tables.d.mts.map +1 -1
- package/dist/store/tables.mjs +11 -0
- package/dist/store/tables.mjs.map +1 -1
- 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(
|
|
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
|
|
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
|
|
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
|
|
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 `
|
|
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.
|