@cosmicdrift/kumiko-framework 0.229.1 → 0.231.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.229.1",
3
+ "version": "0.231.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -198,7 +198,7 @@
198
198
  "./package.json": "./package.json"
199
199
  },
200
200
  "dependencies": {
201
- "@cosmicdrift/kumiko-types": "0.229.1",
201
+ "@cosmicdrift/kumiko-types": "0.231.0",
202
202
  "bullmq": "^5.76.7",
203
203
  "bun-types": "^1.3.13",
204
204
  "hono": "^4.13.1",
@@ -214,7 +214,7 @@
214
214
  "zod": "^4.4.3"
215
215
  },
216
216
  "devDependencies": {
217
- "@cosmicdrift/kumiko-dispatcher-live": "0.229.1",
217
+ "@cosmicdrift/kumiko-dispatcher-live": "0.231.0",
218
218
  "bun-types": "^1.3.13",
219
219
  "pino-pretty": "^13.1.3"
220
220
  },
@@ -82,16 +82,6 @@ describe("KUMIKO_SUB_PROCESSORS", () => {
82
82
  "sccRequired": false,
83
83
  "status": "active",
84
84
  },
85
- {
86
- "appliesTo": [
87
- "all-tiers",
88
- ],
89
- "name": "Heinlein Hosting (Mailbox.org)",
90
- "optInOnly": false,
91
- "region": "EU (Germany)",
92
- "sccRequired": false,
93
- "status": "active",
94
- },
95
85
  {
96
86
  "appliesTo": [
97
87
  "business",
@@ -101,7 +91,7 @@ describe("KUMIKO_SUB_PROCESSORS", () => {
101
91
  "optInOnly": true,
102
92
  "region": "US",
103
93
  "sccRequired": true,
104
- "status": "planned",
94
+ "status": "active",
105
95
  },
106
96
  {
107
97
  "appliesTo": [
@@ -111,7 +101,17 @@ describe("KUMIKO_SUB_PROCESSORS", () => {
111
101
  "optInOnly": false,
112
102
  "region": "Global (US-headquartered)",
113
103
  "sccRequired": true,
114
- "status": "planned",
104
+ "status": "active",
105
+ },
106
+ {
107
+ "appliesTo": [
108
+ "all-tiers",
109
+ ],
110
+ "name": "ALL-INKL.COM – Neue Medien Münnich",
111
+ "optInOnly": false,
112
+ "region": "EU (Germany)",
113
+ "sccRequired": false,
114
+ "status": "active",
115
115
  },
116
116
  ]
117
117
  `);
@@ -101,15 +101,6 @@ export const KUMIKO_SUB_PROCESSORS: readonly SubProcessor[] = [
101
101
  appliesTo: ["standard", "business", "enterprise"],
102
102
  status: "active",
103
103
  },
104
- {
105
- name: "Heinlein Hosting (Mailbox.org)",
106
- purpose: "Marketing Email Delivery",
107
- region: "EU (Germany)",
108
- dpa: "https://mailbox.org/de/datenschutzerklaerung",
109
- addedAt: "2024-03-01",
110
- appliesTo: ["all-tiers"],
111
- status: "active",
112
- },
113
104
  {
114
105
  name: "Anthropic PBC",
115
106
  purpose: "AI Model Inference (L2 Composition Layer, AI-Foundation)",
@@ -120,7 +111,7 @@ export const KUMIKO_SUB_PROCESSORS: readonly SubProcessor[] = [
120
111
  sccRequired: true,
121
112
  optInOnly: true,
122
113
  hipaaBaaAvailable: true,
123
- status: "planned",
114
+ status: "active",
124
115
  },
125
116
  {
126
117
  name: "Stripe, Inc.",
@@ -130,7 +121,16 @@ export const KUMIKO_SUB_PROCESSORS: readonly SubProcessor[] = [
130
121
  addedAt: "2026-06-01",
131
122
  appliesTo: ["all-tiers"],
132
123
  sccRequired: true,
133
- status: "planned",
124
+ status: "active",
125
+ },
126
+ {
127
+ name: "ALL-INKL.COM – Neue Medien Münnich",
128
+ purpose: "Mailbox Hosting (support and contact mailboxes)",
129
+ region: "EU (Germany)",
130
+ dpa: "https://all-inkl.com/members/avv_muster_print.php",
131
+ addedAt: "2026-09-02",
132
+ appliesTo: ["all-tiers"],
133
+ status: "active",
134
134
  },
135
135
  ];
136
136
 
@@ -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,7 @@ 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 { EventMatch, PipelineCtx } from "../types/step";
13
14
 
14
15
  const mockUnsafeAppendEvent = mock();
15
16
 
@@ -96,10 +97,14 @@ describe("buildWaitForEventStep", () => {
96
97
  expect(step.kind).toBe("workflow.waitForEvent");
97
98
  });
98
99
 
99
- it("accepts an optional match resolver", () => {
100
+ it("accepts an optional match AST", () => {
101
+ const match: EventMatch = {
102
+ version: 1,
103
+ expr: { kind: "atom", path: ["email"], op: { kind: "eq", value: "test@test.com" } },
104
+ };
100
105
  const step = buildWaitForEventStep({
101
106
  event: "user.confirmed-email",
102
- match: (payload: unknown) => (payload as { email: string }).email === "test@test.com",
107
+ match,
103
108
  timeout: "P7D",
104
109
  });
105
110
  expect(step.kind).toBe("workflow.waitForEvent");
@@ -138,6 +143,144 @@ describe("workflow.waitForEvent run", () => {
138
143
  expect(typeof eventArg.payload.timeoutAt).toBe("string");
139
144
  expect(eventArg.payload.workflowName).toBe("test-workflow");
140
145
  });
146
+
147
+ it("omits the match key from the payload when no match is given", async () => {
148
+ const stepDef = getStep("workflow.waitForEvent");
149
+ expect(stepDef).toBeDefined();
150
+
151
+ await stepDef!.run({ event: "user.confirmed-email", timeout: "P7D" }, workflowCtx);
152
+
153
+ const eventArg = mockUnsafeAppendEvent.mock.calls[0]![0];
154
+ expect("match" in eventArg.payload).toBe(false);
155
+ });
156
+
157
+ it("resolves a match resolver to a serializable AST that round-trips through JSON and evaluates against real payloads", async () => {
158
+ const stepDef = getStep("workflow.waitForEvent");
159
+ expect(stepDef).toBeDefined();
160
+
161
+ const result = await stepDef!.run(
162
+ {
163
+ event: "user.confirmed-email",
164
+ match: (ctx: PipelineCtx): EventMatch => ({
165
+ version: 1,
166
+ expr: {
167
+ kind: "atom",
168
+ path: ["email"],
169
+ op: { kind: "eq", value: (ctx.event.payload as { email: string }).email },
170
+ },
171
+ }),
172
+ timeout: "P7D",
173
+ },
174
+ workflowCtx,
175
+ );
176
+
177
+ expect(result).toBe(SUSPEND_SENTINEL);
178
+ const eventArg = mockUnsafeAppendEvent.mock.calls[0]![0];
179
+ const persistedMatch = eventArg.payload.match;
180
+
181
+ // Proves the persisted value is the resolved literal AST, not the resolver function.
182
+ expect(persistedMatch).toEqual({
183
+ version: 1,
184
+ expr: {
185
+ kind: "atom",
186
+ path: ["email"],
187
+ op: { kind: "eq", value: "test@example.com" },
188
+ },
189
+ });
190
+
191
+ // Proves serializability — the whole point of the AST switch — by round-tripping
192
+ // through JSON before feeding it to the evaluator, same as a rehydrated event would.
193
+ const roundTripped = JSON.parse(JSON.stringify(persistedMatch));
194
+ expect(evaluateEventMatch(roundTripped, { email: "test@example.com" })).toBe(true);
195
+ expect(evaluateEventMatch(roundTripped, { email: "someone-else@example.com" })).toBe(false);
196
+ });
197
+ });
198
+
199
+ describe("evaluateEventMatch", () => {
200
+ it("throws on an unsupported version", () => {
201
+ const stale = JSON.parse(
202
+ JSON.stringify({
203
+ version: 2,
204
+ expr: { kind: "atom", path: ["x"], op: { kind: "eq", value: 1 } },
205
+ }),
206
+ );
207
+ expect(() => evaluateEventMatch(stale, {})).toThrow(/version/i);
208
+ });
209
+
210
+ it("throws on an unrecognized expr kind", () => {
211
+ const bad = JSON.parse(JSON.stringify({ version: 1, expr: { kind: "not-a-kind" } }));
212
+ expect(() => evaluateEventMatch(bad, {})).toThrow(/kind/i);
213
+ });
214
+
215
+ it("throws on an unrecognized op kind", () => {
216
+ const bad = JSON.parse(
217
+ JSON.stringify({
218
+ version: 1,
219
+ expr: { kind: "atom", path: ["x"], op: { kind: "not-an-op" } },
220
+ }),
221
+ );
222
+ expect(() => evaluateEventMatch(bad, {})).toThrow(/kind/i);
223
+ });
224
+
225
+ it("and with empty nodes is true, or with empty nodes is false", () => {
226
+ expect(evaluateEventMatch({ version: 1, expr: { kind: "and", nodes: [] } }, {})).toBe(true);
227
+ expect(evaluateEventMatch({ version: 1, expr: { kind: "or", nodes: [] } }, {})).toBe(false);
228
+ });
229
+
230
+ it("combines atoms with and/or", () => {
231
+ const match: EventMatch = {
232
+ version: 1,
233
+ expr: {
234
+ kind: "and",
235
+ nodes: [
236
+ { kind: "atom", path: ["status"], op: { kind: "eq", value: "confirmed" } },
237
+ { kind: "atom", path: ["tier"], op: { kind: "in", values: ["gold", "platinum"] } },
238
+ ],
239
+ },
240
+ };
241
+ expect(evaluateEventMatch(match, { status: "confirmed", tier: "gold" })).toBe(true);
242
+ expect(evaluateEventMatch(match, { status: "confirmed", tier: "silver" })).toBe(false);
243
+ });
244
+
245
+ it("ne matches when the resolved value differs", () => {
246
+ const match: EventMatch = {
247
+ version: 1,
248
+ expr: { kind: "atom", path: ["status"], op: { kind: "ne", value: "cancelled" } },
249
+ };
250
+ expect(evaluateEventMatch(match, { status: "confirmed" })).toBe(true);
251
+ expect(evaluateEventMatch(match, { status: "cancelled" })).toBe(false);
252
+ });
253
+
254
+ it("compares numbers with gte", () => {
255
+ const match: EventMatch = {
256
+ version: 1,
257
+ expr: { kind: "atom", path: ["amount"], op: { kind: "gte", value: 100 } },
258
+ };
259
+ expect(evaluateEventMatch(match, { amount: 150 })).toBe(true);
260
+ expect(evaluateEventMatch(match, { amount: 50 })).toBe(false);
261
+ });
262
+
263
+ it("does not match a comparison op when the payload value's type differs from the operand", () => {
264
+ const match: EventMatch = {
265
+ version: 1,
266
+ expr: { kind: "atom", path: ["amount"], op: { kind: "gt", value: 100 } },
267
+ };
268
+ expect(evaluateEventMatch(match, { amount: "150" })).toBe(false);
269
+ });
270
+
271
+ it("resolves nested paths and treats a path running into a non-object as unmatched", () => {
272
+ const match: EventMatch = {
273
+ version: 1,
274
+ expr: {
275
+ kind: "atom",
276
+ path: ["user", "email"],
277
+ op: { kind: "eq", value: "a@b.com" },
278
+ },
279
+ };
280
+ expect(evaluateEventMatch(match, { user: { email: "a@b.com" } })).toBe(true);
281
+ expect(evaluateEventMatch(match, { user: "not-an-object" })).toBe(false);
282
+ expect(evaluateEventMatch(match, {})).toBe(false);
283
+ });
141
284
  });
142
285
 
143
286
  describe("buildRetryStep", () => {
@@ -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,
@@ -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,15 @@
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 receives the event payload
12
- // and must return true for the event to trigger resume.
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.
13
15
 
14
16
  import { defineStep } from "../define-step";
15
- import type { PipelineCtx, StepInstance, StepResolver } from "../types/step";
17
+ import type { EventMatch, PipelineCtx, StepInstance, StepResolver } from "../types/step";
16
18
  import { addDuration } from "./_duration-utils";
17
- import { resolveRequired } from "./_resolver-utils";
19
+ import { resolveOptional, resolveRequired } from "./_resolver-utils";
18
20
  import {
19
21
  SUSPEND_SENTINEL,
20
22
  WORKFLOW_AGGREGATE_TYPE,
@@ -23,7 +25,7 @@ import {
23
25
 
24
26
  type WaitForEventArgs = {
25
27
  readonly event: string;
26
- readonly match?: StepResolver<(payload: unknown) => boolean>;
28
+ readonly match?: StepResolver<EventMatch>;
27
29
  readonly timeout: StepResolver<string>;
28
30
  };
29
31
 
@@ -40,6 +42,7 @@ defineStep<WaitForEventArgs, undefined | typeof SUSPEND_SENTINEL>({
40
42
  }
41
43
 
42
44
  const timeout = resolveRequired(args.timeout, ctx);
45
+ const resolvedMatch = resolveOptional(args.match, ctx);
43
46
 
44
47
  const now = Temporal.Now.instant().toString();
45
48
  const timeoutAt =
@@ -51,6 +54,7 @@ defineStep<WaitForEventArgs, undefined | typeof SUSPEND_SENTINEL>({
51
54
  type: WORKFLOW_WAITING_FOR_EVENT_TYPE,
52
55
  payload: {
53
56
  eventType: args.event,
57
+ ...(resolvedMatch && { match: resolvedMatch }),
54
58
  timeoutAt,
55
59
  stepIndex: ctx.workflow.stepIndex,
56
60
  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", () => {
@@ -45,6 +45,7 @@ export function createFileContext(resolve: () => Promise<FileStorageProvider>):
45
45
  };
46
46
  return {
47
47
  ref: (key) => createFileHandle(key, getProvider),
48
+ list: async (prefix) => (await getProvider()).list(prefix),
48
49
  };
49
50
  }
50
51