@cosmicdrift/kumiko-framework 0.230.0 → 0.232.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/package.json +3 -3
- package/src/bun-db/query.ts +37 -36
- package/src/compliance/__tests__/sub-processors.test.ts +12 -12
- package/src/compliance/sub-processors.ts +11 -11
- package/src/db/__tests__/blind-index.integration.test.ts +32 -0
- package/src/db/blind-index-cleanup.ts +6 -0
- package/src/db/entity-table-meta.ts +23 -23
- package/src/engine/__tests__/build-app-schema.test.ts +42 -0
- package/src/engine/__tests__/define-workflow.test.ts +23 -1
- package/src/engine/__tests__/steps-workflow.test.ts +160 -5
- package/src/engine/define-workflow.ts +48 -11
- package/src/engine/feature-ui-extensions.ts +6 -3
- package/src/engine/index.ts +2 -0
- package/src/engine/pipeline.ts +34 -7
- package/src/engine/steps/_event-match.ts +98 -0
- package/src/engine/steps/wait-for-event.ts +25 -6
- package/src/files/__tests__/file-handle.test.ts +26 -0
- package/src/files/file-handle.ts +1 -0
- package/src/jobs/__tests__/jobs.integration.test.ts +40 -18
- package/src/search/__tests__/search-pii-derived-index.integration.test.ts +41 -1
- package/src/search/purge-subject.ts +5 -0
- package/src/testing/__tests__/wait-for.test.ts +41 -0
- package/src/testing/wait-for.ts +16 -6
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, mock } from "bun:test";
|
|
2
2
|
import { getStep } from "../define-step";
|
|
3
|
+
import { evaluateEventMatch } from "../steps/_event-match";
|
|
3
4
|
import {
|
|
4
5
|
SUSPEND_SENTINEL,
|
|
5
6
|
WORKFLOW_AGGREGATE_TYPE,
|
|
@@ -9,7 +10,12 @@ import {
|
|
|
9
10
|
import { buildRetryStep, calculateBackoff } from "../steps/retry";
|
|
10
11
|
import { buildWaitStep } from "../steps/wait";
|
|
11
12
|
import { buildWaitForEventStep } from "../steps/wait-for-event";
|
|
12
|
-
import type { PipelineCtx } from "../types/step";
|
|
13
|
+
import type { AwaitedEventType, EventMatch, PipelineCtx } from "../types/step";
|
|
14
|
+
|
|
15
|
+
// Test-only helper for the low-level step-builder unit tests below, which
|
|
16
|
+
// call buildWaitForEventStep directly instead of going through a
|
|
17
|
+
// defineWorkflow `awaits` declaration (the only legitimate branding route).
|
|
18
|
+
const asAwaited = (event: string): AwaitedEventType => event as AwaitedEventType;
|
|
13
19
|
|
|
14
20
|
const mockUnsafeAppendEvent = mock();
|
|
15
21
|
|
|
@@ -90,20 +96,31 @@ describe("workflow.wait run", () => {
|
|
|
90
96
|
describe("buildWaitForEventStep", () => {
|
|
91
97
|
it("returns a StepInstance with kind workflow.waitForEvent", () => {
|
|
92
98
|
const step = buildWaitForEventStep({
|
|
93
|
-
event: "user.confirmed-email",
|
|
99
|
+
event: asAwaited("user.confirmed-email"),
|
|
94
100
|
timeout: "P7D",
|
|
95
101
|
});
|
|
96
102
|
expect(step.kind).toBe("workflow.waitForEvent");
|
|
97
103
|
});
|
|
98
104
|
|
|
99
|
-
it("accepts an optional match
|
|
105
|
+
it("accepts an optional match AST", () => {
|
|
106
|
+
const match: EventMatch = {
|
|
107
|
+
version: 1,
|
|
108
|
+
expr: { kind: "atom", path: ["email"], op: { kind: "eq", value: "test@test.com" } },
|
|
109
|
+
};
|
|
100
110
|
const step = buildWaitForEventStep({
|
|
101
|
-
event: "user.confirmed-email",
|
|
102
|
-
match
|
|
111
|
+
event: asAwaited("user.confirmed-email"),
|
|
112
|
+
match,
|
|
103
113
|
timeout: "P7D",
|
|
104
114
|
});
|
|
105
115
|
expect(step.kind).toBe("workflow.waitForEvent");
|
|
106
116
|
});
|
|
117
|
+
|
|
118
|
+
it("@ts-expect-error: event must come from an awaits declaration, not a raw string", () => {
|
|
119
|
+
// @ts-expect-error — a raw string is not an AwaitedEventType; only
|
|
120
|
+
// defineWorkflow's `awaits` map can produce one (D4, workflow-resume-loop.md).
|
|
121
|
+
const step = buildWaitForEventStep({ event: "user.confirmed-email", timeout: "P7D" });
|
|
122
|
+
expect(step.kind).toBe("workflow.waitForEvent");
|
|
123
|
+
});
|
|
107
124
|
});
|
|
108
125
|
|
|
109
126
|
describe("workflow.waitForEvent run", () => {
|
|
@@ -138,6 +155,144 @@ describe("workflow.waitForEvent run", () => {
|
|
|
138
155
|
expect(typeof eventArg.payload.timeoutAt).toBe("string");
|
|
139
156
|
expect(eventArg.payload.workflowName).toBe("test-workflow");
|
|
140
157
|
});
|
|
158
|
+
|
|
159
|
+
it("omits the match key from the payload when no match is given", async () => {
|
|
160
|
+
const stepDef = getStep("workflow.waitForEvent");
|
|
161
|
+
expect(stepDef).toBeDefined();
|
|
162
|
+
|
|
163
|
+
await stepDef!.run({ event: "user.confirmed-email", timeout: "P7D" }, workflowCtx);
|
|
164
|
+
|
|
165
|
+
const eventArg = mockUnsafeAppendEvent.mock.calls[0]![0];
|
|
166
|
+
expect("match" in eventArg.payload).toBe(false);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("resolves a match resolver to a serializable AST that round-trips through JSON and evaluates against real payloads", async () => {
|
|
170
|
+
const stepDef = getStep("workflow.waitForEvent");
|
|
171
|
+
expect(stepDef).toBeDefined();
|
|
172
|
+
|
|
173
|
+
const result = await stepDef!.run(
|
|
174
|
+
{
|
|
175
|
+
event: "user.confirmed-email",
|
|
176
|
+
match: (ctx: PipelineCtx): EventMatch => ({
|
|
177
|
+
version: 1,
|
|
178
|
+
expr: {
|
|
179
|
+
kind: "atom",
|
|
180
|
+
path: ["email"],
|
|
181
|
+
op: { kind: "eq", value: (ctx.event.payload as { email: string }).email },
|
|
182
|
+
},
|
|
183
|
+
}),
|
|
184
|
+
timeout: "P7D",
|
|
185
|
+
},
|
|
186
|
+
workflowCtx,
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
expect(result).toBe(SUSPEND_SENTINEL);
|
|
190
|
+
const eventArg = mockUnsafeAppendEvent.mock.calls[0]![0];
|
|
191
|
+
const persistedMatch = eventArg.payload.match;
|
|
192
|
+
|
|
193
|
+
// Proves the persisted value is the resolved literal AST, not the resolver function.
|
|
194
|
+
expect(persistedMatch).toEqual({
|
|
195
|
+
version: 1,
|
|
196
|
+
expr: {
|
|
197
|
+
kind: "atom",
|
|
198
|
+
path: ["email"],
|
|
199
|
+
op: { kind: "eq", value: "test@example.com" },
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// Proves serializability — the whole point of the AST switch — by round-tripping
|
|
204
|
+
// through JSON before feeding it to the evaluator, same as a rehydrated event would.
|
|
205
|
+
const roundTripped = JSON.parse(JSON.stringify(persistedMatch));
|
|
206
|
+
expect(evaluateEventMatch(roundTripped, { email: "test@example.com" })).toBe(true);
|
|
207
|
+
expect(evaluateEventMatch(roundTripped, { email: "someone-else@example.com" })).toBe(false);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe("evaluateEventMatch", () => {
|
|
212
|
+
it("throws on an unsupported version", () => {
|
|
213
|
+
const stale = JSON.parse(
|
|
214
|
+
JSON.stringify({
|
|
215
|
+
version: 2,
|
|
216
|
+
expr: { kind: "atom", path: ["x"], op: { kind: "eq", value: 1 } },
|
|
217
|
+
}),
|
|
218
|
+
);
|
|
219
|
+
expect(() => evaluateEventMatch(stale, {})).toThrow(/version/i);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("throws on an unrecognized expr kind", () => {
|
|
223
|
+
const bad = JSON.parse(JSON.stringify({ version: 1, expr: { kind: "not-a-kind" } }));
|
|
224
|
+
expect(() => evaluateEventMatch(bad, {})).toThrow(/kind/i);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("throws on an unrecognized op kind", () => {
|
|
228
|
+
const bad = JSON.parse(
|
|
229
|
+
JSON.stringify({
|
|
230
|
+
version: 1,
|
|
231
|
+
expr: { kind: "atom", path: ["x"], op: { kind: "not-an-op" } },
|
|
232
|
+
}),
|
|
233
|
+
);
|
|
234
|
+
expect(() => evaluateEventMatch(bad, {})).toThrow(/kind/i);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("and with empty nodes is true, or with empty nodes is false", () => {
|
|
238
|
+
expect(evaluateEventMatch({ version: 1, expr: { kind: "and", nodes: [] } }, {})).toBe(true);
|
|
239
|
+
expect(evaluateEventMatch({ version: 1, expr: { kind: "or", nodes: [] } }, {})).toBe(false);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("combines atoms with and/or", () => {
|
|
243
|
+
const match: EventMatch = {
|
|
244
|
+
version: 1,
|
|
245
|
+
expr: {
|
|
246
|
+
kind: "and",
|
|
247
|
+
nodes: [
|
|
248
|
+
{ kind: "atom", path: ["status"], op: { kind: "eq", value: "confirmed" } },
|
|
249
|
+
{ kind: "atom", path: ["tier"], op: { kind: "in", values: ["gold", "platinum"] } },
|
|
250
|
+
],
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
expect(evaluateEventMatch(match, { status: "confirmed", tier: "gold" })).toBe(true);
|
|
254
|
+
expect(evaluateEventMatch(match, { status: "confirmed", tier: "silver" })).toBe(false);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("ne matches when the resolved value differs", () => {
|
|
258
|
+
const match: EventMatch = {
|
|
259
|
+
version: 1,
|
|
260
|
+
expr: { kind: "atom", path: ["status"], op: { kind: "ne", value: "cancelled" } },
|
|
261
|
+
};
|
|
262
|
+
expect(evaluateEventMatch(match, { status: "confirmed" })).toBe(true);
|
|
263
|
+
expect(evaluateEventMatch(match, { status: "cancelled" })).toBe(false);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("compares numbers with gte", () => {
|
|
267
|
+
const match: EventMatch = {
|
|
268
|
+
version: 1,
|
|
269
|
+
expr: { kind: "atom", path: ["amount"], op: { kind: "gte", value: 100 } },
|
|
270
|
+
};
|
|
271
|
+
expect(evaluateEventMatch(match, { amount: 150 })).toBe(true);
|
|
272
|
+
expect(evaluateEventMatch(match, { amount: 50 })).toBe(false);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("does not match a comparison op when the payload value's type differs from the operand", () => {
|
|
276
|
+
const match: EventMatch = {
|
|
277
|
+
version: 1,
|
|
278
|
+
expr: { kind: "atom", path: ["amount"], op: { kind: "gt", value: 100 } },
|
|
279
|
+
};
|
|
280
|
+
expect(evaluateEventMatch(match, { amount: "150" })).toBe(false);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("resolves nested paths and treats a path running into a non-object as unmatched", () => {
|
|
284
|
+
const match: EventMatch = {
|
|
285
|
+
version: 1,
|
|
286
|
+
expr: {
|
|
287
|
+
kind: "atom",
|
|
288
|
+
path: ["user", "email"],
|
|
289
|
+
op: { kind: "eq", value: "a@b.com" },
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
expect(evaluateEventMatch(match, { user: { email: "a@b.com" } })).toBe(true);
|
|
293
|
+
expect(evaluateEventMatch(match, { user: "not-an-object" })).toBe(false);
|
|
294
|
+
expect(evaluateEventMatch(match, {})).toBe(false);
|
|
295
|
+
});
|
|
141
296
|
});
|
|
142
297
|
|
|
143
298
|
describe("buildRetryStep", () => {
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { createHash } from "node:crypto";
|
|
7
7
|
import type { WriteEvent } from "./types/handlers";
|
|
8
|
-
import type { PipelineDef } from "./types/step";
|
|
8
|
+
import type { AwaitedEventType, PipelineDef } from "./types/step";
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Trigger configuration for a workflow. Determines what starts a run.
|
|
@@ -28,12 +28,27 @@ export type WorkflowTrigger =
|
|
|
28
28
|
/**
|
|
29
29
|
* Workflow definition — the result of defineWorkflow().
|
|
30
30
|
*/
|
|
31
|
-
export type WorkflowDefinition<
|
|
31
|
+
export type WorkflowDefinition<
|
|
32
|
+
TPayload = unknown,
|
|
33
|
+
TData = unknown,
|
|
34
|
+
TAwaits extends Record<string, string> = Record<string, string>,
|
|
35
|
+
> = {
|
|
32
36
|
readonly __kind: "workflow";
|
|
33
37
|
readonly name: string;
|
|
34
38
|
readonly trigger: WorkflowTrigger;
|
|
39
|
+
/**
|
|
40
|
+
* Declared expected events (D4, workflow-resume-loop.md) — the sole route
|
|
41
|
+
* a waitForEvent step can reach an event type. Read at registration time
|
|
42
|
+
* to build the static apply-map (Phase 3b); values are plain event-type
|
|
43
|
+
* strings, not the branded AwaitedEventType the pipeline closure sees.
|
|
44
|
+
*/
|
|
45
|
+
readonly awaits?: TAwaits;
|
|
35
46
|
/** The pipeline definition containing the step list closure. */
|
|
36
|
-
readonly pipelineDef: PipelineDef<
|
|
47
|
+
readonly pipelineDef: PipelineDef<
|
|
48
|
+
TPayload,
|
|
49
|
+
TData,
|
|
50
|
+
{ readonly [K in keyof TAwaits]: AwaitedEventType }
|
|
51
|
+
>;
|
|
37
52
|
/** Idempotency key for deduplication — prevents duplicate runs. */
|
|
38
53
|
readonly idempotencyKey?: string | ((event: WriteEvent<TPayload>) => string);
|
|
39
54
|
};
|
|
@@ -41,10 +56,15 @@ export type WorkflowDefinition<TPayload = unknown, TData = unknown> = {
|
|
|
41
56
|
/**
|
|
42
57
|
* Input shape for defineWorkflow() — the user-facing API.
|
|
43
58
|
*/
|
|
44
|
-
export type WorkflowInput<
|
|
59
|
+
export type WorkflowInput<
|
|
60
|
+
TPayload = unknown,
|
|
61
|
+
TData = unknown,
|
|
62
|
+
TAwaits extends Record<string, string> = Record<string, string>,
|
|
63
|
+
> = {
|
|
45
64
|
readonly name: string;
|
|
46
65
|
readonly trigger: WorkflowTrigger;
|
|
47
|
-
readonly
|
|
66
|
+
readonly awaits?: TAwaits;
|
|
67
|
+
readonly steps: PipelineDef<TPayload, TData, { readonly [K in keyof TAwaits]: AwaitedEventType }>;
|
|
48
68
|
readonly idempotencyKey?: string | ((event: WriteEvent<TPayload>) => string);
|
|
49
69
|
readonly onError?: PipelineDef<unknown>;
|
|
50
70
|
};
|
|
@@ -69,14 +89,22 @@ export type WorkflowInput<TPayload = unknown, TData = unknown> = {
|
|
|
69
89
|
* });
|
|
70
90
|
* ```
|
|
71
91
|
*/
|
|
72
|
-
export function defineWorkflow<
|
|
73
|
-
|
|
74
|
-
|
|
92
|
+
export function defineWorkflow<
|
|
93
|
+
TPayload = unknown,
|
|
94
|
+
TData = unknown,
|
|
95
|
+
const TAwaits extends Record<string, string> = Record<string, string>,
|
|
96
|
+
>(input: WorkflowInput<TPayload, TData, TAwaits>): WorkflowDefinition<TPayload, TData, TAwaits> {
|
|
97
|
+
// pipelineDef.awaits carries the same raw strings as the declaration
|
|
98
|
+
// below — buildPipelineSteps (pipeline.ts) is where they're branded into
|
|
99
|
+
// AwaitedEventType for the step closure, not here (see PipelineDef.awaits
|
|
100
|
+
// doc comment in types/step.ts for why the brand can't happen at this
|
|
101
|
+
// layer without breaking inference for an inline `stepsPipeline(...)`).
|
|
75
102
|
return {
|
|
76
103
|
__kind: "workflow",
|
|
77
104
|
name: input.name,
|
|
78
105
|
trigger: input.trigger,
|
|
79
|
-
|
|
106
|
+
awaits: input.awaits,
|
|
107
|
+
pipelineDef: { ...input.steps, awaits: input.awaits },
|
|
80
108
|
idempotencyKey: input.idempotencyKey,
|
|
81
109
|
};
|
|
82
110
|
}
|
|
@@ -98,12 +126,21 @@ export function defineWorkflow<TPayload = unknown, TData = unknown>(
|
|
|
98
126
|
* environments. Run-the-fingerprint-in-the-same-environment is the
|
|
99
127
|
* contract; cross-env replay is out of scope.
|
|
100
128
|
*/
|
|
101
|
-
export function computeDefinitionFingerprint
|
|
102
|
-
|
|
129
|
+
export function computeDefinitionFingerprint<
|
|
130
|
+
TPayload = unknown,
|
|
131
|
+
TData = unknown,
|
|
132
|
+
TAwaits extends Record<string, string> = Record<string, string>,
|
|
133
|
+
>(
|
|
134
|
+
workflow: Pick<WorkflowDefinition<TPayload, TData, TAwaits>, "name" | "trigger" | "pipelineDef">,
|
|
103
135
|
): string {
|
|
136
|
+
// `awaits` is part of the routing contract (D4, workflow-resume-loop.md):
|
|
137
|
+
// changing which events a run waits on changes the run's behavior just
|
|
138
|
+
// like changing the step source does, so a changed declaration must flip
|
|
139
|
+
// the fingerprint and trip the Q7 stale-definition check on resume.
|
|
104
140
|
const material = JSON.stringify({
|
|
105
141
|
name: workflow.name,
|
|
106
142
|
trigger: workflow.trigger,
|
|
143
|
+
awaits: workflow.pipelineDef.awaits,
|
|
107
144
|
source: workflow.pipelineDef.build.toString(),
|
|
108
145
|
});
|
|
109
146
|
return createHash("sha256").update(material).digest("hex");
|
|
@@ -360,12 +360,15 @@ export function buildUiExtensionsMethods<TName extends string>(
|
|
|
360
360
|
// Sugar for the common "one nav entry pointing at this screen"
|
|
361
361
|
// case — synthesizes id/screen from the screen's own id. Beyond
|
|
362
362
|
// label/icon/parent/order, declare a standalone r.nav() instead.
|
|
363
|
+
// Optional fields stay absent rather than explicitly undefined —
|
|
364
|
+
// buildAppSchema's JSON-safety check flags undefined values. Same
|
|
365
|
+
// pattern as r.contentCollection() below.
|
|
363
366
|
registerNav({
|
|
364
367
|
id: definition.id,
|
|
365
368
|
label: definition.nav.label,
|
|
366
|
-
icon: definition.nav.icon,
|
|
367
|
-
parent: definition.nav.parent,
|
|
368
|
-
order: definition.nav.order,
|
|
369
|
+
...(definition.nav.icon !== undefined && { icon: definition.nav.icon }),
|
|
370
|
+
...(definition.nav.parent !== undefined && { parent: definition.nav.parent }),
|
|
371
|
+
...(definition.nav.order !== undefined && { order: definition.nav.order }),
|
|
369
372
|
screen: `${name}:screen:${definition.id}`,
|
|
370
373
|
});
|
|
371
374
|
}
|
package/src/engine/index.ts
CHANGED
|
@@ -247,6 +247,7 @@ export {
|
|
|
247
247
|
} from "./screen-helpers";
|
|
248
248
|
export type { TransitionGraph } from "./state-machine";
|
|
249
249
|
export { defineTransitions, guardTransition } from "./state-machine";
|
|
250
|
+
export { evaluateEventMatch } from "./steps/_event-match";
|
|
250
251
|
export {
|
|
251
252
|
SUSPEND_SENTINEL,
|
|
252
253
|
WORKFLOW_AGGREGATE_TYPE,
|
|
@@ -469,6 +470,7 @@ export type {
|
|
|
469
470
|
export { DEFAULT_CURRENCIES, DEFAULT_LOCALES, HookPhases } from "./types";
|
|
470
471
|
export { isSystemTenant, isUuid, parseTenantId, SYSTEM_TENANT_ID } from "./types/identifiers";
|
|
471
472
|
export type {
|
|
473
|
+
AwaitedEventType,
|
|
472
474
|
PipelineBuildCtx,
|
|
473
475
|
PipelineCtx,
|
|
474
476
|
PipelineDef,
|
package/src/engine/pipeline.ts
CHANGED
|
@@ -31,7 +31,13 @@ import { buildWaitStep } from "./steps/wait";
|
|
|
31
31
|
import { buildWaitForEventStep } from "./steps/wait-for-event";
|
|
32
32
|
import { buildWebhookSendStep } from "./steps/webhook-send";
|
|
33
33
|
import type { WriteEvent } from "./types/handlers";
|
|
34
|
-
import type {
|
|
34
|
+
import type {
|
|
35
|
+
AwaitedEventType,
|
|
36
|
+
PipelineBuildCtx,
|
|
37
|
+
PipelineDef,
|
|
38
|
+
StepBuilder,
|
|
39
|
+
StepInstance,
|
|
40
|
+
} from "./types/step";
|
|
35
41
|
|
|
36
42
|
const stepBuilder: StepBuilder = {
|
|
37
43
|
step: {
|
|
@@ -64,9 +70,13 @@ const stepBuilder: StepBuilder = {
|
|
|
64
70
|
},
|
|
65
71
|
};
|
|
66
72
|
|
|
67
|
-
export function stepsPipeline<
|
|
68
|
-
|
|
69
|
-
|
|
73
|
+
export function stepsPipeline<
|
|
74
|
+
TPayload = unknown,
|
|
75
|
+
TData = unknown,
|
|
76
|
+
TAwaits extends Record<string, AwaitedEventType> = Record<string, AwaitedEventType>,
|
|
77
|
+
>(
|
|
78
|
+
closure: (ctx: PipelineBuildCtx<TPayload, TAwaits>) => readonly StepInstance[],
|
|
79
|
+
): PipelineDef<TPayload, TData, TAwaits> {
|
|
70
80
|
return {
|
|
71
81
|
__kind: "pipeline",
|
|
72
82
|
build: closure,
|
|
@@ -76,9 +86,26 @@ export function stepsPipeline<TPayload = unknown, TData = unknown>(
|
|
|
76
86
|
// Internal: invoked by run-pipeline.ts to materialise the step list.
|
|
77
87
|
// Not exported from the engine barrel — pipeline-internal plumbing.
|
|
78
88
|
// @wrapper-known semantic-alias
|
|
79
|
-
|
|
80
|
-
|
|
89
|
+
//
|
|
90
|
+
// `awaits` comes off pipelineDef itself (set by defineWorkflow), not a
|
|
91
|
+
// function argument — every existing buildPipelineSteps caller (runner.ts,
|
|
92
|
+
// resume-run.write.ts, run-pipeline.ts, validate-projection-allowlist.ts)
|
|
93
|
+
// keeps working unchanged while workflow pipelines still get their real
|
|
94
|
+
// awaits map wired into the closure. The cast below is the one place a
|
|
95
|
+
// workflow's raw declared event-type strings become AwaitedEventType — safe
|
|
96
|
+
// because pipelineDef.awaits only ever holds what defineWorkflow's `awaits`
|
|
97
|
+
// carried in (D4, workflow-resume-loop.md), never an unchecked external
|
|
98
|
+
// value.
|
|
99
|
+
export function buildPipelineSteps<
|
|
100
|
+
TPayload,
|
|
101
|
+
TAwaits extends Record<string, AwaitedEventType> = Record<string, AwaitedEventType>,
|
|
102
|
+
>(
|
|
103
|
+
pipelineDef: PipelineDef<TPayload, unknown, TAwaits>,
|
|
81
104
|
event: WriteEvent<TPayload>,
|
|
82
105
|
): readonly StepInstance[] {
|
|
83
|
-
return pipelineDef.build({
|
|
106
|
+
return pipelineDef.build({
|
|
107
|
+
event,
|
|
108
|
+
r: stepBuilder,
|
|
109
|
+
awaits: (pipelineDef.awaits ?? {}) as TAwaits,
|
|
110
|
+
});
|
|
84
111
|
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Evaluator for the persisted r.step.waitForEvent `match` AST (EventMatch).
|
|
2
|
+
// Runtime-only: kept in the framework package (not packages/types) so it
|
|
3
|
+
// stays out of the type graph, same steps/-vs-packages/types split as
|
|
4
|
+
// _duration-utils.ts.
|
|
5
|
+
|
|
6
|
+
import type { EventMatch, EventMatchExpr, EventMatchOp } from "../types/step";
|
|
7
|
+
|
|
8
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
9
|
+
return typeof value === "object" && value !== null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function resolvePath(payload: unknown, path: readonly string[]): unknown {
|
|
13
|
+
let current: unknown = payload;
|
|
14
|
+
for (const segment of path) {
|
|
15
|
+
if (!isRecord(current)) return undefined;
|
|
16
|
+
current = current[segment];
|
|
17
|
+
}
|
|
18
|
+
return current;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function compareOrdered(
|
|
22
|
+
actual: number | string,
|
|
23
|
+
expected: number | string,
|
|
24
|
+
kind: "gt" | "gte" | "lt" | "lte",
|
|
25
|
+
): boolean {
|
|
26
|
+
if (typeof actual === "number" && typeof expected === "number") {
|
|
27
|
+
switch (kind) {
|
|
28
|
+
case "gt":
|
|
29
|
+
return actual > expected;
|
|
30
|
+
case "gte":
|
|
31
|
+
return actual >= expected;
|
|
32
|
+
case "lt":
|
|
33
|
+
return actual < expected;
|
|
34
|
+
case "lte":
|
|
35
|
+
return actual <= expected;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (typeof actual === "string" && typeof expected === "string") {
|
|
39
|
+
switch (kind) {
|
|
40
|
+
case "gt":
|
|
41
|
+
return actual > expected;
|
|
42
|
+
case "gte":
|
|
43
|
+
return actual >= expected;
|
|
44
|
+
case "lt":
|
|
45
|
+
return actual < expected;
|
|
46
|
+
case "lte":
|
|
47
|
+
return actual <= expected;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function evaluateOp(op: EventMatchOp, actual: unknown): boolean {
|
|
54
|
+
switch (op.kind) {
|
|
55
|
+
case "eq":
|
|
56
|
+
return actual === op.value;
|
|
57
|
+
case "ne":
|
|
58
|
+
return actual !== op.value;
|
|
59
|
+
case "in":
|
|
60
|
+
return op.values.some((value) => value === actual);
|
|
61
|
+
case "gt":
|
|
62
|
+
case "gte":
|
|
63
|
+
case "lt":
|
|
64
|
+
case "lte":
|
|
65
|
+
// A payload value of a different primitive kind than the operand
|
|
66
|
+
// doesn't match — bad input, not a malformed AST.
|
|
67
|
+
if (typeof actual !== "number" && typeof actual !== "string") return false;
|
|
68
|
+
return compareOrdered(actual, op.value, op.kind);
|
|
69
|
+
default: {
|
|
70
|
+
// op.kind comes from a persisted event, not from this call's static
|
|
71
|
+
// type — an unrecognized kind means a format we don't understand.
|
|
72
|
+
const unrecognized: { readonly kind: string } = op;
|
|
73
|
+
throw new Error(`Unknown EventMatchOp kind "${unrecognized.kind}"`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function evaluateExpr(expr: EventMatchExpr, payload: unknown): boolean {
|
|
79
|
+
switch (expr.kind) {
|
|
80
|
+
case "and":
|
|
81
|
+
return expr.nodes.every((node) => evaluateExpr(node, payload));
|
|
82
|
+
case "or":
|
|
83
|
+
return expr.nodes.some((node) => evaluateExpr(node, payload));
|
|
84
|
+
case "atom":
|
|
85
|
+
return evaluateOp(expr.op, resolvePath(payload, expr.path));
|
|
86
|
+
default: {
|
|
87
|
+
const unrecognized: { readonly kind: string } = expr;
|
|
88
|
+
throw new Error(`Unknown EventMatchExpr kind "${unrecognized.kind}"`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function evaluateEventMatch(match: EventMatch, payload: unknown): boolean {
|
|
94
|
+
if (match.version !== 1) {
|
|
95
|
+
throw new Error(`Unsupported EventMatch version ${JSON.stringify(match.version)} — expected 1`);
|
|
96
|
+
}
|
|
97
|
+
return evaluateExpr(match.expr, payload);
|
|
98
|
+
}
|
|
@@ -8,13 +8,29 @@
|
|
|
8
8
|
// it writes workflow.step.resumed with the matched event's data.
|
|
9
9
|
//
|
|
10
10
|
// The `match` resolver is optional — when omitted, any event of the given
|
|
11
|
-
// type resumes the workflow. When provided, it
|
|
12
|
-
//
|
|
11
|
+
// type resumes the workflow. When provided, it resolves to a serializable
|
|
12
|
+
// EventMatch AST (not a closure) that is persisted into the waiting event's
|
|
13
|
+
// payload; the Resume-Loop evaluates it via evaluateEventMatch against the
|
|
14
|
+
// candidate event's payload.
|
|
15
|
+
//
|
|
16
|
+
// resultKey is `args.event` itself (the branded event-type string) rather
|
|
17
|
+
// than a new `name` argument — a workflow only ever has one `awaits` entry
|
|
18
|
+
// per event type, so `ctx.steps[awaits.someKey]` is already a unique,
|
|
19
|
+
// intention-revealing handle. resume-run.write.ts (framework#2513 Phase 3b)
|
|
20
|
+
// is the only producer of this key's value: the step itself always
|
|
21
|
+
// suspends (never returns a real result), so a subsequent step only ever
|
|
22
|
+
// sees the matched event's payload after a resume seeds it in.
|
|
13
23
|
|
|
14
24
|
import { defineStep } from "../define-step";
|
|
15
|
-
import type {
|
|
25
|
+
import type {
|
|
26
|
+
AwaitedEventType,
|
|
27
|
+
EventMatch,
|
|
28
|
+
PipelineCtx,
|
|
29
|
+
StepInstance,
|
|
30
|
+
StepResolver,
|
|
31
|
+
} from "../types/step";
|
|
16
32
|
import { addDuration } from "./_duration-utils";
|
|
17
|
-
import { resolveRequired } from "./_resolver-utils";
|
|
33
|
+
import { resolveOptional, resolveRequired } from "./_resolver-utils";
|
|
18
34
|
import {
|
|
19
35
|
SUSPEND_SENTINEL,
|
|
20
36
|
WORKFLOW_AGGREGATE_TYPE,
|
|
@@ -22,8 +38,8 @@ import {
|
|
|
22
38
|
} from "./_step-dispatch-constants";
|
|
23
39
|
|
|
24
40
|
type WaitForEventArgs = {
|
|
25
|
-
readonly event:
|
|
26
|
-
readonly match?: StepResolver<
|
|
41
|
+
readonly event: AwaitedEventType;
|
|
42
|
+
readonly match?: StepResolver<EventMatch>;
|
|
27
43
|
readonly timeout: StepResolver<string>;
|
|
28
44
|
};
|
|
29
45
|
|
|
@@ -31,6 +47,7 @@ defineStep<WaitForEventArgs, undefined | typeof SUSPEND_SENTINEL>({
|
|
|
31
47
|
kind: "workflow.waitForEvent",
|
|
32
48
|
tier: 3,
|
|
33
49
|
defaultFailureStrategy: "throw",
|
|
50
|
+
resultKey: (args) => args.event,
|
|
34
51
|
run: async (args, ctx: PipelineCtx): Promise<undefined | typeof SUSPEND_SENTINEL> => {
|
|
35
52
|
if (!ctx.workflow) {
|
|
36
53
|
throw new Error(
|
|
@@ -40,6 +57,7 @@ defineStep<WaitForEventArgs, undefined | typeof SUSPEND_SENTINEL>({
|
|
|
40
57
|
}
|
|
41
58
|
|
|
42
59
|
const timeout = resolveRequired(args.timeout, ctx);
|
|
60
|
+
const resolvedMatch = resolveOptional(args.match, ctx);
|
|
43
61
|
|
|
44
62
|
const now = Temporal.Now.instant().toString();
|
|
45
63
|
const timeoutAt =
|
|
@@ -51,6 +69,7 @@ defineStep<WaitForEventArgs, undefined | typeof SUSPEND_SENTINEL>({
|
|
|
51
69
|
type: WORKFLOW_WAITING_FOR_EVENT_TYPE,
|
|
52
70
|
payload: {
|
|
53
71
|
eventType: args.event,
|
|
72
|
+
...(resolvedMatch && { match: resolvedMatch }),
|
|
54
73
|
timeoutAt,
|
|
55
74
|
stepIndex: ctx.workflow.stepIndex,
|
|
56
75
|
workflowName: ctx.workflow.workflowName,
|
|
@@ -90,6 +90,32 @@ describe("createFileContext", () => {
|
|
|
90
90
|
const h2 = files.ref("tenant/foo.pdf");
|
|
91
91
|
expect(Array.from(await h2.read())).toEqual([7]);
|
|
92
92
|
});
|
|
93
|
+
|
|
94
|
+
test("list delegates to the provider and returns its result", async () => {
|
|
95
|
+
const provider = createInMemoryFileProvider();
|
|
96
|
+
await provider.write("tenant/photo.jpg", new Uint8Array([1]));
|
|
97
|
+
await provider.write("tenant/photo.medium.jpg", new Uint8Array([2]));
|
|
98
|
+
await provider.write("other/photo.jpg", new Uint8Array([3]));
|
|
99
|
+
const files = createFileContext(() => Promise.resolve(provider));
|
|
100
|
+
|
|
101
|
+
const keys = await files.list("tenant/");
|
|
102
|
+
expect([...keys].sort()).toEqual(["tenant/photo.jpg", "tenant/photo.medium.jpg"].sort());
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("the provider is resolved once and memoized across ref() and list()", async () => {
|
|
106
|
+
const provider = createInMemoryFileProvider();
|
|
107
|
+
let resolveCount = 0;
|
|
108
|
+
const files = createFileContext(() => {
|
|
109
|
+
resolveCount++;
|
|
110
|
+
return Promise.resolve(provider);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
await files.ref("tenant/a.jpg").write(new Uint8Array([1]));
|
|
114
|
+
await files.list("tenant/");
|
|
115
|
+
await files.ref("tenant/b.jpg").write(new Uint8Array([2]));
|
|
116
|
+
|
|
117
|
+
expect(resolveCount).toBe(1);
|
|
118
|
+
});
|
|
93
119
|
});
|
|
94
120
|
|
|
95
121
|
describe("InMemoryFileProvider", () => {
|
package/src/files/file-handle.ts
CHANGED