@shware/workflow 9.0.2 → 9.0.3
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 +11 -0
- package/dist/host/ingest.cjs +86 -3
- package/dist/host/ingest.cjs.map +1 -1
- package/dist/host/ingest.d.cts +30 -3
- package/dist/host/ingest.d.cts.map +1 -1
- package/dist/host/ingest.d.mts +30 -3
- package/dist/host/ingest.d.mts.map +1 -1
- package/dist/host/ingest.mjs +85 -4
- package/dist/host/ingest.mjs.map +1 -1
- package/dist/store/index.cjs.map +1 -1
- package/dist/store/index.d.cts +20 -0
- package/dist/store/index.d.cts.map +1 -1
- package/dist/store/index.d.mts +20 -0
- package/dist/store/index.d.mts.map +1 -1
- package/dist/store/index.mjs.map +1 -1
- package/dist/store/postgres.cjs +14 -0
- package/dist/store/postgres.cjs.map +1 -1
- package/dist/store/postgres.d.cts +3 -1
- package/dist/store/postgres.d.cts.map +1 -1
- package/dist/store/postgres.d.mts +3 -1
- package/dist/store/postgres.d.mts.map +1 -1
- package/dist/store/postgres.mjs +14 -0
- package/dist/store/postgres.mjs.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1404,6 +1404,17 @@ the sweep's maintenance marks an entry left unadmitted for 15 minutes, whose
|
|
|
1404
1404
|
instance the runtime does not know or reports ended, as failed, so the user's
|
|
1405
1405
|
next trigger re-enters. Neither can start a second journey for the pair.
|
|
1406
1406
|
|
|
1407
|
+
An instance can also end after it was admitted without finalizing its entry:
|
|
1408
|
+
terminated from the dashboard or the API, errored for good because a step kept
|
|
1409
|
+
failing, or evicted. The same maintenance samples the entries admitted more than
|
|
1410
|
+
15 minutes ago (a bounded random sample each sweep, so every entry is reached
|
|
1411
|
+
eventually however many journeys are running), and one whose instance the
|
|
1412
|
+
runtime reports `complete`, `errored` or `terminated`, or no longer knows, is
|
|
1413
|
+
marked failed, its wake subscriptions dropped and its ending reported — what the
|
|
1414
|
+
finalize step would have done. An instance still running, or one whose lookup
|
|
1415
|
+
fails for any other reason, is left alone. The next trigger of the pair then
|
|
1416
|
+
re-enters.
|
|
1417
|
+
|
|
1407
1418
|
## Exports
|
|
1408
1419
|
|
|
1409
1420
|
| Subpath | Contents |
|
package/dist/host/ingest.cjs
CHANGED
|
@@ -119,6 +119,24 @@ async function refreshSegmentTriggers(env, store, userId, ts) {
|
|
|
119
119
|
* and act on each membership transition: entering records the membership and starts the
|
|
120
120
|
* segment's routed workflows, leaving drops it. A segment trigger therefore starts journeys for
|
|
121
121
|
* users who enter after it is deployed; a user already a member is not entered again.
|
|
122
|
+
*
|
|
123
|
+
* Why the membership writes and the starts are separate store calls, and why that is safe:
|
|
124
|
+
* - Each segment's transition is one store operation. `addSegmentMember` writes the membership
|
|
125
|
+
* and the starts it owes (`segment_start`) in one locked transaction; `removeSegmentMember`
|
|
126
|
+
* drops both in one transaction. No failure leaves a segment half-entered or half-left.
|
|
127
|
+
* - Starts come only from `pendingSegmentStarts`, which exists only once that transaction has
|
|
128
|
+
* committed, so a journey is never started for a membership that was not recorded. A start that
|
|
129
|
+
* fails stays pending and is retried by the next evaluation (the sweep, the user's next event);
|
|
130
|
+
* it is crossed off only once an instance is known to exist (`entryStarted`).
|
|
131
|
+
* - A retried or concurrent start cannot run a journey twice: `startJourney` goes through the
|
|
132
|
+
* entry ledger, keyed by (workflow, user) under the once policy, and the membership insert is
|
|
133
|
+
* `ON CONFLICT DO NOTHING`.
|
|
134
|
+
* - Segments are independent memberships. A failure on one leaves the others where they were;
|
|
135
|
+
* every evaluation recomputes each from facts and the stored membership, so the next one
|
|
136
|
+
* converges rather than building on a partial run.
|
|
137
|
+
* Folding every segment's writes into one transaction would add nothing: the starts have to happen
|
|
138
|
+
* after the commit anyway (a workflow instance cannot join a database transaction), which is what
|
|
139
|
+
* the `segment_start` outbox already provides.
|
|
122
140
|
*/
|
|
123
141
|
async function refreshSegments(env, store, userId, ts, routes, segments = [], definitions = /* @__PURE__ */ new Map()) {
|
|
124
142
|
if (require_store_identity.isVisitorSubject(userId)) return [];
|
|
@@ -199,6 +217,63 @@ async function failLostStarts(env, store, now) {
|
|
|
199
217
|
}
|
|
200
218
|
return failed;
|
|
201
219
|
}
|
|
220
|
+
/**
|
|
221
|
+
* How long after its admission an entry is left out of the lost-instance sample. No age proves
|
|
222
|
+
* anything about an admitted instance — it can end a moment after admitting itself, and a long
|
|
223
|
+
* wait runs for weeks — so this is not a judgment, only a filter: the freshest entries are the
|
|
224
|
+
* ones most likely to still be running, and the ones a platform status may not have settled
|
|
225
|
+
* for yet. Correctness rests on the lookup and the conditional update, not on the window.
|
|
226
|
+
*/
|
|
227
|
+
const LOST_INSTANCE_AFTER_MS = 9e5;
|
|
228
|
+
/**
|
|
229
|
+
* Admitted entries one maintenance run looks up on the platform: a random sample (see
|
|
230
|
+
* `JourneyStore.admittedEntries`), so every entry is reached eventually however many are
|
|
231
|
+
* running, and the sweep's platform calls stay bounded.
|
|
232
|
+
*/
|
|
233
|
+
const LOST_INSTANCE_BATCH = 50;
|
|
234
|
+
/**
|
|
235
|
+
* Instances that ended on the platform without finalizing their entry: terminated from outside
|
|
236
|
+
* (the dashboard, the API), errored for good because a finalize step kept failing, or no longer
|
|
237
|
+
* known to the runtime at all. The entry stays `running`, and the once-policy would bar the user
|
|
238
|
+
* from the workflow for good. Only admitted entries are looked at — an unadmitted one is
|
|
239
|
+
* `failLostStarts`' — and only a terminal status, or not found, ends one: an admitted instance
|
|
240
|
+
* did reach the runtime, so not found now means it finished and was evicted (as `stopInstance`
|
|
241
|
+
* reads it). A lookup that fails otherwise proves nothing and leaves the entry alone. The update
|
|
242
|
+
* is conditional on the entry still running under the instance id, so a run that finalized
|
|
243
|
+
* between the read and the lookup keeps its own status.
|
|
244
|
+
*
|
|
245
|
+
* `complete` ends in `failed` too: the run's finalize step is what records the real outcome, and
|
|
246
|
+
* an instance complete with its entry still running went without it, so what the journey would
|
|
247
|
+
* have recorded is unknown. Platform-neutral: on AWS the adapter's own maintenance reconciles
|
|
248
|
+
* these too (both writes are idempotent), and its `status` reads running through a stop's
|
|
249
|
+
* drain, so nothing is failed here that the adapter would still wait for.
|
|
250
|
+
*/
|
|
251
|
+
async function failLostInstances(env, store, now) {
|
|
252
|
+
const failed = [];
|
|
253
|
+
for (const entry of await store.admittedEntries(now - LOST_INSTANCE_AFTER_MS, LOST_INSTANCE_BATCH)) {
|
|
254
|
+
let status;
|
|
255
|
+
try {
|
|
256
|
+
const reported = await (await env.JOURNEY.get(entry.instanceId)).status?.();
|
|
257
|
+
if (reported === void 0 || !STOPPED_STATUSES.has(reported.status)) continue;
|
|
258
|
+
status = reported.status;
|
|
259
|
+
} catch (error) {
|
|
260
|
+
if (!(error instanceof require_host_platform.InstanceNotFoundError)) continue;
|
|
261
|
+
status = "not found";
|
|
262
|
+
}
|
|
263
|
+
if (!await store.failAdmittedEntry(entry.instanceId)) continue;
|
|
264
|
+
failed.push(entry.instanceId);
|
|
265
|
+
await store.unsubscribe(entry.instanceId);
|
|
266
|
+
await store.reporting.instanceFinished({
|
|
267
|
+
workflow: entry.workflow,
|
|
268
|
+
instanceId: entry.instanceId,
|
|
269
|
+
userId: entry.userId,
|
|
270
|
+
status: "failed",
|
|
271
|
+
at: now
|
|
272
|
+
});
|
|
273
|
+
console.error(`[workflow] ${entry.instanceId} (${entry.workflow}) ended on the platform (${status}) without finishing: marked failed, the next trigger re-enters`);
|
|
274
|
+
}
|
|
275
|
+
return failed;
|
|
276
|
+
}
|
|
202
277
|
/** How long a visitor who never signs in is kept after they were last heard from. */
|
|
203
278
|
const VISITOR_RETENTION_MS = 7776e6;
|
|
204
279
|
/** Visitors one sweep prunes: the next sweep goes on. */
|
|
@@ -207,9 +282,10 @@ const VISITOR_PRUNE_BATCH = 100;
|
|
|
207
282
|
const SWEEP_PAGE_SIZE = 500;
|
|
208
283
|
/**
|
|
209
284
|
* What the sweep does before it looks at any user: the runtime's own upkeep (AWS: unconfirmed
|
|
210
|
-
* starts, executions that ended unseen)
|
|
211
|
-
* stop). A failure of
|
|
212
|
-
* still pending afterwards are skipped by
|
|
285
|
+
* starts, executions that ended unseen), the entries whose start or instance was lost, and the
|
|
286
|
+
* erasures left unfinished (an instance would not stop). A failure of any is logged, never
|
|
287
|
+
* fatal: the pages still run. Users whose erasure is still pending afterwards are skipped by
|
|
288
|
+
* every page (`sweepSegmentsPaged`).
|
|
213
289
|
*/
|
|
214
290
|
async function sweepMaintenance(env, store) {
|
|
215
291
|
try {
|
|
@@ -222,6 +298,11 @@ async function sweepMaintenance(env, store) {
|
|
|
222
298
|
} catch (error) {
|
|
223
299
|
console.error("[workflow] reclaiming lost starts failed", error);
|
|
224
300
|
}
|
|
301
|
+
try {
|
|
302
|
+
await failLostInstances(env, store, Date.now());
|
|
303
|
+
} catch (error) {
|
|
304
|
+
console.error("[workflow] reclaiming lost instances failed", error);
|
|
305
|
+
}
|
|
225
306
|
try {
|
|
226
307
|
await store.pruneVisitors(Date.now() - VISITOR_RETENTION_MS, VISITOR_PRUNE_BATCH);
|
|
227
308
|
} catch (error) {
|
|
@@ -803,11 +884,13 @@ async function setSubscriptionsByMessage(env, store, messageId, patch, topics, n
|
|
|
803
884
|
};
|
|
804
885
|
}
|
|
805
886
|
exports.BundleIntegrityError = BundleIntegrityError;
|
|
887
|
+
exports.LOST_INSTANCE_AFTER_MS = LOST_INSTANCE_AFTER_MS;
|
|
806
888
|
exports.LOST_START_AFTER_MS = LOST_START_AFTER_MS;
|
|
807
889
|
exports.SUBSCRIPTION_CHANNELS = SUBSCRIPTION_CHANNELS;
|
|
808
890
|
exports.SWEEP_PAGE_SIZE = SWEEP_PAGE_SIZE;
|
|
809
891
|
exports.VISITOR_RETENTION_MS = VISITOR_RETENTION_MS;
|
|
810
892
|
exports.deployBundle = deployBundle;
|
|
893
|
+
exports.failLostInstances = failLostInstances;
|
|
811
894
|
exports.failLostStarts = failLostStarts;
|
|
812
895
|
exports.forgetUser = forgetUser;
|
|
813
896
|
exports.getSubscriptions = getSubscriptions;
|
package/dist/host/ingest.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ingest.cjs","names":["UserErasedError","InstanceNotFoundError","WAKE_EVENT_TYPE","isVisitorSubject","matchesWhere","JourneyFactSource","evaluateCondition","murmur3","profileUpdatedEvent","DEVICES_UPDATED_EVENT","semanticHash","BundleIR","SchemaMismatchError","RECIPIENT_PROPERTY","isMessageId"],"sources":["../../src/host/ingest.ts"],"sourcesContent":["import { murmur3 } from '../engine/bucket';\nimport {\n DEVICES_UPDATED_EVENT,\n evaluateCondition,\n matchesWhere,\n profileUpdatedEvent,\n} from '../engine/condition';\nimport { RECIPIENT_PROPERTY } from '../engine/recipient';\nimport { semanticHash } from '../hash';\nimport { BundleIR, type ConditionIR, type TriggerIR } from '../ir';\nimport { isMessageId } from '../reporting/tracking';\nimport { JourneyFactSource } from '../store/facts';\nimport {\n type BundleVersion,\n type JourneyStore,\n type ProfileProps,\n SchemaMismatchError,\n type SegmentTriggerRoute,\n type SuppressionChange,\n UserErasedError,\n isVisitorSubject,\n} from '../store/index';\nimport {\n InstanceNotFoundError,\n type JourneyEnv,\n type JourneyParams,\n WAKE_EVENT_TYPE,\n type WorkflowInstanceLike,\n} from './platform';\n\n/**\n * Ingest: what the host does with what comes in, HTTP aside (that is `http.ts`). An incoming\n * event does four things: it is stored, it wakes waiting instances, it is matched against event\n * triggers, and it re-evaluates segment-trigger membership. An identify merges a profile, which\n * wakes the waits on the properties it patched and re-evaluates segment triggers too. Plus\n * devices, subscriptions, erasure, the segment sweep and bundle deploys.\n *\n * Storage goes through a JourneyStore; every entry point takes the one the host opened.\n */\n\n/**\n * The fast answer for a user with an erasure tombstone (being erased, or erased for good): every\n * ingest-side entry point starts here and does no work for them. The gate itself is the store's\n * conditional write, which throws the same error when the tombstone lands after this check.\n */\nasync function refuseIfErased(store: JourneyStore, userId: string): Promise<void> {\n if ((await store.erasureState(userId)) !== 'none') throw new UserErasedError(userId);\n}\n\nexport interface IngestInput {\n userId: string;\n event: string;\n payload?: Record<string, unknown>;\n ts?: number;\n /**\n * This occurrence's identity, and with it the idempotency of sending it.\n *\n * Only the sender can know whether two deliveries describe one occurrence or\n * two, so the engine does not decide: an id it has already stored makes the\n * second delivery a no-op. That matters because ingest is reachable from two\n * retrying callers — an HTTP client retrying a 500, and the interpreter's\n * send_event step being retried after it already committed its row — and a\n * second copy of an event silently changes every count- and window-based\n * condition that reads the log.\n *\n * Callers that retry should send an id they can reproduce. Omitting it gets\n * a fresh one, which records the event as its own occurrence.\n */\n id?: string;\n}\n\nexport interface IngestResult {\n /** The occurrence's identity: the caller's `id`, or the one minted for it. */\n id: string;\n /** False when that id was already in the log: nothing was written this time. */\n stored: boolean;\n woke: number;\n started: string[];\n}\n\nexport async function ingestEvent(\n env: JourneyEnv,\n input: IngestInput,\n store: JourneyStore\n): Promise<IngestResult> {\n if (input.event.startsWith('$')) {\n throw new Error(`event name '${input.event}' is reserved ('$' prefix is internal)`);\n }\n // Also reached from a running instance's send_event: an erased user's instances are stopped\n // before the erase, so one that still gets here is refused like any other writer\n await refuseIfErased(store, input.userId);\n const ts = input.ts ?? Date.now();\n const payload = input.payload ?? {};\n // No id means the sender is not claiming this is a repeat of anything, which\n // a fresh one states truthfully — unlike a key derived from the contents,\n // which would silently collapse two identical events a second apart.\n const id = input.id ?? crypto.randomUUID();\n\n const stored = await store.insertEvent({\n id,\n userId: input.userId,\n event: input.event,\n ts,\n payload,\n });\n\n /*\n * The rest runs even for a duplicate. A retry arrives precisely because the\n * first attempt did not finish — it committed the row and then failed — so\n * returning early would strand the journeys the retry exists to start. Both\n * halves are independently idempotent: waking an instance that already moved\n * on is a no-op, and the entry ledger refuses a second entry.\n */\n const woke = await wakeSubscribers(env, store, input.userId, [input.event]);\n const started = await startTriggeredJourneys(env, store, { ...input, payload }, ts);\n started.push(...(await refreshSegmentTriggers(env, store, input.userId, ts)));\n return { id, stored, woke, started };\n}\n\n/**\n * Wake every instance of the user waiting on any of `events`, once each: an instance subscribed\n * to several of them (a wait on two profile properties, patched together) gets one wake-up, and\n * the payload names the first event it matched.\n */\nexport async function wakeSubscribers(\n env: JourneyEnv,\n store: JourneyStore,\n userId: string,\n events: readonly string[]\n): Promise<number> {\n const handles = new Map<string, string>(); // handle → the event that matched it first\n for (const event of events) {\n for (const handle of await store.findWakeHandles(userId, event)) {\n if (!handles.has(handle)) handles.set(handle, event);\n }\n }\n let woke = 0;\n for (const [handle, event] of handles) {\n let instance: WorkflowInstanceLike;\n try {\n instance = await env.JOURNEY.get(handle);\n } catch (error) {\n // Unknown instance (errored out without cleanup): drop the dangling subscription. Any\n // other failure (a database blip) keeps it: deleting would silence the wait for good\n if (error instanceof InstanceNotFoundError) await store.unsubscribe(handle);\n else console.warn(`[workflow] looking up ${handle} to wake it failed`, error);\n continue;\n }\n try {\n await instance.sendEvent({ type: WAKE_EVENT_TYPE, payload: { event } });\n woke++;\n } catch (error) {\n // Transient send failure (or an instance finishing right now): keep the\n // subscription — a later event retries it, and the wait's own timeout\n // bounds the damage. Deleting here would silence all future wake-ups.\n const message = error instanceof Error ? error.message : String(error);\n console.warn(`[workflow] waking ${handle} on ${event} failed: ${message}`);\n }\n }\n return woke;\n}\n\nasync function startTriggeredJourneys(\n env: JourneyEnv,\n store: JourneyStore,\n input: IngestInput & { payload: Record<string, unknown> },\n ts: number\n): Promise<string[]> {\n const started: string[] = [];\n for (const route of await store.findTriggers(input.event)) {\n // A visitor enters only where the workflow asked for them\n if (isVisitorSubject(input.userId) && !route.visitors) continue;\n\n // where: the payload gate — evaluated against the incoming event first\n if (route.where !== null && !matchesWhere(input.payload, route.where)) continue;\n\n // filter: the profile gate\n if (route.filter !== null) {\n const facts = new JourneyFactSource(store, input.userId);\n if (!(await evaluateCondition(route.filter, facts, ts))) continue;\n }\n\n const instanceId = await startJourney(\n env,\n store,\n route.workflow,\n route.hash,\n input.userId,\n ts,\n {\n event: input.event,\n payload: input.payload,\n ts,\n },\n route.version\n );\n if (instanceId !== null) started.push(instanceId);\n }\n return started;\n}\n\n/**\n * Segment-entry triggers, maintained lazily: on each ingest/identify for a\n * user, re-evaluate every trigger-routed segment for that user and act on the\n * membership transition (enter → start journeys, leave → drop membership so a\n * later re-entry is observable). Purely time-driven drift (an inactivity\n * segment turning true by clock alone) has no event to ride on: the scheduled\n * sweep catches it (`sweepSegments`).\n */\nexport async function refreshSegmentTriggers(\n env: JourneyEnv,\n store: JourneyStore,\n userId: string,\n ts: number\n): Promise<string[]> {\n return refreshSegments(env, store, userId, ts, await store.listSegmentTriggers());\n}\n\n/**\n * Re-evaluate segments for one user — the routed ones only, or with `segments` every one named —\n * and act on each membership transition: entering records the membership and starts the\n * segment's routed workflows, leaving drops it. A segment trigger therefore starts journeys for\n * users who enter after it is deployed; a user already a member is not entered again.\n */\nasync function refreshSegments(\n env: JourneyEnv,\n store: JourneyStore,\n userId: string,\n ts: number,\n routes: readonly SegmentTriggerRoute[],\n segments: readonly string[] = [],\n // A sweep loads each definition once for all its users, not once per user\n definitions = new Map<string, ConditionIR | undefined>()\n): Promise<string[]> {\n // Segments are about users: a visitor is not one until they sign in\n if (isVisitorSubject(userId)) return [];\n const bySegment = new Map<string, { workflow: string; hash: string; version?: string }[]>(\n segments.map((name) => [name, []])\n );\n for (const route of routes) {\n const list = bySegment.get(route.segment) ?? [];\n list.push({\n workflow: route.workflow,\n hash: route.hash,\n ...(route.version === undefined ? {} : { version: route.version }),\n });\n bySegment.set(route.segment, list);\n }\n if (bySegment.size === 0) return [];\n\n const facts = new JourneyFactSource(store, userId);\n const started: string[] = [];\n const failures: unknown[] = [];\n for (const [segmentId, segmentRoutes] of bySegment) {\n if (!definitions.has(segmentId)) {\n definitions.set(segmentId, await store.getSegmentCondition(segmentId));\n }\n const definition = definitions.get(segmentId);\n if (definition === undefined) continue; // route without a definition: broken deploy, skip\n\n const matches = await evaluateCondition(definition, facts, ts);\n const member = await store.isSegmentMember(segmentId, userId);\n\n if (matches && !member) {\n // Entry transition: the membership and the starts it owes, recorded together\n await store.addSegmentMember(\n segmentId,\n userId,\n ts,\n segmentRoutes.map((route) => route.workflow)\n );\n } else if (!matches && member) {\n await store.removeSegmentMember(segmentId, userId);\n continue;\n }\n if (!matches) continue;\n\n // Every start the entry still owes — a fresh entry's, or one an earlier evaluation failed on.\n // Each is crossed off once its instance exists, so one workflow failing neither blocks the\n // others nor gets lost: the next evaluation (the sweep, the user's next event) retries it.\n const pending = new Set(await store.pendingSegmentStarts(segmentId, userId));\n for (const route of segmentRoutes) {\n if (!pending.has(route.workflow)) continue;\n try {\n const instanceId = await startJourney(\n env,\n store,\n route.workflow,\n route.hash,\n userId,\n ts,\n { event: '$segment_entry', payload: { segment: segmentId } },\n route.version\n );\n if (instanceId !== null) started.push(instanceId);\n // Crossed off only once an instance is known to exist. A null here means another\n // attempt holds the entry — one whose create may still fail and roll the entry back;\n // crossing the start off on its word would lose the journey for good. Left pending, the\n // next evaluation asks again, and the ledger keeps that from starting a second one.\n if (instanceId !== null || (await entryStarted(env, store, route.workflow, userId))) {\n await store.completeSegmentStart(segmentId, userId, route.workflow);\n }\n } catch (error) {\n failures.push(error);\n }\n }\n }\n // Surface a failure to the caller (an ingest answers 500) once every start had its chance\n if (failures.length > 0) throw failures[0];\n return started;\n}\n\nexport interface SweepResult {\n /** Users the evaluator looked at: those whose membership may have changed, not everyone. */\n evaluated: number;\n /** Journeys the segments' entries started. */\n started: string[];\n /** Each deployed segment's size after the sweep (empty when the store keeps no reporting). */\n sizes: { segment: string; size: number }[];\n}\n\n/**\n * How long an entry may wait for its runner's admission before maintenance looks for its\n * instance. A create takes well under a second and a queued instance is found by `get`, so this\n * only has to outlast a create still in flight — generously, since a wrong call fails a journey.\n */\nexport const LOST_START_AFTER_MS = 15 * 60 * 1000;\n\n/** Entries one maintenance run examines, oldest first; the next run takes the rest. */\nconst LOST_START_BATCH = 25;\n\n/**\n * Starts lost between the entry and the platform's create — the Worker cut off there, or a\n * create that failed with its rollback failing too — leave a `running` entry nothing will ever\n * admit, and the once-policy would then bar the user from the workflow for good. An instance that\n * ended before admitting itself leaves the same. An entry old enough, unadmitted, whose instance\n * the runtime does not know or reports ended, is marked `failed`: the pair's next trigger reclaims\n * it. Nothing of such an instance ran, since admission precedes its first effect. Platform-neutral:\n * on AWS a planned start is always known to `get` (its registry row), and reads as ended only once\n * its retries have given up, so only a start with nothing behind it is touched.\n */\nexport async function failLostStarts(\n env: JourneyEnv,\n store: JourneyStore,\n now: number\n): Promise<string[]> {\n const failed: string[] = [];\n for (const entry of await store.unadmittedEntries(now - LOST_START_AFTER_MS, LOST_START_BATCH)) {\n try {\n const instance = await env.JOURNEY.get(entry.instanceId);\n // Created: queued or about to admit itself, unless it ended without ever doing so\n if (!(await hasEnded(instance))) continue;\n } catch (error) {\n // Only \"not found\" proves the start was lost; a failed lookup proves nothing\n if (!(error instanceof InstanceNotFoundError)) continue;\n }\n if (await store.failUnadmittedEntry(entry.instanceId)) {\n failed.push(entry.instanceId);\n console.error(\n `[workflow] the start of ${entry.instanceId} (${entry.workflow}) was lost before its instance existed: marked failed, the next trigger re-enters`\n );\n }\n }\n return failed;\n}\n\n/** How long a visitor who never signs in is kept after they were last heard from. */\nexport const VISITOR_RETENTION_MS = 90 * 24 * 60 * 60_000;\n/** Visitors one sweep prunes: the next sweep goes on. */\nconst VISITOR_PRUNE_BATCH = 100;\n\n/** How many users one sweep page evaluates: a unit of resumable work (AWS checkpoints each). */\nexport const SWEEP_PAGE_SIZE = 500;\n\n/**\n * What the sweep does before it looks at any user: the runtime's own upkeep (AWS: unconfirmed\n * starts, executions that ended unseen) and the erasures left unfinished (an instance would not\n * stop). A failure of either is logged, never fatal: the pages still run. Users whose erasure is\n * still pending afterwards are skipped by every page (`sweepSegmentsPaged`).\n */\nexport async function sweepMaintenance(env: JourneyEnv, store: JourneyStore): Promise<void> {\n try {\n await env.JOURNEY.maintain?.();\n } catch (error) {\n console.error('[workflow] runtime maintenance failed', error);\n }\n try {\n await failLostStarts(env, store, Date.now());\n } catch (error) {\n console.error('[workflow] reclaiming lost starts failed', error);\n }\n try {\n await store.pruneVisitors(Date.now() - VISITOR_RETENTION_MS, VISITOR_PRUNE_BATCH);\n } catch (error) {\n console.error('[workflow] pruning idle visitors failed', error);\n }\n // Erasures left pending (an admitted instance would not stop, or is still draining): retried\n // until every one of them is confirmed stopped and the data is gone\n for (const userId of await store.pendingErasures()) {\n try {\n await eraseUser(env, userId, store, Date.now());\n } catch (error) {\n // Still pending: the next sweep tries again, and the rest of this one goes on\n console.error(`[workflow] erasure of ${userId} failed`, error);\n }\n }\n}\n\nexport interface SweepPage {\n /** Users evaluated on this page (those being erased are listed but skipped). */\n evaluated: number;\n /** Journeys the segments' entries started on this page. */\n started: string[];\n /** The last user id listed, to resume past (`after`); undefined when the page was empty. */\n lastUserId?: string | undefined;\n /** Whether the page was full, so another may follow. */\n more: boolean;\n}\n\n/**\n * One page of the sweep: the next users (ascending, at most `limit`) the store says may have a\n * membership to change, evaluated for those segments only. The pages are what makes the sweep\n * resumable — a host whose run can be cut short (a Lambda timeout) checkpoints each one and\n * replays the checkpointed pages instead of starting over; `sweepSegments` is the plain loop over\n * them.\n */\nexport async function sweepSegmentsPaged(\n env: JourneyEnv,\n store: JourneyStore,\n page: { after?: string | undefined; limit?: number | undefined; now?: number | undefined }\n): Promise<SweepPage> {\n const now = page.now ?? Date.now();\n const limit = page.limit ?? SWEEP_PAGE_SIZE;\n const segments = await store.listSegments();\n const routes = await store.listSegmentTriggers();\n // Read per page, not once up front: an erasure requested since the last page is honored too\n const erasing = new Set(await store.pendingErasures());\n const listed = await store.listSegmentCandidates({ segments, now, after: page.after, limit });\n const lastUserId = listed.at(-1)?.userId;\n // A store that ignores `after` would hand back the same page forever: refuse to loop on it.\n // Equality only — the order is the store's collation, which JavaScript's `<` does not know\n if (page.after !== undefined && lastUserId === page.after) {\n throw new Error(\n `listSegmentCandidates did not advance past ${page.after}: the store ignores 'after'`\n );\n }\n const candidates = listed.filter(({ userId }) => !erasing.has(userId));\n const started: string[] = [];\n const definitions = new Map<string, ConditionIR | undefined>(\n segments.map((segment) => [segment.id, segment.condition])\n );\n for (const { userId, segments: named } of candidates) {\n const owed = routes.filter((route) => named.includes(route.segment));\n try {\n started.push(...(await refreshSegments(env, store, userId, now, owed, named, definitions)));\n } catch (error) {\n // One user's failed start is kept for the next sweep; it must not stop everyone else's\n console.error(`[workflow] segment refresh for ${userId} failed`, error);\n }\n }\n return { evaluated: candidates.length, started, lastUserId, more: listed.length >= limit };\n}\n\n/**\n * The scheduled sweep: every deployed segment checked against every known user. The store narrows\n * that down in one statement per page (`listSegmentCandidates`), so a sweep with nothing to change\n * costs a few queries however many users there are, and the evaluator a journey's conditions run\n * on decides each remaining case, so a member is exactly who a journey would see in it. Membership\n * follows (joiners keep the time they joined, leavers are removed), segment triggers start the\n * journeys of whoever entered by the clock alone, and the store records each segment's size.\n */\nexport async function sweepSegments(\n env: JourneyEnv,\n store: JourneyStore,\n now = Date.now()\n): Promise<SweepResult> {\n await sweepMaintenance(env, store);\n let evaluated = 0;\n const started: string[] = [];\n let after: string | undefined;\n for (;;) {\n const page = await sweepSegmentsPaged(env, store, { after, limit: SWEEP_PAGE_SIZE, now });\n evaluated += page.evaluated;\n started.push(...page.started);\n if (!page.more || page.lastUserId === undefined) break;\n after = page.lastUserId;\n }\n const sizes = await store.reporting.snapshotSegments(now);\n return { evaluated, started, sizes };\n}\n\n/**\n * Whether the (workflow, user) entry's start is known to have happened: the runner admitted\n * itself, the entry already ended, or the runtime knows its instance and it has not ended. An\n * unadmitted entry whose instance cannot be found, cannot be looked up right now, or ended without\n * admitting itself is not; the caller asks again.\n */\nasync function entryStarted(\n env: JourneyEnv,\n store: JourneyStore,\n workflow: string,\n userId: string\n): Promise<boolean> {\n const entry = await store.getEntry(workflow, userId);\n // A failed entry is reclaimed by the next evaluation: its start is still owed\n if (entry === undefined || entry.status === 'failed') return false;\n if (entry.admitted || entry.status !== 'running') return true;\n try {\n return !(await hasEnded(await env.JOURNEY.get(entry.instanceId)));\n } catch {\n return false;\n }\n}\n\n/**\n * Create the instance, idempotently by id, on a runtime that does not record its starts\n * (Cloudflare): a create the platform refuses because the id already exists — a concurrent\n * retry of the same start won the race — is the instance we wanted, not a failure. Rolling the\n * entry back over it would strand a running instance with no ledger row, and it would never be\n * admitted. A create whose instance the runtime cannot find failed, and so did one refused by an\n * instance that already ended: the id stays taken by that corpse, which will never admit itself,\n * and taking it for the start would leave a running entry nothing ever fails. True when this call\n * created it, false when it already existed.\n */\nasync function createInstance(\n env: JourneyEnv,\n id: string,\n params: JourneyParams\n): Promise<boolean> {\n try {\n await env.JOURNEY.create({ id, params });\n return true;\n } catch (error) {\n let ended: boolean;\n try {\n ended = await hasEnded(await env.JOURNEY.get(id));\n } catch {\n // Not found, or the lookup failed too: the create's own error is the one to surface\n throw error;\n }\n if (ended) throw error;\n return false;\n }\n}\n\n/**\n * Create one journey instance behind the entry ledger. Returns null when the\n * once-policy blocks the entry (the store's attempt is atomic, so concurrent\n * ingests cannot double-enter). A pair whose previous instance is recorded as\n * 'failed' is reclaimed rather than barred forever over one outage.\n */\nasync function startJourney(\n env: JourneyEnv,\n store: JourneyStore,\n workflow: string,\n hash: string,\n userId: string,\n ts: number,\n trigger: { event: string; payload: Record<string, unknown>; ts?: number },\n /** The code version whose bundle routed this start, read with the hash (see TriggerRoute). */\n version?: string\n): Promise<string | null> {\n const instanceId = buildInstanceId(workflow, hash, userId, ts);\n const params: JourneyParams = { workflowId: workflow, contentHash: hash, userId, trigger };\n const execution = await env.JOURNEY.plan?.({ id: instanceId, params, ts, version });\n\n const outcome = await store.enterJourney({ workflow, userId, instanceId, hash, ts, execution });\n if (outcome === null) {\n // Already entered and still alive — unless its start never got through: this retry is the\n // first chance to finish it\n if (env.JOURNEY.resume !== undefined) {\n // A runtime that records its starts (AWS) retries the recorded one\n await env.JOURNEY.resume({ workflow, userId });\n return null;\n }\n // A runtime that does not (Cloudflare): the ledger alone says a start is owed. Only a retry\n // of this very attempt — same instance id, so same event and ts — may finish it; a later\n // event of the user carries another id and must not start a second journey. The id is the\n // platform's identity too, so creating it again is idempotent (`createInstance`).\n const entry = await store.getEntry(workflow, userId);\n if (entry?.instanceId !== instanceId || entry.status !== 'running' || entry.admitted) {\n return null;\n }\n // Reported as started only when this retry is what created it: a duplicate delivery of an\n // event whose first attempt succeeded finds the instance there and starts nothing\n return (await createInstance(env, instanceId, params)) ? instanceId : null;\n }\n\n try {\n // A recorded start (AWS) is its own idempotency: there `get` finds the record even when the\n // submit failed, so only a runtime without one reads \"already exists\" as success\n if (execution !== undefined) await env.JOURNEY.create({ id: instanceId, params });\n else await createInstance(env, instanceId, params);\n } catch (error) {\n // A recorded start stays: the runtime may have taken it, and a retry with the same name\n // and payload finds that execution instead of starting a second one\n if (execution !== undefined) throw error;\n if (outcome === 'reclaimed') {\n // Put the corpse back rather than deleting it: the entry is still a\n // failure, and dropping the row would erase that from the ledger.\n await store.setEntryStatus(instanceId, 'failed');\n } else {\n // Roll the ledger back so a retried ingest can enter — otherwise the\n // once-policy would permanently record an entry that never ran.\n await store.removeEntry(instanceId);\n }\n throw error;\n }\n return instanceId;\n}\n\nconst utf8 = new TextEncoder();\n\n/**\n * Instance id: addressable (the host gets it directly to wake it), ≤100\n * chars, legal charset. The murmur3 fingerprint keeps ids distinct when the\n * sanitized/truncated userId alone would collide.\n */\nfunction buildInstanceId(workflow: string, hash: string, userId: string, ts: number): string {\n const uid = userId.replace(/[^a-zA-Z0-9_-]/g, '-').slice(0, 24);\n const fingerprint = murmur3(utf8.encode(userId)).toString(36);\n return `${workflow.slice(0, 32)}-${hash.slice(0, 8)}-${uid}-${fingerprint}-${ts.toString(36)}`;\n}\n\n/* --------------------------------- identify --------------------------------- */\n\nexport interface IdentifyResult {\n ok: true;\n props: ProfileProps;\n woke: number;\n started: string[];\n}\n\n/**\n * Merge-patch profile properties (a null removes one), then wake property waits\n * and re-evaluate segment triggers. The merge is one statement in the store, so\n * two identifies landing together cannot lose each other's fields.\n */\nexport async function identifyUser(\n env: JourneyEnv,\n userId: string,\n props: ProfileProps,\n store: JourneyStore,\n /** When these values were read; a property written later stays. The Worker's clock by default. */\n at?: number\n): Promise<IdentifyResult> {\n await refuseIfErased(store, userId);\n const ts = Date.now();\n const merged = await store.mergeProfile(userId, props, at ?? ts);\n // A property wait subscribes to its own property's channel (see relevantEvents): only the\n // waits on what this patch touched wake up\n const woke = await wakeSubscribers(\n env,\n store,\n userId,\n Object.keys(props).map(profileUpdatedEvent)\n );\n // A profile change can move the user into (or out of) a trigger-routed segment\n const started = await refreshSegmentTriggers(env, store, userId, ts);\n return { ok: true, props: merged, woke, started };\n}\n\nexport interface DeviceRegistration {\n deviceId?: string | undefined;\n userId?: string | undefined;\n visitorId?: string | undefined;\n token: string;\n platform: string;\n}\n\n/**\n * The visitor signed in as the user: what the engine knows of them moves to the user, once. Their\n * journeys carry on as the user's (`instanceOwner`), except one the user is already in, which\n * stops. Answers nothing to do when already linked: every signed-in batch names its visitor.\n */\nexport async function linkVisitor(\n env: JourneyEnv,\n visitorId: string,\n userId: string,\n store: JourneyStore,\n at = Date.now()\n): Promise<void> {\n await refuseIfErased(store, userId);\n const linked = await store.linkedUser(visitorId);\n if (linked !== undefined) {\n // Signing out renews the visitor, so a second user means a client bug or a claimed id\n if (linked !== userId) {\n console.warn(`[workflow] visitor ${visitorId} stays with its first user, not ${userId}`);\n }\n return;\n }\n const merge = await store.linkVisitor(visitorId, userId, at);\n if (merge === null) return;\n for (const instanceId of merge.dropped) await stopInstance(env, instanceId);\n // What moved can settle what the user's journeys wait on, or put them in a segment\n await wakeSubscribers(env, store, userId, [\n DEVICES_UPDATED_EVENT,\n ...merge.events,\n ...merge.props.map(profileUpdatedEvent),\n ]);\n await refreshSegmentTriggers(env, store, userId, at);\n}\n\n/**\n * A push token registered for its current owner: the user, or before sign-in the visitor. The\n * device holds only that, so a token moves to whoever registered it last. Wakes the waits that read\n * devices (`reachable('push')` inside `waitUntil`), for the new owner and the previous one.\n */\nexport async function registerDevice(\n env: JourneyEnv,\n device: DeviceRegistration,\n store: JourneyStore\n): Promise<{ ok: true; woke: number }> {\n const { deviceId, userId, visitorId, token, platform } = device;\n if (userId !== undefined && visitorId !== undefined) {\n await linkVisitor(env, visitorId, userId, store);\n }\n const subject = userId ?? (await store.touchVisitor(visitorId ?? '', Date.now()));\n await refuseIfErased(store, subject);\n const previous = await store.upsertDevice({\n deviceId,\n userId: isVisitorSubject(subject) ? null : subject,\n visitorId,\n token,\n platform,\n at: Date.now(),\n });\n let woke = await wakeSubscribers(env, store, subject, [DEVICES_UPDATED_EVENT]);\n for (const owner of previous) {\n woke += await wakeSubscribers(env, store, owner, [DEVICES_UPDATED_EVENT]);\n }\n return { ok: true, woke };\n}\n\n/** Workflow instance states that will never run another step. */\nconst STOPPED_STATUSES = new Set(['complete', 'errored', 'terminated']);\n\n/** Whether the instance reports a terminal state. An unreadable status says nothing: false. */\nasync function hasEnded(instance: WorkflowInstanceLike): Promise<boolean> {\n const status = await instance.status?.().catch(() => undefined);\n return status !== undefined && STOPPED_STATUSES.has(status.status);\n}\n\n/**\n * Stop one admitted instance for good; true once it is known not to run again: terminated, or\n * already in a terminal state (terminate refuses those). Any other failure is not taken for\n * \"already gone\": it answers false, and the erasure waits for a retry.\n *\n * An admitted instance the platform no longer knows counts as stopped too — but only an\n * admitted one. Admission is the runner's first act, so an admitted instance did reach the\n * runtime: not found now means it finished and was evicted. An entry that is not admitted is\n * never passed here: for it, not found may just as well mean its `create` is still in flight.\n */\nasync function stopInstance(env: JourneyEnv, instanceId: string): Promise<boolean> {\n let instance: WorkflowInstanceLike;\n try {\n instance = await env.JOURNEY.get(instanceId);\n } catch (error) {\n if (error instanceof InstanceNotFoundError) return true;\n console.warn(`[workflow] looking up ${instanceId} to stop it failed`, error);\n return false;\n }\n if (instance.terminate === undefined) return false;\n try {\n await instance.terminate();\n return true;\n } catch (error) {\n if (await hasEnded(instance)) return true;\n console.warn(`[workflow] terminating ${instanceId} failed`, error);\n return false;\n }\n}\n\n/**\n * Carry out a recorded erasure: stop every running instance of the user, and only once all are\n * known not to run erase their data. The tombstone is already committed, so no new entry,\n * event, profile or device of theirs lands meanwhile.\n *\n * An entry's runner admits itself before its first effect (`admitInstance`, mutually exclusive\n * with the tombstone's commit), which splits the running instances in two:\n *\n * - not admitted: nothing of it has run, and nothing will — a `create` still in flight, or a\n * runner invoked later, meets the tombstone at admission and exits. No stop is needed, and\n * none would be reliable: the platform may not know the instance yet.\n * - admitted: it did reach the runtime and must be stopped (`stopInstance`), or it would write\n * rows back after the erase. One that will not stop leaves the erasure pending.\n *\n * Erasing nothing until then keeps the entries that name the instances still to stop, so a\n * retry can finish the job. The tombstone outlives the erase (`erased_at`), which is what makes\n * a repeat idempotent and a late write harmless. There is no straggler to come back for: every\n * write about a user runs in the user's locked transaction, the same lock the tombstone takes,\n * so a write either committed before the tombstone — and `forgetUser` deletes it here — or\n * waited for the lock and is refused by the tombstone it then sees.\n */\nasync function eraseUser(\n env: JourneyEnv,\n userId: string,\n store: JourneyStore,\n now: number\n): Promise<{ erased: boolean; pending: string[] }> {\n const pending: string[] = [];\n for (const { instanceId, admitted } of await store.runningInstances(userId)) {\n if (!admitted) continue;\n if (!(await stopInstance(env, instanceId))) pending.push(instanceId);\n }\n if (pending.length > 0) return { erased: false, pending };\n await store.forgetUser(userId, now);\n return { erased: true, pending };\n}\n\n/**\n * Forget a user (a deleted account): the tombstone is committed first, then the erasure carried\n * out. `erased: true` means every admitted runner of theirs was confirmed stopped, the entries\n * never admitted can never run, and the data is deleted — for good, with no time window and no\n * second pass: the tombstone commits under the user's lock, which every write about the user\n * also takes, so nothing of theirs can land after it. An admitted instance that will not stop\n * leaves it pending — nothing erased yet — and the scheduled sweep retries it; `erased: false`\n * says so. A user already erased answers `erased: true` again, with nothing to do.\n */\nexport async function forgetUser(\n env: JourneyEnv,\n userId: string,\n store: JourneyStore,\n now = Date.now()\n): Promise<{ ok: true; erased: boolean; pending: string[] }> {\n await store.requestErasure(userId, now);\n return { ok: true, ...(await eraseUser(env, userId, store, now)) };\n}\n\n/**\n * The user signed out on the device: the token stays registered, bound to nobody, so the next\n * sign-in only has to bind it again. Wakes the former owner's device waits (`reachable('push')`\n * may no longer hold).\n */\nexport async function unbindDevice(\n env: JourneyEnv,\n token: string,\n store: JourneyStore\n): Promise<{ ok: true; woke: number }> {\n const owner = await store.unbindDevice(token);\n const woke =\n owner === undefined ? 0 : await wakeSubscribers(env, store, owner, [DEVICES_UPDATED_EVENT]);\n return { ok: true, woke };\n}\n\n/** A push token forgotten for good (the push service reports it gone); wakes its owner's device waits. */\nexport async function removeDevice(\n env: JourneyEnv,\n token: string,\n store: JourneyStore\n): Promise<{ ok: true; woke: number }> {\n const owner = await store.removeDevice(token);\n const woke =\n owner === undefined ? 0 : await wakeSubscribers(env, store, owner, [DEVICES_UPDATED_EVENT]);\n return { ok: true, woke };\n}\n\n/* ---------------------------------- deploy ---------------------------------- */\n\nexport interface DeployResult {\n workflows: string[];\n /**\n * Workflows whose trigger type has no runtime routing yet — they deploy (the\n * IR is stored and versioned) but will never start until the routing lands.\n * Currently: date (TODO — needs cron + Queues, instance creation is\n * rate-limited and a date trigger fans out to the whole audience at once)\n * and webhook (TODO — needs endpoint allocation).\n */\n unrouted: { workflow: string; trigger: TriggerIR['type'] }[];\n /**\n * Whether the routes now point at this bundle: always for a forced deploy, false for a\n * versioned one when a version as new or newer is already live (nothing was swapped).\n */\n applied: boolean;\n}\n\n/** A bundle whose declared hashes do not describe its contents — rejected before anything is written. */\nexport class BundleIntegrityError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'BundleIntegrityError';\n }\n}\n\n/**\n * Recompute every declared contentHash and refuse the bundle if one disagrees.\n *\n * The contentHash is the address an in-flight journey reads its IR from, so\n * a hash that does not describe its own content is a way to change what a\n * pinned instance executes: submit an edited flow under the old hash and the\n * next replay picks it up. The compiler always computes the hash it ships, so\n * a mismatch means a hand-rolled payload or a stale client — neither should be\n * able to overwrite a version that journeys are running.\n *\n * Each hash is recomputed exactly the way its compiler produced it: a workflow\n * hashes its whole IR (contentHash strips itself), a segment hashes only its\n * condition. Mirroring them is load-bearing — recomputing a segment over the\n * whole record would reject every honest deploy.\n */\nfunction verifyBundleHashes(parsed: BundleIR): void {\n const mismatched: string[] = [];\n for (const workflow of parsed.workflows) {\n if (semanticHash(workflow) !== workflow.contentHash) {\n mismatched.push(`workflow '${workflow.id}'`);\n }\n }\n for (const segment of parsed.segments) {\n if (semanticHash(segment.condition) !== segment.contentHash) {\n mismatched.push(`segment '${segment.id}'`);\n }\n }\n if (mismatched.length > 0) {\n throw new BundleIntegrityError(\n `contentHash does not match content for ${mismatched.join(', ')} — recompile the bundle instead of editing it by hand`\n );\n }\n}\n\n/**\n * Bundle deploy, terraform-apply style: workflow bodies first (content-addressed, so a failed\n * deploy leaves only harmless orphans), then the routing swap. With a `version` the swap happens\n * only over an older live version (`replaceRoutesIfNewer`) — how journeyWorker applies the bundle\n * compiled into it — unless `force` is set: then it happens whatever is live, and the version is\n * recorded as live (`POST /bundle/deploy`). Without a version it is forced and records nothing\n * (`POST /deploy`, a hand-posted bundle).\n */\nexport async function deployBundle(\n env: JourneyEnv,\n bundle: unknown,\n store: JourneyStore,\n version?: BundleVersion,\n force = false\n): Promise<DeployResult> {\n const parsed = BundleIR.parse(bundle);\n // Before any write: a bad hash must not reach the store, where it would address content it does not describe\n verifyBundleHashes(parsed);\n // Nor a bundle the database cannot hold: the host migrates, the engine only checks\n const problems = await store.checkSchema();\n if (problems.length > 0) throw new SchemaMismatchError(problems);\n\n for (const workflow of parsed.workflows) await store.putWorkflowIR(workflow);\n let applied = true;\n if (version === undefined || force) await store.replaceRoutes(parsed, version);\n else applied = await store.replaceRoutesIfNewer(parsed, version);\n\n const unrouted = parsed.workflows\n .filter((workflow) => workflow.trigger.type === 'date' || workflow.trigger.type === 'webhook')\n .map((workflow) => ({ workflow: workflow.id, trigger: workflow.trigger.type }));\n\n return {\n workflows: parsed.workflows.map((w) => `${w.id}@${w.contentHash.slice(0, 8)}`),\n unrouted,\n applied,\n };\n}\n\n/* ------------------------------- subscriptions ------------------------------- */\n\n/** The channels a user opts out of per topic: `suppression` rows are keyed by (user, channel, topic). */\nexport const SUBSCRIPTION_CHANNELS = ['email', 'push'] as const;\nexport type SubscriptionChannel = (typeof SUBSCRIPTION_CHANNELS)[number];\n\n/**\n * Where a user stands with one channel: `true` is subscribed, per topic and for the channel as\n * a whole. The two are independent records: `all: false` masks every topic, whatever `topics`\n * says about it, and `all: true` does not switch a topic back on.\n */\nexport interface ChannelSubscriptions<T extends string = string> {\n /**\n * False once the user opted out of the whole channel (all email, all push). No topic is sent\n * while it is false, however `topics` reads, so a consumer treats it as masking every topic.\n */\n all: boolean;\n /**\n * Each declared topic: false once the user opted out of it on this channel. A topic's flag is\n * its own record, answered as it is even while `all` is false, and it stays false when `all`\n * turns true again until a patch names the topic.\n */\n topics: Record<T, boolean>;\n}\n\n/**\n * A user's subscriptions, topic × channel — what `GET /subscriptions/:userId` answers. Every\n * channel lists every declared topic: a topic is a content category, and the user chooses per\n * channel whether to get it.\n */\nexport interface Subscriptions<T extends string = string> {\n email: ChannelSubscriptions<T>;\n push: ChannelSubscriptions<T>;\n}\n\n/** One channel's part of a patch: only what changes. */\nexport interface ChannelSubscriptionsPatch<T extends string = string> {\n all?: boolean;\n topics?: Partial<Record<T, boolean>>;\n}\n\n/**\n * What `PUT /subscriptions/:userId` takes: only what changes; an omitted channel or field stays\n * as it is. `all: true` clears the channel-wide opt-out only — a topic opted out on its own stays\n * off until the patch names it (a \"subscribe to all\" sends `all: true` plus every topic `true`).\n */\nexport interface SubscriptionsPatch<T extends string = string> {\n email?: ChannelSubscriptionsPatch<T>;\n push?: ChannelSubscriptionsPatch<T>;\n}\n\n/** One field of a patch refused, with the path the producer can fix. */\nexport interface SubscriptionsIssue {\n path: string;\n message: string;\n}\n\n/**\n * A subscriptions patch as it arrives over HTTP: untrusted JSON, read field by field and never\n * cast. Every topic must be one the bundle declares — the engine's list, not the caller's word.\n */\nexport function parseSubscriptionsPatch(\n body: unknown,\n topics: readonly string[]\n): { ok: true; patch: SubscriptionsPatch } | { ok: false; issues: SubscriptionsIssue[] } {\n const issues: SubscriptionsIssue[] = [];\n const object = (value: unknown): value is Record<string, unknown> =>\n typeof value === 'object' && value !== null && !Array.isArray(value);\n if (!object(body)) return { ok: false, issues: [{ path: '', message: 'must be an object' }] };\n const known = new Set(topics);\n const patch: SubscriptionsPatch = {};\n for (const [channel, value] of Object.entries(body)) {\n if (!(SUBSCRIPTION_CHANNELS as readonly string[]).includes(channel)) {\n issues.push({ path: channel, message: 'not a subscription channel' });\n continue;\n }\n if (!object(value)) {\n issues.push({ path: channel, message: 'must be an object' });\n continue;\n }\n const part: ChannelSubscriptionsPatch = {};\n for (const [field, setting] of Object.entries(value)) {\n if (field === 'all') {\n if (typeof setting === 'boolean') part.all = setting;\n else issues.push({ path: `${channel}.all`, message: 'must be a boolean' });\n } else if (field === 'topics') {\n if (!object(setting)) {\n issues.push({ path: `${channel}.topics`, message: 'must be an object' });\n continue;\n }\n const chosen: Record<string, boolean> = {};\n for (const [topic, subscribed] of Object.entries(setting)) {\n if (!known.has(topic)) {\n issues.push({ path: `${channel}.topics.${topic}`, message: 'not a declared topic' });\n } else if (typeof subscribed !== 'boolean') {\n issues.push({ path: `${channel}.topics.${topic}`, message: 'must be a boolean' });\n } else {\n chosen[topic] = subscribed;\n }\n }\n part.topics = chosen;\n } else {\n issues.push({ path: `${channel}.${field}`, message: 'unknown field' });\n }\n }\n patch[channel as SubscriptionChannel] = part;\n }\n return issues.length > 0 ? { ok: false, issues } : { ok: true, patch };\n}\n\nasync function readSubscriptions(\n store: JourneyStore,\n userId: string,\n topics: readonly string[]\n): Promise<Subscriptions> {\n const read = async (channel: SubscriptionChannel): Promise<ChannelSubscriptions> => {\n const suppressed = new Set(await store.listSuppressions(userId, channel));\n return {\n all: !suppressed.has(null),\n topics: Object.fromEntries(topics.map((topic) => [topic, !suppressed.has(topic)])),\n };\n };\n return { email: await read('email'), push: await read('push') };\n}\n\n/** The user's subscriptions over the declared `topics`; a user with an erasure tombstone is refused. */\nexport async function getSubscriptions(\n store: JourneyStore,\n userId: string,\n topics: readonly string[]\n): Promise<Subscriptions> {\n await refuseIfErased(store, userId);\n return readSubscriptions(store, userId, topics);\n}\n\n/**\n * Apply a patch — each field given sets or clears the matching opt-out, one transaction per\n * channel — and answer the subscriptions as they now stand. A change of a channel's `all` is a\n * change of its reachability, so the waits reading it wake, both ways: an\n * `exitWhen: not(reachable('email'))` fires on the opt-out, and a wait for the channel to become\n * reachable resumes on the opt-in. Email waits key on the address property, push waits on the\n * device channel (`reachable('push')` subscribes to device changes).\n */\nexport async function setSubscriptions(\n env: JourneyEnv,\n store: JourneyStore,\n userId: string,\n patch: SubscriptionsPatch,\n topics: readonly string[],\n now = Date.now()\n): Promise<Subscriptions> {\n await refuseIfErased(store, userId);\n for (const channel of SUBSCRIPTION_CHANNELS) {\n const part = patch[channel];\n if (part === undefined) continue;\n const changes: SuppressionChange[] = [];\n if (part.all !== undefined) changes.push({ topic: null, suppressed: !part.all });\n for (const [topic, subscribed] of Object.entries(part.topics ?? {})) {\n if (subscribed !== undefined) changes.push({ topic, suppressed: !subscribed });\n }\n if (changes.length === 0) continue;\n await store.updateSuppressions(userId, channel, changes, now);\n if (part.all !== undefined) {\n const wake =\n channel === 'push'\n ? DEVICES_UPDATED_EVENT\n : profileUpdatedEvent(RECIPIENT_PROPERTY.email ?? 'email');\n await wakeSubscribers(env, store, userId, [wake]);\n }\n }\n return readSubscriptions(store, userId, topics);\n}\n\n/** The copy a message-scoped request came from: the channel it went out on, and its topic. */\nexport interface MessageOrigin<T extends string = string> {\n channel: SubscriptionChannel;\n topic: T;\n}\n\n/**\n * A user's subscriptions reached through one of their copies — what\n * `GET` / `PUT /subscriptions/by-message/:messageId` answer: the record, plus which channel and\n * topic that copy belonged to, so a preference page opened from an email can highlight it.\n */\nexport interface MessageSubscriptions<T extends string = string> extends Subscriptions<T> {\n message: MessageOrigin<T>;\n}\n\n/**\n * The recipient of a topic copy, by the copy's id. The id is the capability: a UUID nobody can\n * guess, the same trust `/t/u/<message>` runs on. Undefined — a 404 — for an id that names no\n * copy (never sent, or erased with its user by `forgetUser`), and for a transactional one, which\n * has nothing to opt out of; the same rule as `/t/u`. A push copy's `push_<platform>` channel\n * reads as `push`, the channel its subscriptions are keyed on.\n */\nexport async function resolveMessageOwner(\n store: JourneyStore,\n messageId: string\n): Promise<{ userId: string; message: MessageOrigin } | undefined> {\n if (!isMessageId(messageId)) return undefined;\n const found = await store.reporting.lookupMessage(messageId);\n if (found === undefined) return undefined;\n const { userId, topic } = found;\n // A transactional copy has no topic, and a Slack or Discord copy no subscriptions at all\n if (topic === null) return undefined;\n const channel =\n found.channel === 'email' ? 'email' : found.channel.startsWith('push') ? 'push' : undefined;\n if (channel === undefined) return undefined;\n return { userId, message: { channel, topic } };\n}\n\n/** `getSubscriptions` for the copy's recipient, with the copy's channel and topic; undefined when `resolveMessageOwner` is. */\nexport async function getSubscriptionsByMessage(\n store: JourneyStore,\n messageId: string,\n topics: readonly string[]\n): Promise<MessageSubscriptions | undefined> {\n const owner = await resolveMessageOwner(store, messageId);\n if (owner === undefined) return undefined;\n return { ...(await getSubscriptions(store, owner.userId, topics)), message: owner.message };\n}\n\n/** `setSubscriptions` for the copy's recipient, answering as `getSubscriptionsByMessage` does; undefined when `resolveMessageOwner` is. */\nexport async function setSubscriptionsByMessage(\n env: JourneyEnv,\n store: JourneyStore,\n messageId: string,\n patch: SubscriptionsPatch,\n topics: readonly string[],\n now = Date.now()\n): Promise<MessageSubscriptions | undefined> {\n const owner = await resolveMessageOwner(store, messageId);\n if (owner === undefined) return undefined;\n return {\n ...(await setSubscriptions(env, store, owner.userId, patch, topics, now)),\n message: owner.message,\n };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"ingest.cjs","names":["UserErasedError","InstanceNotFoundError","WAKE_EVENT_TYPE","isVisitorSubject","matchesWhere","JourneyFactSource","evaluateCondition","murmur3","profileUpdatedEvent","DEVICES_UPDATED_EVENT","semanticHash","BundleIR","SchemaMismatchError","RECIPIENT_PROPERTY","isMessageId"],"sources":["../../src/host/ingest.ts"],"sourcesContent":["import { murmur3 } from '../engine/bucket';\nimport {\n DEVICES_UPDATED_EVENT,\n evaluateCondition,\n matchesWhere,\n profileUpdatedEvent,\n} from '../engine/condition';\nimport { RECIPIENT_PROPERTY } from '../engine/recipient';\nimport { semanticHash } from '../hash';\nimport { BundleIR, type ConditionIR, type TriggerIR } from '../ir';\nimport { isMessageId } from '../reporting/tracking';\nimport { JourneyFactSource } from '../store/facts';\nimport {\n type BundleVersion,\n type JourneyStore,\n type ProfileProps,\n SchemaMismatchError,\n type SegmentTriggerRoute,\n type SuppressionChange,\n UserErasedError,\n isVisitorSubject,\n} from '../store/index';\nimport {\n InstanceNotFoundError,\n type JourneyEnv,\n type JourneyParams,\n WAKE_EVENT_TYPE,\n type WorkflowInstanceLike,\n} from './platform';\n\n/**\n * Ingest: what the host does with what comes in, HTTP aside (that is `http.ts`). An incoming\n * event does four things: it is stored, it wakes waiting instances, it is matched against event\n * triggers, and it re-evaluates segment-trigger membership. An identify merges a profile, which\n * wakes the waits on the properties it patched and re-evaluates segment triggers too. Plus\n * devices, subscriptions, erasure, the segment sweep and bundle deploys.\n *\n * Storage goes through a JourneyStore; every entry point takes the one the host opened.\n */\n\n/**\n * The fast answer for a user with an erasure tombstone (being erased, or erased for good): every\n * ingest-side entry point starts here and does no work for them. The gate itself is the store's\n * conditional write, which throws the same error when the tombstone lands after this check.\n */\nasync function refuseIfErased(store: JourneyStore, userId: string): Promise<void> {\n if ((await store.erasureState(userId)) !== 'none') throw new UserErasedError(userId);\n}\n\nexport interface IngestInput {\n userId: string;\n event: string;\n payload?: Record<string, unknown>;\n ts?: number;\n /**\n * This occurrence's identity, and with it the idempotency of sending it.\n *\n * Only the sender can know whether two deliveries describe one occurrence or\n * two, so the engine does not decide: an id it has already stored makes the\n * second delivery a no-op. That matters because ingest is reachable from two\n * retrying callers — an HTTP client retrying a 500, and the interpreter's\n * send_event step being retried after it already committed its row — and a\n * second copy of an event silently changes every count- and window-based\n * condition that reads the log.\n *\n * Callers that retry should send an id they can reproduce. Omitting it gets\n * a fresh one, which records the event as its own occurrence.\n */\n id?: string;\n}\n\nexport interface IngestResult {\n /** The occurrence's identity: the caller's `id`, or the one minted for it. */\n id: string;\n /** False when that id was already in the log: nothing was written this time. */\n stored: boolean;\n woke: number;\n started: string[];\n}\n\nexport async function ingestEvent(\n env: JourneyEnv,\n input: IngestInput,\n store: JourneyStore\n): Promise<IngestResult> {\n if (input.event.startsWith('$')) {\n throw new Error(`event name '${input.event}' is reserved ('$' prefix is internal)`);\n }\n // Also reached from a running instance's send_event: an erased user's instances are stopped\n // before the erase, so one that still gets here is refused like any other writer\n await refuseIfErased(store, input.userId);\n const ts = input.ts ?? Date.now();\n const payload = input.payload ?? {};\n // No id means the sender is not claiming this is a repeat of anything, which\n // a fresh one states truthfully — unlike a key derived from the contents,\n // which would silently collapse two identical events a second apart.\n const id = input.id ?? crypto.randomUUID();\n\n const stored = await store.insertEvent({\n id,\n userId: input.userId,\n event: input.event,\n ts,\n payload,\n });\n\n /*\n * The rest runs even for a duplicate. A retry arrives precisely because the\n * first attempt did not finish — it committed the row and then failed — so\n * returning early would strand the journeys the retry exists to start. Both\n * halves are independently idempotent: waking an instance that already moved\n * on is a no-op, and the entry ledger refuses a second entry.\n */\n const woke = await wakeSubscribers(env, store, input.userId, [input.event]);\n const started = await startTriggeredJourneys(env, store, { ...input, payload }, ts);\n started.push(...(await refreshSegmentTriggers(env, store, input.userId, ts)));\n return { id, stored, woke, started };\n}\n\n/**\n * Wake every instance of the user waiting on any of `events`, once each: an instance subscribed\n * to several of them (a wait on two profile properties, patched together) gets one wake-up, and\n * the payload names the first event it matched.\n */\nexport async function wakeSubscribers(\n env: JourneyEnv,\n store: JourneyStore,\n userId: string,\n events: readonly string[]\n): Promise<number> {\n const handles = new Map<string, string>(); // handle → the event that matched it first\n for (const event of events) {\n for (const handle of await store.findWakeHandles(userId, event)) {\n if (!handles.has(handle)) handles.set(handle, event);\n }\n }\n let woke = 0;\n for (const [handle, event] of handles) {\n let instance: WorkflowInstanceLike;\n try {\n instance = await env.JOURNEY.get(handle);\n } catch (error) {\n // Unknown instance (errored out without cleanup): drop the dangling subscription. Any\n // other failure (a database blip) keeps it: deleting would silence the wait for good\n if (error instanceof InstanceNotFoundError) await store.unsubscribe(handle);\n else console.warn(`[workflow] looking up ${handle} to wake it failed`, error);\n continue;\n }\n try {\n await instance.sendEvent({ type: WAKE_EVENT_TYPE, payload: { event } });\n woke++;\n } catch (error) {\n // Transient send failure (or an instance finishing right now): keep the\n // subscription — a later event retries it, and the wait's own timeout\n // bounds the damage. Deleting here would silence all future wake-ups.\n const message = error instanceof Error ? error.message : String(error);\n console.warn(`[workflow] waking ${handle} on ${event} failed: ${message}`);\n }\n }\n return woke;\n}\n\nasync function startTriggeredJourneys(\n env: JourneyEnv,\n store: JourneyStore,\n input: IngestInput & { payload: Record<string, unknown> },\n ts: number\n): Promise<string[]> {\n const started: string[] = [];\n for (const route of await store.findTriggers(input.event)) {\n // A visitor enters only where the workflow asked for them\n if (isVisitorSubject(input.userId) && !route.visitors) continue;\n\n // where: the payload gate — evaluated against the incoming event first\n if (route.where !== null && !matchesWhere(input.payload, route.where)) continue;\n\n // filter: the profile gate\n if (route.filter !== null) {\n const facts = new JourneyFactSource(store, input.userId);\n if (!(await evaluateCondition(route.filter, facts, ts))) continue;\n }\n\n const instanceId = await startJourney(\n env,\n store,\n route.workflow,\n route.hash,\n input.userId,\n ts,\n {\n event: input.event,\n payload: input.payload,\n ts,\n },\n route.version\n );\n if (instanceId !== null) started.push(instanceId);\n }\n return started;\n}\n\n/**\n * Segment-entry triggers, maintained lazily: on each ingest/identify for a\n * user, re-evaluate every trigger-routed segment for that user and act on the\n * membership transition (enter → start journeys, leave → drop membership so a\n * later re-entry is observable). Purely time-driven drift (an inactivity\n * segment turning true by clock alone) has no event to ride on: the scheduled\n * sweep catches it (`sweepSegments`).\n */\nexport async function refreshSegmentTriggers(\n env: JourneyEnv,\n store: JourneyStore,\n userId: string,\n ts: number\n): Promise<string[]> {\n return refreshSegments(env, store, userId, ts, await store.listSegmentTriggers());\n}\n\n/**\n * Re-evaluate segments for one user — the routed ones only, or with `segments` every one named —\n * and act on each membership transition: entering records the membership and starts the\n * segment's routed workflows, leaving drops it. A segment trigger therefore starts journeys for\n * users who enter after it is deployed; a user already a member is not entered again.\n *\n * Why the membership writes and the starts are separate store calls, and why that is safe:\n * - Each segment's transition is one store operation. `addSegmentMember` writes the membership\n * and the starts it owes (`segment_start`) in one locked transaction; `removeSegmentMember`\n * drops both in one transaction. No failure leaves a segment half-entered or half-left.\n * - Starts come only from `pendingSegmentStarts`, which exists only once that transaction has\n * committed, so a journey is never started for a membership that was not recorded. A start that\n * fails stays pending and is retried by the next evaluation (the sweep, the user's next event);\n * it is crossed off only once an instance is known to exist (`entryStarted`).\n * - A retried or concurrent start cannot run a journey twice: `startJourney` goes through the\n * entry ledger, keyed by (workflow, user) under the once policy, and the membership insert is\n * `ON CONFLICT DO NOTHING`.\n * - Segments are independent memberships. A failure on one leaves the others where they were;\n * every evaluation recomputes each from facts and the stored membership, so the next one\n * converges rather than building on a partial run.\n * Folding every segment's writes into one transaction would add nothing: the starts have to happen\n * after the commit anyway (a workflow instance cannot join a database transaction), which is what\n * the `segment_start` outbox already provides.\n */\nasync function refreshSegments(\n env: JourneyEnv,\n store: JourneyStore,\n userId: string,\n ts: number,\n routes: readonly SegmentTriggerRoute[],\n segments: readonly string[] = [],\n // A sweep loads each definition once for all its users, not once per user\n definitions = new Map<string, ConditionIR | undefined>()\n): Promise<string[]> {\n // Segments are about users: a visitor is not one until they sign in\n if (isVisitorSubject(userId)) return [];\n const bySegment = new Map<string, { workflow: string; hash: string; version?: string }[]>(\n segments.map((name) => [name, []])\n );\n for (const route of routes) {\n const list = bySegment.get(route.segment) ?? [];\n list.push({\n workflow: route.workflow,\n hash: route.hash,\n ...(route.version === undefined ? {} : { version: route.version }),\n });\n bySegment.set(route.segment, list);\n }\n if (bySegment.size === 0) return [];\n\n const facts = new JourneyFactSource(store, userId);\n const started: string[] = [];\n const failures: unknown[] = [];\n for (const [segmentId, segmentRoutes] of bySegment) {\n if (!definitions.has(segmentId)) {\n definitions.set(segmentId, await store.getSegmentCondition(segmentId));\n }\n const definition = definitions.get(segmentId);\n if (definition === undefined) continue; // route without a definition: broken deploy, skip\n\n const matches = await evaluateCondition(definition, facts, ts);\n const member = await store.isSegmentMember(segmentId, userId);\n\n if (matches && !member) {\n // Entry transition: the membership and the starts it owes, recorded together\n await store.addSegmentMember(\n segmentId,\n userId,\n ts,\n segmentRoutes.map((route) => route.workflow)\n );\n } else if (!matches && member) {\n await store.removeSegmentMember(segmentId, userId);\n continue;\n }\n if (!matches) continue;\n\n // Every start the entry still owes — a fresh entry's, or one an earlier evaluation failed on.\n // Each is crossed off once its instance exists, so one workflow failing neither blocks the\n // others nor gets lost: the next evaluation (the sweep, the user's next event) retries it.\n const pending = new Set(await store.pendingSegmentStarts(segmentId, userId));\n for (const route of segmentRoutes) {\n if (!pending.has(route.workflow)) continue;\n try {\n const instanceId = await startJourney(\n env,\n store,\n route.workflow,\n route.hash,\n userId,\n ts,\n { event: '$segment_entry', payload: { segment: segmentId } },\n route.version\n );\n if (instanceId !== null) started.push(instanceId);\n // Crossed off only once an instance is known to exist. A null here means another\n // attempt holds the entry — one whose create may still fail and roll the entry back;\n // crossing the start off on its word would lose the journey for good. Left pending, the\n // next evaluation asks again, and the ledger keeps that from starting a second one.\n if (instanceId !== null || (await entryStarted(env, store, route.workflow, userId))) {\n await store.completeSegmentStart(segmentId, userId, route.workflow);\n }\n } catch (error) {\n failures.push(error);\n }\n }\n }\n // Surface a failure to the caller (an ingest answers 500) once every start had its chance\n if (failures.length > 0) throw failures[0];\n return started;\n}\n\nexport interface SweepResult {\n /** Users the evaluator looked at: those whose membership may have changed, not everyone. */\n evaluated: number;\n /** Journeys the segments' entries started. */\n started: string[];\n /** Each deployed segment's size after the sweep (empty when the store keeps no reporting). */\n sizes: { segment: string; size: number }[];\n}\n\n/**\n * How long an entry may wait for its runner's admission before maintenance looks for its\n * instance. A create takes well under a second and a queued instance is found by `get`, so this\n * only has to outlast a create still in flight — generously, since a wrong call fails a journey.\n */\nexport const LOST_START_AFTER_MS = 15 * 60 * 1000;\n\n/** Entries one maintenance run examines, oldest first; the next run takes the rest. */\nconst LOST_START_BATCH = 25;\n\n/**\n * Starts lost between the entry and the platform's create — the Worker cut off there, or a\n * create that failed with its rollback failing too — leave a `running` entry nothing will ever\n * admit, and the once-policy would then bar the user from the workflow for good. An instance that\n * ended before admitting itself leaves the same. An entry old enough, unadmitted, whose instance\n * the runtime does not know or reports ended, is marked `failed`: the pair's next trigger reclaims\n * it. Nothing of such an instance ran, since admission precedes its first effect. Platform-neutral:\n * on AWS a planned start is always known to `get` (its registry row), and reads as ended only once\n * its retries have given up, so only a start with nothing behind it is touched.\n */\nexport async function failLostStarts(\n env: JourneyEnv,\n store: JourneyStore,\n now: number\n): Promise<string[]> {\n const failed: string[] = [];\n for (const entry of await store.unadmittedEntries(now - LOST_START_AFTER_MS, LOST_START_BATCH)) {\n try {\n const instance = await env.JOURNEY.get(entry.instanceId);\n // Created: queued or about to admit itself, unless it ended without ever doing so\n if (!(await hasEnded(instance))) continue;\n } catch (error) {\n // Only \"not found\" proves the start was lost; a failed lookup proves nothing\n if (!(error instanceof InstanceNotFoundError)) continue;\n }\n if (await store.failUnadmittedEntry(entry.instanceId)) {\n failed.push(entry.instanceId);\n console.error(\n `[workflow] the start of ${entry.instanceId} (${entry.workflow}) was lost before its instance existed: marked failed, the next trigger re-enters`\n );\n }\n }\n return failed;\n}\n\n/**\n * How long after its admission an entry is left out of the lost-instance sample. No age proves\n * anything about an admitted instance — it can end a moment after admitting itself, and a long\n * wait runs for weeks — so this is not a judgment, only a filter: the freshest entries are the\n * ones most likely to still be running, and the ones a platform status may not have settled\n * for yet. Correctness rests on the lookup and the conditional update, not on the window.\n */\nexport const LOST_INSTANCE_AFTER_MS = 15 * 60 * 1000;\n\n/**\n * Admitted entries one maintenance run looks up on the platform: a random sample (see\n * `JourneyStore.admittedEntries`), so every entry is reached eventually however many are\n * running, and the sweep's platform calls stay bounded.\n */\nconst LOST_INSTANCE_BATCH = 50;\n\n/**\n * Instances that ended on the platform without finalizing their entry: terminated from outside\n * (the dashboard, the API), errored for good because a finalize step kept failing, or no longer\n * known to the runtime at all. The entry stays `running`, and the once-policy would bar the user\n * from the workflow for good. Only admitted entries are looked at — an unadmitted one is\n * `failLostStarts`' — and only a terminal status, or not found, ends one: an admitted instance\n * did reach the runtime, so not found now means it finished and was evicted (as `stopInstance`\n * reads it). A lookup that fails otherwise proves nothing and leaves the entry alone. The update\n * is conditional on the entry still running under the instance id, so a run that finalized\n * between the read and the lookup keeps its own status.\n *\n * `complete` ends in `failed` too: the run's finalize step is what records the real outcome, and\n * an instance complete with its entry still running went without it, so what the journey would\n * have recorded is unknown. Platform-neutral: on AWS the adapter's own maintenance reconciles\n * these too (both writes are idempotent), and its `status` reads running through a stop's\n * drain, so nothing is failed here that the adapter would still wait for.\n */\nexport async function failLostInstances(\n env: JourneyEnv,\n store: JourneyStore,\n now: number\n): Promise<string[]> {\n const failed: string[] = [];\n for (const entry of await store.admittedEntries(\n now - LOST_INSTANCE_AFTER_MS,\n LOST_INSTANCE_BATCH\n )) {\n let status: string;\n try {\n const instance = await env.JOURNEY.get(entry.instanceId);\n const reported = await instance.status?.();\n // Still running, or a runtime that cannot say: nothing to conclude\n if (reported === undefined || !STOPPED_STATUSES.has(reported.status)) continue;\n status = reported.status;\n } catch (error) {\n if (!(error instanceof InstanceNotFoundError)) continue;\n status = 'not found';\n }\n if (!(await store.failAdmittedEntry(entry.instanceId))) continue;\n failed.push(entry.instanceId);\n // What the finalize step would have done: the wakes dropped (left, every later event of the\n // user would poke a dead instance), the ending reported\n await store.unsubscribe(entry.instanceId);\n await store.reporting.instanceFinished({\n workflow: entry.workflow,\n instanceId: entry.instanceId,\n userId: entry.userId,\n status: 'failed',\n at: now,\n });\n console.error(\n `[workflow] ${entry.instanceId} (${entry.workflow}) ended on the platform (${status}) without finishing: marked failed, the next trigger re-enters`\n );\n }\n return failed;\n}\n\n/** How long a visitor who never signs in is kept after they were last heard from. */\nexport const VISITOR_RETENTION_MS = 90 * 24 * 60 * 60_000;\n/** Visitors one sweep prunes: the next sweep goes on. */\nconst VISITOR_PRUNE_BATCH = 100;\n\n/** How many users one sweep page evaluates: a unit of resumable work (AWS checkpoints each). */\nexport const SWEEP_PAGE_SIZE = 500;\n\n/**\n * What the sweep does before it looks at any user: the runtime's own upkeep (AWS: unconfirmed\n * starts, executions that ended unseen), the entries whose start or instance was lost, and the\n * erasures left unfinished (an instance would not stop). A failure of any is logged, never\n * fatal: the pages still run. Users whose erasure is still pending afterwards are skipped by\n * every page (`sweepSegmentsPaged`).\n */\nexport async function sweepMaintenance(env: JourneyEnv, store: JourneyStore): Promise<void> {\n try {\n await env.JOURNEY.maintain?.();\n } catch (error) {\n console.error('[workflow] runtime maintenance failed', error);\n }\n try {\n await failLostStarts(env, store, Date.now());\n } catch (error) {\n console.error('[workflow] reclaiming lost starts failed', error);\n }\n try {\n await failLostInstances(env, store, Date.now());\n } catch (error) {\n console.error('[workflow] reclaiming lost instances failed', error);\n }\n try {\n await store.pruneVisitors(Date.now() - VISITOR_RETENTION_MS, VISITOR_PRUNE_BATCH);\n } catch (error) {\n console.error('[workflow] pruning idle visitors failed', error);\n }\n // Erasures left pending (an admitted instance would not stop, or is still draining): retried\n // until every one of them is confirmed stopped and the data is gone\n for (const userId of await store.pendingErasures()) {\n try {\n await eraseUser(env, userId, store, Date.now());\n } catch (error) {\n // Still pending: the next sweep tries again, and the rest of this one goes on\n console.error(`[workflow] erasure of ${userId} failed`, error);\n }\n }\n}\n\nexport interface SweepPage {\n /** Users evaluated on this page (those being erased are listed but skipped). */\n evaluated: number;\n /** Journeys the segments' entries started on this page. */\n started: string[];\n /** The last user id listed, to resume past (`after`); undefined when the page was empty. */\n lastUserId?: string | undefined;\n /** Whether the page was full, so another may follow. */\n more: boolean;\n}\n\n/**\n * One page of the sweep: the next users (ascending, at most `limit`) the store says may have a\n * membership to change, evaluated for those segments only. The pages are what makes the sweep\n * resumable — a host whose run can be cut short (a Lambda timeout) checkpoints each one and\n * replays the checkpointed pages instead of starting over; `sweepSegments` is the plain loop over\n * them.\n */\nexport async function sweepSegmentsPaged(\n env: JourneyEnv,\n store: JourneyStore,\n page: { after?: string | undefined; limit?: number | undefined; now?: number | undefined }\n): Promise<SweepPage> {\n const now = page.now ?? Date.now();\n const limit = page.limit ?? SWEEP_PAGE_SIZE;\n const segments = await store.listSegments();\n const routes = await store.listSegmentTriggers();\n // Read per page, not once up front: an erasure requested since the last page is honored too\n const erasing = new Set(await store.pendingErasures());\n const listed = await store.listSegmentCandidates({ segments, now, after: page.after, limit });\n const lastUserId = listed.at(-1)?.userId;\n // A store that ignores `after` would hand back the same page forever: refuse to loop on it.\n // Equality only — the order is the store's collation, which JavaScript's `<` does not know\n if (page.after !== undefined && lastUserId === page.after) {\n throw new Error(\n `listSegmentCandidates did not advance past ${page.after}: the store ignores 'after'`\n );\n }\n const candidates = listed.filter(({ userId }) => !erasing.has(userId));\n const started: string[] = [];\n const definitions = new Map<string, ConditionIR | undefined>(\n segments.map((segment) => [segment.id, segment.condition])\n );\n for (const { userId, segments: named } of candidates) {\n const owed = routes.filter((route) => named.includes(route.segment));\n try {\n started.push(...(await refreshSegments(env, store, userId, now, owed, named, definitions)));\n } catch (error) {\n // One user's failed start is kept for the next sweep; it must not stop everyone else's\n console.error(`[workflow] segment refresh for ${userId} failed`, error);\n }\n }\n return { evaluated: candidates.length, started, lastUserId, more: listed.length >= limit };\n}\n\n/**\n * The scheduled sweep: every deployed segment checked against every known user. The store narrows\n * that down in one statement per page (`listSegmentCandidates`), so a sweep with nothing to change\n * costs a few queries however many users there are, and the evaluator a journey's conditions run\n * on decides each remaining case, so a member is exactly who a journey would see in it. Membership\n * follows (joiners keep the time they joined, leavers are removed), segment triggers start the\n * journeys of whoever entered by the clock alone, and the store records each segment's size.\n */\nexport async function sweepSegments(\n env: JourneyEnv,\n store: JourneyStore,\n now = Date.now()\n): Promise<SweepResult> {\n await sweepMaintenance(env, store);\n let evaluated = 0;\n const started: string[] = [];\n let after: string | undefined;\n for (;;) {\n const page = await sweepSegmentsPaged(env, store, { after, limit: SWEEP_PAGE_SIZE, now });\n evaluated += page.evaluated;\n started.push(...page.started);\n if (!page.more || page.lastUserId === undefined) break;\n after = page.lastUserId;\n }\n const sizes = await store.reporting.snapshotSegments(now);\n return { evaluated, started, sizes };\n}\n\n/**\n * Whether the (workflow, user) entry's start is known to have happened: the runner admitted\n * itself, the entry already ended, or the runtime knows its instance and it has not ended. An\n * unadmitted entry whose instance cannot be found, cannot be looked up right now, or ended without\n * admitting itself is not; the caller asks again.\n */\nasync function entryStarted(\n env: JourneyEnv,\n store: JourneyStore,\n workflow: string,\n userId: string\n): Promise<boolean> {\n const entry = await store.getEntry(workflow, userId);\n // A failed entry is reclaimed by the next evaluation: its start is still owed\n if (entry === undefined || entry.status === 'failed') return false;\n if (entry.admitted || entry.status !== 'running') return true;\n try {\n return !(await hasEnded(await env.JOURNEY.get(entry.instanceId)));\n } catch {\n return false;\n }\n}\n\n/**\n * Create the instance, idempotently by id, on a runtime that does not record its starts\n * (Cloudflare): a create the platform refuses because the id already exists — a concurrent\n * retry of the same start won the race — is the instance we wanted, not a failure. Rolling the\n * entry back over it would strand a running instance with no ledger row, and it would never be\n * admitted. A create whose instance the runtime cannot find failed, and so did one refused by an\n * instance that already ended: the id stays taken by that corpse, which will never admit itself,\n * and taking it for the start would leave a running entry nothing ever fails. True when this call\n * created it, false when it already existed.\n */\nasync function createInstance(\n env: JourneyEnv,\n id: string,\n params: JourneyParams\n): Promise<boolean> {\n try {\n await env.JOURNEY.create({ id, params });\n return true;\n } catch (error) {\n let ended: boolean;\n try {\n ended = await hasEnded(await env.JOURNEY.get(id));\n } catch {\n // Not found, or the lookup failed too: the create's own error is the one to surface\n throw error;\n }\n if (ended) throw error;\n return false;\n }\n}\n\n/**\n * Create one journey instance behind the entry ledger. Returns null when the\n * once-policy blocks the entry (the store's attempt is atomic, so concurrent\n * ingests cannot double-enter). A pair whose previous instance is recorded as\n * 'failed' is reclaimed rather than barred forever over one outage.\n */\nasync function startJourney(\n env: JourneyEnv,\n store: JourneyStore,\n workflow: string,\n hash: string,\n userId: string,\n ts: number,\n trigger: { event: string; payload: Record<string, unknown>; ts?: number },\n /** The code version whose bundle routed this start, read with the hash (see TriggerRoute). */\n version?: string\n): Promise<string | null> {\n const instanceId = buildInstanceId(workflow, hash, userId, ts);\n const params: JourneyParams = { workflowId: workflow, contentHash: hash, userId, trigger };\n const execution = await env.JOURNEY.plan?.({ id: instanceId, params, ts, version });\n\n const outcome = await store.enterJourney({ workflow, userId, instanceId, hash, ts, execution });\n if (outcome === null) {\n // Already entered and still alive — unless its start never got through: this retry is the\n // first chance to finish it\n if (env.JOURNEY.resume !== undefined) {\n // A runtime that records its starts (AWS) retries the recorded one\n await env.JOURNEY.resume({ workflow, userId });\n return null;\n }\n // A runtime that does not (Cloudflare): the ledger alone says a start is owed. Only a retry\n // of this very attempt — same instance id, so same event and ts — may finish it; a later\n // event of the user carries another id and must not start a second journey. The id is the\n // platform's identity too, so creating it again is idempotent (`createInstance`).\n const entry = await store.getEntry(workflow, userId);\n if (entry?.instanceId !== instanceId || entry.status !== 'running' || entry.admitted) {\n return null;\n }\n // Reported as started only when this retry is what created it: a duplicate delivery of an\n // event whose first attempt succeeded finds the instance there and starts nothing\n return (await createInstance(env, instanceId, params)) ? instanceId : null;\n }\n\n try {\n // A recorded start (AWS) is its own idempotency: there `get` finds the record even when the\n // submit failed, so only a runtime without one reads \"already exists\" as success\n if (execution !== undefined) await env.JOURNEY.create({ id: instanceId, params });\n else await createInstance(env, instanceId, params);\n } catch (error) {\n // A recorded start stays: the runtime may have taken it, and a retry with the same name\n // and payload finds that execution instead of starting a second one\n if (execution !== undefined) throw error;\n if (outcome === 'reclaimed') {\n // Put the corpse back rather than deleting it: the entry is still a\n // failure, and dropping the row would erase that from the ledger.\n await store.setEntryStatus(instanceId, 'failed');\n } else {\n // Roll the ledger back so a retried ingest can enter — otherwise the\n // once-policy would permanently record an entry that never ran.\n await store.removeEntry(instanceId);\n }\n throw error;\n }\n return instanceId;\n}\n\nconst utf8 = new TextEncoder();\n\n/**\n * Instance id: addressable (the host gets it directly to wake it), ≤100\n * chars, legal charset. The murmur3 fingerprint keeps ids distinct when the\n * sanitized/truncated userId alone would collide.\n */\nfunction buildInstanceId(workflow: string, hash: string, userId: string, ts: number): string {\n const uid = userId.replace(/[^a-zA-Z0-9_-]/g, '-').slice(0, 24);\n const fingerprint = murmur3(utf8.encode(userId)).toString(36);\n return `${workflow.slice(0, 32)}-${hash.slice(0, 8)}-${uid}-${fingerprint}-${ts.toString(36)}`;\n}\n\n/* --------------------------------- identify --------------------------------- */\n\nexport interface IdentifyResult {\n ok: true;\n props: ProfileProps;\n woke: number;\n started: string[];\n}\n\n/**\n * Merge-patch profile properties (a null removes one), then wake property waits\n * and re-evaluate segment triggers. The merge is one statement in the store, so\n * two identifies landing together cannot lose each other's fields.\n */\nexport async function identifyUser(\n env: JourneyEnv,\n userId: string,\n props: ProfileProps,\n store: JourneyStore,\n /** When these values were read; a property written later stays. The Worker's clock by default. */\n at?: number\n): Promise<IdentifyResult> {\n await refuseIfErased(store, userId);\n const ts = Date.now();\n const merged = await store.mergeProfile(userId, props, at ?? ts);\n // A property wait subscribes to its own property's channel (see relevantEvents): only the\n // waits on what this patch touched wake up\n const woke = await wakeSubscribers(\n env,\n store,\n userId,\n Object.keys(props).map(profileUpdatedEvent)\n );\n // A profile change can move the user into (or out of) a trigger-routed segment\n const started = await refreshSegmentTriggers(env, store, userId, ts);\n return { ok: true, props: merged, woke, started };\n}\n\nexport interface DeviceRegistration {\n deviceId?: string | undefined;\n userId?: string | undefined;\n visitorId?: string | undefined;\n token: string;\n platform: string;\n}\n\n/**\n * The visitor signed in as the user: what the engine knows of them moves to the user, once. Their\n * journeys carry on as the user's (`instanceOwner`), except one the user is already in, which\n * stops. Answers nothing to do when already linked: every signed-in batch names its visitor.\n */\nexport async function linkVisitor(\n env: JourneyEnv,\n visitorId: string,\n userId: string,\n store: JourneyStore,\n at = Date.now()\n): Promise<void> {\n await refuseIfErased(store, userId);\n const linked = await store.linkedUser(visitorId);\n if (linked !== undefined) {\n // Signing out renews the visitor, so a second user means a client bug or a claimed id\n if (linked !== userId) {\n console.warn(`[workflow] visitor ${visitorId} stays with its first user, not ${userId}`);\n }\n return;\n }\n const merge = await store.linkVisitor(visitorId, userId, at);\n if (merge === null) return;\n for (const instanceId of merge.dropped) await stopInstance(env, instanceId);\n // What moved can settle what the user's journeys wait on, or put them in a segment\n await wakeSubscribers(env, store, userId, [\n DEVICES_UPDATED_EVENT,\n ...merge.events,\n ...merge.props.map(profileUpdatedEvent),\n ]);\n await refreshSegmentTriggers(env, store, userId, at);\n}\n\n/**\n * A push token registered for its current owner: the user, or before sign-in the visitor. The\n * device holds only that, so a token moves to whoever registered it last. Wakes the waits that read\n * devices (`reachable('push')` inside `waitUntil`), for the new owner and the previous one.\n */\nexport async function registerDevice(\n env: JourneyEnv,\n device: DeviceRegistration,\n store: JourneyStore\n): Promise<{ ok: true; woke: number }> {\n const { deviceId, userId, visitorId, token, platform } = device;\n if (userId !== undefined && visitorId !== undefined) {\n await linkVisitor(env, visitorId, userId, store);\n }\n const subject = userId ?? (await store.touchVisitor(visitorId ?? '', Date.now()));\n await refuseIfErased(store, subject);\n const previous = await store.upsertDevice({\n deviceId,\n userId: isVisitorSubject(subject) ? null : subject,\n visitorId,\n token,\n platform,\n at: Date.now(),\n });\n let woke = await wakeSubscribers(env, store, subject, [DEVICES_UPDATED_EVENT]);\n for (const owner of previous) {\n woke += await wakeSubscribers(env, store, owner, [DEVICES_UPDATED_EVENT]);\n }\n return { ok: true, woke };\n}\n\n/** Workflow instance states that will never run another step. */\nconst STOPPED_STATUSES = new Set(['complete', 'errored', 'terminated']);\n\n/** Whether the instance reports a terminal state. An unreadable status says nothing: false. */\nasync function hasEnded(instance: WorkflowInstanceLike): Promise<boolean> {\n const status = await instance.status?.().catch(() => undefined);\n return status !== undefined && STOPPED_STATUSES.has(status.status);\n}\n\n/**\n * Stop one admitted instance for good; true once it is known not to run again: terminated, or\n * already in a terminal state (terminate refuses those). Any other failure is not taken for\n * \"already gone\": it answers false, and the erasure waits for a retry.\n *\n * An admitted instance the platform no longer knows counts as stopped too — but only an\n * admitted one. Admission is the runner's first act, so an admitted instance did reach the\n * runtime: not found now means it finished and was evicted. An entry that is not admitted is\n * never passed here: for it, not found may just as well mean its `create` is still in flight.\n */\nasync function stopInstance(env: JourneyEnv, instanceId: string): Promise<boolean> {\n let instance: WorkflowInstanceLike;\n try {\n instance = await env.JOURNEY.get(instanceId);\n } catch (error) {\n if (error instanceof InstanceNotFoundError) return true;\n console.warn(`[workflow] looking up ${instanceId} to stop it failed`, error);\n return false;\n }\n if (instance.terminate === undefined) return false;\n try {\n await instance.terminate();\n return true;\n } catch (error) {\n if (await hasEnded(instance)) return true;\n console.warn(`[workflow] terminating ${instanceId} failed`, error);\n return false;\n }\n}\n\n/**\n * Carry out a recorded erasure: stop every running instance of the user, and only once all are\n * known not to run erase their data. The tombstone is already committed, so no new entry,\n * event, profile or device of theirs lands meanwhile.\n *\n * An entry's runner admits itself before its first effect (`admitInstance`, mutually exclusive\n * with the tombstone's commit), which splits the running instances in two:\n *\n * - not admitted: nothing of it has run, and nothing will — a `create` still in flight, or a\n * runner invoked later, meets the tombstone at admission and exits. No stop is needed, and\n * none would be reliable: the platform may not know the instance yet.\n * - admitted: it did reach the runtime and must be stopped (`stopInstance`), or it would write\n * rows back after the erase. One that will not stop leaves the erasure pending.\n *\n * Erasing nothing until then keeps the entries that name the instances still to stop, so a\n * retry can finish the job. The tombstone outlives the erase (`erased_at`), which is what makes\n * a repeat idempotent and a late write harmless. There is no straggler to come back for: every\n * write about a user runs in the user's locked transaction, the same lock the tombstone takes,\n * so a write either committed before the tombstone — and `forgetUser` deletes it here — or\n * waited for the lock and is refused by the tombstone it then sees.\n */\nasync function eraseUser(\n env: JourneyEnv,\n userId: string,\n store: JourneyStore,\n now: number\n): Promise<{ erased: boolean; pending: string[] }> {\n const pending: string[] = [];\n for (const { instanceId, admitted } of await store.runningInstances(userId)) {\n if (!admitted) continue;\n if (!(await stopInstance(env, instanceId))) pending.push(instanceId);\n }\n if (pending.length > 0) return { erased: false, pending };\n await store.forgetUser(userId, now);\n return { erased: true, pending };\n}\n\n/**\n * Forget a user (a deleted account): the tombstone is committed first, then the erasure carried\n * out. `erased: true` means every admitted runner of theirs was confirmed stopped, the entries\n * never admitted can never run, and the data is deleted — for good, with no time window and no\n * second pass: the tombstone commits under the user's lock, which every write about the user\n * also takes, so nothing of theirs can land after it. An admitted instance that will not stop\n * leaves it pending — nothing erased yet — and the scheduled sweep retries it; `erased: false`\n * says so. A user already erased answers `erased: true` again, with nothing to do.\n */\nexport async function forgetUser(\n env: JourneyEnv,\n userId: string,\n store: JourneyStore,\n now = Date.now()\n): Promise<{ ok: true; erased: boolean; pending: string[] }> {\n await store.requestErasure(userId, now);\n return { ok: true, ...(await eraseUser(env, userId, store, now)) };\n}\n\n/**\n * The user signed out on the device: the token stays registered, bound to nobody, so the next\n * sign-in only has to bind it again. Wakes the former owner's device waits (`reachable('push')`\n * may no longer hold).\n */\nexport async function unbindDevice(\n env: JourneyEnv,\n token: string,\n store: JourneyStore\n): Promise<{ ok: true; woke: number }> {\n const owner = await store.unbindDevice(token);\n const woke =\n owner === undefined ? 0 : await wakeSubscribers(env, store, owner, [DEVICES_UPDATED_EVENT]);\n return { ok: true, woke };\n}\n\n/** A push token forgotten for good (the push service reports it gone); wakes its owner's device waits. */\nexport async function removeDevice(\n env: JourneyEnv,\n token: string,\n store: JourneyStore\n): Promise<{ ok: true; woke: number }> {\n const owner = await store.removeDevice(token);\n const woke =\n owner === undefined ? 0 : await wakeSubscribers(env, store, owner, [DEVICES_UPDATED_EVENT]);\n return { ok: true, woke };\n}\n\n/* ---------------------------------- deploy ---------------------------------- */\n\nexport interface DeployResult {\n workflows: string[];\n /**\n * Workflows whose trigger type has no runtime routing yet — they deploy (the\n * IR is stored and versioned) but will never start until the routing lands.\n * Currently: date (TODO — needs cron + Queues, instance creation is\n * rate-limited and a date trigger fans out to the whole audience at once)\n * and webhook (TODO — needs endpoint allocation).\n */\n unrouted: { workflow: string; trigger: TriggerIR['type'] }[];\n /**\n * Whether the routes now point at this bundle: always for a forced deploy, false for a\n * versioned one when a version as new or newer is already live (nothing was swapped).\n */\n applied: boolean;\n}\n\n/** A bundle whose declared hashes do not describe its contents — rejected before anything is written. */\nexport class BundleIntegrityError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'BundleIntegrityError';\n }\n}\n\n/**\n * Recompute every declared contentHash and refuse the bundle if one disagrees.\n *\n * The contentHash is the address an in-flight journey reads its IR from, so\n * a hash that does not describe its own content is a way to change what a\n * pinned instance executes: submit an edited flow under the old hash and the\n * next replay picks it up. The compiler always computes the hash it ships, so\n * a mismatch means a hand-rolled payload or a stale client — neither should be\n * able to overwrite a version that journeys are running.\n *\n * Each hash is recomputed exactly the way its compiler produced it: a workflow\n * hashes its whole IR (contentHash strips itself), a segment hashes only its\n * condition. Mirroring them is load-bearing — recomputing a segment over the\n * whole record would reject every honest deploy.\n */\nfunction verifyBundleHashes(parsed: BundleIR): void {\n const mismatched: string[] = [];\n for (const workflow of parsed.workflows) {\n if (semanticHash(workflow) !== workflow.contentHash) {\n mismatched.push(`workflow '${workflow.id}'`);\n }\n }\n for (const segment of parsed.segments) {\n if (semanticHash(segment.condition) !== segment.contentHash) {\n mismatched.push(`segment '${segment.id}'`);\n }\n }\n if (mismatched.length > 0) {\n throw new BundleIntegrityError(\n `contentHash does not match content for ${mismatched.join(', ')} — recompile the bundle instead of editing it by hand`\n );\n }\n}\n\n/**\n * Bundle deploy, terraform-apply style: workflow bodies first (content-addressed, so a failed\n * deploy leaves only harmless orphans), then the routing swap. With a `version` the swap happens\n * only over an older live version (`replaceRoutesIfNewer`) — how journeyWorker applies the bundle\n * compiled into it — unless `force` is set: then it happens whatever is live, and the version is\n * recorded as live (`POST /bundle/deploy`). Without a version it is forced and records nothing\n * (`POST /deploy`, a hand-posted bundle).\n */\nexport async function deployBundle(\n env: JourneyEnv,\n bundle: unknown,\n store: JourneyStore,\n version?: BundleVersion,\n force = false\n): Promise<DeployResult> {\n const parsed = BundleIR.parse(bundle);\n // Before any write: a bad hash must not reach the store, where it would address content it does not describe\n verifyBundleHashes(parsed);\n // Nor a bundle the database cannot hold: the host migrates, the engine only checks\n const problems = await store.checkSchema();\n if (problems.length > 0) throw new SchemaMismatchError(problems);\n\n for (const workflow of parsed.workflows) await store.putWorkflowIR(workflow);\n let applied = true;\n if (version === undefined || force) await store.replaceRoutes(parsed, version);\n else applied = await store.replaceRoutesIfNewer(parsed, version);\n\n const unrouted = parsed.workflows\n .filter((workflow) => workflow.trigger.type === 'date' || workflow.trigger.type === 'webhook')\n .map((workflow) => ({ workflow: workflow.id, trigger: workflow.trigger.type }));\n\n return {\n workflows: parsed.workflows.map((w) => `${w.id}@${w.contentHash.slice(0, 8)}`),\n unrouted,\n applied,\n };\n}\n\n/* ------------------------------- subscriptions ------------------------------- */\n\n/** The channels a user opts out of per topic: `suppression` rows are keyed by (user, channel, topic). */\nexport const SUBSCRIPTION_CHANNELS = ['email', 'push'] as const;\nexport type SubscriptionChannel = (typeof SUBSCRIPTION_CHANNELS)[number];\n\n/**\n * Where a user stands with one channel: `true` is subscribed, per topic and for the channel as\n * a whole. The two are independent records: `all: false` masks every topic, whatever `topics`\n * says about it, and `all: true` does not switch a topic back on.\n */\nexport interface ChannelSubscriptions<T extends string = string> {\n /**\n * False once the user opted out of the whole channel (all email, all push). No topic is sent\n * while it is false, however `topics` reads, so a consumer treats it as masking every topic.\n */\n all: boolean;\n /**\n * Each declared topic: false once the user opted out of it on this channel. A topic's flag is\n * its own record, answered as it is even while `all` is false, and it stays false when `all`\n * turns true again until a patch names the topic.\n */\n topics: Record<T, boolean>;\n}\n\n/**\n * A user's subscriptions, topic × channel — what `GET /subscriptions/:userId` answers. Every\n * channel lists every declared topic: a topic is a content category, and the user chooses per\n * channel whether to get it.\n */\nexport interface Subscriptions<T extends string = string> {\n email: ChannelSubscriptions<T>;\n push: ChannelSubscriptions<T>;\n}\n\n/** One channel's part of a patch: only what changes. */\nexport interface ChannelSubscriptionsPatch<T extends string = string> {\n all?: boolean;\n topics?: Partial<Record<T, boolean>>;\n}\n\n/**\n * What `PUT /subscriptions/:userId` takes: only what changes; an omitted channel or field stays\n * as it is. `all: true` clears the channel-wide opt-out only — a topic opted out on its own stays\n * off until the patch names it (a \"subscribe to all\" sends `all: true` plus every topic `true`).\n */\nexport interface SubscriptionsPatch<T extends string = string> {\n email?: ChannelSubscriptionsPatch<T>;\n push?: ChannelSubscriptionsPatch<T>;\n}\n\n/** One field of a patch refused, with the path the producer can fix. */\nexport interface SubscriptionsIssue {\n path: string;\n message: string;\n}\n\n/**\n * A subscriptions patch as it arrives over HTTP: untrusted JSON, read field by field and never\n * cast. Every topic must be one the bundle declares — the engine's list, not the caller's word.\n */\nexport function parseSubscriptionsPatch(\n body: unknown,\n topics: readonly string[]\n): { ok: true; patch: SubscriptionsPatch } | { ok: false; issues: SubscriptionsIssue[] } {\n const issues: SubscriptionsIssue[] = [];\n const object = (value: unknown): value is Record<string, unknown> =>\n typeof value === 'object' && value !== null && !Array.isArray(value);\n if (!object(body)) return { ok: false, issues: [{ path: '', message: 'must be an object' }] };\n const known = new Set(topics);\n const patch: SubscriptionsPatch = {};\n for (const [channel, value] of Object.entries(body)) {\n if (!(SUBSCRIPTION_CHANNELS as readonly string[]).includes(channel)) {\n issues.push({ path: channel, message: 'not a subscription channel' });\n continue;\n }\n if (!object(value)) {\n issues.push({ path: channel, message: 'must be an object' });\n continue;\n }\n const part: ChannelSubscriptionsPatch = {};\n for (const [field, setting] of Object.entries(value)) {\n if (field === 'all') {\n if (typeof setting === 'boolean') part.all = setting;\n else issues.push({ path: `${channel}.all`, message: 'must be a boolean' });\n } else if (field === 'topics') {\n if (!object(setting)) {\n issues.push({ path: `${channel}.topics`, message: 'must be an object' });\n continue;\n }\n const chosen: Record<string, boolean> = {};\n for (const [topic, subscribed] of Object.entries(setting)) {\n if (!known.has(topic)) {\n issues.push({ path: `${channel}.topics.${topic}`, message: 'not a declared topic' });\n } else if (typeof subscribed !== 'boolean') {\n issues.push({ path: `${channel}.topics.${topic}`, message: 'must be a boolean' });\n } else {\n chosen[topic] = subscribed;\n }\n }\n part.topics = chosen;\n } else {\n issues.push({ path: `${channel}.${field}`, message: 'unknown field' });\n }\n }\n patch[channel as SubscriptionChannel] = part;\n }\n return issues.length > 0 ? { ok: false, issues } : { ok: true, patch };\n}\n\nasync function readSubscriptions(\n store: JourneyStore,\n userId: string,\n topics: readonly string[]\n): Promise<Subscriptions> {\n const read = async (channel: SubscriptionChannel): Promise<ChannelSubscriptions> => {\n const suppressed = new Set(await store.listSuppressions(userId, channel));\n return {\n all: !suppressed.has(null),\n topics: Object.fromEntries(topics.map((topic) => [topic, !suppressed.has(topic)])),\n };\n };\n return { email: await read('email'), push: await read('push') };\n}\n\n/** The user's subscriptions over the declared `topics`; a user with an erasure tombstone is refused. */\nexport async function getSubscriptions(\n store: JourneyStore,\n userId: string,\n topics: readonly string[]\n): Promise<Subscriptions> {\n await refuseIfErased(store, userId);\n return readSubscriptions(store, userId, topics);\n}\n\n/**\n * Apply a patch — each field given sets or clears the matching opt-out, one transaction per\n * channel — and answer the subscriptions as they now stand. A change of a channel's `all` is a\n * change of its reachability, so the waits reading it wake, both ways: an\n * `exitWhen: not(reachable('email'))` fires on the opt-out, and a wait for the channel to become\n * reachable resumes on the opt-in. Email waits key on the address property, push waits on the\n * device channel (`reachable('push')` subscribes to device changes).\n */\nexport async function setSubscriptions(\n env: JourneyEnv,\n store: JourneyStore,\n userId: string,\n patch: SubscriptionsPatch,\n topics: readonly string[],\n now = Date.now()\n): Promise<Subscriptions> {\n await refuseIfErased(store, userId);\n for (const channel of SUBSCRIPTION_CHANNELS) {\n const part = patch[channel];\n if (part === undefined) continue;\n const changes: SuppressionChange[] = [];\n if (part.all !== undefined) changes.push({ topic: null, suppressed: !part.all });\n for (const [topic, subscribed] of Object.entries(part.topics ?? {})) {\n if (subscribed !== undefined) changes.push({ topic, suppressed: !subscribed });\n }\n if (changes.length === 0) continue;\n await store.updateSuppressions(userId, channel, changes, now);\n if (part.all !== undefined) {\n const wake =\n channel === 'push'\n ? DEVICES_UPDATED_EVENT\n : profileUpdatedEvent(RECIPIENT_PROPERTY.email ?? 'email');\n await wakeSubscribers(env, store, userId, [wake]);\n }\n }\n return readSubscriptions(store, userId, topics);\n}\n\n/** The copy a message-scoped request came from: the channel it went out on, and its topic. */\nexport interface MessageOrigin<T extends string = string> {\n channel: SubscriptionChannel;\n topic: T;\n}\n\n/**\n * A user's subscriptions reached through one of their copies — what\n * `GET` / `PUT /subscriptions/by-message/:messageId` answer: the record, plus which channel and\n * topic that copy belonged to, so a preference page opened from an email can highlight it.\n */\nexport interface MessageSubscriptions<T extends string = string> extends Subscriptions<T> {\n message: MessageOrigin<T>;\n}\n\n/**\n * The recipient of a topic copy, by the copy's id. The id is the capability: a UUID nobody can\n * guess, the same trust `/t/u/<message>` runs on. Undefined — a 404 — for an id that names no\n * copy (never sent, or erased with its user by `forgetUser`), and for a transactional one, which\n * has nothing to opt out of; the same rule as `/t/u`. A push copy's `push_<platform>` channel\n * reads as `push`, the channel its subscriptions are keyed on.\n */\nexport async function resolveMessageOwner(\n store: JourneyStore,\n messageId: string\n): Promise<{ userId: string; message: MessageOrigin } | undefined> {\n if (!isMessageId(messageId)) return undefined;\n const found = await store.reporting.lookupMessage(messageId);\n if (found === undefined) return undefined;\n const { userId, topic } = found;\n // A transactional copy has no topic, and a Slack or Discord copy no subscriptions at all\n if (topic === null) return undefined;\n const channel =\n found.channel === 'email' ? 'email' : found.channel.startsWith('push') ? 'push' : undefined;\n if (channel === undefined) return undefined;\n return { userId, message: { channel, topic } };\n}\n\n/** `getSubscriptions` for the copy's recipient, with the copy's channel and topic; undefined when `resolveMessageOwner` is. */\nexport async function getSubscriptionsByMessage(\n store: JourneyStore,\n messageId: string,\n topics: readonly string[]\n): Promise<MessageSubscriptions | undefined> {\n const owner = await resolveMessageOwner(store, messageId);\n if (owner === undefined) return undefined;\n return { ...(await getSubscriptions(store, owner.userId, topics)), message: owner.message };\n}\n\n/** `setSubscriptions` for the copy's recipient, answering as `getSubscriptionsByMessage` does; undefined when `resolveMessageOwner` is. */\nexport async function setSubscriptionsByMessage(\n env: JourneyEnv,\n store: JourneyStore,\n messageId: string,\n patch: SubscriptionsPatch,\n topics: readonly string[],\n now = Date.now()\n): Promise<MessageSubscriptions | undefined> {\n const owner = await resolveMessageOwner(store, messageId);\n if (owner === undefined) return undefined;\n return {\n ...(await setSubscriptions(env, store, owner.userId, patch, topics, now)),\n message: owner.message,\n };\n}\n"],"mappings":"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"}
|
package/dist/host/ingest.d.cts
CHANGED
|
@@ -74,15 +74,42 @@ export declare const LOST_START_AFTER_MS: number;
|
|
|
74
74
|
* its retries have given up, so only a start with nothing behind it is touched.
|
|
75
75
|
*/
|
|
76
76
|
export declare function failLostStarts(env: JourneyEnv, store: JourneyStore, now: number): Promise<string[]>;
|
|
77
|
+
/**
|
|
78
|
+
* How long after its admission an entry is left out of the lost-instance sample. No age proves
|
|
79
|
+
* anything about an admitted instance — it can end a moment after admitting itself, and a long
|
|
80
|
+
* wait runs for weeks — so this is not a judgment, only a filter: the freshest entries are the
|
|
81
|
+
* ones most likely to still be running, and the ones a platform status may not have settled
|
|
82
|
+
* for yet. Correctness rests on the lookup and the conditional update, not on the window.
|
|
83
|
+
*/
|
|
84
|
+
export declare const LOST_INSTANCE_AFTER_MS: number;
|
|
85
|
+
/**
|
|
86
|
+
* Instances that ended on the platform without finalizing their entry: terminated from outside
|
|
87
|
+
* (the dashboard, the API), errored for good because a finalize step kept failing, or no longer
|
|
88
|
+
* known to the runtime at all. The entry stays `running`, and the once-policy would bar the user
|
|
89
|
+
* from the workflow for good. Only admitted entries are looked at — an unadmitted one is
|
|
90
|
+
* `failLostStarts`' — and only a terminal status, or not found, ends one: an admitted instance
|
|
91
|
+
* did reach the runtime, so not found now means it finished and was evicted (as `stopInstance`
|
|
92
|
+
* reads it). A lookup that fails otherwise proves nothing and leaves the entry alone. The update
|
|
93
|
+
* is conditional on the entry still running under the instance id, so a run that finalized
|
|
94
|
+
* between the read and the lookup keeps its own status.
|
|
95
|
+
*
|
|
96
|
+
* `complete` ends in `failed` too: the run's finalize step is what records the real outcome, and
|
|
97
|
+
* an instance complete with its entry still running went without it, so what the journey would
|
|
98
|
+
* have recorded is unknown. Platform-neutral: on AWS the adapter's own maintenance reconciles
|
|
99
|
+
* these too (both writes are idempotent), and its `status` reads running through a stop's
|
|
100
|
+
* drain, so nothing is failed here that the adapter would still wait for.
|
|
101
|
+
*/
|
|
102
|
+
export declare function failLostInstances(env: JourneyEnv, store: JourneyStore, now: number): Promise<string[]>;
|
|
77
103
|
/** How long a visitor who never signs in is kept after they were last heard from. */
|
|
78
104
|
export declare const VISITOR_RETENTION_MS: number;
|
|
79
105
|
/** How many users one sweep page evaluates: a unit of resumable work (AWS checkpoints each). */
|
|
80
106
|
export declare const SWEEP_PAGE_SIZE = 500;
|
|
81
107
|
/**
|
|
82
108
|
* What the sweep does before it looks at any user: the runtime's own upkeep (AWS: unconfirmed
|
|
83
|
-
* starts, executions that ended unseen)
|
|
84
|
-
* stop). A failure of
|
|
85
|
-
* still pending afterwards are skipped by
|
|
109
|
+
* starts, executions that ended unseen), the entries whose start or instance was lost, and the
|
|
110
|
+
* erasures left unfinished (an instance would not stop). A failure of any is logged, never
|
|
111
|
+
* fatal: the pages still run. Users whose erasure is still pending afterwards are skipped by
|
|
112
|
+
* every page (`sweepSegmentsPaged`).
|
|
86
113
|
*/
|
|
87
114
|
export declare function sweepMaintenance(env: JourneyEnv, store: JourneyStore): Promise<void>;
|
|
88
115
|
export interface SweepPage {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ingest.d.cts","names":[],"sources":["../../src/host/ingest.ts"],"mappings":";;;iBAiDiB;EACf;EACA;EACA,UAAU;EACV;;;;;;;;;;;;;;;EAeA;;iBAGe;;EAEf;;EAEA;EACA;EACA;;wBAGoB,YACpB,KAAK,YACL,OAAO,aACP,OAAO,eACN,QAAQ;;;;;;wBAwCW,gBACpB,KAAK,YACL,OAAO,cACP,gBACA,4BACC;;;;;;;;;wBAgFmB,uBACpB,KAAK,YACL,OAAO,cACP,gBACA,aACC;
|
|
1
|
+
{"version":3,"file":"ingest.d.cts","names":[],"sources":["../../src/host/ingest.ts"],"mappings":";;;iBAiDiB;EACf;EACA;EACA,UAAU;EACV;;;;;;;;;;;;;;;EAeA;;iBAGe;;EAEf;;EAEA;EACA;EACA;;wBAGoB,YACpB,KAAK,YACL,OAAO,aACP,OAAO,eACN,QAAQ;;;;;;wBAwCW,gBACpB,KAAK,YACL,OAAO,cACP,gBACA,4BACC;;;;;;;;;wBAgFmB,uBACpB,KAAK,YACL,OAAO,cACP,gBACA,aACC;iBAoHc;;EAEf;;EAEA;;EAEA;IAAS;IAAiB;;;;;;;;qBAQf;;;;;;;;;;;wBAeS,eACpB,KAAK,YACL,OAAO,cACP,cACC;;;;;;;;qBA4BU;;;;;;;;;;;;;;;;;;wBA0BS,kBACpB,KAAK,YACL,OAAO,cACP,cACC;;qBAqCU;;qBAKA;;;;;;;;wBASS,iBAAiB,KAAK,YAAY,OAAO,eAAe;iBAiC7D;;EAEf;;EAEA;;EAEA;;EAEA;;;;;;;;;wBAUoB,mBACpB,KAAK,YACL,OAAO,cACP;EAAQ;EAA4B;EAA4B;IAC/D,QAAQ;;;;;;;;;wBAyCW,cACpB,KAAK,YACL,OAAO,cACP,eACC,QAAQ;iBAuJM;EACf;EACA,OAAO;EACP;EACA;;;;;;;wBAQoB,aACpB,KAAK,YACL,gBACA,OAAO,cACP,OAAO;;AAEP,cACC,QAAQ;iBAiBM;EACf;EACA;EACA;EACA;EACA;;;;;;;wBAQoB,YACpB,KAAK,YACL,mBACA,gBACA,OAAO,cACP,cACC;;;;;;wBA2BmB,eACpB,KAAK,YACL,QAAQ,oBACR,OAAO,eACN;EAAU;EAAU;;;;;;;;;;;wBA2GD,WACpB,KAAK,YACL,gBACA,OAAO,cACP,eACC;EAAU;EAAU;EAAiB;;;;;;;wBAUlB,aACpB,KAAK,YACL,eACA,OAAO,eACN;EAAU;EAAU;;;wBAQD,aACpB,KAAK,YACL,eACA,OAAO,eACN;EAAU;EAAU;;iBASN;EACf;;;;;;;;EAQA;IAAY;IAAkB,SAAS;;;;;;EAKvC;;;qBAIW,6BAA6B;EACxC,YAAY;;;;;;;;;;wBAgDQ,aACpB,KAAK,YACL,iBACA,OAAO,cACP,UAAU,eACV,kBACC,QAAQ;;qBA2BE;YACD,8BAA8B;;;;;;iBAOzB,qBAAqB;;;;;EAKpC;;;;;;EAMA,QAAQ,OAAO;;;;;;;iBAQA,cAAc;EAC7B,OAAO,qBAAqB;EAC5B,MAAM,qBAAqB;;;iBAIZ,0BAA0B;EACzC;EACA,SAAS,QAAQ,OAAO;;;;;;;iBAQT,mBAAmB;EAClC,QAAQ,0BAA0B;EAClC,OAAO,0BAA0B;;;iBAIlB;EACf;EACA;;;;;;wBAOc,wBACd,eACA;EACG;EAAU,OAAO;;EAAyB;EAAW,QAAQ;;;wBA8D5C,iBACpB,OAAO,cACP,gBACA,4BACC,QAAQ;;;;;;;;;wBAaW,iBACpB,KAAK,YACL,OAAO,cACP,gBACA,OAAO,oBACP,2BACA,eACC,QAAQ;;iBAwBM,cAAc;EAC7B,SAAS;EACT,OAAO;;;;;;;iBAQQ,qBAAqB,mCAAmC,cAAc;EACrF,SAAS,cAAc;;;;;;;;;wBAUH,oBACpB,OAAO,cACP,oBACC;EAAU;EAAgB,SAAS;;;wBAchB,0BACpB,OAAO,cACP,mBACA,4BACC,QAAQ;;wBAOW,0BACpB,KAAK,YACL,OAAO,cACP,mBACA,OAAO,oBACP,2BACA,eACC,QAAQ"}
|
package/dist/host/ingest.d.mts
CHANGED
|
@@ -74,15 +74,42 @@ export declare const LOST_START_AFTER_MS: number;
|
|
|
74
74
|
* its retries have given up, so only a start with nothing behind it is touched.
|
|
75
75
|
*/
|
|
76
76
|
export declare function failLostStarts(env: JourneyEnv, store: JourneyStore, now: number): Promise<string[]>;
|
|
77
|
+
/**
|
|
78
|
+
* How long after its admission an entry is left out of the lost-instance sample. No age proves
|
|
79
|
+
* anything about an admitted instance — it can end a moment after admitting itself, and a long
|
|
80
|
+
* wait runs for weeks — so this is not a judgment, only a filter: the freshest entries are the
|
|
81
|
+
* ones most likely to still be running, and the ones a platform status may not have settled
|
|
82
|
+
* for yet. Correctness rests on the lookup and the conditional update, not on the window.
|
|
83
|
+
*/
|
|
84
|
+
export declare const LOST_INSTANCE_AFTER_MS: number;
|
|
85
|
+
/**
|
|
86
|
+
* Instances that ended on the platform without finalizing their entry: terminated from outside
|
|
87
|
+
* (the dashboard, the API), errored for good because a finalize step kept failing, or no longer
|
|
88
|
+
* known to the runtime at all. The entry stays `running`, and the once-policy would bar the user
|
|
89
|
+
* from the workflow for good. Only admitted entries are looked at — an unadmitted one is
|
|
90
|
+
* `failLostStarts`' — and only a terminal status, or not found, ends one: an admitted instance
|
|
91
|
+
* did reach the runtime, so not found now means it finished and was evicted (as `stopInstance`
|
|
92
|
+
* reads it). A lookup that fails otherwise proves nothing and leaves the entry alone. The update
|
|
93
|
+
* is conditional on the entry still running under the instance id, so a run that finalized
|
|
94
|
+
* between the read and the lookup keeps its own status.
|
|
95
|
+
*
|
|
96
|
+
* `complete` ends in `failed` too: the run's finalize step is what records the real outcome, and
|
|
97
|
+
* an instance complete with its entry still running went without it, so what the journey would
|
|
98
|
+
* have recorded is unknown. Platform-neutral: on AWS the adapter's own maintenance reconciles
|
|
99
|
+
* these too (both writes are idempotent), and its `status` reads running through a stop's
|
|
100
|
+
* drain, so nothing is failed here that the adapter would still wait for.
|
|
101
|
+
*/
|
|
102
|
+
export declare function failLostInstances(env: JourneyEnv, store: JourneyStore, now: number): Promise<string[]>;
|
|
77
103
|
/** How long a visitor who never signs in is kept after they were last heard from. */
|
|
78
104
|
export declare const VISITOR_RETENTION_MS: number;
|
|
79
105
|
/** How many users one sweep page evaluates: a unit of resumable work (AWS checkpoints each). */
|
|
80
106
|
export declare const SWEEP_PAGE_SIZE = 500;
|
|
81
107
|
/**
|
|
82
108
|
* What the sweep does before it looks at any user: the runtime's own upkeep (AWS: unconfirmed
|
|
83
|
-
* starts, executions that ended unseen)
|
|
84
|
-
* stop). A failure of
|
|
85
|
-
* still pending afterwards are skipped by
|
|
109
|
+
* starts, executions that ended unseen), the entries whose start or instance was lost, and the
|
|
110
|
+
* erasures left unfinished (an instance would not stop). A failure of any is logged, never
|
|
111
|
+
* fatal: the pages still run. Users whose erasure is still pending afterwards are skipped by
|
|
112
|
+
* every page (`sweepSegmentsPaged`).
|
|
86
113
|
*/
|
|
87
114
|
export declare function sweepMaintenance(env: JourneyEnv, store: JourneyStore): Promise<void>;
|
|
88
115
|
export interface SweepPage {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ingest.d.mts","names":[],"sources":["../../src/host/ingest.ts"],"mappings":";;;iBAiDiB;EACf;EACA;EACA,UAAU;EACV;;;;;;;;;;;;;;;EAeA;;iBAGe;;EAEf;;EAEA;EACA;EACA;;wBAGoB,YACpB,KAAK,YACL,OAAO,aACP,OAAO,eACN,QAAQ;;;;;;wBAwCW,gBACpB,KAAK,YACL,OAAO,cACP,gBACA,4BACC;;;;;;;;;wBAgFmB,uBACpB,KAAK,YACL,OAAO,cACP,gBACA,aACC;
|
|
1
|
+
{"version":3,"file":"ingest.d.mts","names":[],"sources":["../../src/host/ingest.ts"],"mappings":";;;iBAiDiB;EACf;EACA;EACA,UAAU;EACV;;;;;;;;;;;;;;;EAeA;;iBAGe;;EAEf;;EAEA;EACA;EACA;;wBAGoB,YACpB,KAAK,YACL,OAAO,aACP,OAAO,eACN,QAAQ;;;;;;wBAwCW,gBACpB,KAAK,YACL,OAAO,cACP,gBACA,4BACC;;;;;;;;;wBAgFmB,uBACpB,KAAK,YACL,OAAO,cACP,gBACA,aACC;iBAoHc;;EAEf;;EAEA;;EAEA;IAAS;IAAiB;;;;;;;;qBAQf;;;;;;;;;;;wBAeS,eACpB,KAAK,YACL,OAAO,cACP,cACC;;;;;;;;qBA4BU;;;;;;;;;;;;;;;;;;wBA0BS,kBACpB,KAAK,YACL,OAAO,cACP,cACC;;qBAqCU;;qBAKA;;;;;;;;wBASS,iBAAiB,KAAK,YAAY,OAAO,eAAe;iBAiC7D;;EAEf;;EAEA;;EAEA;;EAEA;;;;;;;;;wBAUoB,mBACpB,KAAK,YACL,OAAO,cACP;EAAQ;EAA4B;EAA4B;IAC/D,QAAQ;;;;;;;;;wBAyCW,cACpB,KAAK,YACL,OAAO,cACP,eACC,QAAQ;iBAuJM;EACf;EACA,OAAO;EACP;EACA;;;;;;;wBAQoB,aACpB,KAAK,YACL,gBACA,OAAO,cACP,OAAO;;AAEP,cACC,QAAQ;iBAiBM;EACf;EACA;EACA;EACA;EACA;;;;;;;wBAQoB,YACpB,KAAK,YACL,mBACA,gBACA,OAAO,cACP,cACC;;;;;;wBA2BmB,eACpB,KAAK,YACL,QAAQ,oBACR,OAAO,eACN;EAAU;EAAU;;;;;;;;;;;wBA2GD,WACpB,KAAK,YACL,gBACA,OAAO,cACP,eACC;EAAU;EAAU;EAAiB;;;;;;;wBAUlB,aACpB,KAAK,YACL,eACA,OAAO,eACN;EAAU;EAAU;;;wBAQD,aACpB,KAAK,YACL,eACA,OAAO,eACN;EAAU;EAAU;;iBASN;EACf;;;;;;;;EAQA;IAAY;IAAkB,SAAS;;;;;;EAKvC;;;qBAIW,6BAA6B;EACxC,YAAY;;;;;;;;;;wBAgDQ,aACpB,KAAK,YACL,iBACA,OAAO,cACP,UAAU,eACV,kBACC,QAAQ;;qBA2BE;YACD,8BAA8B;;;;;;iBAOzB,qBAAqB;;;;;EAKpC;;;;;;EAMA,QAAQ,OAAO;;;;;;;iBAQA,cAAc;EAC7B,OAAO,qBAAqB;EAC5B,MAAM,qBAAqB;;;iBAIZ,0BAA0B;EACzC;EACA,SAAS,QAAQ,OAAO;;;;;;;iBAQT,mBAAmB;EAClC,QAAQ,0BAA0B;EAClC,OAAO,0BAA0B;;;iBAIlB;EACf;EACA;;;;;;wBAOc,wBACd,eACA;EACG;EAAU,OAAO;;EAAyB;EAAW,QAAQ;;;wBA8D5C,iBACpB,OAAO,cACP,gBACA,4BACC,QAAQ;;;;;;;;;wBAaW,iBACpB,KAAK,YACL,OAAO,cACP,gBACA,OAAO,oBACP,2BACA,eACC,QAAQ;;iBAwBM,cAAc;EAC7B,SAAS;EACT,OAAO;;;;;;;iBAQQ,qBAAqB,mCAAmC,cAAc;EACrF,SAAS,cAAc;;;;;;;;;wBAUH,oBACpB,OAAO,cACP,oBACC;EAAU;EAAgB,SAAS;;;wBAchB,0BACpB,OAAO,cACP,mBACA,4BACC,QAAQ;;wBAOW,0BACpB,KAAK,YACL,OAAO,cACP,mBACA,OAAO,oBACP,2BACA,eACC,QAAQ"}
|