@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.
Files changed (190) hide show
  1. package/README.md +224 -53
  2. package/dist/aws/index.cjs +1 -1
  3. package/dist/aws/index.mjs +1 -1
  4. package/dist/client.cjs +68 -4
  5. package/dist/client.cjs.map +1 -1
  6. package/dist/client.d.cts +39 -3
  7. package/dist/client.d.cts.map +1 -1
  8. package/dist/client.d.mts +39 -3
  9. package/dist/client.d.mts.map +1 -1
  10. package/dist/client.mjs +68 -4
  11. package/dist/client.mjs.map +1 -1
  12. package/dist/cloudflare/index.cjs +4 -4
  13. package/dist/cloudflare/index.mjs +4 -4
  14. package/dist/cloudflare/runner.cjs +1 -1
  15. package/dist/cloudflare/runner.d.cts +1 -1
  16. package/dist/cloudflare/runner.d.mts +1 -1
  17. package/dist/cloudflare/runner.mjs +1 -1
  18. package/dist/config.cjs +24 -3
  19. package/dist/config.cjs.map +1 -1
  20. package/dist/config.d.cts +15 -9
  21. package/dist/config.d.cts.map +1 -1
  22. package/dist/config.d.mts +15 -9
  23. package/dist/config.d.mts.map +1 -1
  24. package/dist/config.mjs +25 -4
  25. package/dist/config.mjs.map +1 -1
  26. package/dist/contract.cjs +11 -0
  27. package/dist/contract.cjs.map +1 -1
  28. package/dist/contract.d.cts +28 -7
  29. package/dist/contract.d.cts.map +1 -1
  30. package/dist/contract.d.mts +28 -7
  31. package/dist/contract.d.mts.map +1 -1
  32. package/dist/contract.mjs +11 -0
  33. package/dist/contract.mjs.map +1 -1
  34. package/dist/dsl/bundle.cjs +4 -3
  35. package/dist/dsl/bundle.cjs.map +1 -1
  36. package/dist/dsl/bundle.d.cts +5 -1
  37. package/dist/dsl/bundle.d.cts.map +1 -1
  38. package/dist/dsl/bundle.d.mts +5 -1
  39. package/dist/dsl/bundle.d.mts.map +1 -1
  40. package/dist/dsl/bundle.mjs +4 -3
  41. package/dist/dsl/bundle.mjs.map +1 -1
  42. package/dist/dsl/flow.cjs +2 -1
  43. package/dist/dsl/flow.cjs.map +1 -1
  44. package/dist/dsl/flow.d.cts +1 -1
  45. package/dist/dsl/flow.d.cts.map +1 -1
  46. package/dist/dsl/flow.d.mts +1 -1
  47. package/dist/dsl/flow.d.mts.map +1 -1
  48. package/dist/dsl/flow.mjs +2 -1
  49. package/dist/dsl/flow.mjs.map +1 -1
  50. package/dist/dsl/workflow.cjs +2 -2
  51. package/dist/dsl/workflow.mjs +2 -2
  52. package/dist/email-sender.cjs +3 -2
  53. package/dist/email-sender.cjs.map +1 -1
  54. package/dist/email-sender.d.cts +5 -4
  55. package/dist/email-sender.d.cts.map +1 -1
  56. package/dist/email-sender.d.mts +5 -4
  57. package/dist/email-sender.d.mts.map +1 -1
  58. package/dist/email-sender.mjs +3 -2
  59. package/dist/email-sender.mjs.map +1 -1
  60. package/dist/email.cjs +66 -11
  61. package/dist/email.cjs.map +1 -1
  62. package/dist/email.d.cts +72 -12
  63. package/dist/email.d.cts.map +1 -1
  64. package/dist/email.d.mts +72 -12
  65. package/dist/email.d.mts.map +1 -1
  66. package/dist/email.mjs +63 -12
  67. package/dist/email.mjs.map +1 -1
  68. package/dist/engine/condition.cjs.map +1 -1
  69. package/dist/engine/condition.d.cts.map +1 -1
  70. package/dist/engine/condition.d.mts.map +1 -1
  71. package/dist/engine/condition.mjs.map +1 -1
  72. package/dist/engine/index.cjs +1 -1
  73. package/dist/engine/index.mjs +1 -1
  74. package/dist/engine/interpreter.cjs +21 -4
  75. package/dist/engine/interpreter.cjs.map +1 -1
  76. package/dist/engine/interpreter.mjs +21 -4
  77. package/dist/engine/interpreter.mjs.map +1 -1
  78. package/dist/engine/ports.d.cts +14 -2
  79. package/dist/engine/ports.d.cts.map +1 -1
  80. package/dist/engine/ports.d.mts +14 -2
  81. package/dist/engine/ports.d.mts.map +1 -1
  82. package/dist/host/channels.cjs +1 -1
  83. package/dist/host/channels.mjs +1 -1
  84. package/dist/host/execute.cjs +2 -2
  85. package/dist/host/execute.d.cts +1 -1
  86. package/dist/host/execute.d.mts +1 -1
  87. package/dist/host/execute.mjs +2 -2
  88. package/dist/host/http.cjs +75 -5
  89. package/dist/host/http.cjs.map +1 -1
  90. package/dist/host/http.d.cts +2 -2
  91. package/dist/host/http.d.cts.map +1 -1
  92. package/dist/host/http.d.mts +2 -2
  93. package/dist/host/http.d.mts.map +1 -1
  94. package/dist/host/http.mjs +76 -6
  95. package/dist/host/http.mjs.map +1 -1
  96. package/dist/host/ingest.cjs +173 -4
  97. package/dist/host/ingest.cjs.map +1 -1
  98. package/dist/host/ingest.d.cts +99 -0
  99. package/dist/host/ingest.d.cts.map +1 -1
  100. package/dist/host/ingest.d.mts +99 -0
  101. package/dist/host/ingest.d.mts.map +1 -1
  102. package/dist/host/ingest.mjs +167 -5
  103. package/dist/host/ingest.mjs.map +1 -1
  104. package/dist/host/journey-host.cjs +10 -8
  105. package/dist/host/journey-host.cjs.map +1 -1
  106. package/dist/host/journey-host.mjs +10 -8
  107. package/dist/host/journey-host.mjs.map +1 -1
  108. package/dist/index.cjs +8 -2
  109. package/dist/index.d.cts +5 -5
  110. package/dist/index.d.mts +5 -5
  111. package/dist/index.mjs +5 -5
  112. package/dist/ir.cjs +11 -2
  113. package/dist/ir.cjs.map +1 -1
  114. package/dist/ir.d.cts +17 -0
  115. package/dist/ir.d.cts.map +1 -1
  116. package/dist/ir.d.mts +17 -0
  117. package/dist/ir.d.mts.map +1 -1
  118. package/dist/ir.mjs +11 -3
  119. package/dist/ir.mjs.map +1 -1
  120. package/dist/react-email.cjs +6 -5
  121. package/dist/react-email.cjs.map +1 -1
  122. package/dist/react-email.d.cts.map +1 -1
  123. package/dist/react-email.d.mts.map +1 -1
  124. package/dist/react-email.mjs +6 -5
  125. package/dist/react-email.mjs.map +1 -1
  126. package/dist/reporting/observer.cjs +4 -1
  127. package/dist/reporting/observer.cjs.map +1 -1
  128. package/dist/reporting/observer.d.cts +2 -1
  129. package/dist/reporting/observer.d.cts.map +1 -1
  130. package/dist/reporting/observer.d.mts +2 -1
  131. package/dist/reporting/observer.d.mts.map +1 -1
  132. package/dist/reporting/observer.mjs +4 -1
  133. package/dist/reporting/observer.mjs.map +1 -1
  134. package/dist/reporting/tracking.cjs +31 -10
  135. package/dist/reporting/tracking.cjs.map +1 -1
  136. package/dist/reporting/tracking.d.cts +15 -6
  137. package/dist/reporting/tracking.d.cts.map +1 -1
  138. package/dist/reporting/tracking.d.mts +15 -6
  139. package/dist/reporting/tracking.d.mts.map +1 -1
  140. package/dist/reporting/tracking.mjs +31 -11
  141. package/dist/reporting/tracking.mjs.map +1 -1
  142. package/dist/store/facts.cjs +3 -3
  143. package/dist/store/facts.cjs.map +1 -1
  144. package/dist/store/facts.d.cts +2 -2
  145. package/dist/store/facts.d.cts.map +1 -1
  146. package/dist/store/facts.d.mts +2 -2
  147. package/dist/store/facts.d.mts.map +1 -1
  148. package/dist/store/facts.mjs +3 -3
  149. package/dist/store/facts.mjs.map +1 -1
  150. package/dist/store/index.cjs +1 -1
  151. package/dist/store/index.cjs.map +1 -1
  152. package/dist/store/index.d.cts +44 -13
  153. package/dist/store/index.d.cts.map +1 -1
  154. package/dist/store/index.d.mts +44 -13
  155. package/dist/store/index.d.mts.map +1 -1
  156. package/dist/store/index.mjs +1 -1
  157. package/dist/store/index.mjs.map +1 -1
  158. package/dist/store/postgres-reporting.cjs +30 -10
  159. package/dist/store/postgres-reporting.cjs.map +1 -1
  160. package/dist/store/postgres-reporting.d.cts +4 -2
  161. package/dist/store/postgres-reporting.d.cts.map +1 -1
  162. package/dist/store/postgres-reporting.d.mts +4 -2
  163. package/dist/store/postgres-reporting.d.mts.map +1 -1
  164. package/dist/store/postgres-reporting.mjs +30 -10
  165. package/dist/store/postgres-reporting.mjs.map +1 -1
  166. package/dist/store/postgres.cjs +39 -3
  167. package/dist/store/postgres.cjs.map +1 -1
  168. package/dist/store/postgres.d.cts +4 -2
  169. package/dist/store/postgres.d.cts.map +1 -1
  170. package/dist/store/postgres.d.mts +4 -2
  171. package/dist/store/postgres.d.mts.map +1 -1
  172. package/dist/store/postgres.mjs +40 -4
  173. package/dist/store/postgres.mjs.map +1 -1
  174. package/dist/store/tables.cjs +75 -11
  175. package/dist/store/tables.cjs.map +1 -1
  176. package/dist/store/tables.d.cts +17 -1
  177. package/dist/store/tables.d.cts.map +1 -1
  178. package/dist/store/tables.d.mts +17 -1
  179. package/dist/store/tables.d.mts.map +1 -1
  180. package/dist/store/tables.mjs +74 -12
  181. package/dist/store/tables.mjs.map +1 -1
  182. package/dist/templates.cjs +60 -7
  183. package/dist/templates.cjs.map +1 -1
  184. package/dist/templates.d.cts +101 -13
  185. package/dist/templates.d.cts.map +1 -1
  186. package/dist/templates.d.mts +101 -13
  187. package/dist/templates.d.mts.map +1 -1
  188. package/dist/templates.mjs +61 -9
  189. package/dist/templates.mjs.map +1 -1
  190. 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. The property names are JavaScript names
