@cosmicdrift/kumiko-framework 0.153.0 → 0.154.1

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.
Files changed (31) hide show
  1. package/package.json +2 -2
  2. package/src/__tests__/anonymous-access.integration.test.ts +25 -0
  3. package/src/engine/__tests__/boot-validator-action-wiring.test.ts +242 -0
  4. package/src/engine/__tests__/boot-validator.test.ts +7 -4
  5. package/src/engine/__tests__/event-migration-declarative.test.ts +9 -2
  6. package/src/engine/boot-validator/action-wiring.ts +99 -0
  7. package/src/engine/boot-validator/index.ts +7 -0
  8. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +7 -21
  9. package/src/engine/feature-ast/__tests__/parse.test.ts +33 -6
  10. package/src/engine/feature-ast/__tests__/patch.test.ts +8 -13
  11. package/src/engine/feature-ast/__tests__/patcher.test.ts +4 -14
  12. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +37 -1
  13. package/src/engine/feature-ast/extractors/index.ts +0 -1
  14. package/src/engine/feature-ast/extractors/round4.ts +92 -136
  15. package/src/engine/feature-ast/index.ts +0 -2
  16. package/src/engine/feature-ast/parse.ts +0 -3
  17. package/src/engine/feature-ast/patch.ts +0 -41
  18. package/src/engine/feature-ast/patcher.ts +14 -20
  19. package/src/engine/feature-ast/patterns.ts +10 -19
  20. package/src/engine/feature-ast/render.ts +10 -13
  21. package/src/engine/feature-config-events-jobs.ts +77 -51
  22. package/src/engine/pattern-library/__tests__/library.test.ts +0 -10
  23. package/src/engine/pattern-library/library.ts +0 -2
  24. package/src/engine/pattern-library/mixed-schemas.ts +8 -35
  25. package/src/engine/registry-validate.ts +6 -6
  26. package/src/engine/types/feature.ts +18 -17
  27. package/src/engine/types/handlers.ts +6 -3
  28. package/src/event-store/__tests__/upcaster.integration.test.ts +77 -45
  29. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +16 -8
  30. package/src/stack/redis.ts +8 -0
  31. package/src/stack/test-stack.ts +156 -136
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.153.0",
3
+ "version": "0.154.1",
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>",
@@ -193,7 +193,7 @@
193
193
  "zod": "^4.4.3"
194
194
  },
195
195
  "devDependencies": {
196
- "@cosmicdrift/kumiko-dispatcher-live": "0.153.0",
196
+ "@cosmicdrift/kumiko-dispatcher-live": "0.154.1",
197
197
  "bun-types": "^1.3.13",
198
198
  "pino-pretty": "^13.1.3"
199
199
  },
@@ -361,6 +361,31 @@ describe("anonymous access — resolverTrust: authoritative", () => {
361
361
  const body = (await res.json()) as { error: { code: string } };
362
362
  expect(body.error.code).toBe("tenant_mismatch");
363
363
  });
364
+
365
+ // pr-review kumiko-framework #1052/3 — resolveTenant treats header and
366
+ // cookie identically, but every case above only exercised the header.
367
+ // These two lock in that the cookie path behaves the same way.
368
+ test("kumiko_tenant cookie agreeing with the resolver → accepted", async () => {
369
+ const res = await stack.http.raw(
370
+ "POST",
371
+ "/api/query",
372
+ { type: "anonshop:query:product:list", payload: {} },
373
+ { Cookie: `kumiko_tenant=${TENANT_ID}` },
374
+ );
375
+ expect(res.status).toBe(200);
376
+ });
377
+
378
+ test("kumiko_tenant cookie disagreeing with the resolver → 400 tenant_mismatch, same as a disagreeing header", async () => {
379
+ const res = await stack.http.raw(
380
+ "POST",
381
+ "/api/query",
382
+ { type: "anonshop:query:product:list", payload: {} },
383
+ { Cookie: `kumiko_tenant=${OTHER_TENANT_ID}` },
384
+ );
385
+ expect(res.status).toBe(400);
386
+ const body = (await res.json()) as { error: { code: string } };
387
+ expect(body.error.code).toBe("tenant_mismatch");
388
+ });
364
389
  });
365
390
 
