@cosmicdrift/kumiko-framework 0.154.0 → 0.154.2

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 (46) hide show
  1. package/package.json +2 -2
  2. package/src/__tests__/full-stack.integration.test.ts +1 -1
  3. package/src/api/__tests__/batch.integration.test.ts +9 -9
  4. package/src/engine/__tests__/engine.test.ts +16 -0
  5. package/src/engine/__tests__/event-migration-declarative.test.ts +9 -2
  6. package/src/engine/__tests__/hook-phases.test.ts +5 -5
  7. package/src/engine/__tests__/post-query-hook.test.ts +7 -7
  8. package/src/engine/__tests__/registrar-object-form.test.ts +141 -0
  9. package/src/engine/define-feature.ts +22 -4
  10. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +15 -28
  11. package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +1 -2
  12. package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +1 -1
  13. package/src/engine/feature-ast/__tests__/parse.test.ts +55 -14
  14. package/src/engine/feature-ast/__tests__/patch.test.ts +8 -13
  15. package/src/engine/feature-ast/__tests__/patcher.test.ts +12 -22
  16. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +247 -5
  17. package/src/engine/feature-ast/extractors/index.ts +0 -3
  18. package/src/engine/feature-ast/extractors/round4.ts +113 -287
  19. package/src/engine/feature-ast/index.ts +0 -4
  20. package/src/engine/feature-ast/parse.ts +0 -6
  21. package/src/engine/feature-ast/patch.ts +35 -45
  22. package/src/engine/feature-ast/patcher.ts +18 -40
  23. package/src/engine/feature-ast/patterns.ts +20 -41
  24. package/src/engine/feature-ast/render.ts +17 -27
  25. package/src/engine/feature-config-events-jobs.ts +150 -74
  26. package/src/engine/feature-entity-handlers.ts +24 -6
  27. package/src/engine/feature-ui-extensions.ts +116 -45
  28. package/src/engine/object-form.ts +12 -0
  29. package/src/engine/pattern-library/__tests__/library.test.ts +0 -19
  30. package/src/engine/pattern-library/library.ts +0 -4
  31. package/src/engine/pattern-library/mixed-schemas.ts +9 -80
  32. package/src/engine/pattern-library/shared-fields.ts +1 -6
  33. package/src/engine/registry-ingest.ts +7 -2
  34. package/src/engine/registry-validate.ts +6 -6
  35. package/src/engine/types/feature.ts +76 -45
  36. package/src/engine/types/handlers.ts +6 -3
  37. package/src/engine/types/nav.ts +4 -0
  38. package/src/event-store/__tests__/upcaster.integration.test.ts +77 -45
  39. package/src/jobs/__tests__/jobs.integration.test.ts +66 -1
  40. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +2 -2
  41. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +1 -1
  42. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +16 -8
  43. package/src/pipeline/__tests__/post-query-hook.integration.test.ts +3 -3
  44. package/src/search/__tests__/meilisearch-adapter.integration.test.ts +200 -185
  45. package/src/search/__tests__/meilisearch-ids.test.ts +30 -0
  46. package/src/search/meilisearch-adapter.ts +13 -12
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.154.0",
3
+ "version": "0.154.2",
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.154.0",
196
+ "@cosmicdrift/kumiko-dispatcher-live": "0.154.2",
197
197
  "bun-types": "^1.3.13",
198
198
  "pino-pretty": "^13.1.3"
199
199
  },
