@shware/workflow 6.0.0 → 6.1.1
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 +86 -32
- package/dist/client.cjs +34 -11
- package/dist/client.cjs.map +1 -1
- package/dist/client.d.cts +16 -2
- package/dist/client.d.cts.map +1 -1
- package/dist/client.d.mts +16 -2
- package/dist/client.d.mts.map +1 -1
- package/dist/client.mjs +34 -11
- package/dist/client.mjs.map +1 -1
- package/dist/cloudflare/index.cjs +2 -2
- package/dist/cloudflare/index.mjs +2 -2
- package/dist/email-sender.cjs +2 -1
- package/dist/email-sender.cjs.map +1 -1
- package/dist/email-sender.d.cts +4 -3
- package/dist/email-sender.d.cts.map +1 -1
- package/dist/email-sender.d.mts +4 -3
- package/dist/email-sender.d.mts.map +1 -1
- package/dist/email-sender.mjs +2 -1
- package/dist/email-sender.mjs.map +1 -1
- package/dist/email.cjs +38 -10
- package/dist/email.cjs.map +1 -1
- package/dist/email.d.cts +43 -12
- package/dist/email.d.cts.map +1 -1
- package/dist/email.d.mts +43 -12
- package/dist/email.d.mts.map +1 -1
- package/dist/email.mjs +36 -11
- package/dist/email.mjs.map +1 -1
- package/dist/engine/ports.d.cts +6 -0
- package/dist/engine/ports.d.cts.map +1 -1
- package/dist/engine/ports.d.mts +6 -0
- package/dist/engine/ports.d.mts.map +1 -1
- package/dist/host/channels.cjs +17 -1
- package/dist/host/channels.cjs.map +1 -1
- package/dist/host/channels.d.cts.map +1 -1
- package/dist/host/channels.d.mts.map +1 -1
- package/dist/host/channels.mjs +17 -1
- package/dist/host/channels.mjs.map +1 -1
- package/dist/host/http.cjs +39 -4
- package/dist/host/http.cjs.map +1 -1
- package/dist/host/http.d.cts +1 -1
- package/dist/host/http.d.cts.map +1 -1
- package/dist/host/http.d.mts +1 -1
- package/dist/host/http.d.mts.map +1 -1
- package/dist/host/http.mjs +40 -5
- package/dist/host/http.mjs.map +1 -1
- package/dist/host/ingest.cjs +49 -1
- package/dist/host/ingest.cjs.map +1 -1
- package/dist/host/ingest.d.cts +28 -0
- package/dist/host/ingest.d.cts.map +1 -1
- package/dist/host/ingest.d.mts +28 -0
- package/dist/host/ingest.d.mts.map +1 -1
- package/dist/host/ingest.mjs +47 -2
- package/dist/host/ingest.mjs.map +1 -1
- package/dist/index.cjs +3 -0
- package/dist/index.d.cts +2 -2
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +2 -2
- 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 +3 -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 +3 -1
- package/dist/reporting/observer.mjs.map +1 -1
- package/dist/reporting/tracking.cjs +3 -0
- package/dist/reporting/tracking.cjs.map +1 -1
- package/dist/reporting/tracking.d.cts +2 -0
- package/dist/reporting/tracking.d.cts.map +1 -1
- package/dist/reporting/tracking.d.mts +2 -0
- package/dist/reporting/tracking.d.mts.map +1 -1
- package/dist/reporting/tracking.mjs +3 -1
- package/dist/reporting/tracking.mjs.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -525,15 +525,20 @@ the engine does before and after the send:
|
|
|
525
525
|
|
|
526
526
|
There is no global sender: share addresses through a module of your own. A
|
|
527
527
|
topic email's unsubscribe link is the template's `unsubscribeUrl`, else the
|
|
528
|
-
config's `email.unsubscribeUrl`, else none;
|
|
529
|
-
|
|
530
|
-
|
|
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
|
|
531
533
|
there too, for a line like "you get these because you subscribed to tips":
|
|
532
534
|
|
|
533
535
|
```tsx
|
|
534
536
|
import { useEmailContext } from '@shware/workflow/email-context';
|
|
535
537
|
|
|
536
|
-
const { unsubscribeUrl } = useEmailContext();
|
|
538
|
+
const { unsubscribeUrl, preferencesUrl } = useEmailContext();
|
|
539
|
+
{
|
|
540
|
+
preferencesUrl !== undefined && <Link href={preferencesUrl}>Email preferences</Link>;
|
|
541
|
+
}
|
|
537
542
|
{
|
|
538
543
|
unsubscribeUrl !== undefined && <Link href={unsubscribeUrl}>Unsubscribe</Link>;
|
|
539
544
|
}
|
|
@@ -543,6 +548,7 @@ const { unsubscribeUrl } = useEmailContext();
|
|
|
543
548
|
// workflow.config.ts
|
|
544
549
|
email: {
|
|
545
550
|
unsubscribeUrl: 'https://acme.test/unsubscribe', // templates without their own
|
|
551
|
+
preferencesUrl: 'https://acme.test/settings/email', // the app's preference page
|
|
546
552
|
tracking: { quickClickMs: 1_000 }, // the default
|
|
547
553
|
},
|
|
548
554
|
```
|
|
@@ -575,6 +581,26 @@ two buttons: the email's topic, and all email. Without reporting, the header nam
|
|
|
575
581
|
template's own unsubscribe link, with no one-click claim. Only https URLs are named, so a local
|
|
576
582
|
`http://` origin sends no header.
|
|
577
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
|
+
|
|
578
604
|
What a user opted out of is the engine's record, per topic and channel: a topic email is
|
|
579
605
|
skipped while its topic (on email) or all email is opted out, a transactional email never is,
|
|
580
606
|
and `reachable('email')` reads the all-email opt-out only — a user unsubscribed from one topic
|
|
@@ -665,6 +691,10 @@ const relay = await startApnsRelay({ port: 8280 });
|
|
|
665
691
|
// wrangler dev --var APNS_RELAY_ORIGIN:http://localhost:8280
|
|
666
692
|
```
|
|
667
693
|
|
|
694
|
+
The relay sees the provider JWT, so production (`ENVIRONMENT` unset or
|
|
695
|
+
`production`) ignores `APNS_RELAY_ORIGIN`: it calls APNs directly and logs one
|
|
696
|
+
`console.error` saying the variable was ignored.
|
|
697
|
+
|
|
668
698
|
### Slack and Discord
|
|
669
699
|
|
|
670
700
|
Team-channel messages go through incoming webhooks. The URL is a secret, so the
|
|
@@ -704,20 +734,20 @@ Discord; supply anything else with `journeyWorker({ messages })`.
|
|
|
704
734
|
|
|
705
735
|
`defineConfig` takes:
|
|
706
736
|
|
|
707
|
-
| Field | Holds
|
|
708
|
-
| ----------- |
|
|
709
|
-
| `title` | The Studio title
|
|
710
|
-
| `contract` | The `defineContract(...)` every `/events` and `/identify` is validated against
|
|
711
|
-
| `workflows` | The workflows to deploy
|
|
712
|
-
| `segments` | Named segments
|
|
713
|
-
| `actions` | Custom actions `.run()` uses
|
|
714
|
-
| `templates` | The registries: `{ email?, push?, discord?, slack? }`; topics typed by the contract
|
|
715
|
-
| `email` | `{ unsubscribeUrl?, tracking? }`: what every topic email template shares
|
|
716
|
-
| `runtime` | Per mode (`development`, `production`, …): `{ timeScale?, logMessages? }`
|
|
717
|
-
| `apns` | `{ topic, keyId, teamId, environment? }`
|
|
718
|
-
| `fcm` | `{ androidChannelId? }`
|
|
719
|
-
| `webPush` | `{ subject, openedEvent? }`
|
|
720
|
-
| `database` | `{ schema? }`: the Postgres schema, `workflow` by default
|
|
737
|
+
| Field | Holds |
|
|
738
|
+
| ----------- | ----------------------------------------------------------------------------------------- |
|
|
739
|
+
| `title` | The Studio title |
|
|
740
|
+
| `contract` | The `defineContract(...)` every `/events` and `/identify` is validated against |
|
|
741
|
+
| `workflows` | The workflows to deploy |
|
|
742
|
+
| `segments` | Named segments |
|
|
743
|
+
| `actions` | Custom actions `.run()` uses |
|
|
744
|
+
| `templates` | The registries: `{ email?, push?, discord?, slack? }`; topics typed by the contract |
|
|
745
|
+
| `email` | `{ unsubscribeUrl?, preferencesUrl?, tracking? }`: what every topic email template shares |
|
|
746
|
+
| `runtime` | Per mode (`development`, `production`, …): `{ timeScale?, logMessages? }` |
|
|
747
|
+
| `apns` | `{ topic, keyId, teamId, environment? }` |
|
|
748
|
+
| `fcm` | `{ androidChannelId? }` |
|
|
749
|
+
| `webPush` | `{ subject, openedEvent? }` |
|
|
750
|
+
| `database` | `{ schema? }`: the Postgres schema, `workflow` by default |
|
|
721
751
|
|
|
722
752
|
The `ENVIRONMENT` var picks the `runtime` entry (production when unset).
|
|
723
753
|
`timeScale` fast-forwards every duration: 600 turns "1 day" into 2.4 minutes.
|
|
@@ -749,18 +779,20 @@ throws `JourneyClientError` with its status and body.
|
|
|
749
779
|
const journeys = new JourneyClient({ contract, origin, token });
|
|
750
780
|
```
|
|
751
781
|
|
|
752
|
-
| Method | Endpoint
|
|
753
|
-
| --------------------------------------------- |
|
|
754
|
-
| `identify(userId, props)` | `POST /identify`
|
|
755
|
-
| `track(userId, event, payload, { id?, ts? })` | `POST /events`
|
|
756
|
-
| `registerDevice(userId, { token, platform })` | `POST /devices`
|
|
757
|
-
| `unbindDevice(token)` | `POST /devices/unbind`
|
|
758
|
-
| `removeDevice(token)` | `POST /devices/remove`
|
|
759
|
-
| `messageOpened(userId, messageId)` | `POST /messages/opened`
|
|
760
|
-
| `getSubscriptions(userId)` | `GET /subscriptions/…`
|
|
761
|
-
| `setSubscriptions(userId, patch)` | `PUT /subscriptions/…`
|
|
762
|
-
| `
|
|
763
|
-
| `
|
|
782
|
+
| Method | Endpoint | Does |
|
|
783
|
+
| --------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------- |
|
|
784
|
+
| `identify(userId, props)` | `POST /identify` | Merge profile properties; `null` removes one |
|
|
785
|
+
| `track(userId, event, payload, { id?, ts? })` | `POST /events` | Record an event; starts and wakes journeys |
|
|
786
|
+
| `registerDevice(userId, { token, platform })` | `POST /devices` | Register a push token (`ios`, `android` or `web`) |
|
|
787
|
+
| `unbindDevice(token)` | `POST /devices/unbind` | Signed out: the token stays, bound to nobody |
|
|
788
|
+
| `removeDevice(token)` | `POST /devices/remove` | Forget a token for good |
|
|
789
|
+
| `messageOpened(userId, messageId)` | `POST /messages/opened` | A journey push tapped open (the `message_id` it carried) |
|
|
790
|
+
| `getSubscriptions(userId)` | `GET /subscriptions/…` | `{ email, push }`, each `{ all, topics }`, `true` = on |
|
|
791
|
+
| `setSubscriptions(userId, patch)` | `PUT /subscriptions/…` | Change what the patch names; answers the new state |
|
|
792
|
+
| `getSubscriptionsByMessage(messageId)` | `GET /subscriptions/by-message/…` | The recipient's record by a copy's id, plus `message: { channel, topic }` |
|
|
793
|
+
| `setSubscriptionsByMessage(messageId, patch)` | `PUT /subscriptions/by-message/…` | The same patch, for the copy's recipient; answers as the GET does |
|
|
794
|
+
| `forgetUser(userId)` | `POST /users/forget` | Stop the user's journeys and erase their data |
|
|
795
|
+
| `deploy(bundle)` | `POST /deploy` | Deploy a compiled bundle |
|
|
764
796
|
|
|
765
797
|
Pass an `id` to `track` whenever a caller may retry; see
|
|
766
798
|
[Give each event an `id`](#give-each-event-an-id).
|
|
@@ -790,6 +822,17 @@ A "subscribe to all" button sends `all: true` plus every topic `true`; an
|
|
|
790
822
|
"unsubscribe from all" sends `all: false` and leaves the topics as they are, so
|
|
791
823
|
turning `all` back on restores the user's own choices.
|
|
792
824
|
|
|
825
|
+
The same page opened from an email's footer, with no session, reads the `m` query parameter the
|
|
826
|
+
`preferencesUrl` link carries and goes by the copy instead of the user (see
|
|
827
|
+
[Email](#email)); a 404 (`JourneyClientError` with `status` 404) is an expired link:
|
|
828
|
+
|
|
829
|
+
```ts
|
|
830
|
+
// GET /api/email-preferences?m=<message id>, proxied by the app's API, rate-limited there
|
|
831
|
+
const { email, push, message } = await journeys.getSubscriptionsByMessage(m);
|
|
832
|
+
// message: { channel: 'email', topic: 'promotions' } — the email this page was opened from
|
|
833
|
+
await journeys.setSubscriptionsByMessage(m, { email: { topics: { [message.topic]: false } } });
|
|
834
|
+
```
|
|
835
|
+
|
|
793
836
|
## Compiling and planning
|
|
794
837
|
|
|
795
838
|
`compileConfig(config)` returns the `BundleIR` the Worker deploys. It is
|
|
@@ -896,7 +939,7 @@ database.
|
|
|
896
939
|
bundle live (see [Postgres](#postgres)); `POST /bundle/deploy` forces it.
|
|
897
940
|
- **APNs in development.** `wrangler dev` (workerd) cannot make HTTP/2 subrequests: run
|
|
898
941
|
`@shware/workflow/apns-relay` and point `APNS_RELAY_ORIGIN` at it. A deployed Worker needs no
|
|
899
|
-
relay.
|
|
942
|
+
relay, and production ignores the variable.
|
|
900
943
|
|
|
901
944
|
## AWS Lambda
|
|
902
945
|
|
|
@@ -1173,7 +1216,9 @@ On Postgres the engine also keeps:
|
|
|
1173
1216
|
|
|
1174
1217
|
## HTTP surface
|
|
1175
1218
|
|
|
1176
|
-
`handleRequest` serves the ingest API
|
|
1219
|
+
`handleRequest` serves the ingest API. It applies the same production gate as
|
|
1220
|
+
the full host: with `ENVIRONMENT` resolving to `production` and no `API_TOKEN`,
|
|
1221
|
+
everything but `GET /health` answers 503.
|
|
1177
1222
|
|
|
1178
1223
|
- `GET /health` is the only unauthenticated one. Every other route asks the
|
|
1179
1224
|
host's `authorize`, or without one the `API_TOKEN` bearer: its scheme is
|
|
@@ -1206,6 +1251,15 @@ On Postgres the engine also keeps:
|
|
|
1206
1251
|
non-boolean answers 400 with the field paths; an erased user 410. Changing
|
|
1207
1252
|
`email.all` wakes the waits on `reachable('email')`, `push.all` those on
|
|
1208
1253
|
`reachable('push')`.
|
|
1254
|
+
- `GET /subscriptions/by-message/:messageId` answers the same record for the
|
|
1255
|
+
recipient of that copy, plus `message: { channel, topic }` (`channel` is
|
|
1256
|
+
`email` or `push`, the channel the copy went out on); `PUT` takes the same
|
|
1257
|
+
patch as `/subscriptions/:userId` and answers as the GET does. The message id
|
|
1258
|
+
is the capability (the `m` of an email's `preferencesUrl` link), so the app
|
|
1259
|
+
proxies and rate-limits these routes behind its own API; the bearer still
|
|
1260
|
+
guards them. An id that names no copy — never sent, not a UUID, or erased
|
|
1261
|
+
with its user — or a transactional copy (`topic` null) answers 404, the rule
|
|
1262
|
+
`/t/u` applies; a user whose erasure is pending answers 410, as above.
|
|
1209
1263
|
- `POST /users/forget` takes `{ userId }`; the erasure takes the user's opt-outs
|
|
1210
1264
|
with the rest, since nothing is ever sent to that id again.
|
|
1211
1265
|
- `POST /deploy` takes a compiled bundle and swaps the routing table atomically.
|
package/dist/client.cjs
CHANGED
|
@@ -126,19 +126,41 @@ var JourneyClient = class {
|
|
|
126
126
|
* here, before the request; the engine refuses it too.
|
|
127
127
|
*/
|
|
128
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) {
|
|
129
153
|
const contract = this.options.contract;
|
|
130
|
-
if (contract
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}
|
|
139
|
-
if (issues.length > 0) return Promise.reject(new JourneyValidationError("/subscriptions", void 0, issues));
|
|
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
|
+
});
|
|
140
162
|
}
|
|
141
|
-
return
|
|
163
|
+
return issues.length > 0 ? new JourneyValidationError("/subscriptions", void 0, issues) : void 0;
|
|
142
164
|
}
|
|
143
165
|
/** Deploy a compiled bundle (BundleIR). */
|
|
144
166
|
deploy(bundle) {
|
|
@@ -161,6 +183,7 @@ var JourneyClient = class {
|
|
|
161
183
|
}
|
|
162
184
|
};
|
|
163
185
|
const subscriptionsPath = (userId) => `/subscriptions/${encodeURIComponent(userId)}`;
|
|
186
|
+
const subscriptionsByMessagePath = (messageId) => `/subscriptions/by-message/${encodeURIComponent(messageId)}`;
|
|
164
187
|
exports.JourneyClient = JourneyClient;
|
|
165
188
|
exports.JourneyClientError = JourneyClientError;
|
|
166
189
|
exports.JourneyValidationError = JourneyValidationError;
|
package/dist/client.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
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 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 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 contract = this.options.contract;\n if (contract !== 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 if (issues.length > 0) {\n return Promise.reject(new JourneyValidationError('/subscriptions', undefined, issues));\n }\n }\n return this.request('PUT', subscriptionsPath(userId), patch);\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)}`;\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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"}
|
package/dist/client.d.cts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Contract, ContractIssue, ProfilePatch, Topic } from "./contract.cjs";
|
|
2
2
|
import { ProfileProps } from "./store/index.cjs";
|
|
3
|
-
import { ChannelSubscriptions, ChannelSubscriptionsPatch, DeployResult, IdentifyResult, IngestResult, SubscriptionChannel, Subscriptions, SubscriptionsPatch } from "./host/ingest.cjs";
|
|
3
|
+
import { ChannelSubscriptions, ChannelSubscriptionsPatch, DeployResult, IdentifyResult, IngestResult, MessageOrigin, MessageSubscriptions, SubscriptionChannel, Subscriptions, SubscriptionsPatch } from "./host/ingest.cjs";
|
|
4
4
|
import * as core from "zod/v4/core";
|
|
5
5
|
/**
|
|
6
6
|
* `@shware/workflow/client` — the producer side of the ingest API, for the
|
|
@@ -120,10 +120,24 @@ export declare class JourneyClient<C extends Contract | undefined = undefined> {
|
|
|
120
120
|
* here, before the request; the engine refuses it too.
|
|
121
121
|
*/
|
|
122
122
|
setSubscriptions(userId: string, patch: SubscriptionsPatch<ClientTopic<C>>): Promise<Subscriptions<ClientTopic<C>>>;
|
|
123
|
+
/**
|
|
124
|
+
* `getSubscriptions` for the recipient of one copy, by the message id an email's preferences
|
|
125
|
+
* link carries (`email.preferencesUrl?m=<message id>`): how a preference page works without a
|
|
126
|
+
* sign-in. The answer adds `message: { channel, topic }`, the copy the page was opened from,
|
|
127
|
+
* so it can highlight that topic. Rejects with a 404 for an id that names no copy — never
|
|
128
|
+
* sent, or erased with its user — and for a transactional one; the page should show the link
|
|
129
|
+
* as expired. The message id is the capability, so the app proxies this behind its own API
|
|
130
|
+
* and rate-limits it.
|
|
131
|
+
*/
|
|
132
|
+
getSubscriptionsByMessage(messageId: string): Promise<MessageSubscriptions<ClientTopic<C>>>;
|
|
133
|
+
/** `setSubscriptions` for the recipient of one copy; the patch and the answer are those of `getSubscriptionsByMessage`. */
|
|
134
|
+
setSubscriptionsByMessage(messageId: string, patch: SubscriptionsPatch<ClientTopic<C>>): Promise<MessageSubscriptions<ClientTopic<C>>>;
|
|
135
|
+
/** With a contract, the refusal of a patch naming a topic it does not declare; undefined when the patch is fine. */
|
|
136
|
+
private undeclaredTopics;
|
|
123
137
|
/** Deploy a compiled bundle (BundleIR). */
|
|
124
138
|
deploy(bundle: unknown): Promise<DeployResult>;
|
|
125
139
|
private post;
|
|
126
140
|
private request;
|
|
127
141
|
}
|
|
128
|
-
export type { ChannelSubscriptions, ChannelSubscriptionsPatch, SubscriptionChannel, Subscriptions, SubscriptionsPatch };
|
|
142
|
+
export type { ChannelSubscriptions, ChannelSubscriptionsPatch, MessageOrigin, MessageSubscriptions, SubscriptionChannel, Subscriptions, SubscriptionsPatch };
|
|
129
143
|
//# sourceMappingURL=client.d.cts.map
|
package/dist/client.d.cts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.d.cts","names":[],"sources":["../src/client.ts"],"mappings":";;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"client.d.cts","names":[],"sources":["../src/client.ts"],"mappings":";;;;;;;;;;iBAoBiB,qBAAqB,UAAU;;;;;EAK9C,WAAW;;EAEX;;EAEA;;EAEA,eAAe;;qBAGJ,2BAA2B;WAE3B;WACA;WACA;EAHX,YACW,cACA,gBACA;;;qBAQA,+BAA+B;WAE/B;WACA;WACA,QAAQ;EAHnB,YACW,cACA,2BACA,QAAQ;;KAWhB,SAAS,KAAK,UAAU,eAAe,KAAK;;YAErC,gBAAgB,MAAM,wCAAwC,SAAS;KAC9E,UAAU,GAAG,MAAM,0BACnB,UAAU,yBAAyB,UAAU,gBAC9C,gBAAgB,SAAS,MACtB,SAAS,KAAK,MAAM,SAAS,GAAG,KAAK,UAAU;KAEjD,cAAc,MAAM,yBACrB,eACA,UAAU,SAAS,eAAe,KAAK,mBAAmB,KACxD,aAAa;;YAIP,YAAY,MAAM,kCAAkC,MAAM;iBAYrD;;EAEf;;EAEA;;qBAGW,cAAc,UAAU;mBACN;EAA7B,YAA6B,SAAS,qBAAqB;;EAG3D,SAAS,gBAAgB,OAAO,cAAc,KAAK,QAAQ;;;;;;;EAgB3D,MAAM,UAAU,gBAAgB,IAC9B,gBACA,OAAO,MACJ,MAAM,UAAU,GAAG,KACrB,QAAQ;;;;;;EAsBX,eACE,gBACA;IAAU;IAAe;IAAqC;MAC7D;IAAU;IAAU;;;;;;EAcvB,cAAc,gBAAgB,oBAAoB;IAAU;IAAU;;;;;;;;EAUtE,WAAW,iBAAiB;IAAU;IAAU;IAAiB;;;EAKjE,aAAa,gBAAgB;IAAU;IAAU;;;EAKjD,aAAa,gBAAgB;IAAU;IAAU;;;;;;;;;;EAYjD,iBAAiB,iBAAiB,QAAQ,cAAc,YAAY;;;;;;;;;;EAapE,iBACE,gBACA,OAAO,mBAAmB,YAAY,MACrC,QAAQ,cAAc,YAAY;;;;;;;;;;EAerC,0BAA0B,oBAAoB,QAAQ,qBAAqB,YAAY;;EAKvF,0BACE,mBACA,OAAO,mBAAmB,YAAY,MACrC,QAAQ,qBAAqB,YAAY;;UAOpC;;EAmBR,OAAO,kBAAkB,QAAQ;UAIzB;UAIM"}
|
package/dist/client.d.mts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Contract, ContractIssue, ProfilePatch, Topic } from "./contract.mjs";
|
|
2
2
|
import { ProfileProps } from "./store/index.mjs";
|
|
3
|
-
import { ChannelSubscriptions, ChannelSubscriptionsPatch, DeployResult, IdentifyResult, IngestResult, SubscriptionChannel, Subscriptions, SubscriptionsPatch } from "./host/ingest.mjs";
|
|
3
|
+
import { ChannelSubscriptions, ChannelSubscriptionsPatch, DeployResult, IdentifyResult, IngestResult, MessageOrigin, MessageSubscriptions, SubscriptionChannel, Subscriptions, SubscriptionsPatch } from "./host/ingest.mjs";
|
|
4
4
|
import * as core from "zod/v4/core";
|
|
5
5
|
/**
|
|
6
6
|
* `@shware/workflow/client` — the producer side of the ingest API, for the
|
|
@@ -120,10 +120,24 @@ export declare class JourneyClient<C extends Contract | undefined = undefined> {
|
|
|
120
120
|
* here, before the request; the engine refuses it too.
|
|
121
121
|
*/
|
|
122
122
|
setSubscriptions(userId: string, patch: SubscriptionsPatch<ClientTopic<C>>): Promise<Subscriptions<ClientTopic<C>>>;
|
|
123
|
+
/**
|
|
124
|
+
* `getSubscriptions` for the recipient of one copy, by the message id an email's preferences
|
|
125
|
+
* link carries (`email.preferencesUrl?m=<message id>`): how a preference page works without a
|
|
126
|
+
* sign-in. The answer adds `message: { channel, topic }`, the copy the page was opened from,
|
|
127
|
+
* so it can highlight that topic. Rejects with a 404 for an id that names no copy — never
|
|
128
|
+
* sent, or erased with its user — and for a transactional one; the page should show the link
|
|
129
|
+
* as expired. The message id is the capability, so the app proxies this behind its own API
|
|
130
|
+
* and rate-limits it.
|
|
131
|
+
*/
|
|
132
|
+
getSubscriptionsByMessage(messageId: string): Promise<MessageSubscriptions<ClientTopic<C>>>;
|
|
133
|
+
/** `setSubscriptions` for the recipient of one copy; the patch and the answer are those of `getSubscriptionsByMessage`. */
|
|
134
|
+
setSubscriptionsByMessage(messageId: string, patch: SubscriptionsPatch<ClientTopic<C>>): Promise<MessageSubscriptions<ClientTopic<C>>>;
|
|
135
|
+
/** With a contract, the refusal of a patch naming a topic it does not declare; undefined when the patch is fine. */
|
|
136
|
+
private undeclaredTopics;
|
|
123
137
|
/** Deploy a compiled bundle (BundleIR). */
|
|
124
138
|
deploy(bundle: unknown): Promise<DeployResult>;
|
|
125
139
|
private post;
|
|
126
140
|
private request;
|
|
127
141
|
}
|
|
128
|
-
export type { ChannelSubscriptions, ChannelSubscriptionsPatch, SubscriptionChannel, Subscriptions, SubscriptionsPatch };
|
|
142
|
+
export type { ChannelSubscriptions, ChannelSubscriptionsPatch, MessageOrigin, MessageSubscriptions, SubscriptionChannel, Subscriptions, SubscriptionsPatch };
|
|
129
143
|
//# sourceMappingURL=client.d.mts.map
|
package/dist/client.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.d.mts","names":[],"sources":["../src/client.ts"],"mappings":";;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"client.d.mts","names":[],"sources":["../src/client.ts"],"mappings":";;;;;;;;;;iBAoBiB,qBAAqB,UAAU;;;;;EAK9C,WAAW;;EAEX;;EAEA;;EAEA,eAAe;;qBAGJ,2BAA2B;WAE3B;WACA;WACA;EAHX,YACW,cACA,gBACA;;;qBAQA,+BAA+B;WAE/B;WACA;WACA,QAAQ;EAHnB,YACW,cACA,2BACA,QAAQ;;KAWhB,SAAS,KAAK,UAAU,eAAe,KAAK;;YAErC,gBAAgB,MAAM,wCAAwC,SAAS;KAC9E,UAAU,GAAG,MAAM,0BACnB,UAAU,yBAAyB,UAAU,gBAC9C,gBAAgB,SAAS,MACtB,SAAS,KAAK,MAAM,SAAS,GAAG,KAAK,UAAU;KAEjD,cAAc,MAAM,yBACrB,eACA,UAAU,SAAS,eAAe,KAAK,mBAAmB,KACxD,aAAa;;YAIP,YAAY,MAAM,kCAAkC,MAAM;iBAYrD;;EAEf;;EAEA;;qBAGW,cAAc,UAAU;mBACN;EAA7B,YAA6B,SAAS,qBAAqB;;EAG3D,SAAS,gBAAgB,OAAO,cAAc,KAAK,QAAQ;;;;;;;EAgB3D,MAAM,UAAU,gBAAgB,IAC9B,gBACA,OAAO,MACJ,MAAM,UAAU,GAAG,KACrB,QAAQ;;;;;;EAsBX,eACE,gBACA;IAAU;IAAe;IAAqC;MAC7D;IAAU;IAAU;;;;;;EAcvB,cAAc,gBAAgB,oBAAoB;IAAU;IAAU;;;;;;;;EAUtE,WAAW,iBAAiB;IAAU;IAAU;IAAiB;;;EAKjE,aAAa,gBAAgB;IAAU;IAAU;;;EAKjD,aAAa,gBAAgB;IAAU;IAAU;;;;;;;;;;EAYjD,iBAAiB,iBAAiB,QAAQ,cAAc,YAAY;;;;;;;;;;EAapE,iBACE,gBACA,OAAO,mBAAmB,YAAY,MACrC,QAAQ,cAAc,YAAY;;;;;;;;;;EAerC,0BAA0B,oBAAoB,QAAQ,qBAAqB,YAAY;;EAKvF,0BACE,mBACA,OAAO,mBAAmB,YAAY,MACrC,QAAQ,qBAAqB,YAAY;;UAOpC;;EAmBR,OAAO,kBAAkB,QAAQ;UAIzB;UAIM"}
|
package/dist/client.mjs
CHANGED
|
@@ -125,19 +125,41 @@ var JourneyClient = class {
|
|
|
125
125
|
* here, before the request; the engine refuses it too.
|
|
126
126
|
*/
|
|
127
127
|
setSubscriptions(userId, patch) {
|
|
128
|
+
const refused = this.undeclaredTopics(patch);
|
|
129
|
+
if (refused !== void 0) return Promise.reject(refused);
|
|
130
|
+
return this.request("PUT", subscriptionsPath(userId), patch);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* `getSubscriptions` for the recipient of one copy, by the message id an email's preferences
|
|
134
|
+
* link carries (`email.preferencesUrl?m=<message id>`): how a preference page works without a
|
|
135
|
+
* sign-in. The answer adds `message: { channel, topic }`, the copy the page was opened from,
|
|
136
|
+
* so it can highlight that topic. Rejects with a 404 for an id that names no copy — never
|
|
137
|
+
* sent, or erased with its user — and for a transactional one; the page should show the link
|
|
138
|
+
* as expired. The message id is the capability, so the app proxies this behind its own API
|
|
139
|
+
* and rate-limits it.
|
|
140
|
+
*/
|
|
141
|
+
getSubscriptionsByMessage(messageId) {
|
|
142
|
+
return this.request("GET", subscriptionsByMessagePath(messageId));
|
|
143
|
+
}
|
|
144
|
+
/** `setSubscriptions` for the recipient of one copy; the patch and the answer are those of `getSubscriptionsByMessage`. */
|
|
145
|
+
setSubscriptionsByMessage(messageId, patch) {
|
|
146
|
+
const refused = this.undeclaredTopics(patch);
|
|
147
|
+
if (refused !== void 0) return Promise.reject(refused);
|
|
148
|
+
return this.request("PUT", subscriptionsByMessagePath(messageId), patch);
|
|
149
|
+
}
|
|
150
|
+
/** With a contract, the refusal of a patch naming a topic it does not declare; undefined when the patch is fine. */
|
|
151
|
+
undeclaredTopics(patch) {
|
|
128
152
|
const contract = this.options.contract;
|
|
129
|
-
if (contract
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
}
|
|
138
|
-
if (issues.length > 0) return Promise.reject(new JourneyValidationError("/subscriptions", void 0, issues));
|
|
153
|
+
if (contract === void 0) return void 0;
|
|
154
|
+
const issues = [];
|
|
155
|
+
for (const channel of SUBSCRIPTION_CHANNELS) for (const topic of Object.keys(patch[channel]?.topics ?? {})) {
|
|
156
|
+
if (contract.topics.includes(topic)) continue;
|
|
157
|
+
issues.push({
|
|
158
|
+
path: `${channel}.topics.${topic}`,
|
|
159
|
+
message: "not a declared topic"
|
|
160
|
+
});
|
|
139
161
|
}
|
|
140
|
-
return
|
|
162
|
+
return issues.length > 0 ? new JourneyValidationError("/subscriptions", void 0, issues) : void 0;
|
|
141
163
|
}
|
|
142
164
|
/** Deploy a compiled bundle (BundleIR). */
|
|
143
165
|
deploy(bundle) {
|
|
@@ -160,6 +182,7 @@ var JourneyClient = class {
|
|
|
160
182
|
}
|
|
161
183
|
};
|
|
162
184
|
const subscriptionsPath = (userId) => `/subscriptions/${encodeURIComponent(userId)}`;
|
|
185
|
+
const subscriptionsByMessagePath = (messageId) => `/subscriptions/by-message/${encodeURIComponent(messageId)}`;
|
|
163
186
|
export { JourneyClient, JourneyClientError, JourneyValidationError };
|
|
164
187
|
|
|
165
188
|
//# sourceMappingURL=client.mjs.map
|
package/dist/client.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.mjs","names":[],"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 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 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 contract = this.options.contract;\n if (contract !== 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 if (issues.length > 0) {\n return Promise.reject(new JourneyValidationError('/subscriptions', undefined, issues));\n }\n }\n return this.request('PUT', subscriptionsPath(userId), patch);\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)}`;\n"],"mappings":";;AAiCA,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;AAkCA,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,WAAW,KAAK,QAAQ;EAC9B,IAAI,aAAa,KAAA,GAAW;GAC1B,MAAM,SAA0B,CAAC;GACjC,KAAK,MAAM,WAAW,uBACpB,KAAK,MAAM,SAAS,OAAO,KAAK,MAAM,QAAQ,EAAE,UAAU,CAAC,CAAC,GAAG;IAE7D,IAAI,SAAS,OAAO,SAAS,KAAK,GAAG;IACrC,OAAO,KAAK;KAAE,MAAM,GAAG,QAAQ,UAAU;KAAS,SAAS;IAAuB,CAAC;GACrF;GAEF,IAAI,OAAO,SAAS,GAClB,OAAO,QAAQ,OAAO,IAAI,uBAAuB,kBAAkB,KAAA,GAAW,MAAM,CAAC;EAEzF;EACA,OAAO,KAAK,QAAQ,OAAO,kBAAkB,MAAM,GAAG,KAAK;CAC7D;;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"}
|
|
1
|
+
{"version":3,"file":"client.mjs","names":[],"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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"}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
const require_reporting_classify = require("../reporting/classify.cjs");
|
|
3
|
+
const require_reporting_tracking = require("../reporting/tracking.cjs");
|
|
2
4
|
const require_store_facts = require("../store/facts.cjs");
|
|
3
5
|
const require_store_erasure = require("../store/erasure.cjs");
|
|
4
6
|
const require_store_postgres_reporting = require("../store/postgres-reporting.cjs");
|
|
@@ -10,8 +12,6 @@ const require_email_sender = require("../email-sender.cjs");
|
|
|
10
12
|
const require_reporting_observer = require("../reporting/observer.cjs");
|
|
11
13
|
const require_host_senders = require("../host/senders.cjs");
|
|
12
14
|
const require_host_channels = require("../host/channels.cjs");
|
|
13
|
-
const require_reporting_classify = require("../reporting/classify.cjs");
|
|
14
|
-
const require_reporting_tracking = require("../reporting/tracking.cjs");
|
|
15
15
|
const require_host_auth = require("../host/auth.cjs");
|
|
16
16
|
const require_host_http = require("../host/http.cjs");
|
|
17
17
|
const require_host_journey_host = require("../host/journey-host.cjs");
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { BURST_WINDOW_MS, DEFAULT_QUICK_MS, classifyClick, classifyOpen } from "../reporting/classify.mjs";
|
|
2
|
+
import { handleTracking, recordPushOpen } from "../reporting/tracking.mjs";
|
|
1
3
|
import { JourneyFactSource } from "../store/facts.mjs";
|
|
2
4
|
import { UserErasedError } from "../store/erasure.mjs";
|
|
3
5
|
import { PostgresReporting } from "../store/postgres-reporting.mjs";
|
|
@@ -9,8 +11,6 @@ import { EmailSender, trackEmail } from "../email-sender.mjs";
|
|
|
9
11
|
import { reportingObserver } from "../reporting/observer.mjs";
|
|
10
12
|
import { LogMessageSender, WebhookMessageSender, routeByChannel, routeByPlatform } from "../host/senders.mjs";
|
|
11
13
|
import { channelsFromConfig, journeyMode, runtimeFromConfig } from "../host/channels.mjs";
|
|
12
|
-
import { BURST_WINDOW_MS, DEFAULT_QUICK_MS, classifyClick, classifyOpen } from "../reporting/classify.mjs";
|
|
13
|
-
import { handleTracking, recordPushOpen } from "../reporting/tracking.mjs";
|
|
14
14
|
import { authorized, bearerMatches, timingSafeEqual } from "../host/auth.mjs";
|
|
15
15
|
import { handleRequest } from "../host/http.mjs";
|
|
16
16
|
import { EnvValidationError } from "../host/journey-host.mjs";
|
package/dist/email-sender.cjs
CHANGED
|
@@ -14,7 +14,8 @@ var EmailSender = class {
|
|
|
14
14
|
async send(message) {
|
|
15
15
|
if (message.channel !== "email") throw new Error(`EmailSender: unsupported channel '${message.channel}'`);
|
|
16
16
|
if (message.recipient === void 0) throw new Error(`EmailSender: no recipient for user '${message.userId}'`);
|
|
17
|
-
const
|
|
17
|
+
const messageId = message.tracking?.messageId;
|
|
18
|
+
const { subject, html, from, replyTo, unsubscribeUrl } = await this.options.render(message.template, message.props, messageId === void 0 ? {} : { messageId });
|
|
18
19
|
const tracked = message.tracking === void 0 ? html : trackEmail(html, message.tracking, unsubscribeUrl);
|
|
19
20
|
await message.tracking?.commit?.();
|
|
20
21
|
const headers = unsubscribeHeaders(unsubscribeUrl, message.tracking?.unsubscribe);
|