@cosmicdrift/kumiko-framework 0.154.1 → 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 (40) 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__/hook-phases.test.ts +5 -5
  6. package/src/engine/__tests__/post-query-hook.test.ts +7 -7
  7. package/src/engine/__tests__/registrar-object-form.test.ts +141 -0
  8. package/src/engine/define-feature.ts +22 -4
  9. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +8 -7
  10. package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +1 -2
  11. package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +1 -1
  12. package/src/engine/feature-ast/__tests__/parse.test.ts +22 -8
  13. package/src/engine/feature-ast/__tests__/patcher.test.ts +8 -8
  14. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +210 -4
  15. package/src/engine/feature-ast/extractors/index.ts +0 -2
  16. package/src/engine/feature-ast/extractors/round4.ts +21 -151
  17. package/src/engine/feature-ast/index.ts +0 -2
  18. package/src/engine/feature-ast/parse.ts +0 -3
  19. package/src/engine/feature-ast/patch.ts +42 -11
  20. package/src/engine/feature-ast/patcher.ts +4 -20
  21. package/src/engine/feature-ast/patterns.ts +10 -22
  22. package/src/engine/feature-ast/render.ts +7 -14
  23. package/src/engine/feature-config-events-jobs.ts +74 -24
  24. package/src/engine/feature-entity-handlers.ts +24 -6
  25. package/src/engine/feature-ui-extensions.ts +116 -45
  26. package/src/engine/object-form.ts +12 -0
  27. package/src/engine/pattern-library/__tests__/library.test.ts +0 -9
  28. package/src/engine/pattern-library/library.ts +0 -2
  29. package/src/engine/pattern-library/mixed-schemas.ts +1 -45
  30. package/src/engine/pattern-library/shared-fields.ts +1 -6
  31. package/src/engine/registry-ingest.ts +7 -2
  32. package/src/engine/types/feature.ts +58 -28
  33. package/src/engine/types/nav.ts +4 -0
  34. package/src/jobs/__tests__/jobs.integration.test.ts +66 -1
  35. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +2 -2
  36. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +1 -1
  37. package/src/pipeline/__tests__/post-query-hook.integration.test.ts +3 -3
  38. package/src/search/__tests__/meilisearch-adapter.integration.test.ts +200 -185
  39. package/src/search/__tests__/meilisearch-ids.test.ts +30 -0
  40. 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.1",
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.1",
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(
@@ -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) {
@@ -86,9 +86,9 @@ defineFeature("todoList", (r) => {
86
86
  },
87
87
  });
88
88
 
89
- r.entityHook({
89
+ r.hook({
90
90
  type: "postDelete",
91
- entity: "task",
91
+ target: { allOf: "task" },
92
92
  handler: async (event, ctx) => {
93
93
  console.log("task deleted");
94
94
  },
@@ -220,7 +220,6 @@ describe("Canonical Object-Form — parser akzeptiert + extrahiert", () => {
220
220
  "writeHandler",
221
221
  "queryHandler",
222
222
  "hook",
223
- "entityHook",
224
223
  "job",
225
224
  "notification",
226
225
  "authClaims",
@@ -260,12 +259,14 @@ describe("Canonical Object-Form — parser akzeptiert + extrahiert", () => {
260
259
  });
261
260
  });
262
261
 
263
- test("entityHook Pattern: type + entity aus Object-Form", () => {
264
- 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
+ );
265
266
  expect(entityHook).toMatchObject({
266
- kind: "entityHook",
267
+ kind: "hook",
267
268
  hookType: "postDelete",
268
- entityName: "task",
269
+ target: { allOf: "task" },
269
270
  });
270
271
  });
271
272
 
@@ -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
799
  });
800
800
  });
801
801
 
802
- test("rejects validation as entity-hook type (only postSave/preDelete/postDelete allowed)", () => {
802
+ test("captures hookType and the allOf entity from object form", () => {
803
803
  const result = parseInline(`
804
804
  defineFeature("f", (r) => {
805
- r.entityHook("validation", "task", () => {});
805
+ r.hook({ type: "postDelete", target: { allOf: "task" }, handler: () => {} });
806
806
  });
807
807
  `);
808
808
 
809
- expect(result.errors[0]?.methodName).toBe("entityHook");
809
+ expect(result.patterns[0]).toMatchObject({
810
+ kind: "hook",
811
+ hookType: "postDelete",
812
+ target: { allOf: "task" },
813
+ });
814
+ });
815
+
816
+ test("rejects a malformed allOf value (not a string/ref)", () => {
817
+ const result = parseInline(`
818
+ defineFeature("f", (r) => {
819
+ r.hook("postSave", { allOf: 123 }, () => {});
820
+ });
821
+ `);
822
+
823
+ expect(result.errors[0]?.methodName).toBe("hook");
810
824
  });
811
825
  });
812
826
 
@@ -157,18 +157,18 @@ describe("FeaturePatcher — typed add helpers for mixed (closure-bearing) patte
157
157
  });
158
158
  });
159
159
 
160
- test("addEntityHook routes type + entity correctly", () => {
160
+ test("addHook routes entity-wide { allOf } target correctly", () => {
161
161
  const sf = makeSourceFile(STARTER);
162
- createFeaturePatcher(sf).addEntityHook({
162
+ createFeaturePatcher(sf).addHook({
163
163
  type: "postDelete",
164
- entity: "task",
164
+ target: { allOf: "task" },
165
165
  handlerSource: "async (event, ctx) => { /* cleanup */ }",
166
166
  });
167
167
  const result = parseSourceFile(sf);
168
168
  expect(result.patterns[0]).toMatchObject({
169
- kind: "entityHook",
169
+ kind: "hook",
170
170
  hookType: "postDelete",
171
- entityName: "task",
171
+ target: { allOf: "task" },
172
172
  });
173
173
  });
174
174
 
@@ -442,9 +442,9 @@ defineFeature("tasks", (r) => {
442
442
  handlerSource: "async (q, ctx) => []",
443
443
  access: { openToAll: true },
444
444
  });
445
- p.addEntityHook({
445
+ p.addHook({
446
446
  type: "postDelete",
447
- entity: "task",
447
+ target: { allOf: "task" },
448
448
  handlerSource: "async (event, ctx) => { /* cascade-clean */ }",
449
449
  });
450
450
  const result = parseSourceFile(sf);
@@ -453,7 +453,7 @@ defineFeature("tasks", (r) => {
453
453
  "entity",
454
454
  "writeHandler",
455
455
  "queryHandler",
456
- "entityHook",
456
+ "hook",
457
457
  ]);
458
458
  });
459
459
  });