@@ -182,7 +182,7 @@ const userFeature = defineFeature("users", (r) => {
182
182
  { access: { openToAll: true } },
183
183
  );
184
184
 
185
- r.entityHook("postSave", user, async (result) => {
185
+ r.hook("postSave", { allOf: user }, async (result) => {
186
186
  featurePostSaveLog.push(result);
187
187
  });
188
188
 
@@ -81,9 +81,9 @@ const itemFeature = defineFeature("batch", (r) => {
81
81
  );
82
82
 
83
83
  // Entity hook: inTransaction — records in memory
84
- r.entityHook(
84
+ r.hook(
85
85
  "postSave",
86
- item,
86
+ { allOf: item },
87
87
  async (result: SaveContext) => {
88
88
  inTxHookLog.push({ id: result.id, name: (result.data["name"] as string) ?? "" });
89
89
  },
@@ -92,9 +92,9 @@ const itemFeature = defineFeature("batch", (r) => {
92
92
 
93
93
  // Entity hook: inTransaction — writes to DB via ctx.db (the tx-scoped TenantDb).
94
94
  // Proves that hook DB writes roll back with the main transaction on failure.
95
- r.entityHook(
95
+ r.hook(
96
96
  "postSave",
97
- item,
97
+ { allOf: item },
98
98
  async (result, ctx) => {
99
99
  if (!ctx.db) return;
100
100
  await ctx.db.insertOne(auditTable, { action: "item_saved", itemId: result.id });
@@ -103,32 +103,32 @@ const itemFeature = defineFeature("batch", (r) => {
103
103
  );
104
104
 
105
105
  // Entity hook: afterCommit — records in memory (default phase)
106
- r.entityHook("postSave", item, async (result: SaveContext) => {
106
+ r.hook("postSave", { allOf: item }, async (result: SaveContext) => {
107
107
  afterCommitHookLog.push({ id: result.id, name: (result.data["name"] as string) ?? "" });
108
108
  });
109
109
 
110
110
  // Entity hook: afterCommit — may throw, used to verify error isolation
111
- r.entityHook("postSave", item, async () => {
111
+ r.hook("postSave", { allOf: item }, async () => {
112
112
  if (afterCommitShouldThrow) throw new Error("afterCommit_boom");
113
113
  });
114
114
 
115
115
  // Entity hook: afterCommit — runs AFTER the throwing one. Used to prove the
116
116
  // next hooks still fire despite the earlier failure.
117
- r.entityHook("postSave", item, async (result: SaveContext) => {
117
+ r.hook("postSave", { allOf: item }, async (result: SaveContext) => {
118
118
  afterCommitThirdHookRan.push((result.data["name"] as string) ?? "");
119
119
  });
120
120
 
121
121
  // Two hooks used by the parallelism test. Each records its start+end
122
122
  // timestamps so the assertion can compare intervals rather than elapsed
123
123
  // wall-clock time (which is timing-flaky on loaded CI boxes).
124
- r.entityHook("postSave", item, async (result: SaveContext) => {
124
+ r.hook("postSave", { allOf: item }, async (result: SaveContext) => {
125
125
  const name = result.data["name"] as string;
126
126
  if (!name?.startsWith("slowness-")) return;
127
127
  parallelismWindows.push({ hook: "A", start: Date.now() });
128
128
  await new Promise((r) => setTimeout(r, 80));
129
129
  parallelismWindows.push({ hook: "A", end: Date.now() });
130
130
  });
131
- r.entityHook("postSave", item, async (result: SaveContext) => {
131
+ r.hook("postSave", { allOf: item }, async (result: SaveContext) => {
132
132
  const name = result.data["name"] as string;
133
133
  if (!name?.startsWith("slowness-")) return;
134
134
  parallelismWindows.push({ hook: "B", start: Date.now() });
@@ -439,6 +439,22 @@ describe("createRegistry", () => {
439
439
  expect(all["profile:nav.title"]).toEqual({ de: "Profil", en: "Profile" });
440
440
  });
441
441
 
442
+ // #1105: a feature that already qualifies its own translation keys (nav
443
+ // labels referencing "featureName:..." verbatim, see cap-counter) must not
444
+ // be double-prefixed — else server-side t() can never resolve them.
445
+ test("does not double-prefix translation keys that already carry the feature's own namespace", () => {
446
+ const f = defineFeature("cap-counter", (r) => {
447
+ r.translations({
448
+ keys: { "cap-counter:nav.cap-list": { de: "Limits", en: "Caps" } },
449
+ });
450
+ });
451
+
452
+ const registry = createRegistry([f]);
453
+ const all = registry.getAllTranslations();
454
+ expect(all["cap-counter:nav.cap-list"]).toEqual({ de: "Limits", en: "Caps" });
455
+ expect(all["cap-counter:cap-counter:nav.cap-list"]).toBeUndefined();
456
+ });
457
+
442
458
  test("throws when write handler is not entity-mapped in feature with field-access", () => {
443
459
  const feature = defineFeature("hr", (r) => {
444
460
  r.entity(
@@ -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
  });
@@ -59,11 +59,11 @@ describe("HookPhases defaults", () => {
59
59
  expect(entry?.[0]?.phase).toBe(HookPhases.inTransaction);
60
60
  });
61
61
 
62
- test("entityHook postSave defaults to afterCommit, preDelete is forced inTransaction", () => {
62
+ test("hook({allOf}) postSave defaults to afterCommit, preDelete is forced inTransaction", () => {
63
63
  const feature = defineFeature("test", (r) => {
64
64
  const thing = r.entity("thing", createEntity({ table: "things", fields: {} }));
65
- r.entityHook("postSave", thing, noopSave);
66
- r.entityHook("preDelete", thing, async () => undefined);
65
+ r.hook("postSave", { allOf: thing }, noopSave);
66
+ r.hook("preDelete", { allOf: thing }, async () => undefined);
67
67
  });
68
68
 
69
69
  expect(feature.entityHooks?.postSave?.["thing"]?.[0]?.phase).toBe(HookPhases.afterCommit);
@@ -120,8 +120,8 @@ describe("Registry phase filtering", () => {
120
120
 
121
121
  const feature = defineFeature("test", (r) => {
122
122
  const thing = r.entity("thing", createEntity({ table: "things", fields: {} }));
123
- r.entityHook("postSave", thing, inTxFn, { phase: HookPhases.inTransaction });
124
- r.entityHook("postSave", thing, afterFn);
123
+ r.hook("postSave", { allOf: thing }, inTxFn, { phase: HookPhases.inTransaction });
124
+ r.hook("postSave", { allOf: thing }, afterFn);
125
125
  });
126
126
 
127
127
  const registry = createRegistry([feature]);
@@ -10,7 +10,7 @@ const stubContext = {} as unknown as AppContext;
10
10
  // postQuery-Hook (F1) — feuert nach Query-Handler-Execute, vor Field-Access-
11
11
  // Read-Filter. Zwei Registrierungs-Pfade:
12
12
  // - r.hook("postQuery", "ns:query:list", fn) — handler-keyed
13
- // - r.entityHook("postQuery", "thing", fn) — entity-keyed (alle Queries)
13
+ // - r.hook("postQuery", { allOf: "thing" }, fn) — entity-keyed (alle Queries)
14
14
  //
15
15
  // Diese Tests pinnen die Invarianten:
16
16
  // 1. Beide Registrierungs-Pfade landen in unabhängigen Maps
@@ -35,10 +35,10 @@ describe("postQuery hook registration", () => {
35
35
  expect(entry?.[0]?.featureName).toBe("test");
36
36
  });
37
37
 
38
- test("r.entityHook('postQuery', entity, fn) lands in entity-keyed entityHooks map", () => {
38
+ test("r.hook('postQuery', { allOf: entity }, fn) lands in entity-keyed entityHooks map", () => {
39
39
  const feature = defineFeature("test", (r) => {
40
40
  const thing = r.entity("thing", createEntity({ table: "things", fields: {} }));
41
- r.entityHook("postQuery", thing, noop);
41
+ r.hook("postQuery", { allOf: thing }, noop);
42
42
  });
43
43
 
44
44
  const entry = feature.entityHooks?.postQuery?.["thing"];
@@ -52,8 +52,8 @@ describe("postQuery hook registration", () => {
52
52
 
53
53
  const feature = defineFeature("test", (r) => {
54
54
  const thing = r.entity("thing", createEntity({ table: "things", fields: {} }));
55
- r.entityHook("postQuery", thing, hookA);
56
- r.entityHook("postQuery", thing, hookB);
55
+ r.hook("postQuery", { allOf: thing }, hookA);
56
+ r.hook("postQuery", { allOf: thing }, hookB);
57
57
  });
58
58
 
59
59
  expect(feature.entityHooks?.postQuery?.["thing"]).toHaveLength(2);
@@ -81,7 +81,7 @@ describe("Registry getters", () => {
81
81
 
82
82
  const feature = defineFeature("test", (r) => {
83
83
  const thing = r.entity("thing", createEntity({ table: "things", fields: {} }));
84
- r.entityHook("postQuery", thing, fn);
84
+ r.hook("postQuery", { allOf: thing }, fn);
85
85
  });
86
86
 
87
87
  const registry = createRegistry([feature]);
@@ -109,7 +109,7 @@ describe("Hook function semantics", () => {
109
109
 
110
110
  const feature = defineFeature("test", (r) => {
111
111
  const thing = r.entity("thing", createEntity({ table: "things", fields: {} }));
112
- r.entityHook("postQuery", thing, enrich);
112
+ r.hook("postQuery", { allOf: thing }, enrich);
113
113
  });
114
114
 
115
115
  const registry = createRegistry([feature]);
@@ -0,0 +1,141 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { defineFeature } from "../index";
3
+
4
+ // Object-Form is the shape the feature-ast renderer emits for Designer/
5
+ // AI-generated code (`r.entity({ name: "item", ... })` instead of
6
+ // `r.entity("item", { ... })`) — see kumiko-framework#1113. The compile
7
+ // checks in feature-ast/__tests__/render-roundtrip.test.ts prove the TYPES
8
+ // line up; they never execute the runtime dispatch that unpacks Object-Form
9
+ // back into the same state mutations as the positional form. These tests
10
+ // cover that: one per distinct dispatch shape, asserting object-form and
11
+ // positional-form produce identical FeatureDefinition state.
12
+
13
+ describe("registrar Object-Form — runtime parity with positional form", () => {
14
+ test("requires: array-unwrap ({ features }) matches variadic-string form", () => {
15
+ const positional = defineFeature("a", (r) => {
16
+ r.requires("auth", "tenant");
17
+ });
18
+ const objectForm = defineFeature("b", (r) => {
19
+ r.requires({ features: ["auth", "tenant"] });
20
+ });
21
+ expect(objectForm.requires).toEqual(positional.requires);
22
+ });
23
+
24
+ test("optionalRequires: array-unwrap matches variadic-string form", () => {
25
+ const positional = defineFeature("a", (r) => {
26
+ r.optionalRequires("promotions");
27
+ });
28
+ const objectForm = defineFeature("b", (r) => {
29
+ r.optionalRequires({ features: ["promotions"] });
30
+ });
31
+ expect(objectForm.optionalRequires).toEqual(positional.optionalRequires);
32
+ });
33
+
34
+ test("readsConfig: array-unwrap ({ keys }) matches variadic-string form", () => {
35
+ const positional = defineFeature("a", (r) => {
36
+ r.readsConfig("auth.smtpHost");
37
+ });
38
+ const objectForm = defineFeature("b", (r) => {
39
+ r.readsConfig({ keys: ["auth.smtpHost"] });
40
+ });
41
+ expect(objectForm.configReads).toEqual(positional.configReads);
42
+ });
43
+
44
+ test("entity: split-name ({ name, ...fields }) matches (name, definition) form", () => {
45
+ const definition = { fields: { title: { type: "text" as const, required: true } } };
46
+ const positional = defineFeature("a", (r) => {
47
+ r.entity("item", definition);
48
+ });
49
+ const objectForm = defineFeature("b", (r) => {
50
+ r.entity({ name: "item", ...definition });
51
+ });
52
+ expect(objectForm.entities).toEqual(positional.entities);
53
+ });
54
+
55
+ test("metric: split-name matches (shortName, options) form", () => {
56
+ const positional = defineFeature("a", (r) => {
57
+ r.metric("created", { type: "counter" });
58
+ });
59
+ const objectForm = defineFeature("b", (r) => {
60
+ r.metric({ name: "created", type: "counter" });
61
+ });
62
+ expect(objectForm.metrics).toEqual(positional.metrics);
63
+ });
64
+
65
+ test("secret: split-name matches (shortName, options) form (same feature name for qualifiedName parity)", () => {
66
+ const positional = defineFeature("stripe", (r) => {
67
+ r.secret("apiKey", { label: { en: "Stripe API Key" }, scope: "tenant" });
68
+ });
69
+ const objectForm = defineFeature("stripe", (r) => {
70
+ r.secret({ name: "apiKey", label: { en: "Stripe API Key" }, scope: "tenant" });
71
+ });
72
+ expect(objectForm.secretKeys).toEqual(positional.secretKeys);
73
+ });
74
+
75
+ test("relation: two-field ({ entity, name, ...def }) matches (entity, name, definition) form", () => {
76
+ const definition = { type: "belongsTo" as const, target: "user", foreignKey: "supplierId" };
77
+ const positional = defineFeature("a", (r) => {
78
+ r.entity("item", { fields: {} });
79
+ r.relation("item", "supplier", definition);
80
+ });
81
+ const objectForm = defineFeature("b", (r) => {
82
+ r.entity("item", { fields: {} });
83
+ r.relation({ entity: "item", name: "supplier", ...definition });
84
+ });
85
+ expect(objectForm.relations).toEqual(positional.relations);
86
+ });
87
+
88
+ test("referenceData: two-field ({ entity, data, ...opts }) matches (entity, data, options) form", () => {
89
+ const data = [{ id: "a", label: "A" }];
90
+ const positional = defineFeature("a", (r) => {
91
+ r.referenceData("category", data, { upsertKey: "id" });
92
+ });
93
+ const objectForm = defineFeature("b", (r) => {
94
+ r.referenceData({ entity: "category", data, upsertKey: "id" });
95
+ });
96
+ expect(objectForm.referenceData).toEqual(positional.referenceData);
97
+ });
98
+
99
+ test("useExtension: two-field ({ name, entity, ...opts }) matches (extensionName, entity, options) form", () => {
100
+ const positional = defineFeature("a", (r) => {
101
+ r.useExtension("audit-log", "item", { verbose: true });
102
+ });
103
+ const objectForm = defineFeature("b", (r) => {
104
+ r.useExtension({ name: "audit-log", entity: "item", verbose: true });
105
+ });
106
+ expect(objectForm.extensionUsages).toEqual(positional.extensionUsages);
107
+ });
108
+
109
+ test("claimKey: generic split-name matches (shortName, options) form", () => {
110
+ const positional = defineFeature("drivers", (r) => {
111
+ r.claimKey("teamId", { type: "string" });
112
+ });
113
+ const objectForm = defineFeature("drivers", (r) => {
114
+ r.claimKey({ name: "teamId", type: "string" });
115
+ });
116
+ expect(objectForm.claimKeys).toEqual(positional.claimKeys);
117
+ });
118
+
119
+ test("job: full-definition object matches (name, options, handler) form", () => {
120
+ const handler = async () => {};
121
+ const positional = defineFeature("a", (r) => {
122
+ r.job("reconcile", { trigger: { manual: true } }, handler);
123
+ });
124
+ const objectForm = defineFeature("b", (r) => {
125
+ r.job({ name: "reconcile", trigger: { manual: true }, handler });
126
+ });
127
+ expect(objectForm.jobs).toEqual(positional.jobs);
128
+ });
129
+
130
+ test("notification: split-name matches (name, definition) form", () => {
131
+ const recipient = () => null;
132
+ const data = () => ({});
133
+ const positional = defineFeature("a", (r) => {
134
+ r.notification("itemLowStock", { trigger: { on: "item:lowStock" }, recipient, data });
135
+ });
136
+ const objectForm = defineFeature("b", (r) => {
137
+ r.notification({ name: "itemLowStock", trigger: { on: "item:lowStock" }, recipient, data });
138
+ });
139
+ expect(objectForm.notifications).toEqual(positional.notifications);
140
+ });
141
+ });
@@ -18,6 +18,20 @@ import type { RequiresApi } from "./types/feature";
18
18
  // that don't care can keep the default-string and use the wrapper-based
19
19
  // strict-mode (string-literal types per call-site) like before.
20
20
 
21
+ // requires/optionalRequires accept either variadic strings (hand-written
22
+ // call sites) or a single `{ features }` object (the feature-ast renderer's
23
+ // canonical Object-Form for Designer/AI-generated code) — see the matching
24
+ // overloads on RequiresApi / FeatureRegistrar['optionalRequires'].
25
+ function resolveFeatureNamesArgs(
26
+ args: readonly [{ readonly features: readonly string[] }] | readonly string[],
27
+ ): readonly string[] {
28
+ const [first] = args;
29
+ if (typeof first === "object" && first !== null && "features" in first) {
30
+ return first.features;
31
+ }
32
+ return args as readonly string[];
33
+ }
34
+
21
35
  export function defineFeature<const TName extends string, TExports = undefined>(
22
36
  name: TName,
23
37
  setup: (r: FeatureRegistrar<TName>) => TExports,
@@ -40,8 +54,10 @@ export function defineFeature<const TName extends string, TExports = undefined>(
40
54
  state.description = text.trim();
41
55
  },
42
56
  requires: (() => {
43
- const fn = (...featureNames: string[]) => {
44
- state.requires.push(...featureNames);
57
+ const fn = (
58
+ ...args: readonly [{ readonly features: readonly string[] }] | readonly string[]
59
+ ) => {
60
+ state.requires.push(...resolveFeatureNamesArgs(args));
45
61
  };
46
62
  fn.projection = (tableName: string) => {
47
63
  state.requiredProjections.add(tableName);
@@ -51,8 +67,10 @@ export function defineFeature<const TName extends string, TExports = undefined>(
51
67
  };
52
68
  return fn as RequiresApi;
53
69
  })(),
54
- optionalRequires(...featureNames: string[]): void {
55
- state.optionalRequires.push(...featureNames);
70
+ optionalRequires(
71
+ ...args: readonly [{ readonly features: readonly string[] }] | readonly string[]
72
+ ): void {
73
+ state.optionalRequires.push(...resolveFeatureNamesArgs(args));
56
74
  },
57
75
  toggleable(options: { default: boolean }): void {
58
76
  if (state.toggleableDefault !== undefined) {
@@ -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({
@@ -83,9 +86,9 @@ defineFeature("todoList", (r) => {
83
86
  },
84
87
  });
85
88
 
86
- r.entityHook({
89
+ r.hook({
87
90
  type: "postDelete",
88
- entity: "task",
91
+ target: { allOf: "task" },
89
92
  handler: async (event, ctx) => {
90
93
  console.log("task deleted");
91
94
  },
@@ -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 * * *" },
@@ -224,7 +220,6 @@ describe("Canonical Object-Form — parser akzeptiert + extrahiert", () => {
224
220
  "writeHandler",
225
221
  "queryHandler",
226
222
  "hook",
227
- "entityHook",
228
223
  "job",
229
224
  "notification",
230
225
  "authClaims",
@@ -232,7 +227,6 @@ describe("Canonical Object-Form — parser akzeptiert + extrahiert", () => {
232
227
  "projection",
233
228
  "multiStreamProjection",
234
229
  "defineEvent",
235
- "eventMigration",
236
230
  ];
237
231
  for (const e of expected) {
238
232
  expect(kinds.has(e), `expected kind "${e}"`).toBe(true);
@@ -265,12 +259,14 @@ describe("Canonical Object-Form — parser akzeptiert + extrahiert", () => {
265
259
  });
266
260
  });
267
261
 
268
- test("entityHook Pattern: type + entity aus Object-Form", () => {
269
- const entityHook = result.patterns.find((p) => p.kind === "entityHook");
262
+ test("hook Pattern: entity-wide { allOf } target aus Object-Form", () => {
263
+ const entityHook = result.patterns.find(
264
+ (p) => p.kind === "hook" && p.hookType === "postDelete",
265
+ );
270
266
  expect(entityHook).toMatchObject({
271
- kind: "entityHook",
267
+ kind: "hook",
272
268
  hookType: "postDelete",
273
- entityName: "task",
269
+ target: { allOf: "task" },
274
270
  });
275
271
  });
276
272
 
@@ -283,12 +279,13 @@ describe("Canonical Object-Form — parser akzeptiert + extrahiert", () => {
283
279
  expect(claim).toMatchObject({ kind: "claimKey", shortName: "teamId" });
284
280
  });
285
281
 
286
- test("defineEvent: name + version aus Object-Form", () => {
282
+ test("defineEvent: name + version + migrations aus Object-Form", () => {
287
283
  const ev = result.patterns.find((p) => p.kind === "defineEvent");
288
284
  expect(ev).toMatchObject({
289
285
  kind: "defineEvent",
290
286
  eventName: "taskCompleted",
291
- version: 1,
287
+ version: 2,
288
+ migrations: { "1": expect.anything() },
292
289
  });
293
290
  });
294
291
 
@@ -334,16 +331,6 @@ describe("Canonical Object-Form — parser akzeptiert + extrahiert", () => {
334
331
  });
335
332
  });
336
333
 
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
334
  test("job: name + handlerBody.raw enthält den Closure-Body", () => {
348
335
  const job = result.patterns.find((p) => p.kind === "job");
349
336
  expect(job).toMatchObject({ kind: "job", jobName: "cleanupExpired" });
@@ -71,7 +71,7 @@ defineFeature("todoList", (r) => {
71
71
  console.log("task saved");
72
72
  });
73
73
 
74
- r.entityHook("postDelete", "task", async (event, ctx) => {
74
+ r.hook("postDelete", { allOf: "task" }, async (event, ctx) => {
75
75
  console.log("task deleted");
76
76
  });
77
77
 
@@ -136,7 +136,6 @@ describe("parseSourceFile against a complete inline-form feature", () => {
136
136
  "writeHandler",
137
137
  "queryHandler",
138
138
  "hook",
139
- "entityHook",
140
139
  "metric",
141
140
  "secret",
142
141
  "claimKey",
@@ -68,7 +68,7 @@ const FEATURES: readonly RealFeature[] = [
68
68
  {
69
69
  path: "packages/bundled-features/src/sessions/feature.ts",
70
70
  expectedFeatureName: "sessions",
71
- recognisedKinds: ["entityHook", "writeHandler", "queryHandler"],
71
+ recognisedKinds: ["hook", "writeHandler", "queryHandler"],
72
72
  errorMethodNames: ["unmanagedTable", "job"],
73
73
  },
74
74
  {
@@ -784,29 +784,43 @@ defineFeature("f", (r) => {
784
784
  });
785
785
  });
786
786
 
787
- describe("extractEntityHook", () => {
788
- test("captures hookType, entity and the function body", () => {
787
+ describe("extractHook — entity-wide { allOf } target", () => {
788
+ test("captures hookType and the allOf entity from positional form", () => {
789
789
  const result = parseInline(`
790
790
  defineFeature("f", (r) => {
791
- r.entityHook("postSave", "task", (event, ctx) => {});
791
+ r.hook("postSave", { allOf: "task" }, (event, ctx) => {});
792
792
  });
793
793
  `);
794
794
 
795
795
  expect(result.patterns[0]).toMatchObject({
796
- kind: "entityHook",
796
+ kind: "hook",
797
797
  hookType: "postSave",
798
- entityName: "task",
798
+ target: { allOf: "task" },
799
+ });
800
+ });
801
+
802
+ test("captures hookType and the allOf entity from object form", () => {
803
+ const result = parseInline(`
804
+ defineFeature("f", (r) => {
805
+ r.hook({ type: "postDelete", target: { allOf: "task" }, handler: () => {} });
806
+ });
807
+ `);
808
+
809
+ expect(result.patterns[0]).toMatchObject({
810
+ kind: "hook",
811
+ hookType: "postDelete",
812
+ target: { allOf: "task" },
799
813
  });
800
814
  });
801
815
 
802
- test("rejects validation as entity-hook type (only postSave/preDelete/postDelete allowed)", () => {
816
+ test("rejects a malformed allOf value (not a string/ref)", () => {
803
817
  const result = parseInline(`
804
818
  defineFeature("f", (r) => {
805
- r.entityHook("validation", "task", () => {});
819
+ r.hook("postSave", { allOf: 123 }, () => {});
806
820
  });
807
821
  `);
808
822
 
809
- expect(result.errors[0]?.methodName).toBe("entityHook");
823
+ expect(result.errors[0]?.methodName).toBe("hook");
810
824
  });
811
825
  });
812
826
 
@@ -961,19 +975,46 @@ defineFeature("f", (r) => {
961
975
  });
962
976
  });
963
977
 
964
- describe("extractEventMigration", () => {
965
- test("captures fromVersion / toVersion / transform body", () => {
978
+ describe("extractDefineEvent — migrations (formerly extractEventMigration)", () => {
979
+ test("positional-form migrations array captures the fromVersion→transform step", () => {
980
+ const result = parseInline(`
981
+ defineFeature("f", (r) => {
982
+ r.defineEvent("incidentOpened", z.object({ id: z.string() }), {
983
+ version: 2,
984
+ migrations: [
985
+ { fromVersion: 1, toVersion: 2, transform: (payload) => ({ ...payload, severity: "low" }) },
986
+ ],
987
+ });
988
+ });
989
+ `);
990
+
991
+ expect(result.patterns[0]).toMatchObject({
992
+ kind: "defineEvent",
993
+ eventName: "incidentOpened",
994
+ version: 2,
995
+ migrations: { "1": expect.anything() },
996
+ });
997
+ });
998
+
999
+ test("object-form migrations map captures the fromVersion→transform step", () => {
966
1000
  const result = parseInline(`
967
1001
  defineFeature("f", (r) => {
968
- r.eventMigration("incidentOpened", 1, 2, (payload) => ({ ...payload, severity: "low" }));
1002
+ r.defineEvent({
1003
+ name: "incidentOpened",
1004
+ schema: z.object({ id: z.string() }),
1005
+ version: 2,
1006
+ migrations: {
1007
+ "1": (payload) => ({ ...payload, severity: "low" }),
1008
+ },
1009
+ });
969
1010
  });
970
1011
  `);
971
1012
 
972
1013
  expect(result.patterns[0]).toMatchObject({
973
- kind: "eventMigration",
1014
+ kind: "defineEvent",
974
1015
  eventName: "incidentOpened",
975
- fromVersion: 1,
976
- toVersion: 2,
1016
+ version: 2,
1017
+ migrations: { "1": expect.anything() },
977
1018
  });
978
1019
  });
979
1020
  });