@shware/workflow 5.0.1 → 6.1.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 +224 -53
- package/dist/aws/index.cjs +1 -1
- package/dist/aws/index.mjs +1 -1
- package/dist/client.cjs +68 -4
- package/dist/client.cjs.map +1 -1
- package/dist/client.d.cts +39 -3
- package/dist/client.d.cts.map +1 -1
- package/dist/client.d.mts +39 -3
- package/dist/client.d.mts.map +1 -1
- package/dist/client.mjs +68 -4
- package/dist/client.mjs.map +1 -1
- package/dist/cloudflare/index.cjs +4 -4
- package/dist/cloudflare/index.mjs +4 -4
- package/dist/cloudflare/runner.cjs +1 -1
- package/dist/cloudflare/runner.d.cts +1 -1
- package/dist/cloudflare/runner.d.mts +1 -1
- package/dist/cloudflare/runner.mjs +1 -1
- package/dist/config.cjs +24 -3
- package/dist/config.cjs.map +1 -1
- package/dist/config.d.cts +15 -9
- package/dist/config.d.cts.map +1 -1
- package/dist/config.d.mts +15 -9
- package/dist/config.d.mts.map +1 -1
- package/dist/config.mjs +25 -4
- package/dist/config.mjs.map +1 -1
- package/dist/contract.cjs +11 -0
- package/dist/contract.cjs.map +1 -1
- package/dist/contract.d.cts +28 -7
- package/dist/contract.d.cts.map +1 -1
- package/dist/contract.d.mts +28 -7
- package/dist/contract.d.mts.map +1 -1
- package/dist/contract.mjs +11 -0
- package/dist/contract.mjs.map +1 -1
- package/dist/dsl/bundle.cjs +4 -3
- package/dist/dsl/bundle.cjs.map +1 -1
- package/dist/dsl/bundle.d.cts +5 -1
- package/dist/dsl/bundle.d.cts.map +1 -1
- package/dist/dsl/bundle.d.mts +5 -1
- package/dist/dsl/bundle.d.mts.map +1 -1
- package/dist/dsl/bundle.mjs +4 -3
- package/dist/dsl/bundle.mjs.map +1 -1
- package/dist/dsl/flow.cjs +2 -1
- package/dist/dsl/flow.cjs.map +1 -1
- package/dist/dsl/flow.d.cts +1 -1
- package/dist/dsl/flow.d.cts.map +1 -1
- package/dist/dsl/flow.d.mts +1 -1
- package/dist/dsl/flow.d.mts.map +1 -1
- package/dist/dsl/flow.mjs +2 -1
- package/dist/dsl/flow.mjs.map +1 -1
- package/dist/dsl/workflow.cjs +2 -2
- package/dist/dsl/workflow.mjs +2 -2
- package/dist/email-sender.cjs +3 -2
- package/dist/email-sender.cjs.map +1 -1
- package/dist/email-sender.d.cts +5 -4
- package/dist/email-sender.d.cts.map +1 -1
- package/dist/email-sender.d.mts +5 -4
- package/dist/email-sender.d.mts.map +1 -1
- package/dist/email-sender.mjs +3 -2
- package/dist/email-sender.mjs.map +1 -1
- package/dist/email.cjs +66 -11
- package/dist/email.cjs.map +1 -1
- package/dist/email.d.cts +72 -12
- package/dist/email.d.cts.map +1 -1
- package/dist/email.d.mts +72 -12
- package/dist/email.d.mts.map +1 -1
- package/dist/email.mjs +63 -12
- package/dist/email.mjs.map +1 -1
- package/dist/engine/condition.cjs.map +1 -1
- package/dist/engine/condition.d.cts.map +1 -1
- package/dist/engine/condition.d.mts.map +1 -1
- package/dist/engine/condition.mjs.map +1 -1
- package/dist/engine/index.cjs +1 -1
- package/dist/engine/index.mjs +1 -1
- package/dist/engine/interpreter.cjs +21 -4
- package/dist/engine/interpreter.cjs.map +1 -1
- package/dist/engine/interpreter.mjs +21 -4
- package/dist/engine/interpreter.mjs.map +1 -1
- package/dist/engine/ports.d.cts +14 -2
- package/dist/engine/ports.d.cts.map +1 -1
- package/dist/engine/ports.d.mts +14 -2
- package/dist/engine/ports.d.mts.map +1 -1
- package/dist/host/channels.cjs +1 -1
- package/dist/host/channels.mjs +1 -1
- package/dist/host/execute.cjs +2 -2
- package/dist/host/execute.d.cts +1 -1
- package/dist/host/execute.d.mts +1 -1
- package/dist/host/execute.mjs +2 -2
- package/dist/host/http.cjs +75 -5
- package/dist/host/http.cjs.map +1 -1
- package/dist/host/http.d.cts +2 -2
- package/dist/host/http.d.cts.map +1 -1
- package/dist/host/http.d.mts +2 -2
- package/dist/host/http.d.mts.map +1 -1
- package/dist/host/http.mjs +76 -6
- package/dist/host/http.mjs.map +1 -1
- package/dist/host/ingest.cjs +173 -4
- package/dist/host/ingest.cjs.map +1 -1
- package/dist/host/ingest.d.cts +99 -0
- package/dist/host/ingest.d.cts.map +1 -1
- package/dist/host/ingest.d.mts +99 -0
- package/dist/host/ingest.d.mts.map +1 -1
- package/dist/host/ingest.mjs +167 -5
- package/dist/host/ingest.mjs.map +1 -1
- package/dist/host/journey-host.cjs +10 -8
- package/dist/host/journey-host.cjs.map +1 -1
- package/dist/host/journey-host.mjs +10 -8
- package/dist/host/journey-host.mjs.map +1 -1
- package/dist/index.cjs +8 -2
- package/dist/index.d.cts +5 -5
- package/dist/index.d.mts +5 -5
- package/dist/index.mjs +5 -5
- package/dist/ir.cjs +11 -2
- package/dist/ir.cjs.map +1 -1
- package/dist/ir.d.cts +17 -0
- package/dist/ir.d.cts.map +1 -1
- package/dist/ir.d.mts +17 -0
- package/dist/ir.d.mts.map +1 -1
- package/dist/ir.mjs +11 -3
- package/dist/ir.mjs.map +1 -1
- package/dist/react-email.cjs +6 -5
- package/dist/react-email.cjs.map +1 -1
- package/dist/react-email.d.cts.map +1 -1
- package/dist/react-email.d.mts.map +1 -1
- package/dist/react-email.mjs +6 -5
- package/dist/react-email.mjs.map +1 -1
- package/dist/reporting/observer.cjs +4 -1
- package/dist/reporting/observer.cjs.map +1 -1
- package/dist/reporting/observer.d.cts +2 -1
- package/dist/reporting/observer.d.cts.map +1 -1
- package/dist/reporting/observer.d.mts +2 -1
- package/dist/reporting/observer.d.mts.map +1 -1
- package/dist/reporting/observer.mjs +4 -1
- package/dist/reporting/observer.mjs.map +1 -1
- package/dist/reporting/tracking.cjs +31 -10
- package/dist/reporting/tracking.cjs.map +1 -1
- package/dist/reporting/tracking.d.cts +15 -6
- package/dist/reporting/tracking.d.cts.map +1 -1
- package/dist/reporting/tracking.d.mts +15 -6
- package/dist/reporting/tracking.d.mts.map +1 -1
- package/dist/reporting/tracking.mjs +31 -11
- package/dist/reporting/tracking.mjs.map +1 -1
- package/dist/store/facts.cjs +3 -3
- package/dist/store/facts.cjs.map +1 -1
- package/dist/store/facts.d.cts +2 -2
- package/dist/store/facts.d.cts.map +1 -1
- package/dist/store/facts.d.mts +2 -2
- package/dist/store/facts.d.mts.map +1 -1
- package/dist/store/facts.mjs +3 -3
- package/dist/store/facts.mjs.map +1 -1
- package/dist/store/index.cjs +1 -1
- package/dist/store/index.cjs.map +1 -1
- package/dist/store/index.d.cts +44 -13
- package/dist/store/index.d.cts.map +1 -1
- package/dist/store/index.d.mts +44 -13
- package/dist/store/index.d.mts.map +1 -1
- package/dist/store/index.mjs +1 -1
- package/dist/store/index.mjs.map +1 -1
- package/dist/store/postgres-reporting.cjs +30 -10
- package/dist/store/postgres-reporting.cjs.map +1 -1
- package/dist/store/postgres-reporting.d.cts +4 -2
- package/dist/store/postgres-reporting.d.cts.map +1 -1
- package/dist/store/postgres-reporting.d.mts +4 -2
- package/dist/store/postgres-reporting.d.mts.map +1 -1
- package/dist/store/postgres-reporting.mjs +30 -10
- package/dist/store/postgres-reporting.mjs.map +1 -1
- package/dist/store/postgres.cjs +39 -3
- package/dist/store/postgres.cjs.map +1 -1
- package/dist/store/postgres.d.cts +4 -2
- package/dist/store/postgres.d.cts.map +1 -1
- package/dist/store/postgres.d.mts +4 -2
- package/dist/store/postgres.d.mts.map +1 -1
- package/dist/store/postgres.mjs +40 -4
- package/dist/store/postgres.mjs.map +1 -1
- package/dist/store/tables.cjs +75 -11
- package/dist/store/tables.cjs.map +1 -1
- package/dist/store/tables.d.cts +17 -1
- package/dist/store/tables.d.cts.map +1 -1
- package/dist/store/tables.d.mts +17 -1
- package/dist/store/tables.d.mts.map +1 -1
- package/dist/store/tables.mjs +74 -12
- package/dist/store/tables.mjs.map +1 -1
- package/dist/templates.cjs +60 -7
- package/dist/templates.cjs.map +1 -1
- package/dist/templates.d.cts +101 -13
- package/dist/templates.d.cts.map +1 -1
- package/dist/templates.d.mts +101 -13
- package/dist/templates.d.mts.map +1 -1
- package/dist/templates.mjs +61 -9
- package/dist/templates.mjs.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -54,6 +54,7 @@ export const contract = defineContract({
|
|
|
54
54
|
purchase: looseObject({ transaction_id: string(), currency: string(), value: number() }),
|
|
55
55
|
},
|
|
56
56
|
profile: strictObject({ email: optional(email()), name: string() }),
|
|
57
|
+
subscriptions: { topics: ['promotions', 'newsletter', 'tips'] },
|
|
57
58
|
});
|
|
58
59
|
|
|
59
60
|
/** The DSL's reference tables: `e.login`, `u.name`. */
|
|
@@ -66,6 +67,16 @@ The profile is a flat object of string, number, boolean, enum and literal
|
|
|
66
67
|
fields. Defaults, transforms, coercion and custom refinements are refused when
|
|
67
68
|
the contract is defined, because input and output must mean the same JSON.
|
|
68
69
|
|
|
70
|
+
`subscriptions.topics` lists the subscription topics: content categories
|
|
71
|
+
("offers", "tips"), not channels — ids only (a plain token; the app's i18n gives
|
|
72
|
+
them names). A plain array literal is enough: `defineContract` keeps the
|
|
73
|
+
literals, and the union they form types every email and push template's
|
|
74
|
+
`topic`, the config and `JourneyClient.setSubscriptions`. A user opts out of a
|
|
75
|
+
topic per channel (the preference page is topic × channel), or of a whole
|
|
76
|
+
channel; the engine keeps that record and is the one source of truth for it
|
|
77
|
+
(see [Email](#email), [Push](#push) and [`JourneyClient`](#journeyclient)).
|
|
78
|
+
Leave the field out when every email and push is transactional.
|
|
79
|
+
|
|
69
80
|
### 2. Templates
|
|
70
81
|
|
|
71
82
|
One object per template, identified by its own `id` (unique per channel): the
|
|
@@ -74,14 +85,20 @@ stable and rename `title` instead. Its strict Zod `props` schema is the only
|
|
|
74
85
|
source of the props type: the DSL's arguments, the `preview` and the component
|
|
75
86
|
are all derived from it, and every send parses the props against it first.
|
|
76
87
|
|
|
88
|
+
```ts
|
|
89
|
+
// src/contract.ts (continued): the template factories bound to the contract
|
|
90
|
+
export const { emailTemplate, pushTemplate } = templatesFor(contract);
|
|
91
|
+
```
|
|
92
|
+
|
|
77
93
|
```tsx
|
|
78
94
|
// src/email/welcome.tsx
|
|
79
95
|
import { Html, Text } from '@react-email/components';
|
|
80
|
-
import { emailTemplate } from '@shware/workflow';
|
|
81
96
|
import { strictObject, string } from 'zod/mini';
|
|
97
|
+
import { emailTemplate } from '../contract';
|
|
82
98
|
|
|
83
99
|
export default emailTemplate({
|
|
84
100
|
id: 'welcome',
|
|
101
|
+
transactional: true,
|
|
85
102
|
from: { email: 'noreply@acme.test', name: 'Acme' },
|
|
86
103
|
title: 'Welcome',
|
|
87
104
|
props: strictObject({ name: string() }),
|
|
@@ -95,6 +112,17 @@ export default emailTemplate({
|
|
|
95
112
|
});
|
|
96
113
|
```
|
|
97
114
|
|
|
115
|
+
Every email and push template says what it is: `topic: 'tips'` (a topic the
|
|
116
|
+
contract declares, which the user can opt out of on that channel) or
|
|
117
|
+
`transactional: true` (a receipt, a code, an invite: sent whatever they opted
|
|
118
|
+
out of; for an email, with no unsubscribe link). Leaving both out, or giving
|
|
119
|
+
both, is a type error and a compile error, and the error says so in words.
|
|
120
|
+
`templatesFor(contract)` gives the factories with the topic typed by the
|
|
121
|
+
contract, so a topic it does not declare is an error at the template's own
|
|
122
|
+
`topic` line, in the editor and when the module loads; the bare `emailTemplate`
|
|
123
|
+
/ `pushTemplate` from `@shware/workflow` are for a project without a contract
|
|
124
|
+
(transactional templates only), and the compile checks every template again.
|
|
125
|
+
|
|
98
126
|
Gather them per channel; the workflows send the definitions and the config
|
|
99
127
|
registers the whole:
|
|
100
128
|
|
|
@@ -113,11 +141,13 @@ import { email } from './email';
|
|
|
113
141
|
export const templates = { email };
|
|
114
142
|
```
|
|
115
143
|
|
|
116
|
-
`tip.tsx` is written the same way
|
|
117
|
-
only. `.email(email.welcome, props)` checks `props`
|
|
118
|
-
schema, and a push template is not accepted where an
|
|
119
|
-
refuses an id registered twice in a channel,
|
|
120
|
-
definition other than the one registered under its id
|
|
144
|
+
`tip.tsx` is written the same way, with `topic: 'tips'`, and so is a
|
|
145
|
+
`pushTemplate`. The property names are JavaScript names only. `.email(email.welcome, props)` checks `props`
|
|
146
|
+
against that template's schema, and a push template is not accepted where an
|
|
147
|
+
email is. The compile refuses an id registered twice in a channel, a workflow
|
|
148
|
+
sending a definition other than the one registered under its id, and a topic
|
|
149
|
+
the contract does not declare (with a `contract` in the config, the editor
|
|
150
|
+
refuses it first).
|
|
121
151
|
|
|
122
152
|
### 3. A workflow
|
|
123
153
|
|
|
@@ -463,35 +493,52 @@ the compile fails.
|
|
|
463
493
|
|
|
464
494
|
### Email
|
|
465
495
|
|
|
466
|
-
`emailTemplate({ id, from, replyTo?, unsubscribeUrl?, title, description?, props, subject, preview, component })`
|
|
496
|
+
`emailTemplate({ id, topic | transactional, from, replyTo?, unsubscribeUrl?, title, description?, props, subject, preview, component })`
|
|
467
497
|
declares one. The Worker renders it with `@shware/workflow/react-email` (React's
|
|
468
498
|
streaming renderer, which waits for react-email's `<Tailwind>`) and sends it
|
|
469
499
|
through the host's email transport, from the template's own `from`:
|
|
470
500
|
|
|
471
501
|
```ts
|
|
472
|
-
// src/email/
|
|
502
|
+
// src/email/tip.tsx
|
|
473
503
|
export default emailTemplate({
|
|
474
|
-
id: '
|
|
504
|
+
id: 'tip',
|
|
505
|
+
topic: 'tips',
|
|
475
506
|
from: { email: 'noreply@acme.test', name: 'Acme' },
|
|
476
507
|
replyTo: { email: 'support@acme.test', name: 'Acme Support' }, // left out: no Reply-To
|
|
477
|
-
title: '
|
|
508
|
+
title: 'Weekly tip',
|
|
478
509
|
props: strictObject({ name: string() }),
|
|
479
|
-
subject: '
|
|
510
|
+
subject: 'One thing to try this week',
|
|
480
511
|
preview: { name: 'John Doe' },
|
|
481
512
|
component: ({ name }) => <Layout name={name}>…</Layout>,
|
|
482
513
|
});
|
|
483
514
|
```
|
|
484
515
|
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
`
|
|
488
|
-
|
|
489
|
-
|
|
516
|
+
Every template is one of two kinds, by type: a **topic** email names one of the
|
|
517
|
+
contract's `subscriptions.topics`, and a **transactional** email says
|
|
518
|
+
`transactional: true` (and takes no `unsubscribeUrl`). The kind decides what
|
|
519
|
+
the engine does before and after the send:
|
|
520
|
+
|
|
521
|
+
| Email | Before sending | Unsubscribe link, `List-Unsubscribe` |
|
|
522
|
+
| ------------- | -------------------------------------------------------------- | ------------------------------------- |
|
|
523
|
+
| topic | skipped when the user opted out of that topic, or of all email | yes |
|
|
524
|
+
| transactional | always sent (a receipt, a code, an invite) | none, whatever `email.unsubscribeUrl` |
|
|
525
|
+
|
|
526
|
+
There is no global sender: share addresses through a module of your own. A
|
|
527
|
+
topic email's unsubscribe link is the template's `unsubscribeUrl`, else the
|
|
528
|
+
config's `email.unsubscribeUrl`, else none; its preferences link is the config's
|
|
529
|
+
`email.preferencesUrl` with the copy's message id, when the copy has one; a
|
|
530
|
+
transactional email never has either (`resolveEmail` is the rule). A layout
|
|
531
|
+
reads them from the render, never from a constant, so a template's own URL
|
|
532
|
+
reaches its HTML and each copy's link names that copy; the resolved `topic` is
|
|
533
|
+
there too, for a line like "you get these because you subscribed to tips":
|
|
490
534
|
|
|
491
535
|
```tsx
|
|
492
536
|
import { useEmailContext } from '@shware/workflow/email-context';
|
|
493
537
|
|
|
494
|
-
const { unsubscribeUrl } = useEmailContext();
|
|
538
|
+
const { unsubscribeUrl, preferencesUrl } = useEmailContext();
|
|
539
|
+
{
|
|
540
|
+
preferencesUrl !== undefined && <Link href={preferencesUrl}>Email preferences</Link>;
|
|
541
|
+
}
|
|
495
542
|
{
|
|
496
543
|
unsubscribeUrl !== undefined && <Link href={unsubscribeUrl}>Unsubscribe</Link>;
|
|
497
544
|
}
|
|
@@ -501,6 +548,7 @@ const { unsubscribeUrl } = useEmailContext();
|
|
|
501
548
|
// workflow.config.ts
|
|
502
549
|
email: {
|
|
503
550
|
unsubscribeUrl: 'https://acme.test/unsubscribe', // templates without their own
|
|
551
|
+
preferencesUrl: 'https://acme.test/settings/email', // the app's preference page
|
|
504
552
|
tracking: { quickClickMs: 1_000 }, // the default
|
|
505
553
|
},
|
|
506
554
|
```
|
|
@@ -527,10 +575,43 @@ An email with an unsubscribe link also carries `List-Unsubscribe` (RFC 2369), so
|
|
|
527
575
|
a mail client's own unsubscribe button works. With reporting on (a Postgres store and
|
|
528
576
|
`WORKFLOW_ORIGIN`), it names the copy's one-click endpoint `/t/u/<message>`, together with
|
|
529
577
|
`List-Unsubscribe-Post: List-Unsubscribe=One-Click` (RFC 8058). Gmail and Yahoo require
|
|
530
|
-
one-click unsubscribe for bulk senders. The provider's POST there unsubscribes at once
|
|
531
|
-
same URL opened in a browser
|
|
532
|
-
|
|
533
|
-
|
|
578
|
+
one-click unsubscribe for bulk senders. The provider's POST there unsubscribes at once from
|
|
579
|
+
the email's topic; the same URL opened in a browser (and the email's own link) asks first, with
|
|
580
|
+
two buttons: the email's topic, and all email. Without reporting, the header names the
|
|
581
|
+
template's own unsubscribe link, with no one-click claim. Only https URLs are named, so a local
|
|
582
|
+
`http://` origin sends no header.
|
|
583
|
+
|
|
584
|
+
So a topic email carries two ways out, for two readers. The **header** is for the mail client:
|
|
585
|
+
`List-Unsubscribe` points at the Worker's own `/t/u/<message>`, which a one-click POST or the
|
|
586
|
+
Worker's plain confirmation page settles without the app. The **footer** is for the person:
|
|
587
|
+
`preferencesUrl` is the app's own page, branded, signed in or not, reached as
|
|
588
|
+
`https://acme.test/settings/email?m=<message id>` (the page's own query is kept, `m` set beside
|
|
589
|
+
it). The message id in `m` is how the page knows the recipient without a login: it is the
|
|
590
|
+
capability, an unguessable UUID that only ever reached that mailbox, the same trust `/t/u` runs
|
|
591
|
+
on. The page's API calls `GET /subscriptions/by-message/<m>` with the bearer
|
|
592
|
+
(`JourneyClient.getSubscriptionsByMessage`), gets the recipient's record plus
|
|
593
|
+
`message: { channel, topic }` to highlight the topic that email belonged to, and writes with
|
|
594
|
+
`PUT /subscriptions/by-message/<m>` (`setSubscriptionsByMessage`), the same patch as the user
|
|
595
|
+
endpoint. An id that names no copy (never sent, or erased with its user by `/users/forget`) or a
|
|
596
|
+
transactional one answers 404, the rule `/t/u` applies: show the link as expired. The Worker
|
|
597
|
+
does not rate-limit these routes; the app's proxy does, as it sees fit. The link is tracked as
|
|
598
|
+
an ordinary click (`/t/l/...`), never as an unsubscribe: `preferencesUrl` must not share its
|
|
599
|
+
origin and path with `unsubscribeUrl` (or a template's own), and the config refuses one that
|
|
600
|
+
does. Without reporting (no Postgres reporting or no `WORKFLOW_ORIGIN`) there is no message id,
|
|
601
|
+
so `preferencesUrl` is `undefined` in the render and the footer shows nothing; a preview
|
|
602
|
+
(`GET /preview` and the studio) renders it with a placeholder id, `PREVIEW_MESSAGE_ID`.
|
|
603
|
+
|
|
604
|
+
What a user opted out of is the engine's record, per topic and channel: a topic email is
|
|
605
|
+
skipped while its topic (on email) or all email is opted out, a transactional email never is,
|
|
606
|
+
and `reachable('email')` reads the all-email opt-out only — a user unsubscribed from one topic
|
|
607
|
+
is still reachable, so an `exitWhen: not(reachable('email'))` fires on "all email", not on a
|
|
608
|
+
topic. An email opt-out says nothing about push: the same topic is opted out per channel. The
|
|
609
|
+
app reads and sets the record through `GET` / `PUT /subscriptions/:userId`
|
|
610
|
+
(`JourneyClient.getSubscriptions` / `setSubscriptions`), which is how a signed-in preference
|
|
611
|
+
page is built: the engine holds the truth, the page shows it. The all-email opt-out and the
|
|
612
|
+
topic opt-outs are separate records: `all: false` masks every topic, whatever its own flag says,
|
|
613
|
+
and `all: true` clears only the all-email opt-out — a topic opted out on its own stays off until
|
|
614
|
+
a patch names it.
|
|
534
615
|
|
|
535
616
|
An email step refuses to send when the profile has no `email`.
|
|
536
617
|
`GET /preview/<id>?props=<JSON>` renders a template, with the subject in the
|
|
@@ -538,13 +619,14 @@ An email step refuses to send when the profile has no `email`.
|
|
|
538
619
|
|
|
539
620
|
### Push
|
|
540
621
|
|
|
541
|
-
`pushTemplate({ id, title, description?, props, preview, content })`,
|
|
542
|
-
`content(props)` returns `{ title, body, image?, url? }`: final text, never
|
|
622
|
+
`pushTemplate({ id, topic | transactional, title, description?, props, preview, content })`,
|
|
623
|
+
where `content(props)` returns `{ title, body, image?, url? }`: final text, never
|
|
543
624
|
placeholder-expanded. `url` is the page a browser push opens.
|
|
544
625
|
|
|
545
626
|
```ts
|
|
546
627
|
export default pushTemplate({
|
|
547
628
|
id: 'onboarding_tip_push',
|
|
629
|
+
topic: 'tips',
|
|
548
630
|
title: 'Onboarding tip (push)',
|
|
549
631
|
props: strictObject({ name: string() }),
|
|
550
632
|
preview: { name: 'John Doe' },
|
|
@@ -552,6 +634,18 @@ export default pushTemplate({
|
|
|
552
634
|
});
|
|
553
635
|
```
|
|
554
636
|
|
|
637
|
+
A push is a **topic** push (one of the contract's `subscriptions.topics`) or a
|
|
638
|
+
**transactional** one (`transactional: true`: a code, a receipt, a reply), like
|
|
639
|
+
an email. A topic push is skipped while the user opted out of that topic on
|
|
640
|
+
push, or of all push; a transactional push always goes. The app records the
|
|
641
|
+
opt-outs through `PUT /subscriptions/:userId` (`JourneyClient.setSubscriptions`
|
|
642
|
+
with `{ push: { all?, topics? } }`), which is the in-app switch App Store Review
|
|
643
|
+
Guideline 4.5.4 requires for marketing pushes: `push.all: false` is "no
|
|
644
|
+
promotional notifications", a topic `false` is one category off. The opt-outs are
|
|
645
|
+
per channel: a topic switched off for push is still sent by email.
|
|
646
|
+
`reachable('push')` is a device plus no all-push opt-out; a topic opt-out leaves
|
|
647
|
+
it true. Changing `push.all` wakes the waits on it, as a device change does.
|
|
648
|
+
|
|
555
649
|
Pushes go to the devices the engine keeps. The app registers each token with
|
|
556
650
|
`JourneyClient.registerDevice(userId, { token, platform })`, and a token moves
|
|
557
651
|
to the latest user that registers it. Each platform is turned on by the config,
|
|
@@ -636,20 +730,20 @@ Discord; supply anything else with `journeyWorker({ messages })`.
|
|
|
636
730
|
|
|
637
731
|
`defineConfig` takes:
|
|
638
732
|
|
|
639
|
-
| Field | Holds
|
|
640
|
-
| ----------- |
|
|
641
|
-
| `title` | The Studio title
|
|
642
|
-
| `contract` | The `defineContract(...)` every `/events` and `/identify` is validated against
|
|
643
|
-
| `workflows` | The workflows to deploy
|
|
644
|
-
| `segments` | Named segments
|
|
645
|
-
| `actions` | Custom actions `.run()` uses
|
|
646
|
-
| `templates` | The registries: `{ email?, push?, discord?, slack? }
|
|
647
|
-
| `email` | `{ unsubscribeUrl?, tracking? }`: what every email template shares
|
|
648
|
-
| `runtime` | Per mode (`development`, `production`, …): `{ timeScale?, logMessages? }`
|
|
649
|
-
| `apns` | `{ topic, keyId, teamId, environment? }`
|
|
650
|
-
| `fcm` | `{ androidChannelId? }`
|
|
651
|
-
| `webPush` | `{ subject, openedEvent? }`
|
|
652
|
-
| `database` | `{ schema? }`: the Postgres schema, `workflow` by default
|
|
733
|
+
| Field | Holds |
|
|
734
|
+
| ----------- | ----------------------------------------------------------------------------------------- |
|
|
735
|
+
| `title` | The Studio title |
|
|
736
|
+
| `contract` | The `defineContract(...)` every `/events` and `/identify` is validated against |
|
|
737
|
+
| `workflows` | The workflows to deploy |
|
|
738
|
+
| `segments` | Named segments |
|
|
739
|
+
| `actions` | Custom actions `.run()` uses |
|
|
740
|
+
| `templates` | The registries: `{ email?, push?, discord?, slack? }`; topics typed by the contract |
|
|
741
|
+
| `email` | `{ unsubscribeUrl?, preferencesUrl?, tracking? }`: what every topic email template shares |
|
|
742
|
+
| `runtime` | Per mode (`development`, `production`, …): `{ timeScale?, logMessages? }` |
|
|
743
|
+
| `apns` | `{ topic, keyId, teamId, environment? }` |
|
|
744
|
+
| `fcm` | `{ androidChannelId? }` |
|
|
745
|
+
| `webPush` | `{ subject, openedEvent? }` |
|
|
746
|
+
| `database` | `{ schema? }`: the Postgres schema, `workflow` by default |
|
|
653
747
|
|
|
654
748
|
The `ENVIRONMENT` var picks the `runtime` entry (production when unset).
|
|
655
749
|
`timeScale` fast-forwards every duration: 600 turns "1 day" into 2.4 minutes.
|
|
@@ -681,20 +775,60 @@ throws `JourneyClientError` with its status and body.
|
|
|
681
775
|
const journeys = new JourneyClient({ contract, origin, token });
|
|
682
776
|
```
|
|
683
777
|
|
|
684
|
-
| Method | Endpoint
|
|
685
|
-
| --------------------------------------------- |
|
|
686
|
-
| `identify(userId, props)` | `POST /identify`
|
|
687
|
-
| `track(userId, event, payload, { id?, ts? })` | `POST /events`
|
|
688
|
-
| `registerDevice(userId, { token, platform })` | `POST /devices`
|
|
689
|
-
| `unbindDevice(token)` | `POST /devices/unbind`
|
|
690
|
-
| `removeDevice(token)` | `POST /devices/remove`
|
|
691
|
-
| `messageOpened(userId, messageId)` | `POST /messages/opened`
|
|
692
|
-
| `
|
|
693
|
-
| `
|
|
778
|
+
| Method | Endpoint | Does |
|
|
779
|
+
| --------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------- |
|
|
780
|
+
| `identify(userId, props)` | `POST /identify` | Merge profile properties; `null` removes one |
|
|
781
|
+
| `track(userId, event, payload, { id?, ts? })` | `POST /events` | Record an event; starts and wakes journeys |
|
|
782
|
+
| `registerDevice(userId, { token, platform })` | `POST /devices` | Register a push token (`ios`, `android` or `web`) |
|
|
783
|
+
| `unbindDevice(token)` | `POST /devices/unbind` | Signed out: the token stays, bound to nobody |
|
|
784
|
+
| `removeDevice(token)` | `POST /devices/remove` | Forget a token for good |
|
|
785
|
+
| `messageOpened(userId, messageId)` | `POST /messages/opened` | A journey push tapped open (the `message_id` it carried) |
|
|
786
|
+
| `getSubscriptions(userId)` | `GET /subscriptions/…` | `{ email, push }`, each `{ all, topics }`, `true` = on |
|
|
787
|
+
| `setSubscriptions(userId, patch)` | `PUT /subscriptions/…` | Change what the patch names; answers the new state |
|
|
788
|
+
| `getSubscriptionsByMessage(messageId)` | `GET /subscriptions/by-message/…` | The recipient's record by a copy's id, plus `message: { channel, topic }` |
|
|
789
|
+
| `setSubscriptionsByMessage(messageId, patch)` | `PUT /subscriptions/by-message/…` | The same patch, for the copy's recipient; answers as the GET does |
|
|
790
|
+
| `forgetUser(userId)` | `POST /users/forget` | Stop the user's journeys and erase their data |
|
|
791
|
+
| `deploy(bundle)` | `POST /deploy` | Deploy a compiled bundle |
|
|
694
792
|
|
|
695
793
|
Pass an `id` to `track` whenever a caller may retry; see
|
|
696
794
|
[Give each event an `id`](#give-each-event-an-id).
|
|
697
795
|
|
|
796
|
+
`getSubscriptions` and `setSubscriptions` are typed by the contract's
|
|
797
|
+
`subscriptions.topics`, and a topic it does not declare is refused before the
|
|
798
|
+
request. The record is topic × channel: `email` and `push` each list every
|
|
799
|
+
topic. A preference page is a thin proxy over them:
|
|
800
|
+
|
|
801
|
+
```ts
|
|
802
|
+
const { email, push } = await journeys.getSubscriptions(userId);
|
|
803
|
+
// email: { all: false, topics: { promotions: false, newsletter: true, tips: true } }
|
|
804
|
+
// push: { all: true, topics: { promotions: true, newsletter: true, tips: true } }
|
|
805
|
+
// `all: false` masks every topic: newsletter reads true, but no email is sent until `all` is
|
|
806
|
+
// true again. Each flag is its own record, so a preference page shows `all` as the master
|
|
807
|
+
// switch and the topics as they are. Push is untouched by the email record.
|
|
808
|
+
await journeys.setSubscriptions(userId, { email: { all: true } });
|
|
809
|
+
// email: { all: true, topics: { promotions: false, newsletter: true, tips: true } }
|
|
810
|
+
// `all: true` cleared only the all-email opt-out; promotions stays off until named:
|
|
811
|
+
await journeys.setSubscriptions(userId, { email: { topics: { promotions: true } } });
|
|
812
|
+
await journeys.setSubscriptions(userId, { email: { all: false } }); // unsubscribe from everything
|
|
813
|
+
await journeys.setSubscriptions(userId, { push: { all: false } }); // the in-app "no promotional pushes"
|
|
814
|
+
await journeys.setSubscriptions(userId, { push: { topics: { promotions: false } } }); // one category off, on push only
|
|
815
|
+
```
|
|
816
|
+
|
|
817
|
+
A "subscribe to all" button sends `all: true` plus every topic `true`; an
|
|
818
|
+
"unsubscribe from all" sends `all: false` and leaves the topics as they are, so
|
|
819
|
+
turning `all` back on restores the user's own choices.
|
|
820
|
+
|
|
821
|
+
The same page opened from an email's footer, with no session, reads the `m` query parameter the
|
|
822
|
+
`preferencesUrl` link carries and goes by the copy instead of the user (see
|
|
823
|
+
[Email](#email)); a 404 (`JourneyClientError` with `status` 404) is an expired link:
|
|
824
|
+
|
|
825
|
+
```ts
|
|
826
|
+
// GET /api/email-preferences?m=<message id>, proxied by the app's API, rate-limited there
|
|
827
|
+
const { email, push, message } = await journeys.getSubscriptionsByMessage(m);
|
|
828
|
+
// message: { channel: 'email', topic: 'promotions' } — the email this page was opened from
|
|
829
|
+
await journeys.setSubscriptionsByMessage(m, { email: { topics: { [message.topic]: false } } });
|
|
830
|
+
```
|
|
831
|
+
|
|
698
832
|
## Compiling and planning
|
|
699
833
|
|
|
700
834
|
`compileConfig(config)` returns the `BundleIR` the Worker deploys. It is
|
|
@@ -1016,14 +1150,25 @@ says `database: { schema }`, so their short names never meet the app's. The
|
|
|
1016
1150
|
host owns the migrations. `npx workflow-ui generate --out <file>` (from
|
|
1017
1151
|
`@shware/workflow-ui`) writes the tables as one drizzle file on that schema;
|
|
1018
1152
|
re-export it from your drizzle schema and let drizzle-kit write the migration.
|
|
1019
|
-
Run `--check` in CI to catch an SDK upgrade that changed them. The
|
|
1153
|
+
Run `--check` in CI to catch an SDK upgrade that changed them. The file is laid
|
|
1154
|
+
out the way a prettier-compatible formatter at 100 columns lays it out, so
|
|
1155
|
+
formatting it changes nothing and a regeneration shows only real changes. The unique index
|
|
1020
1156
|
on `event` is on `(user_id, event_id)`: regenerate and migrate before deploying —
|
|
1021
|
-
the deploy check verifies the unique indexes as well as the columns, and refuses
|
|
1157
|
+
the deploy check verifies the unique indexes and constraints as well as the columns, and refuses
|
|
1022
1158
|
to run until they match. Without drizzle,
|
|
1023
1159
|
`renderPostgresDdl()` returns the same tables as plain DDL. Nothing creates or
|
|
1024
1160
|
alters them at runtime: `POST /bundle/deploy` checks the live database first
|
|
1025
1161
|
and answers 503 with every missing table or column.
|
|
1026
1162
|
|
|
1163
|
+
**Upgrading to 6.0 (topics).** `suppression` gains a nullable `topic`
|
|
1164
|
+
column, loses its `(user_id, channel)` primary key and gets a
|
|
1165
|
+
`UNIQUE NULLS NOT DISTINCT (user_id, channel, topic)` constraint (Postgres 15+),
|
|
1166
|
+
and `message` gains a nullable `topic` (written for email and push copies).
|
|
1167
|
+
Regenerate the drizzle file and let drizzle-kit write the migration; it needs no
|
|
1168
|
+
data step. Rows from before keep their meaning: a `suppression` row whose
|
|
1169
|
+
`topic` is null is an opt-out from the whole channel, which is what every
|
|
1170
|
+
unsubscribe recorded before topics was.
|
|
1171
|
+
|
|
1027
1172
|
### Deploying is `wrangler deploy`
|
|
1028
1173
|
|
|
1029
1174
|
The journeys are compiled into the Worker, so a new version carries its own
|
|
@@ -1088,14 +1233,40 @@ On Postgres the engine also keeps:
|
|
|
1088
1233
|
- `POST /devices` takes `{ userId, token, platform, ts? }`;
|
|
1089
1234
|
`POST /devices/unbind` and `POST /devices/remove` take `{ token }`.
|
|
1090
1235
|
- `POST /messages/opened` takes `{ userId, messageId }`.
|
|
1091
|
-
- `
|
|
1236
|
+
- `GET /subscriptions/:userId` answers `{ email: { all, topics }, push: { all, topics } }`
|
|
1237
|
+
(`true` is subscribed; each channel's `topics` has one key per topic the
|
|
1238
|
+
deployed bundle declares). `PUT /subscriptions/:userId` takes a partial body
|
|
1239
|
+
`{ email?: { all?, topics? }, push?: { all?, topics? } }` and changes only
|
|
1240
|
+
what it names, per channel: `all: false` opts out of the whole channel,
|
|
1241
|
+
`topics: { newsletter: false }` of one topic on it, and `all: true` clears
|
|
1242
|
+
the channel-wide opt-out only (a topic opted out on its own stays off until
|
|
1243
|
+
named). An answer's `all: false` masks every topic of that channel, whatever
|
|
1244
|
+
the topic's own flag says. An undeclared topic, an unknown channel or a
|
|
1245
|
+
non-boolean answers 400 with the field paths; an erased user 410. Changing
|
|
1246
|
+
`email.all` wakes the waits on `reachable('email')`, `push.all` those on
|
|
1247
|
+
`reachable('push')`.
|
|
1248
|
+
- `GET /subscriptions/by-message/:messageId` answers the same record for the
|
|
1249
|
+
recipient of that copy, plus `message: { channel, topic }` (`channel` is
|
|
1250
|
+
`email` or `push`, the channel the copy went out on); `PUT` takes the same
|
|
1251
|
+
patch as `/subscriptions/:userId` and answers as the GET does. The message id
|
|
1252
|
+
is the capability (the `m` of an email's `preferencesUrl` link), so the app
|
|
1253
|
+
proxies and rate-limits these routes behind its own API; the bearer still
|
|
1254
|
+
guards them. An id that names no copy — never sent, not a UUID, or erased
|
|
1255
|
+
with its user — or a transactional copy (`topic` null) answers 404, the rule
|
|
1256
|
+
`/t/u` applies; a user whose erasure is pending answers 410, as above.
|
|
1257
|
+
- `POST /users/forget` takes `{ userId }`; the erasure takes the user's opt-outs
|
|
1258
|
+
with the rest, since nothing is ever sent to that id again.
|
|
1092
1259
|
- `POST /deploy` takes a compiled bundle and swaps the routing table atomically.
|
|
1093
1260
|
|
|
1094
1261
|
`journeyWorker` adds `GET /bundle` (the compiled config), `POST /bundle/deploy`
|
|
1095
1262
|
(deploy it), `GET /preview/<key>`, `POST /segments/sweep`, and the `/t/...`
|
|
1096
|
-
tracking routes recipients open, which need no bearer.
|
|
1097
|
-
|
|
1098
|
-
|
|
1263
|
+
tracking routes recipients open, which need no bearer. `POST /t/u/<message>`
|
|
1264
|
+
(the one-click unsubscribe, and the confirmation page's first button) opts the
|
|
1265
|
+
recipient out of that email's topic; with `?scope=all` (the second button) out
|
|
1266
|
+
of every email. A transactional email never carried the link, so every `/t/u`
|
|
1267
|
+
variant answers 404 for it. With a contract, an event or identify it refuses is
|
|
1268
|
+
answered with a 400 and the field paths, before anything is stored, woken or
|
|
1269
|
+
started.
|
|
1099
1270
|
|
|
1100
1271
|
### Give each event an `id`
|
|
1101
1272
|
|
package/dist/aws/index.cjs
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
2
|
const require_http2_client = require("../http2-client.cjs");
|
|
3
3
|
const require_engine_retry = require("../engine/retry.cjs");
|
|
4
|
+
const require_host_ingest = require("../host/ingest.cjs");
|
|
4
5
|
const require_aws_engine_step = require("./engine-step.cjs");
|
|
5
6
|
const require_aws_function_url = require("./function-url.cjs");
|
|
6
7
|
const require_engine_duration = require("../engine/duration.cjs");
|
|
7
8
|
const require_host_fresh_read = require("../host/fresh-read.cjs");
|
|
8
|
-
const require_host_ingest = require("../host/ingest.cjs");
|
|
9
9
|
const require_host_execute = require("../host/execute.cjs");
|
|
10
10
|
const require_host_auth = require("../host/auth.cjs");
|
|
11
11
|
const require_host_journey_host = require("../host/journey-host.cjs");
|
package/dist/aws/index.mjs
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { http2Fetch } from "../http2-client.mjs";
|
|
2
2
|
import { PermanentError } from "../engine/retry.mjs";
|
|
3
|
+
import { sweepMaintenance, sweepSegmentsPaged } from "../host/ingest.mjs";
|
|
3
4
|
import { AwsEngineStep, stepRetry } from "./engine-step.mjs";
|
|
4
5
|
import { fromResponse, isFunctionUrlEvent, toRequest } from "./function-url.mjs";
|
|
5
6
|
import { longestJourneyMs } from "../engine/duration.mjs";
|
|
6
7
|
import { readWithRetry } from "../host/fresh-read.mjs";
|
|
7
|
-
import { sweepMaintenance, sweepSegmentsPaged } from "../host/ingest.mjs";
|
|
8
8
|
import { executeJourney } from "../host/execute.mjs";
|
|
9
9
|
import { bearerMatches, timingSafeEqual } from "../host/auth.mjs";
|
|
10
10
|
import { createJourneyHost } from "../host/journey-host.mjs";
|
package/dist/client.cjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
const require_host_ingest = require("./host/ingest.cjs");
|
|
2
3
|
// src/client.ts
|
|
3
4
|
var JourneyClientError = class extends Error {
|
|
4
5
|
path;
|
|
@@ -104,22 +105,85 @@ var JourneyClient = class {
|
|
|
104
105
|
removeDevice(token) {
|
|
105
106
|
return this.post("/devices/remove", { token });
|
|
106
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* Where the user stands, topic × channel: for `email` and for `push`, `all` (false once they
|
|
110
|
+
* opted out of the whole channel) and each declared topic (false once they opted out of it on
|
|
111
|
+
* that channel) — the engine's record, which a preference page shows as it is. The two are
|
|
112
|
+
* independent flags: a topic answers its own opt-out even while `all` is false, so `all: false`
|
|
113
|
+
* masks every topic however they read (nothing is sent on the channel until it is true again).
|
|
114
|
+
* Rejects with a 410 for an erased user.
|
|
115
|
+
*/
|
|
116
|
+
getSubscriptions(userId) {
|
|
117
|
+
return this.request("GET", subscriptionsPath(userId));
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Set subscriptions: only what the patch names changes, per channel (`{ email: { topics: {
|
|
121
|
+
* newsletter: false } } }` opts out of one topic's emails, `{ push: { all: false } }` of every
|
|
122
|
+
* push — the in-app opt-out App Store Review Guideline 4.5.4 asks for). `all: true` clears the
|
|
123
|
+
* channel-wide opt-out only: a topic opted out on its own stays off until the patch names it,
|
|
124
|
+
* so a preference page's "subscribe to all" sends `all: true` plus every topic `true`. Answers
|
|
125
|
+
* the subscriptions as they now stand. With a contract, a topic it does not declare is refused
|
|
126
|
+
* here, before the request; the engine refuses it too.
|
|
127
|
+
*/
|
|
128
|
+
setSubscriptions(userId, patch) {
|
|
129
|
+
const refused = this.undeclaredTopics(patch);
|
|
130
|
+
if (refused !== void 0) return Promise.reject(refused);
|
|
131
|
+
return this.request("PUT", subscriptionsPath(userId), patch);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* `getSubscriptions` for the recipient of one copy, by the message id an email's preferences
|
|
135
|
+
* link carries (`email.preferencesUrl?m=<message id>`): how a preference page works without a
|
|
136
|
+
* sign-in. The answer adds `message: { channel, topic }`, the copy the page was opened from,
|
|
137
|
+
* so it can highlight that topic. Rejects with a 404 for an id that names no copy — never
|
|
138
|
+
* sent, or erased with its user — and for a transactional one; the page should show the link
|
|
139
|
+
* as expired. The message id is the capability, so the app proxies this behind its own API
|
|
140
|
+
* and rate-limits it.
|
|
141
|
+
*/
|
|
142
|
+
getSubscriptionsByMessage(messageId) {
|
|
143
|
+
return this.request("GET", subscriptionsByMessagePath(messageId));
|
|
144
|
+
}
|
|
145
|
+
/** `setSubscriptions` for the recipient of one copy; the patch and the answer are those of `getSubscriptionsByMessage`. */
|
|
146
|
+
setSubscriptionsByMessage(messageId, patch) {
|
|
147
|
+
const refused = this.undeclaredTopics(patch);
|
|
148
|
+
if (refused !== void 0) return Promise.reject(refused);
|
|
149
|
+
return this.request("PUT", subscriptionsByMessagePath(messageId), patch);
|
|
150
|
+
}
|
|
151
|
+
/** With a contract, the refusal of a patch naming a topic it does not declare; undefined when the patch is fine. */
|
|
152
|
+
undeclaredTopics(patch) {
|
|
153
|
+
const contract = this.options.contract;
|
|
154
|
+
if (contract === void 0) return void 0;
|
|
155
|
+
const issues = [];
|
|
156
|
+
for (const channel of require_host_ingest.SUBSCRIPTION_CHANNELS) for (const topic of Object.keys(patch[channel]?.topics ?? {})) {
|
|
157
|
+
if (contract.topics.includes(topic)) continue;
|
|
158
|
+
issues.push({
|
|
159
|
+
path: `${channel}.topics.${topic}`,
|
|
160
|
+
message: "not a declared topic"
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
return issues.length > 0 ? new JourneyValidationError("/subscriptions", void 0, issues) : void 0;
|
|
164
|
+
}
|
|
107
165
|
/** Deploy a compiled bundle (BundleIR). */
|
|
108
166
|
deploy(bundle) {
|
|
109
167
|
return this.post("/deploy", bundle);
|
|
110
168
|
}
|
|
111
|
-
|
|
112
|
-
|
|
169
|
+
post(path, body) {
|
|
170
|
+
return this.request("POST", path, body);
|
|
171
|
+
}
|
|
172
|
+
async request(method, path, body) {
|
|
173
|
+
const headers = {};
|
|
174
|
+
if (body !== void 0) headers["content-type"] = "application/json";
|
|
113
175
|
if (this.options.token !== void 0) headers.authorization = `Bearer ${this.options.token}`;
|
|
114
176
|
const response = await (this.options.fetch ?? fetch)(`${this.options.origin.replace(/\/$/, "")}${path}`, {
|
|
115
|
-
method
|
|
177
|
+
method,
|
|
116
178
|
headers,
|
|
117
|
-
body: JSON.stringify(body)
|
|
179
|
+
...body === void 0 ? {} : { body: JSON.stringify(body) }
|
|
118
180
|
});
|
|
119
181
|
if (!response.ok) throw new JourneyClientError(path, response.status, await response.text());
|
|
120
182
|
return await response.json();
|
|
121
183
|
}
|
|
122
184
|
};
|
|
185
|
+
const subscriptionsPath = (userId) => `/subscriptions/${encodeURIComponent(userId)}`;
|
|
186
|
+
const subscriptionsByMessagePath = (messageId) => `/subscriptions/by-message/${encodeURIComponent(messageId)}`;
|
|
123
187
|
exports.JourneyClient = JourneyClient;
|
|
124
188
|
exports.JourneyClientError = JourneyClientError;
|
|
125
189
|
exports.JourneyValidationError = JourneyValidationError;
|
package/dist/client.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.cjs","names":[],"sources":["../src/client.ts"],"sourcesContent":["import type * as core from 'zod/v4/core';\nimport type { Contract, ContractIssue, ProfilePatch } from './contract';\nimport type { DeployResult, IdentifyResult, IngestResult } from './host/ingest';\nimport type { ProfileProps } from './store';\n\n/**\n * `@shware/workflow/client` — the producer side of the ingest API, for the\n * service that knows what users do (an api server, a webhook handler). Plain\n * fetch, no dependencies; errors carry the status and body so a caller can\n * log them without failing the request that triggered the report.\n */\n\nexport interface JourneyClientOptions<C extends Contract | undefined = undefined> {\n /**\n * The shared `defineContract(...)`: types `track` (the event name picks the\n * payload type) and `identify`, and checks both before anything is sent.\n */\n contract?: C;\n /** The journey Worker's origin, e.g. `https://workflow.example.com`. */\n origin: string;\n /** Its API_TOKEN, sent as a bearer; omit against an open local instance. */\n token?: string;\n /** Fetch implementation (defaults to the global one). */\n fetch?: typeof fetch;\n}\n\nexport class JourneyClientError extends Error {\n constructor(\n readonly path: string,\n readonly status: number,\n readonly body: string\n ) {\n super(`journey ${path} failed: ${status} ${body}`);\n this.name = 'JourneyClientError';\n }\n}\n\n/** A payload or profile patch the contract refuses — raised before the request, with the field paths. */\nexport class JourneyValidationError extends Error {\n constructor(\n readonly path: string,\n readonly event: string | undefined,\n readonly issues: ContractIssue[]\n ) {\n super(\n `journey ${path}${event === undefined ? '' : ` '${event}'`} refused by the contract: ${issues\n .map((i) => `${i.path || '(root)'} ${i.message}`)\n .join('; ')}`\n );\n this.name = 'JourneyValidationError';\n }\n}\n\ntype EventsOf<C> = C extends Contract<infer E> ? E : never;\n/** Event names: the contract's, or any string without one. */\nexport type ClientEventName<C> = [C] extends [undefined] ? string : keyof EventsOf<C> & string;\ntype TrackArgs<C, K> = [C] extends [undefined]\n ? [payload?: Record<string, unknown>, options?: TrackOptions]\n : K extends keyof EventsOf<C>\n ? [payload: core.input<EventsOf<C>[K]>, options?: TrackOptions]\n : never;\ntype IdentifyProps<C> = [C] extends [undefined]\n ? ProfileProps\n : C extends Contract<Record<string, core.$ZodObject>, infer P>\n ? ProfilePatch<P>\n : never;\n\nexport interface TrackOptions {\n /** The occurrence's identity: a retry with the same id is recorded once. */\n id?: string;\n /** When it happened (epoch ms); the Worker's clock when omitted. */\n ts?: number;\n}\n\nexport class JourneyClient<C extends Contract | undefined = undefined> {\n constructor(private readonly options: JourneyClientOptions<C>) {}\n\n /** Merge profile properties (`null` clears one). Every property a journey reads must be identified before the event that starts it. */\n identify(userId: string, props: IdentifyProps<C>): Promise<IdentifyResult> {\n const contract = this.options.contract;\n if (contract !== undefined) {\n const result = contract.parseProfilePatch(props);\n if (!result.ok)\n return Promise.reject(new JourneyValidationError('/identify', undefined, result.issues));\n }\n return this.post<IdentifyResult>('/identify', { userId, props });\n }\n\n /**\n * Record an event; the Worker starts every journey whose trigger matches it\n * and wakes waits on it. A caller that retries should pass the same `id`\n * each time: the Worker stores one occurrence per id, so a retry after a\n * timeout cannot double-count the event. `ts` defaults to the Worker's clock.\n */\n track<K extends ClientEventName<C>>(\n userId: string,\n event: K,\n ...args: TrackArgs<C, K>\n ): Promise<IngestResult> {\n const [payload = {}, options = {}] = args as [Record<string, unknown>?, TrackOptions?];\n const contract = this.options.contract;\n if (contract !== undefined) {\n const result = contract.parseEvent(event, payload);\n if (!result.ok)\n return Promise.reject(new JourneyValidationError('/events', event, result.issues));\n }\n return this.post<IngestResult>('/events', {\n userId,\n event,\n payload,\n ...(options.id === undefined ? {} : { id: options.id }),\n ...(options.ts === undefined ? {} : { ts: options.ts }),\n });\n }\n\n /**\n * A push token the app registered for the user (call it on every registration: a token moves\n * to the latest user). The engine keeps devices itself — pushes go to them, a token APNs / FCM\n * reports as gone is dropped — and journeys waiting on `reachable('push')` wake.\n */\n registerDevice(\n userId: string,\n device: { token: string; platform: 'ios' | 'android' | 'web'; ts?: number }\n ): Promise<{ ok: true; woke: number }> {\n const { token, platform, ts } = device;\n return this.post('/devices', {\n userId,\n token,\n platform,\n ...(ts === undefined ? {} : { ts }),\n });\n }\n\n /**\n * A journey push tapped open: the `message_id` the push carried, reported for the user who\n * tapped it. Recorded as a human open on the copy (an email's opens are tracked by the Worker).\n */\n messageOpened(userId: string, messageId: string): Promise<{ ok: true; recorded: boolean }> {\n return this.post('/messages/opened', { userId, messageId });\n }\n\n /**\n * A deleted account: the engine records the erasure, stops the user's running journeys, then\n * erases everything it keeps about them (profile, devices, events, reporting). Once this\n * resolves the request is durable: `erased: false` means an instance would not stop yet, and\n * the Worker's scheduled sweep finishes the job.\n */\n forgetUser(userId: string): Promise<{ ok: true; erased: boolean; pending: string[] }> {\n return this.post('/users/forget', { userId });\n }\n\n /** The user signed out on that device: the token stays registered, bound to nobody. */\n unbindDevice(token: string): Promise<{ ok: true; woke: number }> {\n return this.post('/devices/unbind', { token });\n }\n\n /** Forget a push token for good (an uninstall the app learned of, a token it replaced). */\n removeDevice(token: string): Promise<{ ok: true; woke: number }> {\n return this.post('/devices/remove', { token });\n }\n\n /** Deploy a compiled bundle (BundleIR). */\n deploy(bundle: unknown): Promise<DeployResult> {\n return this.post<DeployResult>('/deploy', bundle);\n }\n\n private async post<T>(path: string, body: unknown): Promise<T> {\n const headers: Record<string, string> = { 'content-type': 'application/json' };\n if (this.options.token !== undefined) headers.authorization = `Bearer ${this.options.token}`;\n const doFetch = this.options.fetch ?? fetch;\n const response = await doFetch(`${this.options.origin.replace(/\\/$/, '')}${path}`, {\n method: 'POST',\n headers,\n body: JSON.stringify(body),\n });\n if (!response.ok) throw new JourneyClientError(path, response.status, await response.text());\n return (await response.json()) as T;\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"client.cjs","names":["SUBSCRIPTION_CHANNELS"],"sources":["../src/client.ts"],"sourcesContent":["import type * as core from 'zod/v4/core';\nimport type { Contract, ContractIssue, ProfilePatch, Topic } from './contract';\nimport {\n type DeployResult,\n type IdentifyResult,\n type IngestResult,\n type MessageSubscriptions,\n SUBSCRIPTION_CHANNELS,\n type Subscriptions,\n type SubscriptionsPatch,\n} from './host/ingest';\nimport type { ProfileProps } from './store';\n\n/**\n * `@shware/workflow/client` — the producer side of the ingest API, for the\n * service that knows what users do (an api server, a webhook handler). Plain\n * fetch, no dependencies; errors carry the status and body so a caller can\n * log them without failing the request that triggered the report.\n */\n\nexport interface JourneyClientOptions<C extends Contract | undefined = undefined> {\n /**\n * The shared `defineContract(...)`: types `track` (the event name picks the\n * payload type) and `identify`, and checks both before anything is sent.\n */\n contract?: C;\n /** The journey Worker's origin, e.g. `https://workflow.example.com`. */\n origin: string;\n /** Its API_TOKEN, sent as a bearer; omit against an open local instance. */\n token?: string;\n /** Fetch implementation (defaults to the global one). */\n fetch?: typeof fetch;\n}\n\nexport class JourneyClientError extends Error {\n constructor(\n readonly path: string,\n readonly status: number,\n readonly body: string\n ) {\n super(`journey ${path} failed: ${status} ${body}`);\n this.name = 'JourneyClientError';\n }\n}\n\n/** A payload or profile patch the contract refuses — raised before the request, with the field paths. */\nexport class JourneyValidationError extends Error {\n constructor(\n readonly path: string,\n readonly event: string | undefined,\n readonly issues: ContractIssue[]\n ) {\n super(\n `journey ${path}${event === undefined ? '' : ` '${event}'`} refused by the contract: ${issues\n .map((i) => `${i.path || '(root)'} ${i.message}`)\n .join('; ')}`\n );\n this.name = 'JourneyValidationError';\n }\n}\n\ntype EventsOf<C> = C extends Contract<infer E> ? E : never;\n/** Event names: the contract's, or any string without one. */\nexport type ClientEventName<C> = [C] extends [undefined] ? string : keyof EventsOf<C> & string;\ntype TrackArgs<C, K> = [C] extends [undefined]\n ? [payload?: Record<string, unknown>, options?: TrackOptions]\n : K extends keyof EventsOf<C>\n ? [payload: core.input<EventsOf<C>[K]>, options?: TrackOptions]\n : never;\ntype IdentifyProps<C> = [C] extends [undefined]\n ? ProfileProps\n : C extends Contract<Record<string, core.$ZodObject>, infer P>\n ? ProfilePatch<P>\n : never;\n\n/** The topics the client's contract declares; any string without a contract. */\nexport type ClientTopic<C> = [C] extends [undefined] ? string : Topic<C>;\n\nexport type {\n ChannelSubscriptions,\n ChannelSubscriptionsPatch,\n MessageOrigin,\n MessageSubscriptions,\n SubscriptionChannel,\n Subscriptions,\n SubscriptionsPatch,\n} from './host/ingest';\n\nexport interface TrackOptions {\n /** The occurrence's identity: a retry with the same id is recorded once. */\n id?: string;\n /** When it happened (epoch ms); the Worker's clock when omitted. */\n ts?: number;\n}\n\nexport class JourneyClient<C extends Contract | undefined = undefined> {\n constructor(private readonly options: JourneyClientOptions<C>) {}\n\n /** Merge profile properties (`null` clears one). Every property a journey reads must be identified before the event that starts it. */\n identify(userId: string, props: IdentifyProps<C>): Promise<IdentifyResult> {\n const contract = this.options.contract;\n if (contract !== undefined) {\n const result = contract.parseProfilePatch(props);\n if (!result.ok)\n return Promise.reject(new JourneyValidationError('/identify', undefined, result.issues));\n }\n return this.post<IdentifyResult>('/identify', { userId, props });\n }\n\n /**\n * Record an event; the Worker starts every journey whose trigger matches it\n * and wakes waits on it. A caller that retries should pass the same `id`\n * each time: the Worker stores one occurrence per id, so a retry after a\n * timeout cannot double-count the event. `ts` defaults to the Worker's clock.\n */\n track<K extends ClientEventName<C>>(\n userId: string,\n event: K,\n ...args: TrackArgs<C, K>\n ): Promise<IngestResult> {\n const [payload = {}, options = {}] = args as [Record<string, unknown>?, TrackOptions?];\n const contract = this.options.contract;\n if (contract !== undefined) {\n const result = contract.parseEvent(event, payload);\n if (!result.ok)\n return Promise.reject(new JourneyValidationError('/events', event, result.issues));\n }\n return this.post<IngestResult>('/events', {\n userId,\n event,\n payload,\n ...(options.id === undefined ? {} : { id: options.id }),\n ...(options.ts === undefined ? {} : { ts: options.ts }),\n });\n }\n\n /**\n * A push token the app registered for the user (call it on every registration: a token moves\n * to the latest user). The engine keeps devices itself — pushes go to them, a token APNs / FCM\n * reports as gone is dropped — and journeys waiting on `reachable('push')` wake.\n */\n registerDevice(\n userId: string,\n device: { token: string; platform: 'ios' | 'android' | 'web'; ts?: number }\n ): Promise<{ ok: true; woke: number }> {\n const { token, platform, ts } = device;\n return this.post('/devices', {\n userId,\n token,\n platform,\n ...(ts === undefined ? {} : { ts }),\n });\n }\n\n /**\n * A journey push tapped open: the `message_id` the push carried, reported for the user who\n * tapped it. Recorded as a human open on the copy (an email's opens are tracked by the Worker).\n */\n messageOpened(userId: string, messageId: string): Promise<{ ok: true; recorded: boolean }> {\n return this.post('/messages/opened', { userId, messageId });\n }\n\n /**\n * A deleted account: the engine records the erasure, stops the user's running journeys, then\n * erases everything it keeps about them (profile, devices, events, reporting). Once this\n * resolves the request is durable: `erased: false` means an instance would not stop yet, and\n * the Worker's scheduled sweep finishes the job.\n */\n forgetUser(userId: string): Promise<{ ok: true; erased: boolean; pending: string[] }> {\n return this.post('/users/forget', { userId });\n }\n\n /** The user signed out on that device: the token stays registered, bound to nobody. */\n unbindDevice(token: string): Promise<{ ok: true; woke: number }> {\n return this.post('/devices/unbind', { token });\n }\n\n /** Forget a push token for good (an uninstall the app learned of, a token it replaced). */\n removeDevice(token: string): Promise<{ ok: true; woke: number }> {\n return this.post('/devices/remove', { token });\n }\n\n /**\n * Where the user stands, topic × channel: for `email` and for `push`, `all` (false once they\n * opted out of the whole channel) and each declared topic (false once they opted out of it on\n * that channel) — the engine's record, which a preference page shows as it is. The two are\n * independent flags: a topic answers its own opt-out even while `all` is false, so `all: false`\n * masks every topic however they read (nothing is sent on the channel until it is true again).\n * Rejects with a 410 for an erased user.\n */\n getSubscriptions(userId: string): Promise<Subscriptions<ClientTopic<C>>> {\n return this.request('GET', subscriptionsPath(userId));\n }\n\n /**\n * Set subscriptions: only what the patch names changes, per channel (`{ email: { topics: {\n * newsletter: false } } }` opts out of one topic's emails, `{ push: { all: false } }` of every\n * push — the in-app opt-out App Store Review Guideline 4.5.4 asks for). `all: true` clears the\n * channel-wide opt-out only: a topic opted out on its own stays off until the patch names it,\n * so a preference page's \"subscribe to all\" sends `all: true` plus every topic `true`. Answers\n * the subscriptions as they now stand. With a contract, a topic it does not declare is refused\n * here, before the request; the engine refuses it too.\n */\n setSubscriptions(\n userId: string,\n patch: SubscriptionsPatch<ClientTopic<C>>\n ): Promise<Subscriptions<ClientTopic<C>>> {\n const refused = this.undeclaredTopics(patch);\n if (refused !== undefined) return Promise.reject(refused);\n return this.request('PUT', subscriptionsPath(userId), patch);\n }\n\n /**\n * `getSubscriptions` for the recipient of one copy, by the message id an email's preferences\n * link carries (`email.preferencesUrl?m=<message id>`): how a preference page works without a\n * sign-in. The answer adds `message: { channel, topic }`, the copy the page was opened from,\n * so it can highlight that topic. Rejects with a 404 for an id that names no copy — never\n * sent, or erased with its user — and for a transactional one; the page should show the link\n * as expired. The message id is the capability, so the app proxies this behind its own API\n * and rate-limits it.\n */\n getSubscriptionsByMessage(messageId: string): Promise<MessageSubscriptions<ClientTopic<C>>> {\n return this.request('GET', subscriptionsByMessagePath(messageId));\n }\n\n /** `setSubscriptions` for the recipient of one copy; the patch and the answer are those of `getSubscriptionsByMessage`. */\n setSubscriptionsByMessage(\n messageId: string,\n patch: SubscriptionsPatch<ClientTopic<C>>\n ): Promise<MessageSubscriptions<ClientTopic<C>>> {\n const refused = this.undeclaredTopics(patch);\n if (refused !== undefined) return Promise.reject(refused);\n return this.request('PUT', subscriptionsByMessagePath(messageId), patch);\n }\n\n /** With a contract, the refusal of a patch naming a topic it does not declare; undefined when the patch is fine. */\n private undeclaredTopics(\n patch: SubscriptionsPatch<ClientTopic<C>>\n ): JourneyValidationError | undefined {\n const contract = this.options.contract;\n if (contract === undefined) return undefined;\n const issues: ContractIssue[] = [];\n for (const channel of SUBSCRIPTION_CHANNELS) {\n for (const topic of Object.keys(patch[channel]?.topics ?? {})) {\n // `includes`, not the predicate: narrowing `string` by a `topic is string` leaves `never`\n if (contract.topics.includes(topic)) continue;\n issues.push({ path: `${channel}.topics.${topic}`, message: 'not a declared topic' });\n }\n }\n return issues.length > 0\n ? new JourneyValidationError('/subscriptions', undefined, issues)\n : undefined;\n }\n\n /** Deploy a compiled bundle (BundleIR). */\n deploy(bundle: unknown): Promise<DeployResult> {\n return this.post<DeployResult>('/deploy', bundle);\n }\n\n private post<T>(path: string, body: unknown): Promise<T> {\n return this.request<T>('POST', path, body);\n }\n\n private async request<T>(\n method: 'GET' | 'POST' | 'PUT',\n path: string,\n body?: unknown\n ): Promise<T> {\n const headers: Record<string, string> = {};\n if (body !== undefined) headers['content-type'] = 'application/json';\n if (this.options.token !== undefined) headers.authorization = `Bearer ${this.options.token}`;\n const doFetch = this.options.fetch ?? fetch;\n const response = await doFetch(`${this.options.origin.replace(/\\/$/, '')}${path}`, {\n method,\n headers,\n ...(body === undefined ? {} : { body: JSON.stringify(body) }),\n });\n if (!response.ok) throw new JourneyClientError(path, response.status, await response.text());\n return (await response.json()) as T;\n }\n}\n\nconst subscriptionsPath = (userId: string) => `/subscriptions/${encodeURIComponent(userId)}`;\nconst subscriptionsByMessagePath = (messageId: string) =>\n `/subscriptions/by-message/${encodeURIComponent(messageId)}`;\n"],"mappings":"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"}
|