366
391
  describe("anonymous access — resolverTrust: authoritative, resolver returns null", () => {
@@ -0,0 +1,242 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { z } from "zod";
3
+ import { validateBoot } from "../boot-validator";
4
+ import { defineFeature } from "../define-feature";
5
+ import { createEntity, createTextField } from "../factories";
6
+
7
+ describe("validateBoot — action wiring (no function values)", () => {
8
+ test("rowAction writeHandler payload as function → Throw", () => {
9
+ const feature = defineFeature("shop", (r) => {
10
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
11
+ r.screen({
12
+ id: "product-list",
13
+ type: "entityList",
14
+ entity: "product",
15
+ columns: ["name"],
16
+ rowActions: [
17
+ {
18
+ kind: "writeHandler",
19
+ id: "sync",
20
+ label: "actions.sync",
21
+ handler: "shop:write:sync",
22
+ // biome-ignore lint/suspicious/noExplicitAny: intentional type violation under test
23
+ payload: ((row: unknown) => ({ id: row })) as any,
24
+ },
25
+ ],
26
+ });
27
+ r.writeHandler("sync", z.object({}), async () => ({ isSuccess: true as const, data: null }), {
28
+ access: { roles: ["Admin"] },
29
+ });
30
+ });
31
+ expect(() => validateBoot([feature])).toThrow(/rowAction "sync" payload is a function/);
32
+ });
33
+
34
+ test("rowAction writeHandler payload as declarative pick → kein Throw", () => {
35
+ const feature = defineFeature("shop", (r) => {
36
+ r.entity("product", createEntity({ fields: { name: createTextField({ sortable: true }) } }));
37
+ r.screen({
38
+ id: "product-list",
39
+ type: "entityList",
40
+ entity: "product",
41
+ columns: ["name"],
42
+ defaultSort: { field: "name", dir: "asc" },
43
+ rowActions: [
44
+ {
45
+ kind: "writeHandler",
46
+ id: "sync",
47
+ label: "actions.sync",
48
+ handler: "shop:write:sync",
49
+ payload: { pick: ["name"] },
50
+ },
51
+ ],
52
+ });
53
+ r.writeHandler("sync", z.object({}), async () => ({ isSuccess: true as const, data: null }), {
54
+ access: { roles: ["Admin"] },
55
+ });
56
+ r.translations({
57
+ keys: {
58
+ "screen:product-list.title": { de: "Liste", en: "List" },
59
+ "shop:entity:product:field:name": { de: "Name", en: "Name" },
60
+ },
61
+ });
62
+ });
63
+ expect(() => validateBoot([feature])).not.toThrow();
64
+ });
65
+
66
+ test("rowAction navigate visible as function → Throw", () => {
67
+ const feature = defineFeature("shop", (r) => {
68
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
69
+ r.screen({
70
+ id: "product-list",
71
+ type: "entityList",
72
+ entity: "product",
73
+ columns: ["name"],
74
+ rowActions: [
75
+ {
76
+ kind: "navigate",
77
+ id: "edit",
78
+ label: "actions.edit",
79
+ screen: "product-list",
80
+ // biome-ignore lint/suspicious/noExplicitAny: intentional type violation under test
81
+ visible: (() => true) as any,
82
+ },
83
+ ],
84
+ });
85
+ });
86
+ expect(() => validateBoot([feature])).toThrow(/rowAction "edit" visible is a function/);
87
+ });
88
+
89
+ test("toolbarAction writeHandler payload as function → Throw", () => {
90
+ const feature = defineFeature("shop", (r) => {
91
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
92
+ r.screen({
93
+ id: "product-list",
94
+ type: "entityList",
95
+ entity: "product",
96
+ columns: ["name"],
97
+ toolbarActions: [
98
+ {
99
+ kind: "writeHandler",
100
+ id: "sync",
101
+ label: "actions.sync",
102
+ handler: "shop:write:sync",
103
+ // biome-ignore lint/suspicious/noExplicitAny: intentional type violation under test
104
+ payload: (() => ({})) as any,
105
+ },
106
+ ],
107
+ });
108
+ r.writeHandler("sync", z.object({}), async () => ({ isSuccess: true as const, data: null }), {
109
+ access: { roles: ["Admin"] },
110
+ });
111
+ });
112
+ expect(() => validateBoot([feature])).toThrow(/toolbarAction "sync" payload is a function/);
113
+ });
114
+
115
+ test("entityList column renderer as function → Throw", () => {
116
+ const feature = defineFeature("shop", (r) => {
117
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
118
+ r.screen({
119
+ id: "product-list",
120
+ type: "entityList",
121
+ entity: "product",
122
+ columns: [
123
+ // biome-ignore lint/suspicious/noExplicitAny: intentional type violation under test
124
+ { field: "name", renderer: ((v: unknown) => String(v)) as any },
125
+ ],
126
+ });
127
+ });
128
+ expect(() => validateBoot([feature])).toThrow(/column "name" renderer is a function/);
129
+ });
130
+
131
+ test("entityEdit field visible as function → Throw", () => {
132
+ const feature = defineFeature("shop", (r) => {
133
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
134
+ r.screen({
135
+ id: "product-edit",
136
+ type: "entityEdit",
137
+ entity: "product",
138
+ layout: {
139
+ sections: [
140
+ {
141
+ columns: 1,
142
+ // biome-ignore lint/suspicious/noExplicitAny: intentional type violation under test
143
+ fields: [{ field: "name", visible: (() => true) as any }],
144
+ },
145
+ ],
146
+ },
147
+ });
148
+ });
149
+ expect(() => validateBoot([feature])).toThrow(/field "name" visible is a function/);
150
+ });
151
+
152
+ test("entityEdit field renderer as function → Throw", () => {
153
+ const feature = defineFeature("shop", (r) => {
154
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
155
+ r.screen({
156
+ id: "product-edit",
157
+ type: "entityEdit",
158
+ entity: "product",
159
+ layout: {
160
+ sections: [
161
+ {
162
+ columns: 1,
163
+ // biome-ignore lint/suspicious/noExplicitAny: intentional type violation under test
164
+ fields: [{ field: "name", renderer: ((v: unknown) => String(v)) as any }],
165
+ },
166
+ ],
167
+ },
168
+ });
169
+ });
170
+ expect(() => validateBoot([feature])).toThrow(/field "name" renderer is a function/);
171
+ });
172
+
173
+ test("entityEdit field with declarative visible/readOnly/required → kein Throw", () => {
174
+ const feature = defineFeature("shop", (r) => {
175
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
176
+ r.screen({
177
+ id: "product-edit",
178
+ type: "entityEdit",
179
+ entity: "product",
180
+ layout: {
181
+ sections: [
182
+ {
183
+ columns: 1,
184
+ fields: [
185
+ {
186
+ field: "name",
187
+ visible: { field: "name", ne: "" },
188
+ readOnly: false,
189
+ required: true,
190
+ },
191
+ ],
192
+ },
193
+ ],
194
+ },
195
+ });
196
+ r.translations({
197
+ keys: {
198
+ "screen:product-edit.title": { de: "Bearbeiten", en: "Edit" },
199
+ "shop:entity:product:field:name": { de: "Name", en: "Name" },
200
+ },
201
+ });
202
+ });
203
+ expect(() => validateBoot([feature])).not.toThrow();
204
+ });
205
+
206
+ test("projectionList column renderer as function → Throw", () => {
207
+ const feature = defineFeature("shop", (r) => {
208
+ r.screen({
209
+ id: "sales-list",
210
+ type: "projectionList",
211
+ query: "shop:query:sales",
212
+ columns: [
213
+ // biome-ignore lint/suspicious/noExplicitAny: intentional type violation under test
214
+ { field: "amount", renderer: ((v: unknown) => String(v)) as any },
215
+ ],
216
+ });
217
+ });
218
+ expect(() => validateBoot([feature])).toThrow(/column "amount" renderer is a function/);
219
+ });
220
+
221
+ test("projectionList rowAction payload as function → Throw", () => {
222
+ const feature = defineFeature("shop", (r) => {
223
+ r.screen({
224
+ id: "sales-list",
225
+ type: "projectionList",
226
+ query: "shop:query:sales",
227
+ columns: ["amount"],
228
+ rowActions: [
229
+ {
230
+ kind: "writeHandler",
231
+ id: "sync",
232
+ label: "actions.sync",
233
+ handler: "shop:write:sync",
234
+ // biome-ignore lint/suspicious/noExplicitAny: intentional type violation under test
235
+ payload: (() => ({})) as any,
236
+ },
237
+ ],
238
+ });
239
+ });
240
+ expect(() => validateBoot([feature])).toThrow(/rowAction "sync" payload is a function/);
241
+ });
242
+ });
@@ -1231,8 +1231,9 @@ describe("boot-validator", () => {
1231
1231
  // --- entityList column-renderer form-check ---
1232
1232
  // Validator akzeptiert die `{ react: { __component: "Name" } }`-Form
1233
1233
  // (PlatformComponent → client-side Registry-Lookup) und prüft sie
1234
- // strukturell. String-Funktionen, null/undefined, native-only und
1235
- // andere Formen bleiben opak.
1234
+ // strukturell. null/undefined, native-only und andere Formen bleiben
1235
+ // opak. Function-Renderer werfen (action-wiring.ts validateFieldWiring) —
1236
+ // von JSON.stringify verworfen, sonst stiller No-op auf dem Client.
1236
1237
  describe("entityList column renderer form", () => {
1237
1238
  function shopFeature(renderer: unknown) {
1238
1239
  return defineFeature("shop", (r) => {
@@ -1250,8 +1251,10 @@ describe("boot-validator", () => {
1250
1251
  });
1251
1252
  }
1252
1253
 
1253
- test("function-renderer → kein Throw (Bestand)", () => {
1254
- expect(() => validateBoot([shopFeature((v: unknown) => String(v))])).not.toThrow();
1254
+ test("function-renderer → Throw (Function wird von JSON.stringify verworfen)", () => {
1255
+ expect(() => validateBoot([shopFeature((v: unknown) => String(v))])).toThrow(
1256
+ /column "name" renderer is a function/,
1257
+ );
1255
1258
  });
1256
1259
 
1257
1260
  test("undefined renderer → kein Throw (Spalte ohne Renderer)", () => {
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { z } from "zod";
2
3
  import { defineFeature } from "../define-feature";
3
4
  import type { DeclarativeEventMigration, EventUpcastCtx } from "../types";
4
5
 
@@ -7,7 +8,10 @@ const upcastCtx = {} as EventUpcastCtx;
7
8
 
8
9
  function compile(spec: DeclarativeEventMigration) {
9
10
  const feature = defineFeature("billing", (r) => {
10
- r.eventMigration("invoicePaid", 1, 2, spec);
11
+ r.defineEvent("invoicePaid", z.unknown(), {
12
+ version: 2,
13
+ migrations: [{ fromVersion: 1, toVersion: 2, transform: spec }],
14
+ });
11
15
  });
12
16
  const def = feature.eventMigrations["invoicePaid"]?.[0];
13
17
  if (!def) throw new Error("migration not registered");
@@ -51,7 +55,10 @@ describe("declarative eventMigration", () => {
51
55
  test("imperative function variant is stored untouched", () => {
52
56
  const fn = (payload: unknown) => payload;
53
57
  const feature = defineFeature("billing", (r) => {
54
- r.eventMigration("invoicePaid", 1, 2, fn);
58
+ r.defineEvent("invoicePaid", z.unknown(), {
59
+ version: 2,
60
+ migrations: [{ fromVersion: 1, toVersion: 2, transform: fn }],
61
+ });
55
62
  });
56
63
  expect(feature.eventMigrations["invoicePaid"]?.[0]?.transform).toBe(fn);
57
64
  });
@@ -0,0 +1,99 @@
1
+ import type { EditFieldSpec, FeatureDefinition, RowAction, ToolbarAction } from "../types";
2
+ import { isExtensionEditSection, normalizeEditField, normalizeListColumn } from "../types/screen";
3
+
4
+ const FUNCTION_DROPPED_HINT =
5
+ "functions are dropped by JSON.stringify when the screen config reaches the client " +
6
+ "bundle — the action/field silently no-ops at runtime instead of failing loudly.";
7
+
8
+ function throwIfFunction(value: unknown, message: string): void {
9
+ if (typeof value === "function") {
10
+ throw new Error(message);
11
+ }
12
+ }
13
+
14
+ // rowActions/toolbarActions carry row-context extractors (payload/params),
15
+ // static visibility conditions and navigate entityId — all declarative-DSL-
16
+ // only fields (RowFieldExtractor's `{ pick }`/`{ map }`, FieldCondition's
17
+ // `{ field, eq }`/`{ field, ne }`, plain strings). A function literal here
18
+ // compiles fine (the DSL types are structural, not branded) but is silently
19
+ // dropped once the feature config is serialized for the client.
20
+ const ACTION_FUNCTION_FIELDS = ["payload", "params", "entityId", "visible"] as const;
21
+
22
+ function validateActionNoFunctions(
23
+ featureName: string,
24
+ screenId: string,
25
+ actionKind: "rowAction" | "toolbarAction",
26
+ action: RowAction | ToolbarAction,
27
+ ): void {
28
+ const record = action as unknown as Record<string, unknown>;
29
+ for (const field of ACTION_FUNCTION_FIELDS) {
30
+ throwIfFunction(
31
+ record[field],
32
+ `[Feature ${featureName}] Screen "${screenId}" ${actionKind} "${action.id}" ${field} ` +
33
+ `is a function — ${FUNCTION_DROPPED_HINT} Use the declarative DSL ({ pick }, { map }, ` +
34
+ `"fieldName", { field, eq }) instead.`,
35
+ );
36
+ }
37
+ }
38
+
39
+ export function validateActionWiring(feature: FeatureDefinition): void {
40
+ for (const screen of Object.values(feature.screens)) {
41
+ if (screen.type !== "entityList" && screen.type !== "projectionList") continue;
42
+ for (const action of screen.rowActions ?? []) {
43
+ validateActionNoFunctions(feature.name, screen.id, "rowAction", action);
44
+ }
45
+ for (const action of screen.toolbarActions ?? []) {
46
+ validateActionNoFunctions(feature.name, screen.id, "toolbarAction", action);
47
+ }
48
+ }
49
+ }
50
+
51
+ // EditFieldSpec's visible/readOnly/required are the same FieldCondition DSL
52
+ // as rowActions — same footgun, different screen type (entityEdit layouts).
53
+ const EDIT_FIELD_FUNCTION_FIELDS = ["visible", "readOnly", "required"] as const;
54
+
55
+ function validateEditFieldNoFunctions(
56
+ featureName: string,
57
+ screenId: string,
58
+ fieldSpec: EditFieldSpec,
59
+ ): void {
60
+ const normalized = normalizeEditField(fieldSpec);
61
+ const record = normalized as unknown as Record<string, unknown>;
62
+ for (const key of EDIT_FIELD_FUNCTION_FIELDS) {
63
+ throwIfFunction(
64
+ record[key],
65
+ `[Feature ${featureName}] Screen "${screenId}" (entityEdit) field "${normalized.field}" ` +
66
+ `${key} is a function — ${FUNCTION_DROPPED_HINT} Use a FieldCondition ` +
67
+ `(boolean or { field, eq }/{ field, ne }) instead.`,
68
+ );
69
+ }
70
+ throwIfFunction(
71
+ normalized.renderer,
72
+ `[Feature ${featureName}] Screen "${screenId}" (entityEdit) field "${normalized.field}" ` +
73
+ `renderer is a function — ${FUNCTION_DROPPED_HINT} Use a FormatSpec ({ format: "..." }) instead.`,
74
+ );
75
+ }
76
+
77
+ export function validateFieldWiring(feature: FeatureDefinition): void {
78
+ for (const screen of Object.values(feature.screens)) {
79
+ if (screen.type === "entityList" || screen.type === "projectionList") {
80
+ for (const col of screen.columns) {
81
+ const normalized = normalizeListColumn(col);
82
+ throwIfFunction(
83
+ normalized.renderer,
84
+ `[Feature ${feature.name}] Screen "${screen.id}" (${screen.type}) column ` +
85
+ `"${normalized.field}" renderer is a function — ${FUNCTION_DROPPED_HINT} ` +
86
+ `Use a FormatSpec ({ format: "..." }) instead.`,
87
+ );
88
+ }
89
+ continue;
90
+ }
91
+ if (screen.type !== "entityEdit") continue;
92
+ for (const section of screen.layout.sections) {
93
+ if (isExtensionEditSection(section)) continue;
94
+ for (const fieldSpec of section.fields) {
95
+ validateEditFieldNoFunctions(feature.name, screen.id, fieldSpec);
96
+ }
97
+ }
98
+ }
99
+ }
@@ -1,6 +1,7 @@
1
1
  import { validateEntityFieldEncryptionAvailable } from "../../db/entity-field-encryption";
2
2
  import { QnTypes, qualifyEntityName } from "../qualified-name";
3
3
  import type { ClaimKeyDefinition, FeatureDefinition } from "../types";
4
+ import { validateActionWiring, validateFieldWiring } from "./action-wiring";
4
5
  import { validateApiExposureMatching, validateExtensionUsages } from "./api-ext";
5
6
  import {
6
7
  validateCircularDeps,
@@ -171,6 +172,12 @@ export function validateBoot(features: readonly FeatureDefinition[]): void {
171
172
  validateConfigKeyBacking(feature);
172
173
  validateOwnershipRules(feature, allClaimKeys, knownRoles);
173
174
  validateMultiStreamProjections(feature);
175
+ // Vor validateScreens: dessen visible/entityId-Feldref-Checks werfen für
176
+ // einen Function-Wert bereits (mit verwirrender "unknown field undefined"-
177
+ // Message, weil `typeof fn !== "boolean"` true ist), bevor der klare
178
+ // Function-Check hier überhaupt liefe.
179
+ validateActionWiring(feature);
180
+ validateFieldWiring(feature);
174
181
  validateScreens(
175
182
  feature,
176
183
  featureMap,
@@ -56,7 +56,10 @@ defineFeature("todoList", (r) => {
56
56
  r.defineEvent({
57
57
  name: "taskCompleted",
58
58
  schema: z.object({ id: z.string() }),
59
- version: 1,
59
+ version: 2,
60
+ migrations: {
61
+ "1": (old) => ({ ...old, done: true }),
62
+ },
60
63
  });
61
64
 
62
65
  r.writeHandler({
@@ -127,13 +130,6 @@ defineFeature("todoList", (r) => {
127
130
 
128
131
  r.useExtension({ name: "auditLog", entity: "task" });
129
132
 
130
- r.eventMigration({
131
- event: "taskCompleted",
132
- fromVersion: 1,
133
- toVersion: 2,
134
- transform: (old) => ({ ...old, done: true }),
135
- });
136
-
137
133
  r.job({
138
134
  name: "cleanupExpired",
139
135
  schedule: { cron: "0 3 * * *" },
@@ -232,7 +228,6 @@ describe("Canonical Object-Form — parser akzeptiert + extrahiert", () => {
232
228
  "projection",
233
229
  "multiStreamProjection",
234
230
  "defineEvent",
235
- "eventMigration",
236
231
  ];
237
232
  for (const e of expected) {
238
233
  expect(kinds.has(e), `expected kind "${e}"`).toBe(true);
@@ -283,12 +278,13 @@ describe("Canonical Object-Form — parser akzeptiert + extrahiert", () => {
283
278
  expect(claim).toMatchObject({ kind: "claimKey", shortName: "teamId" });
284
279
  });
285
280
 
286
- test("defineEvent: name + version aus Object-Form", () => {
281
+ test("defineEvent: name + version + migrations aus Object-Form", () => {
287
282
  const ev = result.patterns.find((p) => p.kind === "defineEvent");
288
283
  expect(ev).toMatchObject({
289
284
  kind: "defineEvent",
290
285
  eventName: "taskCompleted",
291
- version: 1,
286
+ version: 2,
287
+ migrations: { "1": expect.anything() },
292
288
  });
293
289
  });
294
290
 
@@ -334,16 +330,6 @@ describe("Canonical Object-Form — parser akzeptiert + extrahiert", () => {
334
330
  });
335
331
  });
336
332
 
337
- test("eventMigration: event + fromVersion + toVersion aus Object-Form", () => {
338
- const em = result.patterns.find((p) => p.kind === "eventMigration");
339
- expect(em).toMatchObject({
340
- kind: "eventMigration",
341
- eventName: "taskCompleted",
342
- fromVersion: 1,
343
- toVersion: 2,
344
- });
345
- });
346
-
347
333
  test("job: name + handlerBody.raw enthält den Closure-Body", () => {
348
334
  const job = result.patterns.find((p) => p.kind === "job");
349
335
  expect(job).toMatchObject({ kind: "job", jobName: "cleanupExpired" });
@@ -961,19 +961,46 @@ defineFeature("f", (r) => {
961
961
  });
962
962
  });
963
963
 
964
- describe("extractEventMigration", () => {
965
- test("captures fromVersion / toVersion / transform body", () => {
964
+ describe("extractDefineEvent — migrations (formerly extractEventMigration)", () => {
965
+ test("positional-form migrations array captures the fromVersion→transform step", () => {
966
966
  const result = parseInline(`
967
967
  defineFeature("f", (r) => {
968
- r.eventMigration("incidentOpened", 1, 2, (payload) => ({ ...payload, severity: "low" }));
968
+ r.defineEvent("incidentOpened", z.object({ id: z.string() }), {
969
+ version: 2,
970
+ migrations: [
971
+ { fromVersion: 1, toVersion: 2, transform: (payload) => ({ ...payload, severity: "low" }) },
972
+ ],
973
+ });
969
974
  });
970
975
  `);
971
976
 
972
977
  expect(result.patterns[0]).toMatchObject({
973
- kind: "eventMigration",
978
+ kind: "defineEvent",
974
979
  eventName: "incidentOpened",
975
- fromVersion: 1,
976
- toVersion: 2,
980
+ version: 2,
981
+ migrations: { "1": expect.anything() },
982
+ });
983
+ });
984
+
985
+ test("object-form migrations map captures the fromVersion→transform step", () => {
986
+ const result = parseInline(`
987
+ defineFeature("f", (r) => {
988
+ r.defineEvent({
989
+ name: "incidentOpened",
990
+ schema: z.object({ id: z.string() }),
991
+ version: 2,
992
+ migrations: {
993
+ "1": (payload) => ({ ...payload, severity: "low" }),
994
+ },
995
+ });
996
+ });
997
+ `);
998
+
999
+ expect(result.patterns[0]).toMatchObject({
1000
+ kind: "defineEvent",
1001
+ eventName: "incidentOpened",
1002
+ version: 2,
1003
+ migrations: { "1": expect.anything() },
977
1004
  });
978
1005
  });
979
1006
  });
@@ -301,21 +301,16 @@ describe("patch coverage for the remaining pattern-kinds", () => {
301
301
  expect(parseSourceFile(sf).patterns.find((p) => p.kind === "authClaims")).toBeUndefined();
302
302
  });
303
303
 
304
- test("add + remove via PatternId for eventMigration (event+versions key)", () => {
304
+ test("add + remove via PatternId for defineEvent with migrations", () => {
305
305
  const sf = makeSourceFile(STARTER);
306
- createFeaturePatcher(sf).addEventMigration({
307
- event: "itemCreated",
308
- fromVersion: 1,
309
- toVersion: 2,
310
- transformSource: "(old) => old",
311
- });
312
- removePattern(sf, {
313
- kind: "eventMigration",
314
- eventName: "itemCreated",
315
- fromVersion: 1,
316
- toVersion: 2,
306
+ createFeaturePatcher(sf).addDefineEvent({
307
+ name: "itemCreated",
308
+ schemaSource: "z.object({ id: z.string() })",
309
+ version: 2,
310
+ migrations: { "1": "(old) => old" },
317
311
  });
318
- expect(parseSourceFile(sf).patterns.find((p) => p.kind === "eventMigration")).toBeUndefined();
312
+ removePattern(sf, { kind: "defineEvent", eventName: "itemCreated" });
313
+ expect(parseSourceFile(sf).patterns.find((p) => p.kind === "defineEvent")).toBeUndefined();
319
314
  });
320
315
  });
321
316
 
@@ -172,33 +172,23 @@ describe("FeaturePatcher — typed add helpers for mixed (closure-bearing) patte
172
172
  });
173
173
  });
174
174
 
175
- test("addDefineEvent + addEventMigration form a complete event-versioning chain", () => {
175
+ test("addDefineEvent with migrations forms a complete event-versioning chain", () => {
176
176
  const sf = makeSourceFile(STARTER);
177
177
  const p = createFeaturePatcher(sf);
178
178
  p.addDefineEvent({
179
179
  name: "stepCompleted",
180
180
  schemaSource: "z.object({ id: z.string() })",
181
181
  version: 2,
182
- });
183
- p.addEventMigration({
184
- event: "stepCompleted",
185
- fromVersion: 1,
186
- toVersion: 2,
187
- transformSource: '(old) => ({ id: old.id ?? "" })',
182
+ migrations: { "1": '(old) => ({ id: old.id ?? "" })' },
188
183
  });
189
184
  const result = parseSourceFile(sf);
190
185
  expect(result.errors).toEqual([]);
191
- expect(result.patterns).toHaveLength(2);
186
+ expect(result.patterns).toHaveLength(1);
192
187
  expect(result.patterns[0]).toMatchObject({
193
188
  kind: "defineEvent",
194
189
  eventName: "stepCompleted",
195
190
  version: 2,
196
- });
197
- expect(result.patterns[1]).toMatchObject({
198
- kind: "eventMigration",
199
- eventName: "stepCompleted",
200
- fromVersion: 1,
201
- toVersion: 2,
191
+ migrations: { "1": expect.anything() },
202
192
  });
203
193
  });
204
194
  });