117
- only. `.email(email.welcome, props)` checks `props` against that template's
118
- schema, and a push template is not accepted where an email is. The compile
119
- refuses an id registered twice in a channel, and a workflow sending a
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/welcome.tsx
502
+ // src/email/tip.tsx
473
503
  export default emailTemplate({
474
- id: 'welcome',
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: 'Welcome',
508
+ title: 'Weekly tip',
478
509
  props: strictObject({ name: string() }),
479
- subject: 'Welcome to Acme',
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
- There is no global sender: share addresses through a module of your own. The
486
- unsubscribe link is the template's `unsubscribeUrl`, else the config's
487
- `email.unsubscribeUrl`, else none (`resolveEmail` is the rule). A layout reads
488
- it from the render, never from a constant, so a template's own URL reaches
489
- its HTML:
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, and the
531
- same URL opened in a browser asks first. Without reporting, the header names the template's own
532
- unsubscribe link, with no one-click claim. Only https URLs are named, so a local `http://`
533
- origin sends no header.
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 })`, where
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 | Does |
685
- | --------------------------------------------- | ----------------------- | -------------------------------------------------------- |
686
- | `identify(userId, props)` | `POST /identify` | Merge profile properties; `null` removes one |
687
- | `track(userId, event, payload, { id?, ts? })` | `POST /events` | Record an event; starts and wakes journeys |
688
- | `registerDevice(userId, { token, platform })` | `POST /devices` | Register a push token (`ios`, `android` or `web`) |
689
- | `unbindDevice(token)` | `POST /devices/unbind` | Signed out: the token stays, bound to nobody |
690
- | `removeDevice(token)` | `POST /devices/remove` | Forget a token for good |
691
- | `messageOpened(userId, messageId)` | `POST /messages/opened` | A journey push tapped open (the `message_id` it carried) |
692
- | `forgetUser(userId)` | `POST /users/forget` | Stop the user's journeys and erase their data |
693
- | `deploy(bundle)` | `POST /deploy` | Deploy a compiled bundle |
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 unique index
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
- - `POST /users/forget` takes `{ userId }`.
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. With a contract, an
1097
- event or identify it refuses is answered with a 400 and the field paths, before
1098
- anything is stored, woken or started.
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
 
@@ -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");
@@ -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
- async post(path, body) {
112
- const headers = { "content-type": "application/json" };
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: "POST",
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;
@@ -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":";;;AAkCA,IAAa,qBAAb,cAAwC,MAAM;CAEjC;CACA;CACA;CAHX,YACE,MACA,QACA,MACA;EACA,MAAM,WAAW,KAAK,WAAW,OAAO,GAAG,MAAM;EAJxC,KAAA,OAAA;EACA,KAAA,SAAA;EACA,KAAA,OAAA;EAGT,KAAK,OAAO;CACd;AACF;;AAGA,IAAa,yBAAb,cAA4C,MAAM;CAErC;CACA;CACA;CAHX,YACE,MACA,OACA,QACA;EACA,MACE,WAAW,OAAO,UAAU,KAAA,IAAY,KAAK,KAAK,MAAM,GAAG,4BAA4B,OACpF,KAAK,MAAM,GAAG,EAAE,QAAQ,SAAS,GAAG,EAAE,SAAS,CAAC,CAChD,KAAK,IAAI,GACd;EARS,KAAA,OAAA;EACA,KAAA,QAAA;EACA,KAAA,SAAA;EAOT,KAAK,OAAO;CACd;AACF;AAoCA,IAAa,gBAAb,MAAuE;CACxC;CAA7B,YAAY,SAAmD;EAAlC,KAAA,UAAA;CAAmC;;CAGhE,SAAS,QAAgB,OAAkD;EACzE,MAAM,WAAW,KAAK,QAAQ;EAC9B,IAAI,aAAa,KAAA,GAAW;GAC1B,MAAM,SAAS,SAAS,kBAAkB,KAAK;GAC/C,IAAI,CAAC,OAAO,IACV,OAAO,QAAQ,OAAO,IAAI,uBAAuB,aAAa,KAAA,GAAW,OAAO,MAAM,CAAC;EAC3F;EACA,OAAO,KAAK,KAAqB,aAAa;GAAE;GAAQ;EAAM,CAAC;CACjE;;;;;;;CAQA,MACE,QACA,OACA,GAAG,MACoB;EACvB,MAAM,CAAC,UAAU,CAAC,GAAG,UAAU,CAAC,KAAK;EACrC,MAAM,WAAW,KAAK,QAAQ;EAC9B,IAAI,aAAa,KAAA,GAAW;GAC1B,MAAM,SAAS,SAAS,WAAW,OAAO,OAAO;GACjD,IAAI,CAAC,OAAO,IACV,OAAO,QAAQ,OAAO,IAAI,uBAAuB,WAAW,OAAO,OAAO,MAAM,CAAC;EACrF;EACA,OAAO,KAAK,KAAmB,WAAW;GACxC;GACA;GACA;GACA,GAAI,QAAQ,OAAO,KAAA,IAAY,CAAC,IAAI,EAAE,IAAI,QAAQ,GAAG;GACrD,GAAI,QAAQ,OAAO,KAAA,IAAY,CAAC,IAAI,EAAE,IAAI,QAAQ,GAAG;EACvD,CAAC;CACH;;;;;;CAOA,eACE,QACA,QACqC;EACrC,MAAM,EAAE,OAAO,UAAU,OAAO;EAChC,OAAO,KAAK,KAAK,YAAY;GAC3B;GACA;GACA;GACA,GAAI,OAAO,KAAA,IAAY,CAAC,IAAI,EAAE,GAAG;EACnC,CAAC;CACH;;;;;CAMA,cAAc,QAAgB,WAA6D;EACzF,OAAO,KAAK,KAAK,oBAAoB;GAAE;GAAQ;EAAU,CAAC;CAC5D;;;;;;;CAQA,WAAW,QAA2E;EACpF,OAAO,KAAK,KAAK,iBAAiB,EAAE,OAAO,CAAC;CAC9C;;CAGA,aAAa,OAAoD;EAC/D,OAAO,KAAK,KAAK,mBAAmB,EAAE,MAAM,CAAC;CAC/C;;CAGA,aAAa,OAAoD;EAC/D,OAAO,KAAK,KAAK,mBAAmB,EAAE,MAAM,CAAC;CAC/C;;;;;;;;;CAUA,iBAAiB,QAAwD;EACvE,OAAO,KAAK,QAAQ,OAAO,kBAAkB,MAAM,CAAC;CACtD;;;;;;;;;;CAWA,iBACE,QACA,OACwC;EACxC,MAAM,UAAU,KAAK,iBAAiB,KAAK;EAC3C,IAAI,YAAY,KAAA,GAAW,OAAO,QAAQ,OAAO,OAAO;EACxD,OAAO,KAAK,QAAQ,OAAO,kBAAkB,MAAM,GAAG,KAAK;CAC7D;;;;;;;;;;CAWA,0BAA0B,WAAkE;EAC1F,OAAO,KAAK,QAAQ,OAAO,2BAA2B,SAAS,CAAC;CAClE;;CAGA,0BACE,WACA,OAC+C;EAC/C,MAAM,UAAU,KAAK,iBAAiB,KAAK;EAC3C,IAAI,YAAY,KAAA,GAAW,OAAO,QAAQ,OAAO,OAAO;EACxD,OAAO,KAAK,QAAQ,OAAO,2BAA2B,SAAS,GAAG,KAAK;CACzE;;CAGA,iBACE,OACoC;EACpC,MAAM,WAAW,KAAK,QAAQ;EAC9B,IAAI,aAAa,KAAA,GAAW,OAAO,KAAA;EACnC,MAAM,SAA0B,CAAC;EACjC,KAAK,MAAM,WAAWA,oBAAAA,uBACpB,KAAK,MAAM,SAAS,OAAO,KAAK,MAAM,QAAQ,EAAE,UAAU,CAAC,CAAC,GAAG;GAE7D,IAAI,SAAS,OAAO,SAAS,KAAK,GAAG;GACrC,OAAO,KAAK;IAAE,MAAM,GAAG,QAAQ,UAAU;IAAS,SAAS;GAAuB,CAAC;EACrF;EAEF,OAAO,OAAO,SAAS,IACnB,IAAI,uBAAuB,kBAAkB,KAAA,GAAW,MAAM,IAC9D,KAAA;CACN;;CAGA,OAAO,QAAwC;EAC7C,OAAO,KAAK,KAAmB,WAAW,MAAM;CAClD;CAEA,KAAgB,MAAc,MAA2B;EACvD,OAAO,KAAK,QAAW,QAAQ,MAAM,IAAI;CAC3C;CAEA,MAAc,QACZ,QACA,MACA,MACY;EACZ,MAAM,UAAkC,CAAC;EACzC,IAAI,SAAS,KAAA,GAAW,QAAQ,kBAAkB;EAClD,IAAI,KAAK,QAAQ,UAAU,KAAA,GAAW,QAAQ,gBAAgB,UAAU,KAAK,QAAQ;EAErF,MAAM,WAAW,OADD,KAAK,QAAQ,SAAS,MAAA,CACP,GAAG,KAAK,QAAQ,OAAO,QAAQ,OAAO,EAAE,IAAI,QAAQ;GACjF;GACA;GACA,GAAI,SAAS,KAAA,IAAY,CAAC,IAAI,EAAE,MAAM,KAAK,UAAU,IAAI,EAAE;EAC7D,CAAC;EACD,IAAI,CAAC,SAAS,IAAI,MAAM,IAAI,mBAAmB,MAAM,SAAS,QAAQ,MAAM,SAAS,KAAK,CAAC;EAC3F,OAAQ,MAAM,SAAS,KAAK;CAC9B;AACF;AAEA,MAAM,qBAAqB,WAAmB,kBAAkB,mBAAmB,MAAM;AACzF,MAAM,8BAA8B,cAClC,6BAA6B,mBAAmB,SAAS"}