@shware/workflow 7.0.0 → 8.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +121 -34
- package/dist/aws/engine-step.cjs +4 -1
- package/dist/aws/engine-step.cjs.map +1 -1
- package/dist/aws/engine-step.d.cts.map +1 -1
- package/dist/aws/engine-step.d.mts.map +1 -1
- package/dist/aws/engine-step.mjs +4 -1
- package/dist/aws/engine-step.mjs.map +1 -1
- package/dist/client.cjs +66 -41
- package/dist/client.cjs.map +1 -1
- package/dist/client.d.cts +72 -22
- package/dist/client.d.cts.map +1 -1
- package/dist/client.d.mts +72 -22
- package/dist/client.d.mts.map +1 -1
- package/dist/client.mjs +66 -42
- package/dist/client.mjs.map +1 -1
- package/dist/cloudflare/index.d.cts +4 -3
- package/dist/cloudflare/index.d.mts +4 -3
- package/dist/cloudflare/runner.cjs +3 -1
- package/dist/cloudflare/runner.cjs.map +1 -1
- package/dist/cloudflare/runner.d.cts.map +1 -1
- package/dist/cloudflare/runner.d.mts.map +1 -1
- package/dist/cloudflare/runner.mjs +3 -1
- package/dist/cloudflare/runner.mjs.map +1 -1
- package/dist/cloudflare/worker.cjs +12 -1
- package/dist/cloudflare/worker.cjs.map +1 -1
- package/dist/cloudflare/worker.d.cts +9 -1
- package/dist/cloudflare/worker.d.cts.map +1 -1
- package/dist/cloudflare/worker.d.mts +9 -1
- package/dist/cloudflare/worker.d.mts.map +1 -1
- package/dist/cloudflare/worker.mjs +12 -1
- package/dist/cloudflare/worker.mjs.map +1 -1
- package/dist/contract.cjs.map +1 -1
- package/dist/contract.d.cts +4 -4
- package/dist/contract.d.mts +4 -4
- package/dist/contract.mjs.map +1 -1
- package/dist/dsl/trigger.cjs +2 -1
- package/dist/dsl/trigger.cjs.map +1 -1
- package/dist/dsl/trigger.d.cts +6 -0
- package/dist/dsl/trigger.d.cts.map +1 -1
- package/dist/dsl/trigger.d.mts +6 -0
- package/dist/dsl/trigger.d.mts.map +1 -1
- package/dist/dsl/trigger.mjs +2 -1
- package/dist/dsl/trigger.mjs.map +1 -1
- package/dist/engine/condition.cjs +3 -3
- package/dist/engine/condition.cjs.map +1 -1
- package/dist/engine/condition.d.cts +3 -3
- package/dist/engine/condition.d.mts +3 -3
- package/dist/engine/condition.mjs +3 -3
- package/dist/engine/condition.mjs.map +1 -1
- package/dist/engine/interpreter.cjs +9 -5
- package/dist/engine/interpreter.cjs.map +1 -1
- package/dist/engine/interpreter.d.cts.map +1 -1
- package/dist/engine/interpreter.d.mts.map +1 -1
- package/dist/engine/interpreter.mjs +9 -5
- package/dist/engine/interpreter.mjs.map +1 -1
- package/dist/engine/ports.d.cts +2 -0
- package/dist/engine/ports.d.cts.map +1 -1
- package/dist/engine/ports.d.mts +2 -0
- package/dist/engine/ports.d.mts.map +1 -1
- package/dist/host/batch.cjs +172 -0
- package/dist/host/batch.cjs.map +1 -0
- package/dist/host/batch.d.cts +88 -0
- package/dist/host/batch.d.cts.map +1 -0
- package/dist/host/batch.d.mts +88 -0
- package/dist/host/batch.d.mts.map +1 -0
- package/dist/host/batch.mjs +163 -0
- package/dist/host/batch.mjs.map +1 -0
- package/dist/host/execute.cjs +10 -6
- package/dist/host/execute.cjs.map +1 -1
- package/dist/host/execute.mjs +10 -6
- package/dist/host/execute.mjs.map +1 -1
- package/dist/host/http.cjs +30 -89
- 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 +24 -83
- package/dist/host/http.mjs.map +1 -1
- package/dist/host/ingest.cjs +56 -13
- package/dist/host/ingest.cjs.map +1 -1
- package/dist/host/ingest.d.cts +17 -6
- package/dist/host/ingest.d.cts.map +1 -1
- package/dist/host/ingest.d.mts +17 -6
- package/dist/host/ingest.d.mts.map +1 -1
- package/dist/host/ingest.mjs +55 -14
- package/dist/host/ingest.mjs.map +1 -1
- package/dist/host/journey-host.cjs +6 -0
- package/dist/host/journey-host.cjs.map +1 -1
- package/dist/host/journey-host.d.cts +5 -2
- package/dist/host/journey-host.d.cts.map +1 -1
- package/dist/host/journey-host.d.mts +5 -2
- package/dist/host/journey-host.d.mts.map +1 -1
- package/dist/host/journey-host.mjs +6 -0
- package/dist/host/journey-host.mjs.map +1 -1
- package/dist/host/platform.cjs.map +1 -1
- package/dist/host/platform.d.cts +2 -0
- package/dist/host/platform.d.cts.map +1 -1
- package/dist/host/platform.d.mts +2 -0
- package/dist/host/platform.d.mts.map +1 -1
- package/dist/host/platform.mjs.map +1 -1
- package/dist/ir.cjs +2 -1
- package/dist/ir.cjs.map +1 -1
- package/dist/ir.d.cts +3 -0
- package/dist/ir.d.cts.map +1 -1
- package/dist/ir.d.mts +3 -0
- package/dist/ir.d.mts.map +1 -1
- package/dist/ir.mjs +2 -1
- package/dist/ir.mjs.map +1 -1
- package/dist/store/facts.cjs +13 -9
- package/dist/store/facts.cjs.map +1 -1
- package/dist/store/facts.d.cts +6 -2
- package/dist/store/facts.d.cts.map +1 -1
- package/dist/store/facts.d.mts +6 -2
- package/dist/store/facts.d.mts.map +1 -1
- package/dist/store/facts.mjs +13 -9
- package/dist/store/facts.mjs.map +1 -1
- package/dist/store/identity.cjs +34 -0
- package/dist/store/identity.cjs.map +1 -0
- package/dist/store/identity.d.cts +13 -0
- package/dist/store/identity.d.cts.map +1 -0
- package/dist/store/identity.d.mts +13 -0
- package/dist/store/identity.d.mts.map +1 -0
- package/dist/store/identity.mjs +30 -0
- package/dist/store/identity.mjs.map +1 -0
- package/dist/store/index.cjs +5 -0
- package/dist/store/index.cjs.map +1 -1
- package/dist/store/index.d.cts +49 -15
- package/dist/store/index.d.cts.map +1 -1
- package/dist/store/index.d.mts +49 -15
- package/dist/store/index.d.mts.map +1 -1
- package/dist/store/index.mjs +2 -1
- package/dist/store/index.mjs.map +1 -1
- package/dist/store/postgres.cjs +206 -44
- package/dist/store/postgres.cjs.map +1 -1
- package/dist/store/postgres.d.cts +17 -4
- package/dist/store/postgres.d.cts.map +1 -1
- package/dist/store/postgres.d.mts +17 -4
- package/dist/store/postgres.d.mts.map +1 -1
- package/dist/store/postgres.mjs +207 -45
- package/dist/store/postgres.mjs.map +1 -1
- package/dist/store/tables.cjs +24 -6
- package/dist/store/tables.cjs.map +1 -1
- package/dist/store/tables.d.cts +1 -0
- package/dist/store/tables.d.cts.map +1 -1
- package/dist/store/tables.d.mts +1 -0
- package/dist/store/tables.d.mts.map +1 -1
- package/dist/store/tables.mjs +24 -6
- package/dist/store/tables.mjs.map +1 -1
- package/dist/webpush.cjs.map +1 -1
- package/dist/webpush.d.cts +1 -1
- package/dist/webpush.d.mts +1 -1
- package/dist/webpush.mjs.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -264,8 +264,11 @@ export const journeys = new JourneyClient({
|
|
|
264
264
|
token: process.env.WORKFLOW_API_TOKEN,
|
|
265
265
|
});
|
|
266
266
|
|
|
267
|
-
await journeys.
|
|
268
|
-
|
|
267
|
+
await journeys.ingest({
|
|
268
|
+
userId: user.id,
|
|
269
|
+
profile: { email: user.email, name: user.name },
|
|
270
|
+
events: [{ event: 'login', payload: { method: 'google' }, id: `login-${session.id}` }],
|
|
271
|
+
});
|
|
269
272
|
```
|
|
270
273
|
|
|
271
274
|
Identify the properties a journey reads before the event that starts it.
|
|
@@ -737,7 +740,7 @@ Discord; supply anything else with `journeyWorker({ messages })`.
|
|
|
737
740
|
| Field | Holds |
|
|
738
741
|
| ----------- | ----------------------------------------------------------------------------------------- |
|
|
739
742
|
| `title` | The Studio title |
|
|
740
|
-
| `contract` | The `defineContract(...)` every `/
|
|
743
|
+
| `contract` | The `defineContract(...)` every `/ingest` is validated against |
|
|
741
744
|
| `workflows` | The workflows to deploy |
|
|
742
745
|
| `segments` | Named segments |
|
|
743
746
|
| `actions` | Custom actions `.run()` uses |
|
|
@@ -779,24 +782,105 @@ throws `JourneyClientError` with its status and body.
|
|
|
779
782
|
const journeys = new JourneyClient({ contract, origin, token });
|
|
780
783
|
```
|
|
781
784
|
|
|
782
|
-
| Method
|
|
783
|
-
|
|
|
784
|
-
| `
|
|
785
|
-
| `
|
|
786
|
-
| `
|
|
787
|
-
| `
|
|
788
|
-
| `
|
|
789
|
-
| `
|
|
790
|
-
| `
|
|
791
|
-
| `
|
|
792
|
-
| `
|
|
793
|
-
| `
|
|
794
|
-
| `
|
|
795
|
-
| `deploy(bundle)`
|
|
796
|
-
|
|
797
|
-
Pass
|
|
785
|
+
| Method | Endpoint | Does |
|
|
786
|
+
| --------------------------------------------------------------------- | --------------------------------- | ---------------------------------------------------------------------------------- |
|
|
787
|
+
| `ingest({ userId?, visitorId?, profile?, events? })` | `POST /ingest` | The one way in: merge a visitor, then the profile, then record the events in order |
|
|
788
|
+
| `registerDevice({ deviceId?, userId?, visitorId?, token, platform })` | `POST /devices` | Register a push token for whoever holds the device now |
|
|
789
|
+
| `unbindDevice(token)` | `POST /devices/unbind` | Signed out: the token stays, bound to nobody |
|
|
790
|
+
| `removeDevice(token)` | `POST /devices/remove` | Forget a token for good |
|
|
791
|
+
| `messageOpened(userId, messageId)` | `POST /messages/opened` | A journey push tapped open (the `message_id` it carried) |
|
|
792
|
+
| `getSubscriptions(userId)` | `GET /subscriptions/…` | `{ email, push }`, each `{ all, topics }`, `true` = on |
|
|
793
|
+
| `setSubscriptions(userId, patch)` | `PUT /subscriptions/…` | Change what the patch names; answers the new state |
|
|
794
|
+
| `getSubscriptionsByMessage(messageId)` | `GET /subscriptions/by-message/…` | The recipient's record by a copy's id, plus `message: { channel, topic }` |
|
|
795
|
+
| `setSubscriptionsByMessage(messageId, patch)` | `PUT /subscriptions/by-message/…` | The same patch, for the copy's recipient; answers as the GET does |
|
|
796
|
+
| `forgetUser(userId)` | `POST /users/forget` | Stop the user's journeys and erase their data |
|
|
797
|
+
| `forgetVisitor(visitorId)` | `POST /users/forget` | The same for a visitor who never signed in |
|
|
798
|
+
| `deploy(bundle)` | `POST /deploy` | Deploy a compiled bundle |
|
|
799
|
+
|
|
800
|
+
Pass each event's `id` whenever a caller may retry; see
|
|
798
801
|
[Give each event an `id`](#give-each-event-an-id).
|
|
799
802
|
|
|
803
|
+
`ingest` is the only way to report: the profile and the events it belongs to travel
|
|
804
|
+
as one batch, so a journey an event starts always finds its recipient. With separate
|
|
805
|
+
calls an email step could run before the address arrives and settle as `no_recipient`.
|
|
806
|
+
|
|
807
|
+
### Visitors and devices
|
|
808
|
+
|
|
809
|
+
Three ids, with different jobs:
|
|
810
|
+
|
|
811
|
+
| Id | What it says | At sign-out |
|
|
812
|
+
| ----------- | ------------------------------- | ---------------- |
|
|
813
|
+
| `userId` | Which account | No longer sent |
|
|
814
|
+
| `visitorId` | Who this is before signing in | Must be replaced |
|
|
815
|
+
| `deviceId` | Which device holds a push token | Stays |
|
|
816
|
+
|
|
817
|
+
`visitorId` is any stable id for one client, as long as it changes when a user signs
|
|
818
|
+
out; `visitorId()` from `@shware/analytics` already does. Journeys and events belong
|
|
819
|
+
to a user or a visitor, never to a device: one device can pass between people.
|
|
820
|
+
|
|
821
|
+
```ts
|
|
822
|
+
await journeys.ingest({ visitorId, events }); // not signed in
|
|
823
|
+
await journeys.ingest({ userId, visitorId, profile, events }); // signed in
|
|
824
|
+
await journeys.registerDevice({ deviceId, visitorId, token, platform });
|
|
825
|
+
```
|
|
826
|
+
|
|
827
|
+
Naming both ids says this visitor is this user. Their events, profile, devices and
|
|
828
|
+
opt-outs move to the user, once, and a later report of that visitor reaches the user.
|
|
829
|
+
A visitor already linked stays with its first user. Their running journeys carry on
|
|
830
|
+
as the user's, except one the user is already in, which stops.
|
|
831
|
+
|
|
832
|
+
A visitor enters a workflow only if its trigger asks for them. They have no address,
|
|
833
|
+
so such a journey reaches them by push, and an entry made as a visitor counts as the
|
|
834
|
+
user's once they sign in:
|
|
835
|
+
|
|
836
|
+
```ts
|
|
837
|
+
workflow({ id: 'offer', trigger: trigger.event(e.browse, { visitors: true }) });
|
|
838
|
+
```
|
|
839
|
+
|
|
840
|
+
Segments are about users: visitors are not swept and do not enter segment triggers.
|
|
841
|
+
|
|
842
|
+
A visitor who never signs in is deleted 90 days after they were last heard from, unless
|
|
843
|
+
a journey of theirs is still running. A link to a user is kept, so late reports still
|
|
844
|
+
reach the user and, once the user is erased, are refused.
|
|
845
|
+
|
|
846
|
+
A device table row holds only the current owner, so after a sign-out the previous user
|
|
847
|
+
can no longer be reached there. `unbindDevice` clears it at sign-out; the client then
|
|
848
|
+
registers again with its new `visitorId`.
|
|
849
|
+
|
|
850
|
+
### `JourneyQueue`
|
|
851
|
+
|
|
852
|
+
`JourneyQueue` sends the same batch through a queue, for a producer that must
|
|
853
|
+
neither lose a report nor wait for the engine:
|
|
854
|
+
|
|
855
|
+
```ts
|
|
856
|
+
const queue = new JourneyQueue({ contract, queue: env.JOURNEY_QUEUE });
|
|
857
|
+
await queue.ingest({
|
|
858
|
+
userId,
|
|
859
|
+
visitorId,
|
|
860
|
+
profile: { email, name },
|
|
861
|
+
events: [{ event: 'login', payload: { method }, id: rowId, ts }],
|
|
862
|
+
});
|
|
863
|
+
```
|
|
864
|
+
|
|
865
|
+
Once `ingest` resolves the batch is durable. A rejection means it was not queued,
|
|
866
|
+
and the caller decides what follows. Export `journeys.queue` from the Worker and
|
|
867
|
+
declare the queue with a dead letter queue:
|
|
868
|
+
|
|
869
|
+
```jsonc
|
|
870
|
+
"queues": {
|
|
871
|
+
"consumers": [
|
|
872
|
+
{ "queue": "journey", "max_retries": 8, "dead_letter_queue": "journey-dead" }
|
|
873
|
+
]
|
|
874
|
+
}
|
|
875
|
+
```
|
|
876
|
+
|
|
877
|
+
The consumer retries a failed batch with a backoff from 30 seconds up to 15
|
|
878
|
+
minutes. It acknowledges a batch the contract refuses, logging its field paths,
|
|
879
|
+
and one for an erased user: neither can ever succeed. Delivery is at least once
|
|
880
|
+
and in no guaranteed order. Batches can arrive out of order, but event ids absorb
|
|
881
|
+
repeats and each profile property keeps its latest `at`, so send what must stay
|
|
882
|
+
in order as one batch.
|
|
883
|
+
|
|
800
884
|
`getSubscriptions` and `setSubscriptions` are typed by the contract's
|
|
801
885
|
`subscriptions.topics`, and a topic it does not declare is refused before the
|
|
802
886
|
request. The record is topic × channel: `email` and `push` each list every
|
|
@@ -1031,12 +1115,12 @@ Checked against a real durable Lambda (Node 24, us-east-1).
|
|
|
1031
1115
|
**Function URL: `AuthType: NONE`, with the API protected in the app.** Not every caller can
|
|
1032
1116
|
sign a request:
|
|
1033
1117
|
|
|
1034
|
-
| Caller | Routes
|
|
1035
|
-
| ------------------------------------------ |
|
|
1036
|
-
| Your backend | `/
|
|
1037
|
-
| Recipients' mail clients and browsers | `/t/o`, `/t/l`, `/t/u`
|
|
1038
|
-
| Gmail / Yahoo one-click unsubscribe | `POST /t/u/<message>` (RFC 8058)
|
|
1039
|
-
| iOS notification extension, service worker | `POST /t/d`, `POST /t/p`
|
|
1118
|
+
| Caller | Routes |
|
|
1119
|
+
| ------------------------------------------ | ------------------------------------------- |
|
|
1120
|
+
| Your backend | `/ingest`, `/devices`, `/users/forget`, ... |
|
|
1121
|
+
| Recipients' mail clients and browsers | `/t/o`, `/t/l`, `/t/u` |
|
|
1122
|
+
| Gmail / Yahoo one-click unsubscribe | `POST /t/u/<message>` (RFC 8058) |
|
|
1123
|
+
| iOS notification extension, service worker | `POST /t/d`, `POST /t/p` |
|
|
1040
1124
|
|
|
1041
1125
|
An alias has one URL and one auth type, and the tracking and one-click routes must stay public:
|
|
1042
1126
|
|
|
@@ -1225,16 +1309,19 @@ everything but `GET /health` answers 503.
|
|
|
1225
1309
|
- `GET /health` is the only unauthenticated one. Every other route asks the
|
|
1226
1310
|
host's `authorize`, or without one the `API_TOKEN` bearer: its scheme is
|
|
1227
1311
|
matched case-insensitively and the token compared in constant time.
|
|
1228
|
-
- `POST /
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
`
|
|
1235
|
-
|
|
1312
|
+
- `POST /ingest` takes `{ userId?, visitorId?, profile?, at?, events? }` with at
|
|
1313
|
+
least one id, each event being `{ event, payload?, ts?, id? }`, and answers
|
|
1314
|
+
`{ profile?, events }`, each event's result naming the id it was recorded under.
|
|
1315
|
+
The whole batch is checked first, one bad part refuses all of it; then a visitor
|
|
1316
|
+
merges, the profile is merged and the events recorded in order. Event names
|
|
1317
|
+
beginning with `$` are reserved, and a `userId` cannot begin with `visitor:`.
|
|
1318
|
+
`ts` is epoch milliseconds: a non-negative integer no later than five minutes
|
|
1319
|
+
ahead of the host's clock (400 otherwise). Profile values are scalars (string,
|
|
1320
|
+
number, boolean); a property set to `null` is removed, which is the only way to
|
|
1321
|
+
unset one. Nested objects are refused with a 400. A property written with a later
|
|
1322
|
+
`at` keeps its value.
|
|
1236
1323
|
- A user with an erasure tombstone (`POST /users/forget`, finished or not) is
|
|
1237
|
-
refused on `/
|
|
1324
|
+
refused on `/ingest` and `/devices` with a 410: nothing is
|
|
1238
1325
|
written about them between the stop of their journeys and the erase, and the
|
|
1239
1326
|
id is never reactivated after it.
|
|
1240
1327
|
- A body that is not JSON answers 400, never 500.
|
package/dist/aws/engine-step.cjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
2
|
const require_engine_retry = require("../engine/retry.cjs");
|
|
3
|
+
const require_store_identity = require("../store/identity.cjs");
|
|
4
|
+
require("../store/index.cjs");
|
|
3
5
|
let _aws_durable_execution_sdk_js = require("@aws/durable-execution-sdk-js");
|
|
4
6
|
// src/aws/engine-step.ts
|
|
5
7
|
/**
|
|
@@ -57,7 +59,8 @@ var AwsEngineStep = class {
|
|
|
57
59
|
settled: false
|
|
58
60
|
};
|
|
59
61
|
await this.context.step(name, async () => {
|
|
60
|
-
await this.store
|
|
62
|
+
const owner = await require_store_identity.instanceOwner(this.store, this.instanceId, this.userId);
|
|
63
|
+
await this.store.subscribe(this.instanceId, owner, events, Date.now());
|
|
61
64
|
await this.executions.armCallback(this.instanceId, {
|
|
62
65
|
id: callbackId,
|
|
63
66
|
generation,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"engine-step.cjs","names":["isRetryable","CallbackTimeoutError"],"sources":["../../src/aws/engine-step.ts"],"sourcesContent":["import {\n CallbackTimeoutError,\n type DurableContext,\n type DurablePromise,\n type RetryDecision,\n} from '@aws/durable-execution-sdk-js';\nimport type { EngineStep } from '../engine/ports';\nimport { isRetryable } from '../engine/retry';\nimport type
|
|
1
|
+
{"version":3,"file":"engine-step.cjs","names":["isRetryable","instanceOwner","CallbackTimeoutError"],"sources":["../../src/aws/engine-step.ts"],"sourcesContent":["import {\n CallbackTimeoutError,\n type DurableContext,\n type DurablePromise,\n type RetryDecision,\n} from '@aws/durable-execution-sdk-js';\nimport type { EngineStep } from '../engine/ports';\nimport { isRetryable } from '../engine/retry';\nimport { type ExecutionRegistry, type JourneyStore, instanceOwner } from '../store/index';\nimport type { LambdaApi } from './lambda-api';\n\n/**\n * EngineStep over a Lambda durable execution.\n *\n * Steps, sleeps and wakes map onto the SDK's `step`, `wait` and callbacks. A wake handle is a\n * callback, which is one-shot, so every wait arms a fresh one (the interpreter subscribes on\n * every attempt): created where the durable context allows it — outside any step, so its id\n * replays — then recorded in Postgres together with the subscription rows, numbered by a\n * generation so a late wake or cleanup of an older wait cannot touch a newer one. The callback\n * is only a hint; the facts the wait re-checks are Postgres'.\n */\n\n/** What the step retries, bounded: 6 attempts with exponential backoff up to a minute. */\nexport function stepRetry(error: Error, attempt: number): RetryDecision {\n if (!isRetryable(error) || attempt >= 6) return { shouldRetry: false };\n return { shouldRetry: true, delay: { seconds: Math.min(60, 2 ** attempt) } };\n}\n\ninterface Armed {\n promise: DurablePromise<string>;\n callbackId: string;\n generation: number;\n /** The wait consumed it (woken or timed out): there is nothing left to close. */\n settled: boolean;\n}\n\nexport class AwsEngineStep implements EngineStep {\n private generation = 0;\n private armed: Armed | undefined;\n\n constructor(\n private readonly context: DurableContext,\n private readonly store: JourneyStore,\n private readonly executions: ExecutionRegistry,\n private readonly lambda: LambdaApi,\n private readonly userId: string,\n private readonly instanceId: string\n ) {}\n\n do<T>(name: string, fn: () => Promise<T>): Promise<T> {\n return this.context.step(name, () => fn(), { retryStrategy: stepRetry });\n }\n\n async sleep(name: string, ms: number): Promise<void> {\n // A wait is whole seconds: rounded up, so it never ends early; nothing to wait at zero\n if (!(ms > 0)) return;\n await this.context.wait(name, { seconds: Math.ceil(ms / 1000) });\n }\n\n async subscribe(\n name: string,\n events: readonly string[],\n options: { deadlineMs?: number } = {}\n ): Promise<void> {\n const generation = ++this.generation;\n const { deadlineMs } = options;\n // The callback's own timeout is the wait's deadline: fixed when it is created\n const seconds =\n deadlineMs === undefined\n ? undefined\n : await this.context.step(`${name}:timeout`, async () =>\n Math.max(1, Math.ceil((deadlineMs - Date.now()) / 1000))\n );\n const [promise, callbackId] = await this.context.createCallback(\n `${name}:callback`,\n seconds === undefined ? {} : { timeout: { seconds } }\n );\n const previous = this.armed;\n this.armed = { promise, callbackId, generation, settled: false };\n await this.context.step(\n name,\n async () => {\n // Rows first, then the callback: a wake that lands in between finds the rows and the\n // callback of the last wait — its completion is refused, and this wait checks the facts\n const owner = await instanceOwner(this.store, this.instanceId, this.userId);\n await this.store.subscribe(this.instanceId, owner, events, Date.now());\n await this.executions.armCallback(this.instanceId, {\n id: callbackId,\n generation,\n deadlineAt: deadlineMs,\n });\n if (previous !== undefined && !previous.settled) {\n await this.lambda.completeCallback(previous.callbackId, '{}');\n }\n },\n { retryStrategy: stepRetry }\n );\n if (previous !== undefined) previous.settled = true;\n }\n\n async unsubscribe(name: string): Promise<void> {\n const armed = this.armed;\n await this.context.step(\n name,\n async () => {\n await this.store.unsubscribe(this.instanceId);\n if (armed === undefined) return;\n await this.executions.clearCallback(this.instanceId, armed.generation);\n // The condition held before the wait parked: its callback is closed through the public\n // API (a closed one answers false), rather than left open until it times out\n if (!armed.settled) await this.lambda.completeCallback(armed.callbackId, '{}');\n },\n { retryStrategy: stepRetry }\n );\n if (armed !== undefined) armed.settled = true;\n this.armed = undefined;\n }\n\n /**\n * The timeout is ignored on purpose: the callback's own timeout was set from the deadline\n * when it was armed (`subscribe`), and only its expiry says the wait timed out.\n */\n async waitForWake(\n name: string,\n _wait: { timeoutMs: number; deadlineMs: number }\n ): Promise<'event' | 'timeout'> {\n const armed = this.armed;\n if (armed === undefined) {\n throw new Error(`waitForWake '${name}' without a subscribe: nothing is armed to wake it`);\n }\n try {\n await armed.promise;\n return 'event';\n } catch (error) {\n // Only the callback's own expiry is a timeout; any other failure is a failure\n if (error instanceof CallbackTimeoutError) return 'timeout';\n throw error;\n } finally {\n armed.settled = true;\n }\n }\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"engine-step.d.cts","names":[],"sources":["../../src/aws/engine-step.ts"],"mappings":";;;;;;;;;;;;;;;wBAuBgB,UAAU,OAAO,OAAO,kBAAkB;qBAa7C,yBAAyB;mBAKjB;mBACA;mBACA;mBACA;mBACA;mBACA;UATX;UACA;EAER,YACmB,SAAS,gBACT,OAAO,cACP,YAAY,mBACZ,QAAQ,WACR,gBACA;EAGnB,GAAG,GAAG,cAAc,UAAU,QAAQ,KAAK,QAAQ;EAI7C,MAAM,cAAc,aAAa;EAMjC,UACJ,cACA,2BACA;IAAW;MACV;
|
|
1
|
+
{"version":3,"file":"engine-step.d.cts","names":[],"sources":["../../src/aws/engine-step.ts"],"mappings":";;;;;;;;;;;;;;;wBAuBgB,UAAU,OAAO,OAAO,kBAAkB;qBAa7C,yBAAyB;mBAKjB;mBACA;mBACA;mBACA;mBACA;mBACA;UATX;UACA;EAER,YACmB,SAAS,gBACT,OAAO,cACP,YAAY,mBACZ,QAAQ,WACR,gBACA;EAGnB,GAAG,GAAG,cAAc,UAAU,QAAQ,KAAK,QAAQ;EAI7C,MAAM,cAAc,aAAa;EAMjC,UACJ,cACA,2BACA;IAAW;MACV;EAqCG,YAAY,eAAe;;;;;EAsB3B,YACJ,cACA;IAAS;IAAmB;MAC3B"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"engine-step.d.mts","names":[],"sources":["../../src/aws/engine-step.ts"],"mappings":";;;;;;;;;;;;;;;wBAuBgB,UAAU,OAAO,OAAO,kBAAkB;qBAa7C,yBAAyB;mBAKjB;mBACA;mBACA;mBACA;mBACA;mBACA;UATX;UACA;EAER,YACmB,SAAS,gBACT,OAAO,cACP,YAAY,mBACZ,QAAQ,WACR,gBACA;EAGnB,GAAG,GAAG,cAAc,UAAU,QAAQ,KAAK,QAAQ;EAI7C,MAAM,cAAc,aAAa;EAMjC,UACJ,cACA,2BACA;IAAW;MACV;
|
|
1
|
+
{"version":3,"file":"engine-step.d.mts","names":[],"sources":["../../src/aws/engine-step.ts"],"mappings":";;;;;;;;;;;;;;;wBAuBgB,UAAU,OAAO,OAAO,kBAAkB;qBAa7C,yBAAyB;mBAKjB;mBACA;mBACA;mBACA;mBACA;mBACA;UATX;UACA;EAER,YACmB,SAAS,gBACT,OAAO,cACP,YAAY,mBACZ,QAAQ,WACR,gBACA;EAGnB,GAAG,GAAG,cAAc,UAAU,QAAQ,KAAK,QAAQ;EAI7C,MAAM,cAAc,aAAa;EAMjC,UACJ,cACA,2BACA;IAAW;MACV;EAqCG,YAAY,eAAe;;;;;EAsB3B,YACJ,cACA;IAAS;IAAmB;MAC3B"}
|
package/dist/aws/engine-step.mjs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { isRetryable } from "../engine/retry.mjs";
|
|
2
|
+
import { instanceOwner } from "../store/identity.mjs";
|
|
3
|
+
import "../store/index.mjs";
|
|
2
4
|
import { CallbackTimeoutError } from "@aws/durable-execution-sdk-js";
|
|
3
5
|
// src/aws/engine-step.ts
|
|
4
6
|
/**
|
|
@@ -56,7 +58,8 @@ var AwsEngineStep = class {
|
|
|
56
58
|
settled: false
|
|
57
59
|
};
|
|
58
60
|
await this.context.step(name, async () => {
|
|
59
|
-
await this.store
|
|
61
|
+
const owner = await instanceOwner(this.store, this.instanceId, this.userId);
|
|
62
|
+
await this.store.subscribe(this.instanceId, owner, events, Date.now());
|
|
60
63
|
await this.executions.armCallback(this.instanceId, {
|
|
61
64
|
id: callbackId,
|
|
62
65
|
generation,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"engine-step.mjs","names":[],"sources":["../../src/aws/engine-step.ts"],"sourcesContent":["import {\n CallbackTimeoutError,\n type DurableContext,\n type DurablePromise,\n type RetryDecision,\n} from '@aws/durable-execution-sdk-js';\nimport type { EngineStep } from '../engine/ports';\nimport { isRetryable } from '../engine/retry';\nimport type
|
|
1
|
+
{"version":3,"file":"engine-step.mjs","names":[],"sources":["../../src/aws/engine-step.ts"],"sourcesContent":["import {\n CallbackTimeoutError,\n type DurableContext,\n type DurablePromise,\n type RetryDecision,\n} from '@aws/durable-execution-sdk-js';\nimport type { EngineStep } from '../engine/ports';\nimport { isRetryable } from '../engine/retry';\nimport { type ExecutionRegistry, type JourneyStore, instanceOwner } from '../store/index';\nimport type { LambdaApi } from './lambda-api';\n\n/**\n * EngineStep over a Lambda durable execution.\n *\n * Steps, sleeps and wakes map onto the SDK's `step`, `wait` and callbacks. A wake handle is a\n * callback, which is one-shot, so every wait arms a fresh one (the interpreter subscribes on\n * every attempt): created where the durable context allows it — outside any step, so its id\n * replays — then recorded in Postgres together with the subscription rows, numbered by a\n * generation so a late wake or cleanup of an older wait cannot touch a newer one. The callback\n * is only a hint; the facts the wait re-checks are Postgres'.\n */\n\n/** What the step retries, bounded: 6 attempts with exponential backoff up to a minute. */\nexport function stepRetry(error: Error, attempt: number): RetryDecision {\n if (!isRetryable(error) || attempt >= 6) return { shouldRetry: false };\n return { shouldRetry: true, delay: { seconds: Math.min(60, 2 ** attempt) } };\n}\n\ninterface Armed {\n promise: DurablePromise<string>;\n callbackId: string;\n generation: number;\n /** The wait consumed it (woken or timed out): there is nothing left to close. */\n settled: boolean;\n}\n\nexport class AwsEngineStep implements EngineStep {\n private generation = 0;\n private armed: Armed | undefined;\n\n constructor(\n private readonly context: DurableContext,\n private readonly store: JourneyStore,\n private readonly executions: ExecutionRegistry,\n private readonly lambda: LambdaApi,\n private readonly userId: string,\n private readonly instanceId: string\n ) {}\n\n do<T>(name: string, fn: () => Promise<T>): Promise<T> {\n return this.context.step(name, () => fn(), { retryStrategy: stepRetry });\n }\n\n async sleep(name: string, ms: number): Promise<void> {\n // A wait is whole seconds: rounded up, so it never ends early; nothing to wait at zero\n if (!(ms > 0)) return;\n await this.context.wait(name, { seconds: Math.ceil(ms / 1000) });\n }\n\n async subscribe(\n name: string,\n events: readonly string[],\n options: { deadlineMs?: number } = {}\n ): Promise<void> {\n const generation = ++this.generation;\n const { deadlineMs } = options;\n // The callback's own timeout is the wait's deadline: fixed when it is created\n const seconds =\n deadlineMs === undefined\n ? undefined\n : await this.context.step(`${name}:timeout`, async () =>\n Math.max(1, Math.ceil((deadlineMs - Date.now()) / 1000))\n );\n const [promise, callbackId] = await this.context.createCallback(\n `${name}:callback`,\n seconds === undefined ? {} : { timeout: { seconds } }\n );\n const previous = this.armed;\n this.armed = { promise, callbackId, generation, settled: false };\n await this.context.step(\n name,\n async () => {\n // Rows first, then the callback: a wake that lands in between finds the rows and the\n // callback of the last wait — its completion is refused, and this wait checks the facts\n const owner = await instanceOwner(this.store, this.instanceId, this.userId);\n await this.store.subscribe(this.instanceId, owner, events, Date.now());\n await this.executions.armCallback(this.instanceId, {\n id: callbackId,\n generation,\n deadlineAt: deadlineMs,\n });\n if (previous !== undefined && !previous.settled) {\n await this.lambda.completeCallback(previous.callbackId, '{}');\n }\n },\n { retryStrategy: stepRetry }\n );\n if (previous !== undefined) previous.settled = true;\n }\n\n async unsubscribe(name: string): Promise<void> {\n const armed = this.armed;\n await this.context.step(\n name,\n async () => {\n await this.store.unsubscribe(this.instanceId);\n if (armed === undefined) return;\n await this.executions.clearCallback(this.instanceId, armed.generation);\n // The condition held before the wait parked: its callback is closed through the public\n // API (a closed one answers false), rather than left open until it times out\n if (!armed.settled) await this.lambda.completeCallback(armed.callbackId, '{}');\n },\n { retryStrategy: stepRetry }\n );\n if (armed !== undefined) armed.settled = true;\n this.armed = undefined;\n }\n\n /**\n * The timeout is ignored on purpose: the callback's own timeout was set from the deadline\n * when it was armed (`subscribe`), and only its expiry says the wait timed out.\n */\n async waitForWake(\n name: string,\n _wait: { timeoutMs: number; deadlineMs: number }\n ): Promise<'event' | 'timeout'> {\n const armed = this.armed;\n if (armed === undefined) {\n throw new Error(`waitForWake '${name}' without a subscribe: nothing is armed to wake it`);\n }\n try {\n await armed.promise;\n return 'event';\n } catch (error) {\n // Only the callback's own expiry is a timeout; any other failure is a failure\n if (error instanceof CallbackTimeoutError) return 'timeout';\n throw error;\n } finally {\n armed.settled = true;\n }\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAuBA,SAAgB,UAAU,OAAc,SAAgC;CACtE,IAAI,CAAC,YAAY,KAAK,KAAK,WAAW,GAAG,OAAO,EAAE,aAAa,MAAM;CACrE,OAAO;EAAE,aAAa;EAAM,OAAO,EAAE,SAAS,KAAK,IAAI,IAAI,KAAK,OAAO,EAAE;CAAE;AAC7E;AAUA,IAAa,gBAAb,MAAiD;CAK5B;CACA;CACA;CACA;CACA;CACA;CATnB,aAAqB;CACrB;CAEA,YACE,SACA,OACA,YACA,QACA,QACA,YACA;EANiB,KAAA,UAAA;EACA,KAAA,QAAA;EACA,KAAA,aAAA;EACA,KAAA,SAAA;EACA,KAAA,SAAA;EACA,KAAA,aAAA;CAChB;CAEH,GAAM,MAAc,IAAkC;EACpD,OAAO,KAAK,QAAQ,KAAK,YAAY,GAAG,GAAG,EAAE,eAAe,UAAU,CAAC;CACzE;CAEA,MAAM,MAAM,MAAc,IAA2B;EAEnD,IAAI,EAAE,KAAK,IAAI;EACf,MAAM,KAAK,QAAQ,KAAK,MAAM,EAAE,SAAS,KAAK,KAAK,KAAK,GAAI,EAAE,CAAC;CACjE;CAEA,MAAM,UACJ,MACA,QACA,UAAmC,CAAC,GACrB;EACf,MAAM,aAAa,EAAE,KAAK;EAC1B,MAAM,EAAE,eAAe;EAEvB,MAAM,UACJ,eAAe,KAAA,IACX,KAAA,IACA,MAAM,KAAK,QAAQ,KAAK,GAAG,KAAK,WAAW,YACzC,KAAK,IAAI,GAAG,KAAK,MAAM,aAAa,KAAK,IAAI,KAAK,GAAI,CAAC,CACzD;EACN,MAAM,CAAC,SAAS,cAAc,MAAM,KAAK,QAAQ,eAC/C,GAAG,KAAK,YACR,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,CACtD;EACA,MAAM,WAAW,KAAK;EACtB,KAAK,QAAQ;GAAE;GAAS;GAAY;GAAY,SAAS;EAAM;EAC/D,MAAM,KAAK,QAAQ,KACjB,MACA,YAAY;GAGV,MAAM,QAAQ,MAAM,cAAc,KAAK,OAAO,KAAK,YAAY,KAAK,MAAM;GAC1E,MAAM,KAAK,MAAM,UAAU,KAAK,YAAY,OAAO,QAAQ,KAAK,IAAI,CAAC;GACrE,MAAM,KAAK,WAAW,YAAY,KAAK,YAAY;IACjD,IAAI;IACJ;IACA,YAAY;GACd,CAAC;GACD,IAAI,aAAa,KAAA,KAAa,CAAC,SAAS,SACtC,MAAM,KAAK,OAAO,iBAAiB,SAAS,YAAY,IAAI;EAEhE,GACA,EAAE,eAAe,UAAU,CAC7B;EACA,IAAI,aAAa,KAAA,GAAW,SAAS,UAAU;CACjD;CAEA,MAAM,YAAY,MAA6B;EAC7C,MAAM,QAAQ,KAAK;EACnB,MAAM,KAAK,QAAQ,KACjB,MACA,YAAY;GACV,MAAM,KAAK,MAAM,YAAY,KAAK,UAAU;GAC5C,IAAI,UAAU,KAAA,GAAW;GACzB,MAAM,KAAK,WAAW,cAAc,KAAK,YAAY,MAAM,UAAU;GAGrE,IAAI,CAAC,MAAM,SAAS,MAAM,KAAK,OAAO,iBAAiB,MAAM,YAAY,IAAI;EAC/E,GACA,EAAE,eAAe,UAAU,CAC7B;EACA,IAAI,UAAU,KAAA,GAAW,MAAM,UAAU;EACzC,KAAK,QAAQ,KAAA;CACf;;;;;CAMA,MAAM,YACJ,MACA,OAC8B;EAC9B,MAAM,QAAQ,KAAK;EACnB,IAAI,UAAU,KAAA,GACZ,MAAM,IAAI,MAAM,gBAAgB,KAAK,mDAAmD;EAE1F,IAAI;GACF,MAAM,MAAM;GACZ,OAAO;EACT,SAAS,OAAO;GAEd,IAAI,iBAAiB,sBAAsB,OAAO;GAClD,MAAM;EACR,UAAU;GACR,MAAM,UAAU;EAClB;CACF;AACF"}
|
package/dist/client.cjs
CHANGED
|
@@ -26,57 +26,77 @@ var JourneyValidationError = class extends Error {
|
|
|
26
26
|
this.name = "JourneyValidationError";
|
|
27
27
|
}
|
|
28
28
|
};
|
|
29
|
-
|
|
29
|
+
/**
|
|
30
|
+
* The batch as it travels: checked against the contract, with the time and the event ids and
|
|
31
|
+
* timestamps fixed here, so every redelivery is the same report.
|
|
32
|
+
*/
|
|
33
|
+
function prepareBatch(contract, input) {
|
|
34
|
+
const { userId, visitorId, profile, events } = input;
|
|
35
|
+
if (contract !== void 0) {
|
|
36
|
+
if (profile !== void 0) {
|
|
37
|
+
const result = contract.parseProfilePatch(profile);
|
|
38
|
+
if (!result.ok) throw new JourneyValidationError("/ingest", void 0, result.issues);
|
|
39
|
+
}
|
|
40
|
+
for (const { event, payload } of events ?? []) {
|
|
41
|
+
const result = contract.parseEvent(event, payload ?? {});
|
|
42
|
+
if (!result.ok) throw new JourneyValidationError("/ingest", event, result.issues);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const now = Date.now();
|
|
46
|
+
return {
|
|
47
|
+
...userId === void 0 ? {} : { userId },
|
|
48
|
+
...visitorId === void 0 ? {} : { visitorId },
|
|
49
|
+
...profile === void 0 ? {} : {
|
|
50
|
+
profile,
|
|
51
|
+
at: now
|
|
52
|
+
},
|
|
53
|
+
...events === void 0 ? {} : { events: events.map((e) => ({
|
|
54
|
+
event: e.event,
|
|
55
|
+
payload: e.payload ?? {},
|
|
56
|
+
id: e.id ?? crypto.randomUUID(),
|
|
57
|
+
ts: e.ts ?? now
|
|
58
|
+
})) }
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* `JourneyClient.ingest` through a queue, for a producer that must not lose a report and must not
|
|
63
|
+
* wait for the engine: once `ingest` resolves the batch is durable, and the consumer retries it
|
|
64
|
+
* until it is applied. Delivery is at least once and in no guaranteed order: the batch is the unit
|
|
65
|
+
* that stays in order and event ids and property times absorb the rest. A rejected `ingest` was
|
|
66
|
+
* not queued; the caller decides (a 5xx so its client retries, say).
|
|
67
|
+
*/
|
|
68
|
+
var JourneyQueue = class {
|
|
30
69
|
options;
|
|
31
70
|
constructor(options) {
|
|
32
71
|
this.options = options;
|
|
33
72
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
userId,
|
|
43
|
-
props
|
|
44
|
-
});
|
|
73
|
+
async ingest(input) {
|
|
74
|
+
await this.options.queue.send(prepareBatch(this.options.contract, input));
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
var JourneyClient = class {
|
|
78
|
+
options;
|
|
79
|
+
constructor(options) {
|
|
80
|
+
this.options = options;
|
|
45
81
|
}
|
|
46
82
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
83
|
+
* The one way in: a profile and the events it belongs to, as one batch. A visitor who signed in
|
|
84
|
+
* merges first, then the profile, then the events in order, so a journey cannot start without
|
|
85
|
+
* its recipient. A caller that retries should pass each event's `id`: the Worker stores one
|
|
86
|
+
* occurrence per id. `ts` defaults to now. `JourneyQueue` is the same report through a queue.
|
|
51
87
|
*/
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const contract = this.options.contract;
|
|
55
|
-
if (contract !== void 0) {
|
|
56
|
-
const result = contract.parseEvent(event, payload);
|
|
57
|
-
if (!result.ok) return Promise.reject(new JourneyValidationError("/events", event, result.issues));
|
|
58
|
-
}
|
|
59
|
-
return this.post("/events", {
|
|
60
|
-
userId,
|
|
61
|
-
event,
|
|
62
|
-
payload,
|
|
63
|
-
...options.id === void 0 ? {} : { id: options.id },
|
|
64
|
-
...options.ts === void 0 ? {} : { ts: options.ts }
|
|
65
|
-
});
|
|
88
|
+
async ingest(input) {
|
|
89
|
+
return this.post("/ingest", prepareBatch(this.options.contract, input));
|
|
66
90
|
}
|
|
67
91
|
/**
|
|
68
|
-
* A push token
|
|
69
|
-
* to the latest
|
|
70
|
-
*
|
|
92
|
+
* A push token registered for whoever holds the device now (call it on every registration): a
|
|
93
|
+
* token moves to the latest owner, and a `deviceId` seen with a new token replaces the old one.
|
|
94
|
+
* The engine keeps devices itself — pushes go to them, a token APNs / FCM reports as gone is
|
|
95
|
+
* dropped — and journeys waiting on `reachable('push')` wake. Naming both ids merges the visitor,
|
|
96
|
+
* as `ingest` does.
|
|
71
97
|
*/
|
|
72
|
-
registerDevice(
|
|
73
|
-
|
|
74
|
-
return this.post("/devices", {
|
|
75
|
-
userId,
|
|
76
|
-
token,
|
|
77
|
-
platform,
|
|
78
|
-
...ts === void 0 ? {} : { ts }
|
|
79
|
-
});
|
|
98
|
+
registerDevice(device) {
|
|
99
|
+
return this.post("/devices", device);
|
|
80
100
|
}
|
|
81
101
|
/**
|
|
82
102
|
* A journey push tapped open: the `message_id` the push carried, reported for the user who
|
|
@@ -97,6 +117,10 @@ var JourneyClient = class {
|
|
|
97
117
|
forgetUser(userId) {
|
|
98
118
|
return this.post("/users/forget", { userId });
|
|
99
119
|
}
|
|
120
|
+
/** `forgetUser` for a visitor who never signed in. */
|
|
121
|
+
forgetVisitor(visitorId) {
|
|
122
|
+
return this.post("/users/forget", { visitorId });
|
|
123
|
+
}
|
|
100
124
|
/** The user signed out on that device: the token stays registered, bound to nobody. */
|
|
101
125
|
unbindDevice(token) {
|
|
102
126
|
return this.post("/devices/unbind", { token });
|
|
@@ -186,6 +210,7 @@ const subscriptionsPath = (userId) => `/subscriptions/${encodeURIComponent(userI
|
|
|
186
210
|
const subscriptionsByMessagePath = (messageId) => `/subscriptions/by-message/${encodeURIComponent(messageId)}`;
|
|
187
211
|
exports.JourneyClient = JourneyClient;
|
|
188
212
|
exports.JourneyClientError = JourneyClientError;
|
|
213
|
+
exports.JourneyQueue = JourneyQueue;
|
|
189
214
|
exports.JourneyValidationError = JourneyValidationError;
|
|
190
215
|
|
|
191
216
|
//# sourceMappingURL=client.cjs.map
|
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 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
|
+
{"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 type { BatchResult, JourneyBatch } from './host/batch';\nimport {\n type DeployResult,\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 `ingest` (the event name picks the payload type, the\n * profile patch is the contract's), 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;\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 { BatchEvent, BatchResult, JourneyBatch } from './host/batch';\nexport type {\n ChannelSubscriptions,\n ChannelSubscriptionsPatch,\n MessageOrigin,\n MessageSubscriptions,\n SubscriptionChannel,\n Subscriptions,\n SubscriptionsPatch,\n} from './host/ingest';\n\n/** One event of a batch: with a contract, the name picks the payload type. */\nexport type BatchEventInput<C> = [C] extends [undefined]\n ? { event: string; payload?: Record<string, unknown>; id?: string; ts?: number }\n : {\n [K in keyof EventsOf<C> & string]: {\n event: K;\n payload: core.input<EventsOf<C>[K]>;\n id?: string;\n ts?: number;\n };\n }[keyof EventsOf<C> & string];\n\n/**\n * Who a report or device is about: a signed-in user, or a visitor before sign-in (a stable id for\n * one client, renewed when a user signs out). Naming both says this visitor is this user: what the\n * engine knows of the visitor moves to the user, once.\n */\nexport type JourneyIdentity =\n | { userId: string; visitorId?: string | undefined }\n | { userId?: undefined; visitorId: string };\n\n/** What the profile now says and what they just did, as one report. */\nexport type BatchInput<C> = JourneyIdentity & {\n /** Merged before the first event: a journey the events start finds this recipient. */\n profile?: IdentifyProps<C>;\n events?: readonly BatchEventInput<C>[];\n};\n\n/**\n * The batch as it travels: checked against the contract, with the time and the event ids and\n * timestamps fixed here, so every redelivery is the same report.\n */\nfunction prepareBatch<C extends Contract | undefined>(\n contract: C,\n input: BatchInput<C>\n): JourneyBatch {\n const { userId, visitorId, profile, events } = input as {\n userId?: string;\n visitorId?: string;\n profile?: ProfileProps;\n events?: readonly {\n event: string;\n payload?: Record<string, unknown>;\n id?: string;\n ts?: number;\n }[];\n };\n if (contract !== undefined) {\n if (profile !== undefined) {\n const result = contract.parseProfilePatch(profile);\n if (!result.ok) throw new JourneyValidationError('/ingest', undefined, result.issues);\n }\n for (const { event, payload } of events ?? []) {\n const result = contract.parseEvent(event, payload ?? {});\n if (!result.ok) throw new JourneyValidationError('/ingest', event, result.issues);\n }\n }\n const now = Date.now();\n return {\n ...(userId === undefined ? {} : { userId }),\n ...(visitorId === undefined ? {} : { visitorId }),\n ...(profile === undefined ? {} : { profile, at: now }),\n ...(events === undefined\n ? {}\n : {\n events: events.map((e) => ({\n event: e.event,\n payload: e.payload ?? {},\n id: e.id ?? crypto.randomUUID(),\n ts: e.ts ?? now,\n })),\n }),\n };\n}\n\n/** The producer side of a queue (the subset of a Cloudflare Queues binding used). */\nexport interface JourneyQueueBinding {\n send(body: JourneyBatch): Promise<unknown>;\n}\n\nexport interface JourneyQueueOptions<C extends Contract | undefined = undefined> {\n /** The shared `defineContract(...)`: types `ingest` and checks each batch before it is sent. */\n contract?: C;\n /** The queue `journeyWorker`'s `queue` handler consumes. */\n queue: JourneyQueueBinding;\n}\n\n/**\n * `JourneyClient.ingest` through a queue, for a producer that must not lose a report and must not\n * wait for the engine: once `ingest` resolves the batch is durable, and the consumer retries it\n * until it is applied. Delivery is at least once and in no guaranteed order: the batch is the unit\n * that stays in order and event ids and property times absorb the rest. A rejected `ingest` was\n * not queued; the caller decides (a 5xx so its client retries, say).\n */\nexport class JourneyQueue<C extends Contract | undefined = undefined> {\n constructor(private readonly options: JourneyQueueOptions<C>) {}\n\n async ingest(input: BatchInput<C>): Promise<void> {\n await this.options.queue.send(prepareBatch(this.options.contract as C, input));\n }\n}\n\nexport class JourneyClient<C extends Contract | undefined = undefined> {\n constructor(private readonly options: JourneyClientOptions<C>) {}\n\n /**\n * The one way in: a profile and the events it belongs to, as one batch. A visitor who signed in\n * merges first, then the profile, then the events in order, so a journey cannot start without\n * its recipient. A caller that retries should pass each event's `id`: the Worker stores one\n * occurrence per id. `ts` defaults to now. `JourneyQueue` is the same report through a queue.\n */\n async ingest(input: BatchInput<C>): Promise<BatchResult> {\n return this.post<BatchResult>('/ingest', prepareBatch(this.options.contract as C, input));\n }\n\n /**\n * A push token registered for whoever holds the device now (call it on every registration): a\n * token moves to the latest owner, and a `deviceId` seen with a new token replaces the old one.\n * The engine keeps devices itself — pushes go to them, a token APNs / FCM reports as gone is\n * dropped — and journeys waiting on `reachable('push')` wake. Naming both ids merges the visitor,\n * as `ingest` does.\n */\n registerDevice(\n device: JourneyIdentity & {\n deviceId?: string;\n token: string;\n platform: 'ios' | 'android' | 'web';\n }\n ): Promise<{ ok: true; woke: number }> {\n return this.post('/devices', device);\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 /** `forgetUser` for a visitor who never signed in. */\n forgetVisitor(visitorId: string): Promise<{ ok: true; erased: boolean; pending: string[] }> {\n return this.post('/users/forget', { visitorId });\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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"}
|