@shware/workflow 6.1.1 → 8.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +132 -35
- package/dist/aws/engine-step.cjs +4 -1
- package/dist/aws/engine-step.cjs.map +1 -1
- package/dist/aws/engine-step.d.cts.map +1 -1
- package/dist/aws/engine-step.d.mts.map +1 -1
- package/dist/aws/engine-step.mjs +4 -1
- package/dist/aws/engine-step.mjs.map +1 -1
- package/dist/aws/index.cjs +3 -2
- package/dist/aws/index.cjs.map +1 -1
- package/dist/aws/index.mjs +3 -2
- package/dist/aws/index.mjs.map +1 -1
- package/dist/client.cjs +66 -41
- package/dist/client.cjs.map +1 -1
- package/dist/client.d.cts +72 -22
- package/dist/client.d.cts.map +1 -1
- package/dist/client.d.mts +72 -22
- package/dist/client.d.mts.map +1 -1
- package/dist/client.mjs +66 -42
- package/dist/client.mjs.map +1 -1
- package/dist/cloudflare/index.d.cts +4 -3
- package/dist/cloudflare/index.d.mts +4 -3
- package/dist/cloudflare/runner.cjs +3 -1
- package/dist/cloudflare/runner.cjs.map +1 -1
- package/dist/cloudflare/runner.d.cts.map +1 -1
- package/dist/cloudflare/runner.d.mts.map +1 -1
- package/dist/cloudflare/runner.mjs +3 -1
- package/dist/cloudflare/runner.mjs.map +1 -1
- package/dist/cloudflare/worker.cjs +13 -2
- package/dist/cloudflare/worker.cjs.map +1 -1
- package/dist/cloudflare/worker.d.cts +9 -1
- package/dist/cloudflare/worker.d.cts.map +1 -1
- package/dist/cloudflare/worker.d.mts +9 -1
- package/dist/cloudflare/worker.d.mts.map +1 -1
- package/dist/cloudflare/worker.mjs +13 -2
- package/dist/cloudflare/worker.mjs.map +1 -1
- package/dist/contract.cjs.map +1 -1
- package/dist/contract.d.cts +4 -4
- package/dist/contract.d.mts +4 -4
- package/dist/contract.mjs.map +1 -1
- package/dist/dsl/trigger.cjs +2 -1
- package/dist/dsl/trigger.cjs.map +1 -1
- package/dist/dsl/trigger.d.cts +6 -0
- package/dist/dsl/trigger.d.cts.map +1 -1
- package/dist/dsl/trigger.d.mts +6 -0
- package/dist/dsl/trigger.d.mts.map +1 -1
- package/dist/dsl/trigger.mjs +2 -1
- package/dist/dsl/trigger.mjs.map +1 -1
- package/dist/email-sender.cjs +8 -1
- package/dist/email-sender.cjs.map +1 -1
- package/dist/email-sender.d.cts +2 -2
- package/dist/email-sender.d.cts.map +1 -1
- package/dist/email-sender.d.mts +2 -2
- package/dist/email-sender.d.mts.map +1 -1
- package/dist/email-sender.mjs +9 -2
- package/dist/email-sender.mjs.map +1 -1
- package/dist/email.cjs +5 -0
- package/dist/email.cjs.map +1 -1
- package/dist/email.d.cts +2 -0
- package/dist/email.d.cts.map +1 -1
- package/dist/email.d.mts +2 -0
- package/dist/email.d.mts.map +1 -1
- package/dist/email.mjs +5 -1
- package/dist/email.mjs.map +1 -1
- package/dist/engine/condition.cjs +7 -5
- package/dist/engine/condition.cjs.map +1 -1
- package/dist/engine/condition.d.cts +3 -3
- package/dist/engine/condition.d.cts.map +1 -1
- package/dist/engine/condition.d.mts +3 -3
- package/dist/engine/condition.d.mts.map +1 -1
- package/dist/engine/condition.mjs +7 -5
- package/dist/engine/condition.mjs.map +1 -1
- package/dist/engine/interpreter.cjs +11 -6
- package/dist/engine/interpreter.cjs.map +1 -1
- package/dist/engine/interpreter.d.cts.map +1 -1
- package/dist/engine/interpreter.d.mts.map +1 -1
- package/dist/engine/interpreter.mjs +11 -6
- package/dist/engine/interpreter.mjs.map +1 -1
- package/dist/engine/ports.d.cts +21 -3
- package/dist/engine/ports.d.cts.map +1 -1
- package/dist/engine/ports.d.mts +21 -3
- package/dist/engine/ports.d.mts.map +1 -1
- package/dist/host/batch.cjs +172 -0
- package/dist/host/batch.cjs.map +1 -0
- package/dist/host/batch.d.cts +88 -0
- package/dist/host/batch.d.cts.map +1 -0
- package/dist/host/batch.d.mts +88 -0
- package/dist/host/batch.d.mts.map +1 -0
- package/dist/host/batch.mjs +163 -0
- package/dist/host/batch.mjs.map +1 -0
- package/dist/host/execute.cjs +10 -6
- package/dist/host/execute.cjs.map +1 -1
- package/dist/host/execute.mjs +10 -6
- package/dist/host/execute.mjs.map +1 -1
- package/dist/host/http.cjs +31 -90
- package/dist/host/http.cjs.map +1 -1
- package/dist/host/http.d.cts +4 -2
- package/dist/host/http.d.cts.map +1 -1
- package/dist/host/http.d.mts +4 -2
- package/dist/host/http.d.mts.map +1 -1
- package/dist/host/http.mjs +25 -84
- package/dist/host/http.mjs.map +1 -1
- package/dist/host/ingest.cjs +164 -25
- package/dist/host/ingest.cjs.map +1 -1
- package/dist/host/ingest.d.cts +38 -8
- package/dist/host/ingest.d.cts.map +1 -1
- package/dist/host/ingest.d.mts +38 -8
- package/dist/host/ingest.d.mts.map +1 -1
- package/dist/host/ingest.mjs +161 -26
- package/dist/host/ingest.mjs.map +1 -1
- package/dist/host/journey-host.cjs +8 -1
- package/dist/host/journey-host.cjs.map +1 -1
- package/dist/host/journey-host.d.cts +10 -4
- package/dist/host/journey-host.d.cts.map +1 -1
- package/dist/host/journey-host.d.mts +10 -4
- package/dist/host/journey-host.d.mts.map +1 -1
- package/dist/host/journey-host.mjs +8 -1
- package/dist/host/journey-host.mjs.map +1 -1
- package/dist/host/platform.cjs.map +1 -1
- package/dist/host/platform.d.cts +2 -0
- package/dist/host/platform.d.cts.map +1 -1
- package/dist/host/platform.d.mts +2 -0
- package/dist/host/platform.d.mts.map +1 -1
- package/dist/host/platform.mjs.map +1 -1
- package/dist/index.cjs +1 -0
- package/dist/index.d.cts +2 -2
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +2 -2
- package/dist/ir.cjs +2 -1
- package/dist/ir.cjs.map +1 -1
- package/dist/ir.d.cts +3 -0
- package/dist/ir.d.cts.map +1 -1
- package/dist/ir.d.mts +3 -0
- package/dist/ir.d.mts.map +1 -1
- package/dist/ir.mjs +2 -1
- package/dist/ir.mjs.map +1 -1
- package/dist/reporting/observer.cjs +1 -0
- package/dist/reporting/observer.cjs.map +1 -1
- package/dist/reporting/observer.mjs +1 -0
- package/dist/reporting/observer.mjs.map +1 -1
- package/dist/store/facts.cjs +29 -9
- package/dist/store/facts.cjs.map +1 -1
- package/dist/store/facts.d.cts +7 -2
- package/dist/store/facts.d.cts.map +1 -1
- package/dist/store/facts.d.mts +7 -2
- package/dist/store/facts.d.mts.map +1 -1
- package/dist/store/facts.mjs +29 -9
- package/dist/store/facts.mjs.map +1 -1
- package/dist/store/identity.cjs +34 -0
- package/dist/store/identity.cjs.map +1 -0
- package/dist/store/identity.d.cts +13 -0
- package/dist/store/identity.d.cts.map +1 -0
- package/dist/store/identity.d.mts +13 -0
- package/dist/store/identity.d.mts.map +1 -0
- package/dist/store/identity.mjs +30 -0
- package/dist/store/identity.mjs.map +1 -0
- package/dist/store/index.cjs +5 -0
- package/dist/store/index.cjs.map +1 -1
- package/dist/store/index.d.cts +119 -25
- package/dist/store/index.d.cts.map +1 -1
- package/dist/store/index.d.mts +119 -25
- package/dist/store/index.d.mts.map +1 -1
- package/dist/store/index.mjs +2 -1
- package/dist/store/index.mjs.map +1 -1
- package/dist/store/postgres-reporting.cjs +4 -0
- package/dist/store/postgres-reporting.cjs.map +1 -1
- package/dist/store/postgres-reporting.d.cts +1 -0
- package/dist/store/postgres-reporting.d.cts.map +1 -1
- package/dist/store/postgres-reporting.d.mts +1 -0
- package/dist/store/postgres-reporting.d.mts.map +1 -1
- package/dist/store/postgres-reporting.mjs +4 -0
- package/dist/store/postgres-reporting.mjs.map +1 -1
- package/dist/store/postgres.cjs +271 -55
- package/dist/store/postgres.cjs.map +1 -1
- package/dist/store/postgres.d.cts +29 -7
- package/dist/store/postgres.d.cts.map +1 -1
- package/dist/store/postgres.d.mts +29 -7
- package/dist/store/postgres.d.mts.map +1 -1
- package/dist/store/postgres.mjs +272 -56
- package/dist/store/postgres.mjs.map +1 -1
- package/dist/store/tables.cjs +28 -9
- package/dist/store/tables.cjs.map +1 -1
- package/dist/store/tables.d.cts +1 -0
- package/dist/store/tables.d.cts.map +1 -1
- package/dist/store/tables.d.mts +1 -0
- package/dist/store/tables.d.mts.map +1 -1
- package/dist/store/tables.mjs +28 -9
- package/dist/store/tables.mjs.map +1 -1
- package/dist/webpush.cjs.map +1 -1
- package/dist/webpush.d.cts +1 -1
- package/dist/webpush.d.mts +1 -1
- package/dist/webpush.mjs.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { UserErasedError } from "../store/erasure.mjs";
|
|
2
|
+
import { isVisitorSubject } from "../store/identity.mjs";
|
|
3
|
+
import "../store/index.mjs";
|
|
4
|
+
import { identifyUser, ingestEvent, linkVisitor } from "./ingest.mjs";
|
|
5
|
+
import { checkEvent } from "../contract.mjs";
|
|
6
|
+
// src/host/batch.ts
|
|
7
|
+
function isPlainObject(value) {
|
|
8
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
9
|
+
}
|
|
10
|
+
function isNonEmptyString(value) {
|
|
11
|
+
return typeof value === "string" && value.length > 0;
|
|
12
|
+
}
|
|
13
|
+
/** A profile value: a scalar or null (which removes the property). Nested values are not profile data. */
|
|
14
|
+
function isScalarOrNull(value) {
|
|
15
|
+
return value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean";
|
|
16
|
+
}
|
|
17
|
+
/** How far ahead of the server's clock an event or device `ts` may be: clock skew, not the future. */
|
|
18
|
+
const TS_FUTURE_TOLERANCE_MS = 3e5;
|
|
19
|
+
const TS_ERROR = "ts must be a non-negative integer no later than 5 minutes in the future";
|
|
20
|
+
/**
|
|
21
|
+
* A caller's timestamp: epoch milliseconds, whole, not negative, and not past the skew tolerance.
|
|
22
|
+
* A far-future ts would put the event ahead of everything a window condition reads, and a
|
|
23
|
+
* fractional or unsafe one does not survive the store.
|
|
24
|
+
*/
|
|
25
|
+
function isValidTs(ts) {
|
|
26
|
+
return typeof ts === "number" && Number.isSafeInteger(ts) && ts >= 0 && ts <= Date.now() + TS_FUTURE_TOLERANCE_MS;
|
|
27
|
+
}
|
|
28
|
+
/** What is wrong with an untrusted identity: at least one id, and a user id outside the visitors' prefix. */
|
|
29
|
+
function identityError(userId, visitorId) {
|
|
30
|
+
if (userId === void 0 && visitorId === void 0) return "userId or visitorId required";
|
|
31
|
+
if (userId !== void 0 && !isNonEmptyString(userId)) return "userId must be a non-empty string";
|
|
32
|
+
if (typeof userId === "string" && isVisitorSubject(userId)) return "userId must not start with 'visitor:'";
|
|
33
|
+
if (visitorId !== void 0 && !isNonEmptyString(visitorId)) return "visitorId must be a non-empty string";
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* An untrusted batch, checked whole: one bad part refuses all of it, so a producer never has half
|
|
37
|
+
* a report applied. Contract `mode: 'report'` logs what it would refuse and keeps the original.
|
|
38
|
+
*/
|
|
39
|
+
function parseBatch(raw, validation) {
|
|
40
|
+
const refuse = (error) => ({
|
|
41
|
+
ok: false,
|
|
42
|
+
error
|
|
43
|
+
});
|
|
44
|
+
if (!isPlainObject(raw)) return refuse("batch must be an object");
|
|
45
|
+
const { userId, visitorId, profile, at, events } = raw;
|
|
46
|
+
const identity = identityError(userId, visitorId);
|
|
47
|
+
if (identity !== void 0) return refuse(identity);
|
|
48
|
+
if (at !== void 0 && !isValidTs(at)) return refuse(`at: ${TS_ERROR}`);
|
|
49
|
+
if (profile === void 0 && events === void 0) return refuse("profile or events required");
|
|
50
|
+
const batch = {
|
|
51
|
+
...userId === void 0 ? {} : { userId },
|
|
52
|
+
...visitorId === void 0 ? {} : { visitorId },
|
|
53
|
+
...at === void 0 ? {} : { at }
|
|
54
|
+
};
|
|
55
|
+
const contract = validation.contract;
|
|
56
|
+
const report = (what, issues) => console.warn(`[workflow] contract would refuse ${what}: ${issues.map((i) => `${i.path || "(root)"}: ${i.message}`).join("; ")}`);
|
|
57
|
+
if (profile !== void 0) {
|
|
58
|
+
if (!isPlainObject(profile)) return refuse("profile must be an object");
|
|
59
|
+
if (!Object.values(profile).every(isScalarOrNull)) return refuse("profile values must be scalars (string, number, boolean) or null");
|
|
60
|
+
batch.profile = profile;
|
|
61
|
+
if (contract !== void 0) {
|
|
62
|
+
const result = contract.parseProfilePatch(profile);
|
|
63
|
+
if (result.ok) batch.profile = result.data;
|
|
64
|
+
else if (validation.mode === "report") report("the profile", result.issues);
|
|
65
|
+
else return {
|
|
66
|
+
ok: false,
|
|
67
|
+
error: "profile refused by the contract",
|
|
68
|
+
issues: result.issues
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (events !== void 0) {
|
|
73
|
+
if (!Array.isArray(events)) return refuse("events must be an array");
|
|
74
|
+
const parsed = [];
|
|
75
|
+
for (const item of events) {
|
|
76
|
+
if (!isPlainObject(item)) return refuse("every event must be an object");
|
|
77
|
+
const { event, payload, id, ts } = item;
|
|
78
|
+
if (!isNonEmptyString(event)) return refuse("every event needs a name");
|
|
79
|
+
if (event.startsWith("$")) return refuse("event names starting with $ are reserved");
|
|
80
|
+
if (id !== void 0 && !isNonEmptyString(id)) return refuse("an event id must be a non-empty string");
|
|
81
|
+
if (ts !== void 0 && !isValidTs(ts)) return refuse(TS_ERROR);
|
|
82
|
+
if (payload !== void 0 && !isPlainObject(payload)) return refuse("payload must be an object");
|
|
83
|
+
let accepted = payload;
|
|
84
|
+
if (contract !== void 0) {
|
|
85
|
+
const result = checkEvent(contract, event, payload ?? {});
|
|
86
|
+
const what = `event '${event}'${id === void 0 ? "" : ` (${id})`}`;
|
|
87
|
+
if (result.ok) accepted = result.data;
|
|
88
|
+
else if (validation.mode === "report") report(what, result.issues);
|
|
89
|
+
else return {
|
|
90
|
+
ok: false,
|
|
91
|
+
error: `${what} refused by the contract`,
|
|
92
|
+
issues: result.issues
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
parsed.push({
|
|
96
|
+
event,
|
|
97
|
+
payload: accepted,
|
|
98
|
+
id,
|
|
99
|
+
ts
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
batch.events = parsed;
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
ok: true,
|
|
106
|
+
batch
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Apply a checked batch: the visitor's merge, the profile, then each event in order. Every part is idempotent (a
|
|
111
|
+
* property older than the stored one is skipped, an event id is stored once), so a caller retries
|
|
112
|
+
* a failed batch whole.
|
|
113
|
+
*/
|
|
114
|
+
async function ingestBatch(env, batch, store) {
|
|
115
|
+
const { userId, visitorId, profile, at, events = [] } = batch;
|
|
116
|
+
if (userId !== void 0 && visitorId !== void 0) await linkVisitor(env, visitorId, userId, store, at);
|
|
117
|
+
const subject = userId ?? await store.touchVisitor(visitorId ?? "", at ?? Date.now());
|
|
118
|
+
const result = { events: [] };
|
|
119
|
+
if (profile !== void 0) result.profile = await identifyUser(env, subject, profile, store, at);
|
|
120
|
+
for (const { event, payload, id, ts } of events) result.events.push(await ingestEvent(env, {
|
|
121
|
+
userId: subject,
|
|
122
|
+
event,
|
|
123
|
+
payload,
|
|
124
|
+
id,
|
|
125
|
+
ts
|
|
126
|
+
}, store));
|
|
127
|
+
return result;
|
|
128
|
+
}
|
|
129
|
+
/** First retry after 30 s, doubling up to 15 minutes. */
|
|
130
|
+
function retryDelay(attempts) {
|
|
131
|
+
return Math.min(30 * 2 ** Math.max(attempts - 1, 0), 900);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Consume delivered batches one at a time. A batch that can never succeed is acknowledged, not
|
|
135
|
+
* retried: one the contract refuses (logged with the field paths, never the payload) and one for
|
|
136
|
+
* an erased user. Anything else is retried with a backoff until the queue's `max_retries` hands
|
|
137
|
+
* it to its dead letter queue.
|
|
138
|
+
*/
|
|
139
|
+
async function consumeBatches(env, messages, store, validation) {
|
|
140
|
+
for (const message of messages) {
|
|
141
|
+
const parsed = parseBatch(message.body, validation);
|
|
142
|
+
if (!parsed.ok) {
|
|
143
|
+
const issues = parsed.issues?.map((i) => `${i.path || "(root)"}: ${i.message}`).join("; ");
|
|
144
|
+
console.error(`[workflow queue] batch ${message.id} refused: ${parsed.error}${issues ? ` (${issues})` : ""}`);
|
|
145
|
+
message.ack();
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
try {
|
|
149
|
+
await ingestBatch(env, parsed.batch, store);
|
|
150
|
+
message.ack();
|
|
151
|
+
} catch (error) {
|
|
152
|
+
if (error instanceof UserErasedError) {
|
|
153
|
+
message.ack();
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
console.error(`[workflow queue] batch ${message.id} failed`, error);
|
|
157
|
+
message.retry({ delaySeconds: retryDelay(message.attempts) });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
export { TS_ERROR, consumeBatches, identityError, ingestBatch, isNonEmptyString, isPlainObject, isScalarOrNull, isValidTs, parseBatch };
|
|
162
|
+
|
|
163
|
+
//# sourceMappingURL=batch.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"batch.mjs","names":[],"sources":["../../src/host/batch.ts"],"sourcesContent":["import { checkEvent } from '../contract';\nimport {\n type JourneyStore,\n type ProfileProps,\n UserErasedError,\n isVisitorSubject,\n} from '../store/index';\nimport type { IngestValidation } from './http';\nimport {\n type IdentifyResult,\n type IngestResult,\n identifyUser,\n ingestEvent,\n linkVisitor,\n} from './ingest';\nimport type { JourneyEnv } from './platform';\n\n/**\n * One producer report as a single unit: what the user's profile now says and what they just did.\n * The profile is merged before the first event, so a journey an event starts always finds the\n * recipient that report knew about, whichever transport carried it and whatever arrived around\n * it. The same shape travels over HTTP (`POST /ingest`) and through a queue.\n */\nexport interface JourneyBatch {\n /** The signed-in user. With `visitorId` too, that visitor is this user: they merge, once. */\n userId?: string | undefined;\n /** The visitor before sign-in: a stable id for one client, renewed when a user signs out. */\n visitorId?: string | undefined;\n profile?: ProfileProps | undefined;\n /**\n * When the profile was read (epoch ms), the Worker's clock when omitted. A property written\n * later stays: a redelivered batch cannot put an old address back.\n */\n at?: number | undefined;\n events?: readonly BatchEvent[] | undefined;\n}\n\nexport interface BatchEvent {\n event: string;\n payload?: Record<string, unknown> | undefined;\n /** The occurrence's identity: a redelivered batch records it once. */\n id?: string | undefined;\n ts?: number | undefined;\n}\n\nexport interface BatchResult {\n profile?: IdentifyResult;\n events: IngestResult[];\n}\n\nexport function isPlainObject(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value);\n}\n\nexport function isNonEmptyString(value: unknown): value is string {\n return typeof value === 'string' && value.length > 0;\n}\n\n/** A profile value: a scalar or null (which removes the property). Nested values are not profile data. */\nexport function isScalarOrNull(value: unknown): value is string | number | boolean | null {\n return (\n value === null ||\n typeof value === 'string' ||\n typeof value === 'number' ||\n typeof value === 'boolean'\n );\n}\n\n/** How far ahead of the server's clock an event or device `ts` may be: clock skew, not the future. */\nconst TS_FUTURE_TOLERANCE_MS = 5 * 60_000;\nexport const TS_ERROR = 'ts must be a non-negative integer no later than 5 minutes in the future';\n\n/**\n * A caller's timestamp: epoch milliseconds, whole, not negative, and not past the skew tolerance.\n * A far-future ts would put the event ahead of everything a window condition reads, and a\n * fractional or unsafe one does not survive the store.\n */\nexport function isValidTs(ts: unknown): ts is number {\n return (\n typeof ts === 'number' &&\n Number.isSafeInteger(ts) &&\n ts >= 0 &&\n ts <= Date.now() + TS_FUTURE_TOLERANCE_MS\n );\n}\n\n/** What is wrong with an untrusted identity: at least one id, and a user id outside the visitors' prefix. */\nexport function identityError(userId: unknown, visitorId: unknown): string | undefined {\n if (userId === undefined && visitorId === undefined) return 'userId or visitorId required';\n if (userId !== undefined && !isNonEmptyString(userId)) {\n return 'userId must be a non-empty string';\n }\n if (typeof userId === 'string' && isVisitorSubject(userId)) {\n return \"userId must not start with 'visitor:'\";\n }\n if (visitorId !== undefined && !isNonEmptyString(visitorId)) {\n return 'visitorId must be a non-empty string';\n }\n return undefined;\n}\n\nexport type ParsedBatch =\n | { ok: true; batch: JourneyBatch }\n | { ok: false; error: string; issues?: readonly { path: string; message: string }[] };\n\n/**\n * An untrusted batch, checked whole: one bad part refuses all of it, so a producer never has half\n * a report applied. Contract `mode: 'report'` logs what it would refuse and keeps the original.\n */\nexport function parseBatch(raw: unknown, validation: IngestValidation): ParsedBatch {\n const refuse = (error: string): ParsedBatch => ({ ok: false, error });\n if (!isPlainObject(raw)) return refuse('batch must be an object');\n const { userId, visitorId, profile, at, events } = raw;\n const identity = identityError(userId, visitorId);\n if (identity !== undefined) return refuse(identity);\n if (at !== undefined && !isValidTs(at)) return refuse(`at: ${TS_ERROR}`);\n if (profile === undefined && events === undefined) return refuse('profile or events required');\n\n const batch: JourneyBatch = {\n ...(userId === undefined ? {} : { userId: userId as string }),\n ...(visitorId === undefined ? {} : { visitorId: visitorId as string }),\n ...(at === undefined ? {} : { at }),\n };\n const contract = validation.contract;\n const report = (what: string, issues: readonly { path: string; message: string }[]) =>\n console.warn(\n `[workflow] contract would refuse ${what}: ${issues\n .map((i) => `${i.path || '(root)'}: ${i.message}`)\n .join('; ')}`\n );\n\n if (profile !== undefined) {\n if (!isPlainObject(profile)) return refuse('profile must be an object');\n if (!Object.values(profile).every(isScalarOrNull)) {\n return refuse('profile values must be scalars (string, number, boolean) or null');\n }\n batch.profile = profile as ProfileProps;\n if (contract !== undefined) {\n const result = contract.parseProfilePatch(profile);\n if (result.ok) batch.profile = result.data as ProfileProps;\n else if (validation.mode === 'report') report('the profile', result.issues);\n else return { ok: false, error: 'profile refused by the contract', issues: result.issues };\n }\n }\n\n if (events !== undefined) {\n if (!Array.isArray(events)) return refuse('events must be an array');\n const parsed: BatchEvent[] = [];\n for (const item of events as unknown[]) {\n if (!isPlainObject(item)) return refuse('every event must be an object');\n const { event, payload, id, ts } = item;\n if (!isNonEmptyString(event)) return refuse('every event needs a name');\n if (event.startsWith('$')) return refuse('event names starting with $ are reserved');\n if (id !== undefined && !isNonEmptyString(id)) {\n return refuse('an event id must be a non-empty string');\n }\n if (ts !== undefined && !isValidTs(ts)) return refuse(TS_ERROR);\n if (payload !== undefined && !isPlainObject(payload)) {\n return refuse('payload must be an object');\n }\n let accepted = payload;\n if (contract !== undefined) {\n const result = checkEvent(contract, event, payload ?? {});\n const what = `event '${event}'${id === undefined ? '' : ` (${id})`}`;\n if (result.ok) accepted = result.data;\n else if (validation.mode === 'report') report(what, result.issues);\n else return { ok: false, error: `${what} refused by the contract`, issues: result.issues };\n }\n parsed.push({ event, payload: accepted, id, ts });\n }\n batch.events = parsed;\n }\n return { ok: true, batch };\n}\n\n/**\n * Apply a checked batch: the visitor's merge, the profile, then each event in order. Every part is idempotent (a\n * property older than the stored one is skipped, an event id is stored once), so a caller retries\n * a failed batch whole.\n */\nexport async function ingestBatch(\n env: JourneyEnv,\n batch: JourneyBatch,\n store: JourneyStore\n): Promise<BatchResult> {\n const { userId, visitorId, profile, at, events = [] } = batch;\n if (userId !== undefined && visitorId !== undefined) {\n await linkVisitor(env, visitorId, userId, store, at);\n }\n // A late report of a visitor who has signed in since reaches the user\n const subject = userId ?? (await store.touchVisitor(visitorId ?? '', at ?? Date.now()));\n const result: BatchResult = { events: [] };\n if (profile !== undefined) result.profile = await identifyUser(env, subject, profile, store, at);\n for (const { event, payload, id, ts } of events) {\n result.events.push(await ingestEvent(env, { userId: subject, event, payload, id, ts }, store));\n }\n return result;\n}\n\n/** A delivered queue message (the subset of the Cloudflare Queues `Message` used). */\nexport interface QueueMessageLike {\n id: string;\n body: unknown;\n /** Delivery attempts so far, this one included. */\n attempts: number;\n ack(): void;\n retry(options?: { delaySeconds?: number }): void;\n}\n\n/** First retry after 30 s, doubling up to 15 minutes. */\nfunction retryDelay(attempts: number): number {\n return Math.min(30 * 2 ** Math.max(attempts - 1, 0), 900);\n}\n\n/**\n * Consume delivered batches one at a time. A batch that can never succeed is acknowledged, not\n * retried: one the contract refuses (logged with the field paths, never the payload) and one for\n * an erased user. Anything else is retried with a backoff until the queue's `max_retries` hands\n * it to its dead letter queue.\n */\nexport async function consumeBatches(\n env: JourneyEnv,\n messages: readonly QueueMessageLike[],\n store: JourneyStore,\n validation: IngestValidation\n): Promise<void> {\n for (const message of messages) {\n const parsed = parseBatch(message.body, validation);\n if (!parsed.ok) {\n const issues = parsed.issues?.map((i) => `${i.path || '(root)'}: ${i.message}`).join('; ');\n console.error(\n `[workflow queue] batch ${message.id} refused: ${parsed.error}${issues ? ` (${issues})` : ''}`\n );\n message.ack();\n continue;\n }\n try {\n await ingestBatch(env, parsed.batch, store);\n message.ack();\n } catch (error) {\n if (error instanceof UserErasedError) {\n message.ack();\n continue;\n }\n console.error(`[workflow queue] batch ${message.id} failed`, error);\n message.retry({ delaySeconds: retryDelay(message.attempts) });\n }\n }\n}\n"],"mappings":"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"}
|
package/dist/host/execute.cjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
2
|
const require_store_facts = require("../store/facts.cjs");
|
|
3
|
+
const require_store_identity = require("../store/identity.cjs");
|
|
4
|
+
require("../store/index.cjs");
|
|
3
5
|
const require_host_ingest = require("./ingest.cjs");
|
|
4
6
|
const require_contract = require("../contract.cjs");
|
|
5
7
|
const require_runtime = require("../runtime.cjs");
|
|
@@ -19,10 +21,11 @@ async function executeJourney(input, parts) {
|
|
|
19
21
|
const { workflowId, contentHash, userId } = params;
|
|
20
22
|
const { store, env, observer, contract } = parts;
|
|
21
23
|
const runtime = require_runtime.resolveRuntime({ current: parts.runtime }, "current");
|
|
22
|
-
const
|
|
24
|
+
const owner = () => require_store_identity.instanceOwner(store, instanceId, userId);
|
|
25
|
+
const finished = async (status, convertedAt) => observer?.finished?.({
|
|
23
26
|
workflow: workflowId,
|
|
24
27
|
instanceId,
|
|
25
|
-
userId,
|
|
28
|
+
userId: await owner(),
|
|
26
29
|
status,
|
|
27
30
|
at: Date.now(),
|
|
28
31
|
...convertedAt === void 0 ? {} : { convertedAt }
|
|
@@ -37,12 +40,12 @@ async function executeJourney(input, parts) {
|
|
|
37
40
|
}
|
|
38
41
|
await require_host_ingest.ingestEvent(env, {
|
|
39
42
|
id,
|
|
40
|
-
userId,
|
|
43
|
+
userId: await owner(),
|
|
41
44
|
event: name,
|
|
42
45
|
payload: accepted
|
|
43
46
|
}, store);
|
|
44
47
|
} };
|
|
45
|
-
if (!await require_host_fresh_read.readWithRetry(() => store.admitInstance(instanceId,
|
|
48
|
+
if (!await require_host_fresh_read.readWithRetry(async () => store.admitInstance(instanceId, await owner()), {
|
|
46
49
|
what: "admission",
|
|
47
50
|
instanceId
|
|
48
51
|
})) {
|
|
@@ -63,9 +66,10 @@ async function executeJourney(input, parts) {
|
|
|
63
66
|
outcome = await require_engine_interpreter.runJourney(ir, {
|
|
64
67
|
userId,
|
|
65
68
|
instanceId,
|
|
66
|
-
|
|
69
|
+
currentUserId: owner,
|
|
70
|
+
enteredAtMs: Math.min(input.enteredAtMs, input.params.trigger.ts ?? input.enteredAtMs),
|
|
67
71
|
step,
|
|
68
|
-
facts: new require_store_facts.JourneyFactSource(store, userId),
|
|
72
|
+
facts: new require_store_facts.JourneyFactSource(store, userId, owner),
|
|
69
73
|
messages,
|
|
70
74
|
events,
|
|
71
75
|
actions: parts.actions,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"execute.cjs","names":["resolveRuntime","checkEvent","ContractValidationError","ingestEvent","readWithRetry","scaleDurations","runJourney","JourneyFactSource"],"sources":["../../src/host/execute.ts"],"sourcesContent":["import { type Contract, ContractValidationError, checkEvent } from '../contract';\nimport { runJourney } from '../engine/interpreter';\nimport type {\n ActionInvoker,\n EngineStep,\n EventSink,\n JourneyObserver,\n JourneyOutcome,\n MessageSender,\n MessageValidator,\n} from '../engine/ports';\nimport { scaleDurations } from '../engine/time-scale';\nimport { type JourneyRuntimeOptions, resolveRuntime } from '../runtime';\nimport { JourneyFactSource } from '../store/facts';\nimport type { JourneyStore } from '../store/index';\nimport { readWithRetry } from './fresh-read';\nimport { ingestEvent } from './ingest';\nimport type { JourneyEnv, JourneyParams } from './platform';\n\n/**\n * One journey instance run to its end, on whatever durable runtime supplies the `EngineStep`:\n * the runner admitted against the user's erasure tombstone, the IR loaded by the hash pinned at\n * entry, the outlets wired, the interpreter run, and the ledger finalized. Cloudflare's runner\n * and the Lambda handler both come through here; only their EngineStep differs.\n */\n\n/** Everything a run is wired to, assembled by the host. */\nexport interface JourneyRunParts {\n store: JourneyStore;\n /**\n * The env the emitted events are ingested with: its `JOURNEY` starts and wakes instances on\n * this runtime.\n */\n env: JourneyEnv;\n runtime: JourneyRuntimeOptions;\n messages: MessageSender;\n actions: ActionInvoker;\n contract?: Contract | undefined;\n validator?: MessageValidator | undefined;\n observer?: JourneyObserver | undefined;\n}\n\nexport interface JourneyRunInput {\n instanceId: string;\n params: JourneyParams;\n /** When the trigger happened: replay-stable, never Date.now() at run time. */\n enteredAtMs: number;\n step: EngineStep;\n}\n\n/** Development aid: echo each outbound message before handing it to the real sender. */\nfunction logging(sender: MessageSender): MessageSender {\n return {\n async send(message) {\n console.log(\n `[journey] ${message.channel} ${message.template} → ${message.recipient ?? message.userId} ${JSON.stringify(message.props)}`\n );\n return sender.send(message);\n },\n };\n}\n\nexport async function executeJourney(\n input: JourneyRunInput,\n parts: JourneyRunParts\n): Promise<JourneyOutcome> {\n const { instanceId, params, step } = input;\n const { workflowId, contentHash, userId } = params;\n const { store, env, observer, contract } = parts;\n\n const runtime = resolveRuntime({ current: parts.runtime }, 'current');\n\n const finished = (status: JourneyOutcome['status'] | 'failed', convertedAt?: number) =>\n observer?.finished?.({\n workflow: workflowId,\n instanceId,\n userId,\n status,\n at: Date.now(),\n ...(convertedAt === undefined ? {} : { convertedAt }),\n });\n\n const messages = runtime.logMessages ? logging(parts.messages) : parts.messages;\n\n // send_event feeds straight back into the router logic — an in-process call\n // forming the event edge between workflows. The step's identity travels as\n // the event id, so a retried emit records the occurrence once.\n // An emitted event passes the same contract check as one arriving over HTTP.\n const events: EventSink = {\n emit: async (name, payload, id) => {\n let accepted: Record<string, unknown> = payload;\n if (contract !== undefined) {\n const verdict = checkEvent(contract, name, payload);\n if (!verdict.ok) throw new ContractValidationError(name, verdict.issues);\n accepted = verdict.data;\n }\n await ingestEvent(env, { id, userId, event: name, payload: accepted }, store);\n },\n };\n\n /*\n * Admission: the ledger entry was committed before the runtime was asked to create this\n * instance, so an erasure can land in between (the entry is not found by `get`, the erase\n * goes through, and then the create lands). The runner is the only one who can refuse that\n * late run, and it must do so before its first observable effect — `observer.started`, the\n * IR read, any action or send.\n *\n * Outside any durable step, on purpose: a step would checkpoint the first invocation's\n * \"admitted\" and replay it on every wake and every rebuild after an eviction, which is\n * exactly when the tombstone may have arrived since. `admitInstance` is idempotent — the\n * entry's `admitted_at` keeps its first value — so a replay re-admits the same instance, and\n * the store makes the admission mutually exclusive with the erasure: either the entry is\n * admitted first and the erasure finds it and stops it, or the tombstone commits first and\n * this read answers false. Only the read itself is retried, briefly, when the database\n * stumbled; one that keeps failing throws, so the instance errors without running anything,\n * its entry stays, and the erasure protocol (or a later wake) decides what becomes of it.\n */\n const admitted = await readWithRetry(() => store.admitInstance(instanceId, userId), {\n what: 'admission',\n instanceId,\n });\n if (!admitted) {\n /*\n * No finalize either way: the entry is gone (erased, or never ours to run) or is being\n * erased, and the finalize steps would write it back — a status on an entry the erasure\n * deletes, a `finished` the reporting records as a new row of an erased user. The\n * subscriptions, if any, go with the user's data.\n */\n console.warn(`[workflow] instance ${instanceId} of ${userId} refused admission: not running`);\n return { status: 'exited', reason: 'erased' };\n }\n\n let outcome: JourneyOutcome;\n try {\n // In a step: a read that fails is retried like any other, and one that keeps failing ends\n // the instance through the failure path below rather than leaving its entry 'running'.\n // Content addressing makes the IR immutable, so the checkpointed copy is the one to replay\n const stored = await step.do('$workflow:ir', async () => {\n const found = await store.getWorkflowIR(contentHash);\n if (found === undefined)\n throw new Error(`WorkflowIR not found: ${workflowId}@${contentHash}`);\n return found;\n });\n // Development runs the journey in fast-forward; the pinned hash still names the version.\n const ir = scaleDurations(stored, runtime.timeScale);\n outcome = await runJourney(ir, {\n userId,\n instanceId,\n enteredAtMs: input.enteredAtMs,\n step,\n facts: new JourneyFactSource(store, userId),\n messages,\n events,\n actions: parts.actions,\n validator: parts.validator,\n observer,\n });\n } catch (error) {\n /*\n * An error escaping run() ends the instance, and the ledger is the only\n * place anyone looks afterwards: leaving it at 'running' meant a dead\n * journey stayed indistinguishable from a waiting one, in the dashboard\n * and in the once-policy, forever.\n *\n * Subscriptions go too, as on every ordinary ending: left behind, the\n * rows would only make every later event of the user poke a dead\n * instance (one wake call to the runtime each, refused every time).\n *\n * A paused instance never gets here: a durable wait suspends the run\n * without throwing through it.\n */\n await step.do('finalize:failed', async () => {\n await store.setEntryStatus(instanceId, 'failed');\n await store.unsubscribe(instanceId);\n await finished('failed');\n });\n throw error;\n }\n\n await step.do('finalize', async () => {\n await store.setEntryStatus(instanceId, outcome.status);\n await store.unsubscribe(instanceId);\n await finished(outcome.status, outcome.convertedAt);\n });\n\n return outcome;\n}\n"],"mappings":";;;;;;;;;;AAmDA,SAAS,QAAQ,QAAsC;CACrD,OAAO,EACL,MAAM,KAAK,SAAS;EAClB,QAAQ,IACN,aAAa,QAAQ,QAAQ,GAAG,QAAQ,SAAS,KAAK,QAAQ,aAAa,QAAQ,OAAO,GAAG,KAAK,UAAU,QAAQ,KAAK,GAC3H;EACA,OAAO,OAAO,KAAK,OAAO;CAC5B,EACF;AACF;AAEA,eAAsB,eACpB,OACA,OACyB;CACzB,MAAM,EAAE,YAAY,QAAQ,SAAS;CACrC,MAAM,EAAE,YAAY,aAAa,WAAW;CAC5C,MAAM,EAAE,OAAO,KAAK,UAAU,aAAa;CAE3C,MAAM,UAAUA,gBAAAA,eAAe,EAAE,SAAS,MAAM,QAAQ,GAAG,SAAS;CAEpE,MAAM,YAAY,QAA6C,gBAC7D,UAAU,WAAW;EACnB,UAAU;EACV;EACA;EACA;EACA,IAAI,KAAK,IAAI;EACb,GAAI,gBAAgB,KAAA,IAAY,CAAC,IAAI,EAAE,YAAY;CACrD,CAAC;CAEH,MAAM,WAAW,QAAQ,cAAc,QAAQ,MAAM,QAAQ,IAAI,MAAM;CAMvE,MAAM,SAAoB,EACxB,MAAM,OAAO,MAAM,SAAS,OAAO;EACjC,IAAI,WAAoC;EACxC,IAAI,aAAa,KAAA,GAAW;GAC1B,MAAM,UAAUC,iBAAAA,WAAW,UAAU,MAAM,OAAO;GAClD,IAAI,CAAC,QAAQ,IAAI,MAAM,IAAIC,iBAAAA,wBAAwB,MAAM,QAAQ,MAAM;GACvE,WAAW,QAAQ;EACrB;EACA,MAAMC,oBAAAA,YAAY,KAAK;GAAE;GAAI;GAAQ,OAAO;GAAM,SAAS;EAAS,GAAG,KAAK;CAC9E,EACF;CAuBA,IAAI,CAAC,MAJkBC,wBAAAA,oBAAoB,MAAM,cAAc,YAAY,MAAM,GAAG;EAClF,MAAM;EACN;CACF,CAAC,GACc;EAOb,QAAQ,KAAK,uBAAuB,WAAW,MAAM,OAAO,gCAAgC;EAC5F,OAAO;GAAE,QAAQ;GAAU,QAAQ;EAAS;CAC9C;CAEA,IAAI;CACJ,IAAI;EAIF,MAAM,SAAS,MAAM,KAAK,GAAG,gBAAgB,YAAY;GACvD,MAAM,QAAQ,MAAM,MAAM,cAAc,WAAW;GACnD,IAAI,UAAU,KAAA,GACZ,MAAM,IAAI,MAAM,yBAAyB,WAAW,GAAG,aAAa;GACtE,OAAO;EACT,CAAC;EAED,MAAM,KAAKC,0BAAAA,eAAe,QAAQ,QAAQ,SAAS;EACnD,UAAU,MAAMC,2BAAAA,WAAW,IAAI;GAC7B;GACA;GACA,aAAa,MAAM;GACnB;GACA,OAAO,IAAIC,oBAAAA,kBAAkB,OAAO,MAAM;GAC1C;GACA;GACA,SAAS,MAAM;GACf,WAAW,MAAM;GACjB;EACF,CAAC;CACH,SAAS,OAAO;EAcd,MAAM,KAAK,GAAG,mBAAmB,YAAY;GAC3C,MAAM,MAAM,eAAe,YAAY,QAAQ;GAC/C,MAAM,MAAM,YAAY,UAAU;GAClC,MAAM,SAAS,QAAQ;EACzB,CAAC;EACD,MAAM;CACR;CAEA,MAAM,KAAK,GAAG,YAAY,YAAY;EACpC,MAAM,MAAM,eAAe,YAAY,QAAQ,MAAM;EACrD,MAAM,MAAM,YAAY,UAAU;EAClC,MAAM,SAAS,QAAQ,QAAQ,QAAQ,WAAW;CACpD,CAAC;CAED,OAAO;AACT"}
|
|
1
|
+
{"version":3,"file":"execute.cjs","names":["resolveRuntime","instanceOwner","checkEvent","ContractValidationError","ingestEvent","readWithRetry","scaleDurations","runJourney","JourneyFactSource"],"sources":["../../src/host/execute.ts"],"sourcesContent":["import { type Contract, ContractValidationError, checkEvent } from '../contract';\nimport { runJourney } from '../engine/interpreter';\nimport type {\n ActionInvoker,\n EngineStep,\n EventSink,\n JourneyObserver,\n JourneyOutcome,\n MessageSender,\n MessageValidator,\n} from '../engine/ports';\nimport { scaleDurations } from '../engine/time-scale';\nimport { type JourneyRuntimeOptions, resolveRuntime } from '../runtime';\nimport { JourneyFactSource } from '../store/facts';\nimport { type JourneyStore, instanceOwner } from '../store/index';\nimport { readWithRetry } from './fresh-read';\nimport { ingestEvent } from './ingest';\nimport type { JourneyEnv, JourneyParams } from './platform';\n\n/**\n * One journey instance run to its end, on whatever durable runtime supplies the `EngineStep`:\n * the runner admitted against the user's erasure tombstone, the IR loaded by the hash pinned at\n * entry, the outlets wired, the interpreter run, and the ledger finalized. Cloudflare's runner\n * and the Lambda handler both come through here; only their EngineStep differs.\n */\n\n/** Everything a run is wired to, assembled by the host. */\nexport interface JourneyRunParts {\n store: JourneyStore;\n /**\n * The env the emitted events are ingested with: its `JOURNEY` starts and wakes instances on\n * this runtime.\n */\n env: JourneyEnv;\n runtime: JourneyRuntimeOptions;\n messages: MessageSender;\n actions: ActionInvoker;\n contract?: Contract | undefined;\n validator?: MessageValidator | undefined;\n observer?: JourneyObserver | undefined;\n}\n\nexport interface JourneyRunInput {\n instanceId: string;\n params: JourneyParams;\n /** When the trigger happened: replay-stable, never Date.now() at run time. */\n enteredAtMs: number;\n step: EngineStep;\n}\n\n/** Development aid: echo each outbound message before handing it to the real sender. */\nfunction logging(sender: MessageSender): MessageSender {\n return {\n async send(message) {\n console.log(\n `[journey] ${message.channel} ${message.template} → ${message.recipient ?? message.userId} ${JSON.stringify(message.props)}`\n );\n return sender.send(message);\n },\n };\n}\n\nexport async function executeJourney(\n input: JourneyRunInput,\n parts: JourneyRunParts\n): Promise<JourneyOutcome> {\n const { instanceId, params, step } = input;\n const { workflowId, contentHash, userId } = params;\n const { store, env, observer, contract } = parts;\n\n const runtime = resolveRuntime({ current: parts.runtime }, 'current');\n const owner = () => instanceOwner(store, instanceId, userId);\n\n const finished = async (status: JourneyOutcome['status'] | 'failed', convertedAt?: number) =>\n observer?.finished?.({\n workflow: workflowId,\n instanceId,\n userId: await owner(),\n status,\n at: Date.now(),\n ...(convertedAt === undefined ? {} : { convertedAt }),\n });\n\n const messages = runtime.logMessages ? logging(parts.messages) : parts.messages;\n\n // send_event feeds straight back into the router logic — an in-process call\n // forming the event edge between workflows. The step's identity travels as\n // the event id, so a retried emit records the occurrence once.\n // An emitted event passes the same contract check as one arriving over HTTP.\n const events: EventSink = {\n emit: async (name, payload, id) => {\n let accepted: Record<string, unknown> = payload;\n if (contract !== undefined) {\n const verdict = checkEvent(contract, name, payload);\n if (!verdict.ok) throw new ContractValidationError(name, verdict.issues);\n accepted = verdict.data;\n }\n await ingestEvent(env, { id, userId: await owner(), event: name, payload: accepted }, store);\n },\n };\n\n /*\n * Admission: the ledger entry was committed before the runtime was asked to create this\n * instance, so an erasure can land in between (the entry is not found by `get`, the erase\n * goes through, and then the create lands). The runner is the only one who can refuse that\n * late run, and it must do so before its first observable effect — `observer.started`, the\n * IR read, any action or send.\n *\n * Outside any durable step, on purpose: a step would checkpoint the first invocation's\n * \"admitted\" and replay it on every wake and every rebuild after an eviction, which is\n * exactly when the tombstone may have arrived since. `admitInstance` is idempotent — the\n * entry's `admitted_at` keeps its first value — so a replay re-admits the same instance, and\n * the store makes the admission mutually exclusive with the erasure: either the entry is\n * admitted first and the erasure finds it and stops it, or the tombstone commits first and\n * this read answers false. Only the read itself is retried, briefly, when the database\n * stumbled; one that keeps failing throws, so the instance errors without running anything,\n * its entry stays, and the erasure protocol (or a later wake) decides what becomes of it.\n */\n const admitted = await readWithRetry(async () => store.admitInstance(instanceId, await owner()), {\n what: 'admission',\n instanceId,\n });\n if (!admitted) {\n /*\n * No finalize either way: the entry is gone (erased, or never ours to run) or is being\n * erased, and the finalize steps would write it back — a status on an entry the erasure\n * deletes, a `finished` the reporting records as a new row of an erased user. The\n * subscriptions, if any, go with the user's data.\n */\n console.warn(`[workflow] instance ${instanceId} of ${userId} refused admission: not running`);\n return { status: 'exited', reason: 'erased' };\n }\n\n let outcome: JourneyOutcome;\n try {\n // In a step: a read that fails is retried like any other, and one that keeps failing ends\n // the instance through the failure path below rather than leaving its entry 'running'.\n // Content addressing makes the IR immutable, so the checkpointed copy is the one to replay\n const stored = await step.do('$workflow:ir', async () => {\n const found = await store.getWorkflowIR(contentHash);\n if (found === undefined)\n throw new Error(`WorkflowIR not found: ${workflowId}@${contentHash}`);\n return found;\n });\n // Development runs the journey in fast-forward; the pinned hash still names the version.\n const ir = scaleDurations(stored, runtime.timeScale);\n outcome = await runJourney(ir, {\n userId,\n instanceId,\n currentUserId: owner,\n // A delivery that arrives late still enters when the trigger happened: a purchase made\n // while the begin_checkout was being retried is the goal, not history from before entry\n enteredAtMs: Math.min(input.enteredAtMs, input.params.trigger.ts ?? input.enteredAtMs),\n step,\n facts: new JourneyFactSource(store, userId, owner),\n messages,\n events,\n actions: parts.actions,\n validator: parts.validator,\n observer,\n });\n } catch (error) {\n /*\n * An error escaping run() ends the instance, and the ledger is the only\n * place anyone looks afterwards: leaving it at 'running' meant a dead\n * journey stayed indistinguishable from a waiting one, in the dashboard\n * and in the once-policy, forever.\n *\n * Subscriptions go too, as on every ordinary ending: left behind, the\n * rows would only make every later event of the user poke a dead\n * instance (one wake call to the runtime each, refused every time).\n *\n * A paused instance never gets here: a durable wait suspends the run\n * without throwing through it.\n */\n await step.do('finalize:failed', async () => {\n await store.setEntryStatus(instanceId, 'failed');\n await store.unsubscribe(instanceId);\n await finished('failed');\n });\n throw error;\n }\n\n await step.do('finalize', async () => {\n await store.setEntryStatus(instanceId, outcome.status);\n await store.unsubscribe(instanceId);\n await finished(outcome.status, outcome.convertedAt);\n });\n\n return outcome;\n}\n"],"mappings":";;;;;;;;;;;;AAmDA,SAAS,QAAQ,QAAsC;CACrD,OAAO,EACL,MAAM,KAAK,SAAS;EAClB,QAAQ,IACN,aAAa,QAAQ,QAAQ,GAAG,QAAQ,SAAS,KAAK,QAAQ,aAAa,QAAQ,OAAO,GAAG,KAAK,UAAU,QAAQ,KAAK,GAC3H;EACA,OAAO,OAAO,KAAK,OAAO;CAC5B,EACF;AACF;AAEA,eAAsB,eACpB,OACA,OACyB;CACzB,MAAM,EAAE,YAAY,QAAQ,SAAS;CACrC,MAAM,EAAE,YAAY,aAAa,WAAW;CAC5C,MAAM,EAAE,OAAO,KAAK,UAAU,aAAa;CAE3C,MAAM,UAAUA,gBAAAA,eAAe,EAAE,SAAS,MAAM,QAAQ,GAAG,SAAS;CACpE,MAAM,cAAcC,uBAAAA,cAAc,OAAO,YAAY,MAAM;CAE3D,MAAM,WAAW,OAAO,QAA6C,gBACnE,UAAU,WAAW;EACnB,UAAU;EACV;EACA,QAAQ,MAAM,MAAM;EACpB;EACA,IAAI,KAAK,IAAI;EACb,GAAI,gBAAgB,KAAA,IAAY,CAAC,IAAI,EAAE,YAAY;CACrD,CAAC;CAEH,MAAM,WAAW,QAAQ,cAAc,QAAQ,MAAM,QAAQ,IAAI,MAAM;CAMvE,MAAM,SAAoB,EACxB,MAAM,OAAO,MAAM,SAAS,OAAO;EACjC,IAAI,WAAoC;EACxC,IAAI,aAAa,KAAA,GAAW;GAC1B,MAAM,UAAUC,iBAAAA,WAAW,UAAU,MAAM,OAAO;GAClD,IAAI,CAAC,QAAQ,IAAI,MAAM,IAAIC,iBAAAA,wBAAwB,MAAM,QAAQ,MAAM;GACvE,WAAW,QAAQ;EACrB;EACA,MAAMC,oBAAAA,YAAY,KAAK;GAAE;GAAI,QAAQ,MAAM,MAAM;GAAG,OAAO;GAAM,SAAS;EAAS,GAAG,KAAK;CAC7F,EACF;CAuBA,IAAI,CAAC,MAJkBC,wBAAAA,cAAc,YAAY,MAAM,cAAc,YAAY,MAAM,MAAM,CAAC,GAAG;EAC/F,MAAM;EACN;CACF,CAAC,GACc;EAOb,QAAQ,KAAK,uBAAuB,WAAW,MAAM,OAAO,gCAAgC;EAC5F,OAAO;GAAE,QAAQ;GAAU,QAAQ;EAAS;CAC9C;CAEA,IAAI;CACJ,IAAI;EAIF,MAAM,SAAS,MAAM,KAAK,GAAG,gBAAgB,YAAY;GACvD,MAAM,QAAQ,MAAM,MAAM,cAAc,WAAW;GACnD,IAAI,UAAU,KAAA,GACZ,MAAM,IAAI,MAAM,yBAAyB,WAAW,GAAG,aAAa;GACtE,OAAO;EACT,CAAC;EAED,MAAM,KAAKC,0BAAAA,eAAe,QAAQ,QAAQ,SAAS;EACnD,UAAU,MAAMC,2BAAAA,WAAW,IAAI;GAC7B;GACA;GACA,eAAe;GAGf,aAAa,KAAK,IAAI,MAAM,aAAa,MAAM,OAAO,QAAQ,MAAM,MAAM,WAAW;GACrF;GACA,OAAO,IAAIC,oBAAAA,kBAAkB,OAAO,QAAQ,KAAK;GACjD;GACA;GACA,SAAS,MAAM;GACf,WAAW,MAAM;GACjB;EACF,CAAC;CACH,SAAS,OAAO;EAcd,MAAM,KAAK,GAAG,mBAAmB,YAAY;GAC3C,MAAM,MAAM,eAAe,YAAY,QAAQ;GAC/C,MAAM,MAAM,YAAY,UAAU;GAClC,MAAM,SAAS,QAAQ;EACzB,CAAC;EACD,MAAM;CACR;CAEA,MAAM,KAAK,GAAG,YAAY,YAAY;EACpC,MAAM,MAAM,eAAe,YAAY,QAAQ,MAAM;EACrD,MAAM,MAAM,YAAY,UAAU;EAClC,MAAM,SAAS,QAAQ,QAAQ,QAAQ,WAAW;CACpD,CAAC;CAED,OAAO;AACT"}
|
package/dist/host/execute.mjs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { JourneyFactSource } from "../store/facts.mjs";
|
|
2
|
+
import { instanceOwner } from "../store/identity.mjs";
|
|
3
|
+
import "../store/index.mjs";
|
|
2
4
|
import { ingestEvent } from "./ingest.mjs";
|
|
3
5
|
import { ContractValidationError, checkEvent } from "../contract.mjs";
|
|
4
6
|
import { resolveRuntime } from "../runtime.mjs";
|
|
@@ -18,10 +20,11 @@ async function executeJourney(input, parts) {
|
|
|
18
20
|
const { workflowId, contentHash, userId } = params;
|
|
19
21
|
const { store, env, observer, contract } = parts;
|
|
20
22
|
const runtime = resolveRuntime({ current: parts.runtime }, "current");
|
|
21
|
-
const
|
|
23
|
+
const owner = () => instanceOwner(store, instanceId, userId);
|
|
24
|
+
const finished = async (status, convertedAt) => observer?.finished?.({
|
|
22
25
|
workflow: workflowId,
|
|
23
26
|
instanceId,
|
|
24
|
-
userId,
|
|
27
|
+
userId: await owner(),
|
|
25
28
|
status,
|
|
26
29
|
at: Date.now(),
|
|
27
30
|
...convertedAt === void 0 ? {} : { convertedAt }
|
|
@@ -36,12 +39,12 @@ async function executeJourney(input, parts) {
|
|
|
36
39
|
}
|
|
37
40
|
await ingestEvent(env, {
|
|
38
41
|
id,
|
|
39
|
-
userId,
|
|
42
|
+
userId: await owner(),
|
|
40
43
|
event: name,
|
|
41
44
|
payload: accepted
|
|
42
45
|
}, store);
|
|
43
46
|
} };
|
|
44
|
-
if (!await readWithRetry(() => store.admitInstance(instanceId,
|
|
47
|
+
if (!await readWithRetry(async () => store.admitInstance(instanceId, await owner()), {
|
|
45
48
|
what: "admission",
|
|
46
49
|
instanceId
|
|
47
50
|
})) {
|
|
@@ -62,9 +65,10 @@ async function executeJourney(input, parts) {
|
|
|
62
65
|
outcome = await runJourney(ir, {
|
|
63
66
|
userId,
|
|
64
67
|
instanceId,
|
|
65
|
-
|
|
68
|
+
currentUserId: owner,
|
|
69
|
+
enteredAtMs: Math.min(input.enteredAtMs, input.params.trigger.ts ?? input.enteredAtMs),
|
|
66
70
|
step,
|
|
67
|
-
facts: new JourneyFactSource(store, userId),
|
|
71
|
+
facts: new JourneyFactSource(store, userId, owner),
|
|
68
72
|
messages,
|
|
69
73
|
events,
|
|
70
74
|
actions: parts.actions,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"execute.mjs","names":[],"sources":["../../src/host/execute.ts"],"sourcesContent":["import { type Contract, ContractValidationError, checkEvent } from '../contract';\nimport { runJourney } from '../engine/interpreter';\nimport type {\n ActionInvoker,\n EngineStep,\n EventSink,\n JourneyObserver,\n JourneyOutcome,\n MessageSender,\n MessageValidator,\n} from '../engine/ports';\nimport { scaleDurations } from '../engine/time-scale';\nimport { type JourneyRuntimeOptions, resolveRuntime } from '../runtime';\nimport { JourneyFactSource } from '../store/facts';\nimport type { JourneyStore } from '../store/index';\nimport { readWithRetry } from './fresh-read';\nimport { ingestEvent } from './ingest';\nimport type { JourneyEnv, JourneyParams } from './platform';\n\n/**\n * One journey instance run to its end, on whatever durable runtime supplies the `EngineStep`:\n * the runner admitted against the user's erasure tombstone, the IR loaded by the hash pinned at\n * entry, the outlets wired, the interpreter run, and the ledger finalized. Cloudflare's runner\n * and the Lambda handler both come through here; only their EngineStep differs.\n */\n\n/** Everything a run is wired to, assembled by the host. */\nexport interface JourneyRunParts {\n store: JourneyStore;\n /**\n * The env the emitted events are ingested with: its `JOURNEY` starts and wakes instances on\n * this runtime.\n */\n env: JourneyEnv;\n runtime: JourneyRuntimeOptions;\n messages: MessageSender;\n actions: ActionInvoker;\n contract?: Contract | undefined;\n validator?: MessageValidator | undefined;\n observer?: JourneyObserver | undefined;\n}\n\nexport interface JourneyRunInput {\n instanceId: string;\n params: JourneyParams;\n /** When the trigger happened: replay-stable, never Date.now() at run time. */\n enteredAtMs: number;\n step: EngineStep;\n}\n\n/** Development aid: echo each outbound message before handing it to the real sender. */\nfunction logging(sender: MessageSender): MessageSender {\n return {\n async send(message) {\n console.log(\n `[journey] ${message.channel} ${message.template} → ${message.recipient ?? message.userId} ${JSON.stringify(message.props)}`\n );\n return sender.send(message);\n },\n };\n}\n\nexport async function executeJourney(\n input: JourneyRunInput,\n parts: JourneyRunParts\n): Promise<JourneyOutcome> {\n const { instanceId, params, step } = input;\n const { workflowId, contentHash, userId } = params;\n const { store, env, observer, contract } = parts;\n\n const runtime = resolveRuntime({ current: parts.runtime }, 'current');\n\n const finished = (status: JourneyOutcome['status'] | 'failed', convertedAt?: number) =>\n observer?.finished?.({\n workflow: workflowId,\n instanceId,\n userId,\n status,\n at: Date.now(),\n ...(convertedAt === undefined ? {} : { convertedAt }),\n });\n\n const messages = runtime.logMessages ? logging(parts.messages) : parts.messages;\n\n // send_event feeds straight back into the router logic — an in-process call\n // forming the event edge between workflows. The step's identity travels as\n // the event id, so a retried emit records the occurrence once.\n // An emitted event passes the same contract check as one arriving over HTTP.\n const events: EventSink = {\n emit: async (name, payload, id) => {\n let accepted: Record<string, unknown> = payload;\n if (contract !== undefined) {\n const verdict = checkEvent(contract, name, payload);\n if (!verdict.ok) throw new ContractValidationError(name, verdict.issues);\n accepted = verdict.data;\n }\n await ingestEvent(env, { id, userId, event: name, payload: accepted }, store);\n },\n };\n\n /*\n * Admission: the ledger entry was committed before the runtime was asked to create this\n * instance, so an erasure can land in between (the entry is not found by `get`, the erase\n * goes through, and then the create lands). The runner is the only one who can refuse that\n * late run, and it must do so before its first observable effect — `observer.started`, the\n * IR read, any action or send.\n *\n * Outside any durable step, on purpose: a step would checkpoint the first invocation's\n * \"admitted\" and replay it on every wake and every rebuild after an eviction, which is\n * exactly when the tombstone may have arrived since. `admitInstance` is idempotent — the\n * entry's `admitted_at` keeps its first value — so a replay re-admits the same instance, and\n * the store makes the admission mutually exclusive with the erasure: either the entry is\n * admitted first and the erasure finds it and stops it, or the tombstone commits first and\n * this read answers false. Only the read itself is retried, briefly, when the database\n * stumbled; one that keeps failing throws, so the instance errors without running anything,\n * its entry stays, and the erasure protocol (or a later wake) decides what becomes of it.\n */\n const admitted = await readWithRetry(() => store.admitInstance(instanceId, userId), {\n what: 'admission',\n instanceId,\n });\n if (!admitted) {\n /*\n * No finalize either way: the entry is gone (erased, or never ours to run) or is being\n * erased, and the finalize steps would write it back — a status on an entry the erasure\n * deletes, a `finished` the reporting records as a new row of an erased user. The\n * subscriptions, if any, go with the user's data.\n */\n console.warn(`[workflow] instance ${instanceId} of ${userId} refused admission: not running`);\n return { status: 'exited', reason: 'erased' };\n }\n\n let outcome: JourneyOutcome;\n try {\n // In a step: a read that fails is retried like any other, and one that keeps failing ends\n // the instance through the failure path below rather than leaving its entry 'running'.\n // Content addressing makes the IR immutable, so the checkpointed copy is the one to replay\n const stored = await step.do('$workflow:ir', async () => {\n const found = await store.getWorkflowIR(contentHash);\n if (found === undefined)\n throw new Error(`WorkflowIR not found: ${workflowId}@${contentHash}`);\n return found;\n });\n // Development runs the journey in fast-forward; the pinned hash still names the version.\n const ir = scaleDurations(stored, runtime.timeScale);\n outcome = await runJourney(ir, {\n userId,\n instanceId,\n enteredAtMs: input.enteredAtMs,\n step,\n facts: new JourneyFactSource(store, userId),\n messages,\n events,\n actions: parts.actions,\n validator: parts.validator,\n observer,\n });\n } catch (error) {\n /*\n * An error escaping run() ends the instance, and the ledger is the only\n * place anyone looks afterwards: leaving it at 'running' meant a dead\n * journey stayed indistinguishable from a waiting one, in the dashboard\n * and in the once-policy, forever.\n *\n * Subscriptions go too, as on every ordinary ending: left behind, the\n * rows would only make every later event of the user poke a dead\n * instance (one wake call to the runtime each, refused every time).\n *\n * A paused instance never gets here: a durable wait suspends the run\n * without throwing through it.\n */\n await step.do('finalize:failed', async () => {\n await store.setEntryStatus(instanceId, 'failed');\n await store.unsubscribe(instanceId);\n await finished('failed');\n });\n throw error;\n }\n\n await step.do('finalize', async () => {\n await store.setEntryStatus(instanceId, outcome.status);\n await store.unsubscribe(instanceId);\n await finished(outcome.status, outcome.convertedAt);\n });\n\n return outcome;\n}\n"],"mappings":";;;;;;;;;AAmDA,SAAS,QAAQ,QAAsC;CACrD,OAAO,EACL,MAAM,KAAK,SAAS;EAClB,QAAQ,IACN,aAAa,QAAQ,QAAQ,GAAG,QAAQ,SAAS,KAAK,QAAQ,aAAa,QAAQ,OAAO,GAAG,KAAK,UAAU,QAAQ,KAAK,GAC3H;EACA,OAAO,OAAO,KAAK,OAAO;CAC5B,EACF;AACF;AAEA,eAAsB,eACpB,OACA,OACyB;CACzB,MAAM,EAAE,YAAY,QAAQ,SAAS;CACrC,MAAM,EAAE,YAAY,aAAa,WAAW;CAC5C,MAAM,EAAE,OAAO,KAAK,UAAU,aAAa;CAE3C,MAAM,UAAU,eAAe,EAAE,SAAS,MAAM,QAAQ,GAAG,SAAS;CAEpE,MAAM,YAAY,QAA6C,gBAC7D,UAAU,WAAW;EACnB,UAAU;EACV;EACA;EACA;EACA,IAAI,KAAK,IAAI;EACb,GAAI,gBAAgB,KAAA,IAAY,CAAC,IAAI,EAAE,YAAY;CACrD,CAAC;CAEH,MAAM,WAAW,QAAQ,cAAc,QAAQ,MAAM,QAAQ,IAAI,MAAM;CAMvE,MAAM,SAAoB,EACxB,MAAM,OAAO,MAAM,SAAS,OAAO;EACjC,IAAI,WAAoC;EACxC,IAAI,aAAa,KAAA,GAAW;GAC1B,MAAM,UAAU,WAAW,UAAU,MAAM,OAAO;GAClD,IAAI,CAAC,QAAQ,IAAI,MAAM,IAAI,wBAAwB,MAAM,QAAQ,MAAM;GACvE,WAAW,QAAQ;EACrB;EACA,MAAM,YAAY,KAAK;GAAE;GAAI;GAAQ,OAAO;GAAM,SAAS;EAAS,GAAG,KAAK;CAC9E,EACF;CAuBA,IAAI,CAAC,MAJkB,oBAAoB,MAAM,cAAc,YAAY,MAAM,GAAG;EAClF,MAAM;EACN;CACF,CAAC,GACc;EAOb,QAAQ,KAAK,uBAAuB,WAAW,MAAM,OAAO,gCAAgC;EAC5F,OAAO;GAAE,QAAQ;GAAU,QAAQ;EAAS;CAC9C;CAEA,IAAI;CACJ,IAAI;EAIF,MAAM,SAAS,MAAM,KAAK,GAAG,gBAAgB,YAAY;GACvD,MAAM,QAAQ,MAAM,MAAM,cAAc,WAAW;GACnD,IAAI,UAAU,KAAA,GACZ,MAAM,IAAI,MAAM,yBAAyB,WAAW,GAAG,aAAa;GACtE,OAAO;EACT,CAAC;EAED,MAAM,KAAK,eAAe,QAAQ,QAAQ,SAAS;EACnD,UAAU,MAAM,WAAW,IAAI;GAC7B;GACA;GACA,aAAa,MAAM;GACnB;GACA,OAAO,IAAI,kBAAkB,OAAO,MAAM;GAC1C;GACA;GACA,SAAS,MAAM;GACf,WAAW,MAAM;GACjB;EACF,CAAC;CACH,SAAS,OAAO;EAcd,MAAM,KAAK,GAAG,mBAAmB,YAAY;GAC3C,MAAM,MAAM,eAAe,YAAY,QAAQ;GAC/C,MAAM,MAAM,YAAY,UAAU;GAClC,MAAM,SAAS,QAAQ;EACzB,CAAC;EACD,MAAM;CACR;CAEA,MAAM,KAAK,GAAG,YAAY,YAAY;EACpC,MAAM,MAAM,eAAe,YAAY,QAAQ,MAAM;EACrD,MAAM,MAAM,YAAY,UAAU;EAClC,MAAM,SAAS,QAAQ,QAAQ,QAAQ,WAAW;CACpD,CAAC;CAED,OAAO;AACT"}
|
|
1
|
+
{"version":3,"file":"execute.mjs","names":[],"sources":["../../src/host/execute.ts"],"sourcesContent":["import { type Contract, ContractValidationError, checkEvent } from '../contract';\nimport { runJourney } from '../engine/interpreter';\nimport type {\n ActionInvoker,\n EngineStep,\n EventSink,\n JourneyObserver,\n JourneyOutcome,\n MessageSender,\n MessageValidator,\n} from '../engine/ports';\nimport { scaleDurations } from '../engine/time-scale';\nimport { type JourneyRuntimeOptions, resolveRuntime } from '../runtime';\nimport { JourneyFactSource } from '../store/facts';\nimport { type JourneyStore, instanceOwner } from '../store/index';\nimport { readWithRetry } from './fresh-read';\nimport { ingestEvent } from './ingest';\nimport type { JourneyEnv, JourneyParams } from './platform';\n\n/**\n * One journey instance run to its end, on whatever durable runtime supplies the `EngineStep`:\n * the runner admitted against the user's erasure tombstone, the IR loaded by the hash pinned at\n * entry, the outlets wired, the interpreter run, and the ledger finalized. Cloudflare's runner\n * and the Lambda handler both come through here; only their EngineStep differs.\n */\n\n/** Everything a run is wired to, assembled by the host. */\nexport interface JourneyRunParts {\n store: JourneyStore;\n /**\n * The env the emitted events are ingested with: its `JOURNEY` starts and wakes instances on\n * this runtime.\n */\n env: JourneyEnv;\n runtime: JourneyRuntimeOptions;\n messages: MessageSender;\n actions: ActionInvoker;\n contract?: Contract | undefined;\n validator?: MessageValidator | undefined;\n observer?: JourneyObserver | undefined;\n}\n\nexport interface JourneyRunInput {\n instanceId: string;\n params: JourneyParams;\n /** When the trigger happened: replay-stable, never Date.now() at run time. */\n enteredAtMs: number;\n step: EngineStep;\n}\n\n/** Development aid: echo each outbound message before handing it to the real sender. */\nfunction logging(sender: MessageSender): MessageSender {\n return {\n async send(message) {\n console.log(\n `[journey] ${message.channel} ${message.template} → ${message.recipient ?? message.userId} ${JSON.stringify(message.props)}`\n );\n return sender.send(message);\n },\n };\n}\n\nexport async function executeJourney(\n input: JourneyRunInput,\n parts: JourneyRunParts\n): Promise<JourneyOutcome> {\n const { instanceId, params, step } = input;\n const { workflowId, contentHash, userId } = params;\n const { store, env, observer, contract } = parts;\n\n const runtime = resolveRuntime({ current: parts.runtime }, 'current');\n const owner = () => instanceOwner(store, instanceId, userId);\n\n const finished = async (status: JourneyOutcome['status'] | 'failed', convertedAt?: number) =>\n observer?.finished?.({\n workflow: workflowId,\n instanceId,\n userId: await owner(),\n status,\n at: Date.now(),\n ...(convertedAt === undefined ? {} : { convertedAt }),\n });\n\n const messages = runtime.logMessages ? logging(parts.messages) : parts.messages;\n\n // send_event feeds straight back into the router logic — an in-process call\n // forming the event edge between workflows. The step's identity travels as\n // the event id, so a retried emit records the occurrence once.\n // An emitted event passes the same contract check as one arriving over HTTP.\n const events: EventSink = {\n emit: async (name, payload, id) => {\n let accepted: Record<string, unknown> = payload;\n if (contract !== undefined) {\n const verdict = checkEvent(contract, name, payload);\n if (!verdict.ok) throw new ContractValidationError(name, verdict.issues);\n accepted = verdict.data;\n }\n await ingestEvent(env, { id, userId: await owner(), event: name, payload: accepted }, store);\n },\n };\n\n /*\n * Admission: the ledger entry was committed before the runtime was asked to create this\n * instance, so an erasure can land in between (the entry is not found by `get`, the erase\n * goes through, and then the create lands). The runner is the only one who can refuse that\n * late run, and it must do so before its first observable effect — `observer.started`, the\n * IR read, any action or send.\n *\n * Outside any durable step, on purpose: a step would checkpoint the first invocation's\n * \"admitted\" and replay it on every wake and every rebuild after an eviction, which is\n * exactly when the tombstone may have arrived since. `admitInstance` is idempotent — the\n * entry's `admitted_at` keeps its first value — so a replay re-admits the same instance, and\n * the store makes the admission mutually exclusive with the erasure: either the entry is\n * admitted first and the erasure finds it and stops it, or the tombstone commits first and\n * this read answers false. Only the read itself is retried, briefly, when the database\n * stumbled; one that keeps failing throws, so the instance errors without running anything,\n * its entry stays, and the erasure protocol (or a later wake) decides what becomes of it.\n */\n const admitted = await readWithRetry(async () => store.admitInstance(instanceId, await owner()), {\n what: 'admission',\n instanceId,\n });\n if (!admitted) {\n /*\n * No finalize either way: the entry is gone (erased, or never ours to run) or is being\n * erased, and the finalize steps would write it back — a status on an entry the erasure\n * deletes, a `finished` the reporting records as a new row of an erased user. The\n * subscriptions, if any, go with the user's data.\n */\n console.warn(`[workflow] instance ${instanceId} of ${userId} refused admission: not running`);\n return { status: 'exited', reason: 'erased' };\n }\n\n let outcome: JourneyOutcome;\n try {\n // In a step: a read that fails is retried like any other, and one that keeps failing ends\n // the instance through the failure path below rather than leaving its entry 'running'.\n // Content addressing makes the IR immutable, so the checkpointed copy is the one to replay\n const stored = await step.do('$workflow:ir', async () => {\n const found = await store.getWorkflowIR(contentHash);\n if (found === undefined)\n throw new Error(`WorkflowIR not found: ${workflowId}@${contentHash}`);\n return found;\n });\n // Development runs the journey in fast-forward; the pinned hash still names the version.\n const ir = scaleDurations(stored, runtime.timeScale);\n outcome = await runJourney(ir, {\n userId,\n instanceId,\n currentUserId: owner,\n // A delivery that arrives late still enters when the trigger happened: a purchase made\n // while the begin_checkout was being retried is the goal, not history from before entry\n enteredAtMs: Math.min(input.enteredAtMs, input.params.trigger.ts ?? input.enteredAtMs),\n step,\n facts: new JourneyFactSource(store, userId, owner),\n messages,\n events,\n actions: parts.actions,\n validator: parts.validator,\n observer,\n });\n } catch (error) {\n /*\n * An error escaping run() ends the instance, and the ledger is the only\n * place anyone looks afterwards: leaving it at 'running' meant a dead\n * journey stayed indistinguishable from a waiting one, in the dashboard\n * and in the once-policy, forever.\n *\n * Subscriptions go too, as on every ordinary ending: left behind, the\n * rows would only make every later event of the user poke a dead\n * instance (one wake call to the runtime each, refused every time).\n *\n * A paused instance never gets here: a durable wait suspends the run\n * without throwing through it.\n */\n await step.do('finalize:failed', async () => {\n await store.setEntryStatus(instanceId, 'failed');\n await store.unsubscribe(instanceId);\n await finished('failed');\n });\n throw error;\n }\n\n await step.do('finalize', async () => {\n await store.setEntryStatus(instanceId, outcome.status);\n await store.unsubscribe(instanceId);\n await finished(outcome.status, outcome.convertedAt);\n });\n\n return outcome;\n}\n"],"mappings":";;;;;;;;;;;AAmDA,SAAS,QAAQ,QAAsC;CACrD,OAAO,EACL,MAAM,KAAK,SAAS;EAClB,QAAQ,IACN,aAAa,QAAQ,QAAQ,GAAG,QAAQ,SAAS,KAAK,QAAQ,aAAa,QAAQ,OAAO,GAAG,KAAK,UAAU,QAAQ,KAAK,GAC3H;EACA,OAAO,OAAO,KAAK,OAAO;CAC5B,EACF;AACF;AAEA,eAAsB,eACpB,OACA,OACyB;CACzB,MAAM,EAAE,YAAY,QAAQ,SAAS;CACrC,MAAM,EAAE,YAAY,aAAa,WAAW;CAC5C,MAAM,EAAE,OAAO,KAAK,UAAU,aAAa;CAE3C,MAAM,UAAU,eAAe,EAAE,SAAS,MAAM,QAAQ,GAAG,SAAS;CACpE,MAAM,cAAc,cAAc,OAAO,YAAY,MAAM;CAE3D,MAAM,WAAW,OAAO,QAA6C,gBACnE,UAAU,WAAW;EACnB,UAAU;EACV;EACA,QAAQ,MAAM,MAAM;EACpB;EACA,IAAI,KAAK,IAAI;EACb,GAAI,gBAAgB,KAAA,IAAY,CAAC,IAAI,EAAE,YAAY;CACrD,CAAC;CAEH,MAAM,WAAW,QAAQ,cAAc,QAAQ,MAAM,QAAQ,IAAI,MAAM;CAMvE,MAAM,SAAoB,EACxB,MAAM,OAAO,MAAM,SAAS,OAAO;EACjC,IAAI,WAAoC;EACxC,IAAI,aAAa,KAAA,GAAW;GAC1B,MAAM,UAAU,WAAW,UAAU,MAAM,OAAO;GAClD,IAAI,CAAC,QAAQ,IAAI,MAAM,IAAI,wBAAwB,MAAM,QAAQ,MAAM;GACvE,WAAW,QAAQ;EACrB;EACA,MAAM,YAAY,KAAK;GAAE;GAAI,QAAQ,MAAM,MAAM;GAAG,OAAO;GAAM,SAAS;EAAS,GAAG,KAAK;CAC7F,EACF;CAuBA,IAAI,CAAC,MAJkB,cAAc,YAAY,MAAM,cAAc,YAAY,MAAM,MAAM,CAAC,GAAG;EAC/F,MAAM;EACN;CACF,CAAC,GACc;EAOb,QAAQ,KAAK,uBAAuB,WAAW,MAAM,OAAO,gCAAgC;EAC5F,OAAO;GAAE,QAAQ;GAAU,QAAQ;EAAS;CAC9C;CAEA,IAAI;CACJ,IAAI;EAIF,MAAM,SAAS,MAAM,KAAK,GAAG,gBAAgB,YAAY;GACvD,MAAM,QAAQ,MAAM,MAAM,cAAc,WAAW;GACnD,IAAI,UAAU,KAAA,GACZ,MAAM,IAAI,MAAM,yBAAyB,WAAW,GAAG,aAAa;GACtE,OAAO;EACT,CAAC;EAED,MAAM,KAAK,eAAe,QAAQ,QAAQ,SAAS;EACnD,UAAU,MAAM,WAAW,IAAI;GAC7B;GACA;GACA,eAAe;GAGf,aAAa,KAAK,IAAI,MAAM,aAAa,MAAM,OAAO,QAAQ,MAAM,MAAM,WAAW;GACrF;GACA,OAAO,IAAI,kBAAkB,OAAO,QAAQ,KAAK;GACjD;GACA;GACA,SAAS,MAAM;GACf,WAAW,MAAM;GACjB;EACF,CAAC;CACH,SAAS,OAAO;EAcd,MAAM,KAAK,GAAG,mBAAmB,YAAY;GAC3C,MAAM,MAAM,eAAe,YAAY,QAAQ;GAC/C,MAAM,MAAM,YAAY,UAAU;GAClC,MAAM,SAAS,QAAQ;EACzB,CAAC;EACD,MAAM;CACR;CAEA,MAAM,KAAK,GAAG,YAAY,YAAY;EACpC,MAAM,MAAM,eAAe,YAAY,QAAQ,MAAM;EACrD,MAAM,MAAM,YAAY,UAAU;EAClC,MAAM,SAAS,QAAQ,QAAQ,QAAQ,WAAW;CACpD,CAAC;CAED,OAAO;AACT"}
|
package/dist/host/http.cjs
CHANGED
|
@@ -2,11 +2,12 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
|
2
2
|
const require_runtime = require("../_virtual/_rolldown/runtime.cjs");
|
|
3
3
|
const require_reporting_tracking = require("../reporting/tracking.cjs");
|
|
4
4
|
const require_store_erasure = require("../store/erasure.cjs");
|
|
5
|
+
const require_store_identity = require("../store/identity.cjs");
|
|
5
6
|
const require_store_index = require("../store/index.cjs");
|
|
6
7
|
const require_host_ingest = require("./ingest.cjs");
|
|
7
8
|
const require_email = require("../email.cjs");
|
|
8
9
|
const require_templates = require("../templates.cjs");
|
|
9
|
-
const
|
|
10
|
+
const require_host_batch = require("./batch.cjs");
|
|
10
11
|
const require_host_channels = require("./channels.cjs");
|
|
11
12
|
const require_host_auth = require("./auth.cjs");
|
|
12
13
|
let zod_mini = require("zod/mini");
|
|
@@ -35,41 +36,7 @@ async function readJson(request) {
|
|
|
35
36
|
*/
|
|
36
37
|
async function readBody(request) {
|
|
37
38
|
const body = await readJson(request);
|
|
38
|
-
return isPlainObject(body) ? body : {};
|
|
39
|
-
}
|
|
40
|
-
function isPlainObject(value) {
|
|
41
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
42
|
-
}
|
|
43
|
-
function isNonEmptyString(value) {
|
|
44
|
-
return typeof value === "string" && value.length > 0;
|
|
45
|
-
}
|
|
46
|
-
/** A profile value: a scalar or null (which removes the property). Nested values are not profile data. */
|
|
47
|
-
function isScalarOrNull(value) {
|
|
48
|
-
return value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean";
|
|
49
|
-
}
|
|
50
|
-
/** How far ahead of the server's clock an event or device `ts` may be: clock skew, not the future. */
|
|
51
|
-
const TS_FUTURE_TOLERANCE_MS = 3e5;
|
|
52
|
-
const TS_ERROR = "ts must be a non-negative integer no later than 5 minutes in the future";
|
|
53
|
-
/**
|
|
54
|
-
* A caller's timestamp: epoch milliseconds, whole, not negative, and not past the skew tolerance.
|
|
55
|
-
* A far-future ts would put the event ahead of everything a window condition reads, and a
|
|
56
|
-
* fractional or unsafe one does not survive the store.
|
|
57
|
-
*/
|
|
58
|
-
function isValidTs(ts) {
|
|
59
|
-
return typeof ts === "number" && Number.isSafeInteger(ts) && ts >= 0 && ts <= Date.now() + TS_FUTURE_TOLERANCE_MS;
|
|
60
|
-
}
|
|
61
|
-
/** The contract's verdict: the value to use, or the response refusing the request. */
|
|
62
|
-
function checked(validation, result, original, what) {
|
|
63
|
-
if (result.ok) return { value: result.data };
|
|
64
|
-
const issues = result.issues.map((i) => `${i.path || "(root)"}: ${i.message}`);
|
|
65
|
-
if (validation.mode === "report") {
|
|
66
|
-
console.warn(`[workflow http] contract would refuse ${what}: ${issues.join("; ")}`);
|
|
67
|
-
return { value: original };
|
|
68
|
-
}
|
|
69
|
-
return { refused: json({
|
|
70
|
-
error: `${what} refused by the contract`,
|
|
71
|
-
issues: result.issues
|
|
72
|
-
}, 400) };
|
|
39
|
+
return require_host_batch.isPlainObject(body) ? body : {};
|
|
73
40
|
}
|
|
74
41
|
/**
|
|
75
42
|
* The topics the engine knows: the deployed bundle's list (compiled from the contract), or the
|
|
@@ -106,46 +73,18 @@ const routes = [
|
|
|
106
73
|
},
|
|
107
74
|
{
|
|
108
75
|
method: "POST",
|
|
109
|
-
path: "/
|
|
110
|
-
auth: "api",
|
|
111
|
-
async handle({ request, env, store, validation }) {
|
|
112
|
-
const { id, userId, event, payload, ts } = await readBody(request);
|
|
113
|
-
if (!isNonEmptyString(userId) || !isNonEmptyString(event)) return json({ error: "userId and event required" }, 400);
|
|
114
|
-
if (event.startsWith("$")) return json({ error: "event names starting with $ are reserved" }, 400);
|
|
115
|
-
if (id !== void 0 && !isNonEmptyString(id)) return json({ error: "id must be a non-empty string" }, 400);
|
|
116
|
-
if (ts !== void 0 && !isValidTs(ts)) return json({ error: TS_ERROR }, 400);
|
|
117
|
-
if (payload !== void 0 && !isPlainObject(payload)) return json({ error: "payload must be an object" }, 400);
|
|
118
|
-
let accepted = payload;
|
|
119
|
-
if (validation.contract !== void 0) {
|
|
120
|
-
const verdict = checked(validation, require_contract.checkEvent(validation.contract, event, payload ?? {}), accepted ?? {}, `event '${event}'${typeof id === "string" ? ` (${id})` : ""}`);
|
|
121
|
-
if ("refused" in verdict) return verdict.refused;
|
|
122
|
-
accepted = verdict.value;
|
|
123
|
-
}
|
|
124
|
-
return json(await require_host_ingest.ingestEvent(env, {
|
|
125
|
-
id,
|
|
126
|
-
userId,
|
|
127
|
-
event,
|
|
128
|
-
payload: accepted,
|
|
129
|
-
ts
|
|
130
|
-
}, store));
|
|
131
|
-
}
|
|
132
|
-
},
|
|
133
|
-
{
|
|
134
|
-
method: "POST",
|
|
135
|
-
path: "/identify",
|
|
76
|
+
path: "/ingest",
|
|
136
77
|
auth: "api",
|
|
137
78
|
async handle({ request, env, store, validation }) {
|
|
138
|
-
const
|
|
139
|
-
if (!
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
if ("refused" in verdict) return verdict.refused;
|
|
146
|
-
patch = verdict.value;
|
|
79
|
+
const parsed = require_host_batch.parseBatch(await readJson(request), validation);
|
|
80
|
+
if (!parsed.ok) {
|
|
81
|
+
const { error, issues } = parsed;
|
|
82
|
+
return json({
|
|
83
|
+
error,
|
|
84
|
+
...issues === void 0 ? {} : { issues }
|
|
85
|
+
}, 400);
|
|
147
86
|
}
|
|
148
|
-
return json(await
|
|
87
|
+
return json(await require_host_batch.ingestBatch(env, parsed.batch, store));
|
|
149
88
|
}
|
|
150
89
|
},
|
|
151
90
|
{
|
|
@@ -153,18 +92,19 @@ const routes = [
|
|
|
153
92
|
path: "/devices",
|
|
154
93
|
auth: "api",
|
|
155
94
|
async handle({ request, env, store }) {
|
|
156
|
-
const { userId, token, platform
|
|
157
|
-
|
|
158
|
-
if (
|
|
95
|
+
const { deviceId, userId, visitorId, token, platform } = await readBody(request);
|
|
96
|
+
const refused = require_host_batch.identityError(userId, visitorId);
|
|
97
|
+
if (refused !== void 0) return json({ error: refused }, 400);
|
|
98
|
+
if (deviceId !== void 0 && !require_host_batch.isNonEmptyString(deviceId)) return json({ error: "deviceId must be a non-empty string" }, 400);
|
|
99
|
+
if (!require_host_batch.isNonEmptyString(token)) return json({ error: "token required" }, 400);
|
|
159
100
|
if (platform !== "ios" && platform !== "android" && platform !== "web") return json({ error: "platform must be 'ios', 'android' or 'web'" }, 400);
|
|
160
|
-
|
|
161
|
-
|
|
101
|
+
return json(await require_host_ingest.registerDevice(env, {
|
|
102
|
+
deviceId,
|
|
162
103
|
userId,
|
|
104
|
+
visitorId,
|
|
163
105
|
token,
|
|
164
|
-
platform
|
|
165
|
-
|
|
166
|
-
};
|
|
167
|
-
return json(await require_host_ingest.registerDevice(env, device, store));
|
|
106
|
+
platform
|
|
107
|
+
}, store));
|
|
168
108
|
}
|
|
169
109
|
},
|
|
170
110
|
{
|
|
@@ -173,7 +113,7 @@ const routes = [
|
|
|
173
113
|
auth: "api",
|
|
174
114
|
async handle({ request, env, store }) {
|
|
175
115
|
const { token } = await readBody(request);
|
|
176
|
-
if (!isNonEmptyString(token)) return json({ error: "token required" }, 400);
|
|
116
|
+
if (!require_host_batch.isNonEmptyString(token)) return json({ error: "token required" }, 400);
|
|
177
117
|
return json(await require_host_ingest.unbindDevice(env, token, store));
|
|
178
118
|
}
|
|
179
119
|
},
|
|
@@ -183,7 +123,7 @@ const routes = [
|
|
|
183
123
|
auth: "api",
|
|
184
124
|
async handle({ request, env, store }) {
|
|
185
125
|
const { token } = await readBody(request);
|
|
186
|
-
if (!isNonEmptyString(token)) return json({ error: "token required" }, 400);
|
|
126
|
+
if (!require_host_batch.isNonEmptyString(token)) return json({ error: "token required" }, 400);
|
|
187
127
|
return json(await require_host_ingest.removeDevice(env, token, store));
|
|
188
128
|
}
|
|
189
129
|
},
|
|
@@ -193,8 +133,8 @@ const routes = [
|
|
|
193
133
|
auth: "api",
|
|
194
134
|
async handle({ request, store }) {
|
|
195
135
|
const { userId, messageId } = await readBody(request);
|
|
196
|
-
if (!isNonEmptyString(userId)) return json({ error: "userId required" }, 400);
|
|
197
|
-
if (!isNonEmptyString(messageId)) return json({ error: "messageId required" }, 400);
|
|
136
|
+
if (!require_host_batch.isNonEmptyString(userId)) return json({ error: "userId required" }, 400);
|
|
137
|
+
if (!require_host_batch.isNonEmptyString(messageId)) return json({ error: "messageId required" }, 400);
|
|
198
138
|
const recorded = await require_reporting_tracking.recordPushOpen(store.reporting, userId, messageId);
|
|
199
139
|
return json({
|
|
200
140
|
ok: true,
|
|
@@ -259,8 +199,9 @@ const routes = [
|
|
|
259
199
|
path: "/users/forget",
|
|
260
200
|
auth: "api",
|
|
261
201
|
async handle({ request, env, store }) {
|
|
262
|
-
const { userId } = await readBody(request);
|
|
263
|
-
if (
|
|
202
|
+
const { userId, visitorId } = await readBody(request);
|
|
203
|
+
if (require_host_batch.isNonEmptyString(visitorId)) return json(await require_host_ingest.forgetUser(env, require_store_identity.visitorSubject(visitorId), store));
|
|
204
|
+
if (!require_host_batch.isNonEmptyString(userId)) return json({ error: "userId or visitorId required" }, 400);
|
|
264
205
|
return json(await require_host_ingest.forgetUser(env, userId, store));
|
|
265
206
|
}
|
|
266
207
|
},
|
|
@@ -286,7 +227,7 @@ const routes = [
|
|
|
286
227
|
auth: "api",
|
|
287
228
|
async handle({ env, store, host }) {
|
|
288
229
|
if (host.bundle === void 0) return notFound();
|
|
289
|
-
return json(await require_host_ingest.deployBundle(env, host.bundle(), store));
|
|
230
|
+
return json(await require_host_ingest.deployBundle(env, host.bundle(), store, host.bundleVersion, true));
|
|
290
231
|
}
|
|
291
232
|
},
|
|
292
233
|
{
|
|
@@ -312,7 +253,7 @@ const routes = [
|
|
|
312
253
|
} catch {
|
|
313
254
|
return json({ error: "props must be a JSON object" }, 400);
|
|
314
255
|
}
|
|
315
|
-
if (!isPlainObject(props)) return json({ error: "props must be a JSON object" }, 400);
|
|
256
|
+
if (!require_host_batch.isPlainObject(props)) return json({ error: "props must be a JSON object" }, 400);
|
|
316
257
|
const { subject, html } = await preview.render(key, props, { messageId: require_email.PREVIEW_MESSAGE_ID });
|
|
317
258
|
return new Response(html, { headers: {
|
|
318
259
|
"content-type": "text/html; charset=utf-8",
|