@cosmicdrift/kumiko-framework 0.24.0 → 0.25.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.
Files changed (39) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/schema-cli-status.integration.test.ts +2 -1
  3. package/src/bun-db/__tests__/extract-table-info.test.ts +72 -0
  4. package/src/bun-db/query.ts +52 -94
  5. package/src/db/__tests__/source-shadow-create.integration.test.ts +54 -0
  6. package/src/db/__tests__/sql-inventory.test.ts +26 -1
  7. package/src/db/__tests__/table-builder-indexes.test.ts +15 -0
  8. package/src/db/__tests__/tenant-db-where-merge.test.ts +81 -0
  9. package/src/db/dialect.ts +6 -0
  10. package/src/db/entity-table-meta.ts +1 -1
  11. package/src/db/queries/event-store.ts +18 -7
  12. package/src/db/sql-inventory.ts +9 -0
  13. package/src/db/tenant-db.ts +5 -2
  14. package/src/engine/__tests__/boot-validator.test.ts +79 -0
  15. package/src/engine/__tests__/post-query-hook.test.ts +6 -6
  16. package/src/engine/__tests__/projection-helpers.test.ts +12 -7
  17. package/src/engine/__tests__/registry.test.ts +58 -0
  18. package/src/engine/__tests__/search-payload-extension.test.ts +49 -3
  19. package/src/engine/__tests__/unmanaged-table.test.ts +30 -1
  20. package/src/engine/boot-validator/api-ext.ts +1 -5
  21. package/src/engine/boot-validator/screens-nav.ts +18 -2
  22. package/src/engine/registry.ts +60 -29
  23. package/src/engine/types/fields.ts +2 -1
  24. package/src/engine/types/handlers.ts +1 -1
  25. package/src/engine/types/hooks.ts +4 -1
  26. package/src/engine/validate-projection-allowlist.ts +13 -3
  27. package/src/errors/__tests__/classes.test.ts +5 -0
  28. package/src/errors/classes.ts +4 -2
  29. package/src/event-store/event-store.ts +15 -0
  30. package/src/event-store/index.ts +1 -0
  31. package/src/files/__tests__/file-ref-entity.test.ts +34 -0
  32. package/src/pipeline/__tests__/dispatcher.test.ts +53 -0
  33. package/src/pipeline/__tests__/post-query-hook.integration.test.ts +50 -1
  34. package/src/pipeline/dispatcher.ts +57 -47
  35. package/src/pipeline/system-hooks.ts +17 -5
  36. package/src/stack/table-helpers.ts +19 -9
  37. package/src/stack/test-stack.ts +3 -4
  38. package/src/testing/db-cleanup.ts +7 -9
  39. package/src/errors/__tests__/field-issue-compat.test.ts +0 -16
@@ -235,6 +235,20 @@ describe("boot-validator", () => {
235
235
  expect(() => validateBoot([ext, consumer])).not.toThrow();
236
236
  });
237
237
 
238
+ test("passes when a feature provides AND uses its own extension (self-extension)", () => {
239
+ // tier-engine pattern: a feature defines an extension-point and ships a
240
+ // default plugin for it, so providerFeature === feature.name. Requiring
241
+ // the feature to requires(self) would be circular — the validator must
242
+ // skip the requires-check for self-provided extensions. The cross-feature
243
+ // tests above only exercise providerFeature !== feature.name.
244
+ const self = defineFeature("tier-stub", (r) => {
245
+ r.extendsRegistrar("tenantTierResolver", { onRegister: () => {} });
246
+ r.entity("dummy", createEntity({ table: "Dummies", fields: {} }));
247
+ r.useExtension("tenantTierResolver", "dummy");
248
+ });
249
+ expect(() => validateBoot([self])).not.toThrow();
250
+ });
251
+
238
252
  // --- FILE_STORAGE_PROVIDER ---
239
253
 
240
254
  test("throws when file fields exist but FILE_STORAGE_PROVIDER not set", () => {
@@ -1372,6 +1386,20 @@ describe("boot-validator", () => {
1372
1386
  /redirect "ghost-screen" does not resolve to a registered screen/,
1373
1387
  );
1374
1388
  });
1389
+
1390
+ test("extension section ohne component → Throw (Parität zu entityEdit)", () => {
1391
+ // synthesizeActionFormScreen reicht die layout 1:1 an RenderEdit weiter —
1392
+ // eine Extension-Section ohne react/native-Marker rendert sonst stumm leer.
1393
+ const section = { kind: "extension", title: "Custom", component: {} };
1394
+ expect(() => validateBoot([makeFeature({ sections: [section] as never })])).toThrow(
1395
+ /\(actionForm\) extension section "Custom" has no component/,
1396
+ );
1397
+ });
1398
+
1399
+ test("extension section mit react component → kein Throw", () => {
1400
+ const section = { kind: "extension", title: "Custom", component: { react: "Panel" } };
1401
+ expect(() => validateBoot([makeFeature({ sections: [section] as never })])).not.toThrow();
1402
+ });
1375
1403
  });
1376
1404
 
1377
1405
  // --- configEdit-Screen ---
@@ -1474,6 +1502,57 @@ describe("boot-validator", () => {
1474
1502
  ]),
1475
1503
  ).toThrow(/Config-Key "shop:config:typo-here" ist in keiner Feature-Registry deklariert/);
1476
1504
  });
1505
+
1506
+ test("extension section ohne component → Throw (Parität zu entityEdit)", () => {
1507
+ // synthesizeConfigEditScreen reicht die layout 1:1 an RenderEdit weiter —
1508
+ // eine Extension-Section ohne react/native-Marker rendert sonst stumm leer.
1509
+ const section = { kind: "extension", title: "Custom", component: {} };
1510
+ expect(() => validateBoot([makeFeature({ sections: [section] as never })])).toThrow(
1511
+ /\(configEdit\) extension section "Custom" has no component/,
1512
+ );
1513
+ });
1514
+
1515
+ test("extension section mit react component → kein Throw", () => {
1516
+ const section = { kind: "extension", title: "Custom", component: { react: "Panel" } };
1517
+ expect(() => validateBoot([makeFeature({ sections: [section] as never })])).not.toThrow();
1518
+ });
1519
+ });
1520
+
1521
+ // --- entityEdit extension section ---
1522
+ // Eine extension-Section delegiert das Rendering an eine feature-provided
1523
+ // PlatformComponent (custom-fields-Panel etc.), die client-seitig per Name
1524
+ // aufgelöst wird. Ohne react/native-Marker bliebe der Slot zur Laufzeit leer
1525
+ // — Boot-Fail statt stummem Loch. Field-Sections bleiben davon unberührt.
1526
+ describe("entityEdit extension section", () => {
1527
+ function makeFeature(component: unknown) {
1528
+ return defineFeature("shop", (r) => {
1529
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
1530
+ r.screen({
1531
+ id: "product-edit",
1532
+ type: "entityEdit",
1533
+ entity: "product",
1534
+ layout: {
1535
+ sections: [
1536
+ { kind: "extension", title: "Custom Fields", component: component as never },
1537
+ ],
1538
+ },
1539
+ });
1540
+ });
1541
+ }
1542
+
1543
+ test("extension section ohne react/native component → Throw", () => {
1544
+ expect(() => validateBoot([makeFeature({})])).toThrow(
1545
+ /extension section "Custom Fields" has no component — declare a react\/native component marker/,
1546
+ );
1547
+ });
1548
+
1549
+ test("extension section mit react component → kein Throw", () => {
1550
+ expect(() => validateBoot([makeFeature({ react: "CustomFieldsPanel" })])).not.toThrow();
1551
+ });
1552
+
1553
+ test("extension section mit native component → kein Throw", () => {
1554
+ expect(() => validateBoot([makeFeature({ native: "CustomFieldsPanel" })])).not.toThrow();
1555
+ });
1477
1556
  });
1478
1557
 
1479
1558
  // --- Tier 2.7e-3: ReferenceFieldDef ---
@@ -1,7 +1,11 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { z } from "zod";
3
3
  import { createEntity, createRegistry, defineFeature } from "../index";
4
- import type { PostQueryHookFn } from "../types";
4
+ import type { AppContext, PostQueryHookFn } from "../types";
5
+
6
+ // The hooks under test never read context (they only transform rows), so a
7
+ // stub at the cast-boundary is sufficient — no real db/redis/registry needed.
8
+ const stubContext = {} as unknown as AppContext;
5
9
 
6
10
  // postQuery-Hook (F1) — feuert nach Query-Handler-Execute, vor Field-Access-
7
11
  // Read-Filter. Zwei Registrierungs-Pfade:
@@ -112,11 +116,7 @@ describe("Hook function semantics", () => {
112
116
  const hooks = registry.getEntityPostQueryHooks("thing");
113
117
  const inputRows: ReadonlyArray<Record<string, unknown>> = [{ id: "1" }, { id: "2" }];
114
118
  // Context shape is { user, db, ... } in real runtime; unit-tests stub.
115
- const result = await hooks[0]?.(
116
- { entityName: "thing", rows: inputRows },
117
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
118
- {} as never,
119
- );
119
+ const result = await hooks[0]?.({ entityName: "thing", rows: inputRows }, stubContext);
120
120
  expect(result?.rows).toEqual([
121
121
  { id: "1", enriched: true },
122
122
  { id: "2", enriched: true },
@@ -3,15 +3,20 @@ import type { StoredEvent } from "../../event-store/event-store";
3
3
  import { setFields } from "../projection-helpers";
4
4
  import type { ProjectionTable } from "../types/projection";
5
5
 
6
- // Minimal fake table: only the `id` column is needed for setFields, plus
7
- // the kumiko:schema:Name + kumiko:schema:Columns symbols that bun-db introspects for
8
- // table-name + column-mapping. We don't run real SQL — unsafe() is mocked.
9
- const fakeIdCol = { name: "id" };
6
+ // Minimal fake table: an EntityTableMeta (what bun-db introspects for
7
+ // table-name + column-mapping) plus a top-level `id` handle, which setFields
8
+ // existence-checks before building its apply fn. We don't run real SQL —
9
+ // unsafe() is mocked.
10
10
  const fakeTable = Object.assign(
11
- { id: fakeIdCol },
11
+ { id: { name: "id" } },
12
12
  {
13
- [Symbol.for("kumiko:schema:Name")]: "fake_table",
14
- [Symbol.for("kumiko:schema:Columns")]: { id: fakeIdCol },
13
+ tableName: "fake_table",
14
+ source: "unmanaged",
15
+ indexes: [],
16
+ columns: [
17
+ { name: "id", pgType: "uuid", notNull: true },
18
+ { name: "status", pgType: "text", notNull: false },
19
+ ],
15
20
  },
16
21
  ) as unknown as ProjectionTable;
17
22
 
@@ -0,0 +1,58 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createRegistry } from "../registry";
3
+ import type { FeatureDefinition } from "../types/feature";
4
+
5
+ // Hand-built FeatureDefinition that bypasses defineFeature() — the latter
6
+ // initializes every slot (entities, entityHooks, …) to an empty map. A
7
+ // FeatureDefinition assembled off that path (cast at a system boundary) can
8
+ // leave slots `undefined`, which the type forbids but createRegistry's
9
+ // entity-iteration paths must survive: `Object.entries/values(undefined)`
10
+ // throws. The double-cast is the deliberate type-violation that reproduces it.
11
+ function bareFeature(overrides: Record<string, unknown> = {}): FeatureDefinition {
12
+ return {
13
+ name: "probe",
14
+ requires: [],
15
+ optionalRequires: [],
16
+ ...overrides,
17
+ } as unknown as FeatureDefinition;
18
+ }
19
+
20
+ describe("createRegistry slot robustness", () => {
21
+ // Regression for the hardening PRs (#95/#98/#210): the entity- and
22
+ // hook-iterating paths in createRegistry must not assume the optional
23
+ // `entities` / `entityHooks` slots are present. defineFeature masks this in
24
+ // every test that goes through the normal author API, so the gap only
25
+ // surfaced when a partial feature reached the boot path.
26
+
27
+ test("tolerates a hand-built feature with entities + entityHooks omitted", () => {
28
+ // Exercises the entity-iteration paths (allEntities loop + hasFieldAccessRules)
29
+ // — both crash on `Object.{keys,values}(undefined)` without the `?? {}` guard.
30
+ expect(() => createRegistry([bareFeature()])).not.toThrow();
31
+ });
32
+
33
+ test("tolerates entities: undefined (Object.keys/values guard)", () => {
34
+ expect(() => createRegistry([bareFeature({ entities: undefined })])).not.toThrow();
35
+ });
36
+
37
+ test("tolerates entityHooks with every slot undefined", () => {
38
+ expect(() =>
39
+ createRegistry([
40
+ bareFeature({
41
+ entities: {},
42
+ entityHooks: {
43
+ postSave: undefined,
44
+ preDelete: undefined,
45
+ postDelete: undefined,
46
+ postQuery: undefined,
47
+ },
48
+ }),
49
+ ]),
50
+ ).not.toThrow();
51
+ });
52
+
53
+ test("tolerates entityHooks map itself undefined", () => {
54
+ expect(() =>
55
+ createRegistry([bareFeature({ entities: {}, entityHooks: undefined })]),
56
+ ).not.toThrow();
57
+ });
58
+ });
@@ -1,5 +1,6 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { createEntity, createRegistry, defineFeature } from "../index";
1
+ import { afterEach, describe, expect, spyOn, test } from "bun:test";
2
+ import { buildSearchDocument } from "../../pipeline/system-hooks";
3
+ import { createEntity, createRegistry, createTextField, defineFeature } from "../index";
3
4
  import type { SearchPayloadContributorFn } from "../types";
4
5
 
5
6
  // F3 — Search-Payload-Extension registers per-entity contributors that
@@ -115,6 +116,36 @@ describe("effectiveFeatures filtering", () => {
115
116
  });
116
117
  });
117
118
 
119
+ describe("buildSearchDocument — contributor precedence (base fields win)", () => {
120
+ const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
121
+ afterEach(() => warnSpy.mockClear());
122
+
123
+ function registryWith(contributor: SearchPayloadContributorFn) {
124
+ const feature = defineFeature("test", (r) => {
125
+ const thing = r.entity(
126
+ "thing",
127
+ createEntity({ table: "things", fields: { title: createTextField({ searchable: true }) } }),
128
+ );
129
+ r.searchPayloadExtension(thing, contributor);
130
+ });
131
+ return createRegistry([feature]);
132
+ }
133
+
134
+ test("a contributor cannot overwrite an indexed Stammfield value", async () => {
135
+ const registry = registryWith(() => ({ title: "from-contributor" }));
136
+ const doc = await buildSearchDocument("thing", "t1", { title: "real-value" }, registry);
137
+ expect(doc?.fields["title"]).toBe("real-value");
138
+ expect(warnSpy).toHaveBeenCalledTimes(1);
139
+ });
140
+
141
+ test("a non-colliding contributor key is still merged in", async () => {
142
+ const registry = registryWith(() => ({ flatTags: "a,b" }));
143
+ const doc = await buildSearchDocument("thing", "t1", { title: "real-value" }, registry);
144
+ expect(doc?.fields).toMatchObject({ title: "real-value", flatTags: "a,b" });
145
+ expect(warnSpy).not.toHaveBeenCalled();
146
+ });
147
+ });
148
+
118
149
  describe("Boot-Validation", () => {
119
150
  test("rejects searchPayloadExtension on unknown entity-name (sibling to entity-hooks)", () => {
120
151
  expect(() =>
@@ -131,6 +162,21 @@ describe("Boot-Validation", () => {
131
162
  r.searchPayloadExtension("propery", noop);
132
163
  });
133
164
  createRegistry([feature]);
134
- }).toThrow(/searchPayloadExtension.*"propery".*no entity/);
165
+ }).toThrow(/searchPayloadExtension extension targets entity "propery" but no entity/);
166
+ });
167
+
168
+ test("error message calls a searchPayloadExtension an 'extension', not a 'hook'", () => {
169
+ const feature = defineFeature("test", (r) => {
170
+ r.entity("thing", createEntity({ table: "things", fields: {} }));
171
+ r.searchPayloadExtension("propery", noop);
172
+ });
173
+ let message = "";
174
+ try {
175
+ createRegistry([feature]);
176
+ } catch (err) {
177
+ message = err instanceof Error ? err.message : String(err);
178
+ }
179
+ expect(message).toContain("searchPayloadExtension extension");
180
+ expect(message).not.toContain("searchPayloadExtension hook");
135
181
  });
136
182
  });
@@ -3,8 +3,9 @@
3
3
  // drizzle migrate-runner). See define-feature.ts / DX-4.
4
4
 
5
5
  import { describe, expect, test } from "bun:test";
6
- import { defineUnmanagedTable } from "../../db/entity-table-meta";
6
+ import { defineUnmanagedTable, resolveTableName } from "../../db/entity-table-meta";
7
7
  import { defineFeature } from "../define-feature";
8
+ import { createEntity, createTextField } from "../index";
8
9
  import { createRegistry } from "../registry";
9
10
 
10
11
  const probeMeta = defineUnmanagedTable({
@@ -95,4 +96,32 @@ describe("createRegistry — unmanagedTable aggregation", () => {
95
96
  });
96
97
  expect(() => createRegistry([featA, featB])).not.toThrow();
97
98
  });
99
+
100
+ test("rejects an unmanaged-table that collides with an entity's physical name", () => {
101
+ const widget = createEntity({ fields: { name: createTextField() } });
102
+ // resolveTableName mirrors the migrate-runner — pin the exact physical name.
103
+ const physical = resolveTableName("widget", widget, "shop");
104
+ const clashing = defineUnmanagedTable({
105
+ tableName: physical,
106
+ columns: [{ name: "id", pgType: "text", notNull: true, primaryKey: true }],
107
+ });
108
+ const entityFeature = defineFeature("shop", (r) => {
109
+ r.entity("widget", widget);
110
+ });
111
+ const tableFeature = defineFeature("other", (r) => {
112
+ r.unmanagedTable(clashing, { reason: "clash" });
113
+ });
114
+
115
+ // Entity registered first, then the colliding unmanaged table.
116
+ expect(() => createRegistry([entityFeature, tableFeature])).toThrow(
117
+ new RegExp(
118
+ `Unmanaged-table "${physical}".*collides with the physical table of entity "widget"`,
119
+ ),
120
+ );
121
+
122
+ // Order-independent: unmanaged table registered first, then the entity.
123
+ expect(() => createRegistry([tableFeature, entityFeature])).toThrow(
124
+ new RegExp(`Entity "widget".*collides with r.unmanagedTable\\("${physical}"\\)`),
125
+ );
126
+ });
98
127
  });
@@ -67,11 +67,7 @@ export function validateExtensionUsages(
67
67
  );
68
68
  }
69
69
 
70
- // Self-extension (feature provides AND consumes the same extension)
71
- // doesn't need requires(self) — that would be a circular declaration.
72
- // tier-engine is the canonical case: defines + uses tenantTierResolver
73
- // because it ships a default tier-resolver-plugin alongside the
74
- // extension-point.
70
+ // self-extension is legitimate: requires(self) would be circular.
75
71
  if (providerFeature === feature.name) {
76
72
  continue;
77
73
  }
@@ -62,7 +62,15 @@ export function validateScreens(
62
62
  );
63
63
  }
64
64
  for (const section of screen.layout.sections) {
65
- if (isExtensionEditSection(section)) continue;
65
+ if (isExtensionEditSection(section)) {
66
+ if (section.component.react === undefined && section.component.native === undefined) {
67
+ throw new Error(
68
+ `[Feature ${feature.name}] Screen "${screenId}" (configEdit) extension section ` +
69
+ `"${section.title}" has no component — declare a react/native component marker.`,
70
+ );
71
+ }
72
+ continue;
73
+ }
66
74
  if (section.fields.length === 0) {
67
75
  throw new Error(
68
76
  `[Feature ${feature.name}] Screen "${screenId}" (configEdit) has a section "${section.title}" ` +
@@ -161,7 +169,15 @@ export function validateScreens(
161
169
  );
162
170
  }
163
171
  for (const section of screen.layout.sections) {
164
- if (isExtensionEditSection(section)) continue;
172
+ if (isExtensionEditSection(section)) {
173
+ if (section.component.react === undefined && section.component.native === undefined) {
174
+ throw new Error(
175
+ `[Feature ${feature.name}] Screen "${screenId}" (actionForm) extension section ` +
176
+ `"${section.title}" has no component — declare a react/native component marker.`,
177
+ );
178
+ }
179
+ continue;
180
+ }
165
181
  if (section.fields.length === 0) {
166
182
  throw new Error(
167
183
  `[Feature ${feature.name}] Screen "${screenId}" (actionForm) has a section "${section.title}" ` +
@@ -1,4 +1,5 @@
1
1
  import { applyEntityEvent } from "../db/apply-entity-event";
2
+ import { resolveTableName } from "../db/entity-table-meta";
2
3
  import { buildEntityTable } from "../db/table-builder";
3
4
  import { buildMetricName, validateMetricName } from "../observability";
4
5
  import { type QnType, qualifyEntityName } from "./qualified-name";
@@ -174,6 +175,13 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
174
175
  // Cousin of rawTables: same uniqueness-by-tableName invariant, different
175
176
  // storage shape (post-drizzle migrate-runner consumes EntityTableMeta).
176
177
  const unmanagedTableMap = new Map<string, UnmanagedTableDef>();
178
+ // Final physical table names (entity-derived + unmanaged) → owner. Catches
179
+ // a collision between an r.unmanagedTable() tableName and an r.entity()
180
+ // physical name at boot instead of as a duplicate CREATE TABLE in migrate.
181
+ const physicalTableOwners = new Map<
182
+ string,
183
+ { kind: "entity" | "unmanaged"; owner: string; featureName: string }
184
+ >();
177
185
  // Auth-claims hooks — tagged with featureName so the login resolver can
178
186
  // auto-prefix each hook's returned keys with "<feature>:".
179
187
  const authClaimsHooks: AuthClaimsHookDef[] = [];
@@ -321,6 +329,16 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
321
329
  throw new Error(`Duplicate entity: "${name}" (registered by multiple features)`);
322
330
  }
323
331
  entityMap.set(name, entity);
332
+ const physical = resolveTableName(name, entity, feature.name);
333
+ const clash = physicalTableOwners.get(physical);
334
+ if (clash?.kind === "unmanaged") {
335
+ throw new Error(
336
+ `Entity "${name}" (feature "${feature.name}") has physical table "${physical}" which ` +
337
+ `collides with r.unmanagedTable("${physical}") (feature "${clash.featureName}"). ` +
338
+ `Pick a different tableName — both would emit CREATE TABLE "${physical}".`,
339
+ );
340
+ }
341
+ physicalTableOwners.set(physical, { kind: "entity", owner: name, featureName: feature.name });
324
342
  }
325
343
 
326
344
  // Relations: entityName (not prefixed)
@@ -422,18 +440,18 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
422
440
  // Lifecycle hooks: keyed by handler QN. featureName rides along on each
423
441
  // hook entry — defineFeature sets it, the registry just appends.
424
442
  // Save/delete hooks target write handlers, query hooks target query handlers.
425
- mergeHookListQualified(preSaveHooks, feature.hooks.preSave, feature.name, "write");
426
- mergeHookListQualified(postSaveHooks, feature.hooks.postSave, feature.name, "write");
427
- mergeHookListQualified(preDeleteHooks, feature.hooks.preDelete, feature.name, "write");
428
- mergeHookListQualified(postDeleteHooks, feature.hooks.postDelete, feature.name, "write");
429
- mergeHookListQualified(preQueryHooks, feature.hooks.preQuery, feature.name, "query");
430
- mergeHookListQualified(postQueryHooks, feature.hooks.postQuery, feature.name, "query");
443
+ mergeHookListQualified(preSaveHooks, feature.hooks?.preSave, feature.name, "write");
444
+ mergeHookListQualified(postSaveHooks, feature.hooks?.postSave, feature.name, "write");
445
+ mergeHookListQualified(preDeleteHooks, feature.hooks?.preDelete, feature.name, "write");
446
+ mergeHookListQualified(postDeleteHooks, feature.hooks?.postDelete, feature.name, "write");
447
+ mergeHookListQualified(preQueryHooks, feature.hooks?.preQuery, feature.name, "query");
448
+ mergeHookListQualified(postQueryHooks, feature.hooks?.postQuery, feature.name, "query");
431
449
 
432
450
  // Entity hooks: NOT prefixed, keyed by entity name
433
- mergeHookList(entityPostSaveHooks, feature.entityHooks.postSave);
434
- mergeHookList(entityPreDeleteHooks, feature.entityHooks.preDelete);
435
- mergeHookList(entityPostDeleteHooks, feature.entityHooks.postDelete);
436
- mergeHookList(entityPostQueryHooks, feature.entityHooks.postQuery);
451
+ mergeHookList(entityPostSaveHooks, feature.entityHooks?.postSave);
452
+ mergeHookList(entityPreDeleteHooks, feature.entityHooks?.preDelete);
453
+ mergeHookList(entityPostDeleteHooks, feature.entityHooks?.postDelete);
454
+ mergeHookList(entityPostQueryHooks, feature.entityHooks?.postQuery);
437
455
 
438
456
  // F3 search-payload-extensions: per-entity contributors merged additively
439
457
  for (const [entityName, contributors] of Object.entries(
@@ -453,9 +471,9 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
453
471
  }
454
472
  extensionMap.set(extName, extDef);
455
473
  }
456
- extensionUsages.push(...feature.extensionUsages);
457
- allReferenceData.push(...feature.referenceData);
458
- allConfigSeeds.push(...feature.configSeeds);
474
+ extensionUsages.push(...(feature.extensionUsages ?? []));
475
+ allReferenceData.push(...(feature.referenceData ?? []));
476
+ allConfigSeeds.push(...(feature.configSeeds ?? []));
459
477
 
460
478
  // Metrics: validate + qualify per feature. Collisions across features are
461
479
  // rejected here — two features can't both register "created_total" under
@@ -477,7 +495,7 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
477
495
  // Secret keys: already qualified during defineFeature (same "<feature>:<short>"
478
496
  // convention used elsewhere). Reject cross-feature duplicates — extensions
479
497
  // could theoretically register on another feature's namespace.
480
- for (const def of Object.values(feature.secretKeys)) {
498
+ for (const def of Object.values(feature.secretKeys ?? {})) {
481
499
  if (secretKeyMap.has(def.qualifiedName)) {
482
500
  throw new Error(
483
501
  `[Kumiko Secrets] Secret key "${def.qualifiedName}" registered multiple times. ` +
@@ -559,6 +577,19 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
559
577
  `"${feature.name}". Pick a feature-prefixed tableName to disambiguate.`,
560
578
  );
561
579
  }
580
+ const physicalClash = physicalTableOwners.get(umName);
581
+ if (physicalClash?.kind === "entity") {
582
+ throw new Error(
583
+ `Unmanaged-table "${umName}" (feature "${feature.name}") collides with the physical ` +
584
+ `table of entity "${physicalClash.owner}" (feature "${physicalClash.featureName}"). ` +
585
+ `Pick a different tableName — both would emit CREATE TABLE "${umName}".`,
586
+ );
587
+ }
588
+ physicalTableOwners.set(umName, {
589
+ kind: "unmanaged",
590
+ owner: umName,
591
+ featureName: feature.name,
592
+ });
562
593
  unmanagedTableMap.set(umName, { ...umDef, featureName: feature.name });
563
594
  }
564
595
 
@@ -567,7 +598,7 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
567
598
  // correctness — the only way to hit this is a hand-built FeatureDefinition
568
599
  // bypassing defineFeature's per-feature duplicate check.
569
600
  const declaredShortNames = new Set<string>();
570
- for (const def of Object.values(feature.claimKeys)) {
601
+ for (const def of Object.values(feature.claimKeys ?? {})) {
571
602
  if (claimKeyMap.has(def.qualifiedName)) {
572
603
  throw new Error(
573
604
  `[Kumiko ClaimKeys] Claim key "${def.qualifiedName}" registered multiple times. ` +
@@ -670,7 +701,7 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
670
701
  // on undeclared inner-keys (typo / rename drift). Features that don't
671
702
  // declare claimKeys skip the check entirely — it's opt-in.
672
703
  const declaredKeys = declaredShortNames.size > 0 ? declaredShortNames : undefined;
673
- for (const fn of feature.authClaimsHooks) {
704
+ for (const fn of feature.authClaimsHooks ?? []) {
674
705
  authClaimsHooks.push({
675
706
  featureName: feature.name,
676
707
  fn,
@@ -870,7 +901,7 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
870
901
  if (!hasFieldAccessRules(feature)) continue;
871
902
 
872
903
  // Write handlers: ALL must be entity-mapped (security-critical, writes need field-access checks)
873
- for (const handlerName of Object.keys(feature.writeHandlers)) {
904
+ for (const handlerName of Object.keys(feature.writeHandlers ?? {})) {
874
905
  const qualified = qualify(feature.name, "write", handlerName);
875
906
  if (!handlerEntityMap.has(qualified)) {
876
907
  throw new Error(
@@ -882,7 +913,7 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
882
913
 
883
914
  // Query handlers: only those with a dash must resolve (typo protection).
884
915
  // No dash = standalone query (dashboard, stats) — intentionally not entity-bound.
885
- for (const handlerName of Object.keys(feature.queryHandlers)) {
916
+ for (const handlerName of Object.keys(feature.queryHandlers ?? {})) {
886
917
  if (!handlerName.includes(":")) continue;
887
918
  const qualified = qualify(feature.name, "query", handlerName);
888
919
  if (!handlerEntityMap.has(qualified)) {
@@ -1035,7 +1066,7 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
1035
1066
 
1036
1067
  // Validate: all required features must be registered
1037
1068
  for (const feature of features) {
1038
- for (const required of feature.requires) {
1069
+ for (const required of feature.requires ?? []) {
1039
1070
  if (!featureMap.has(required)) {
1040
1071
  throw new Error(
1041
1072
  `Feature "${feature.name}" requires feature "${required}" which is not registered`,
@@ -1131,23 +1162,23 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
1131
1162
  // postQuery-special.
1132
1163
  const allEntities = new Set<string>();
1133
1164
  for (const feature of features) {
1134
- for (const entityName of Object.keys(feature.entities)) {
1165
+ for (const entityName of Object.keys(feature.entities ?? {})) {
1135
1166
  allEntities.add(entityName);
1136
1167
  }
1137
1168
  }
1138
1169
  const entityHookMaps = [
1139
- { map: entityPostSaveHooks, phase: "postSave (entityHook)" },
1140
- { map: entityPreDeleteHooks, phase: "preDelete (entityHook)" },
1141
- { map: entityPostDeleteHooks, phase: "postDelete (entityHook)" },
1142
- { map: entityPostQueryHooks, phase: "postQuery (entityHook)" },
1143
- { map: searchPayloadExtensions, phase: "searchPayloadExtension" },
1170
+ { map: entityPostSaveHooks, phase: "postSave (entityHook)", kind: "hook" },
1171
+ { map: entityPreDeleteHooks, phase: "preDelete (entityHook)", kind: "hook" },
1172
+ { map: entityPostDeleteHooks, phase: "postDelete (entityHook)", kind: "hook" },
1173
+ { map: entityPostQueryHooks, phase: "postQuery (entityHook)", kind: "hook" },
1174
+ { map: searchPayloadExtensions, phase: "searchPayloadExtension", kind: "extension" },
1144
1175
  ] as const;
1145
- for (const { map, phase } of entityHookMaps) {
1176
+ for (const { map, phase, kind } of entityHookMaps) {
1146
1177
  for (const entityName of map.keys()) {
1147
1178
  if (!allEntities.has(entityName)) {
1148
1179
  throw new Error(
1149
- `${phase} hook targets entity "${entityName}" but no entity with that name exists. ` +
1150
- `Check for typos — the hook will never fire.`,
1180
+ `${phase} ${kind} targets entity "${entityName}" but no entity with that name exists. ` +
1181
+ `Check for typos — the ${kind} will never fire.`,
1151
1182
  );
1152
1183
  }
1153
1184
  }
@@ -1492,7 +1523,7 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
1492
1523
 
1493
1524
  /** Returns true if any entity in the feature has field-level access rules (read or write). */
1494
1525
  function hasFieldAccessRules(feature: FeatureDefinition): boolean {
1495
- for (const entity of Object.values(feature.entities)) {
1526
+ for (const entity of Object.values(feature.entities ?? {})) {
1496
1527
  for (const field of Object.values(entity.fields)) {
1497
1528
  if (field.access?.read?.length || field.access?.write?.length) {
1498
1529
  return true;
@@ -484,7 +484,8 @@ export type TransitionMap = Readonly<Record<string, readonly string[]>>;
484
484
  * vermeidet Migration-Churn beim Refactor.
485
485
  *
486
486
  * Single-column indices über `tenantId` sind redundant (buildEntityTable
487
- * legt die immer automatisch an); die Boot-Validation warnt. */
487
+ * legt die immer automatisch an); die Boot-Validation warnt (außer
488
+ * `{ unique: true }` — semantische 1:1-Constraint, kein Performance-Hint). */
488
489
  export type EntityIndexDef = {
489
490
  readonly columns: readonly [string, ...string[]];
490
491
  readonly unique?: boolean;
@@ -223,7 +223,7 @@ type SharedContextFields = {
223
223
  // set handler needs to encrypt on write, the resolver needs to decrypt
224
224
  // on read, and both reach for the same provider. Wired via extraContext.
225
225
  readonly configEncryption?: import("../../db").EncryptionProvider;
226
- // Rate-limit resolver. Wired by the framework when the `rateLimiting`
226
+ // Rate-limit resolver. Wired by the framework when the `rate-limiting`
227
227
  // feature is loaded — pipeline reads handler.rateLimit and calls
228
228
  // .enforce() on this resolver before access-check. Absent when the
229
229
  // app didn't load the feature: handlers with rateLimit set are
@@ -84,7 +84,10 @@ export type PreQueryHookFn = (
84
84
  // on added fields (field-access-filter only knows entity's stammfields).
85
85
  export type PostQueryHookFn = (
86
86
  result: {
87
- readonly entityName: string;
87
+ // undefined for standalone queries (no-colon handler names like
88
+ // "ns:dashboard") — those have no backing entity, but handler-keyed
89
+ // postQuery hooks still fire on them.
90
+ readonly entityName: string | undefined;
88
91
  readonly rows: ReadonlyArray<Record<string, unknown>>;
89
92
  },
90
93
  context: AppContext,
@@ -55,13 +55,23 @@ function* walkAllSteps(steps: readonly StepInstance[]): Generator<StepInstance,
55
55
  }
56
56
  }
57
57
 
58
- // @cast-boundary drizzle-bridge — reads table name from drizzle Symbol
59
- // without importing drizzle-orm (bun-db pattern, see bun-db/query.ts).
58
+ // @cast-boundary drizzle-bridge — reads table name from a Symbol without
59
+ // importing drizzle-orm (bun-db pattern, see bun-db/query.ts).
60
60
  const KUMIKO_NAME_SYMBOL = Symbol.for("kumiko:schema:Name");
61
+ // table()/buildEntityTable spread column handles as enumerable props, so an
62
+ // entity field named `tableName`/`source` would shadow the matching meta key —
63
+ // the canonical meta under this symbol is the only collision-safe source.
64
+ const KUMIKO_META_SYMBOL = Symbol.for("kumiko:schema:Meta");
61
65
 
62
66
  function resolveTableNameFromStep(table: unknown): string {
63
67
  if (typeof table === "object" && table !== null) {
64
- // EntityTableMeta discriminator
68
+ // Canonical meta under the unshadowable symbol — preferred path.
69
+ const meta = (table as Record<symbol, unknown>)[KUMIKO_META_SYMBOL];
70
+ if (meta !== null && typeof meta === "object") {
71
+ const metaName = (meta as Record<string, unknown>)["tableName"];
72
+ if (typeof metaName === "string") return metaName;
73
+ }
74
+ // Plain meta (buildEntityTableMeta / defineUnmanagedTable — no handle-spread).
65
75
  if (
66
76
  "source" in table &&
67
77
  "tableName" in table &&
@@ -241,6 +241,11 @@ describe("NotFoundError", () => {
241
241
  const err = new NotFoundError("billing-period", 7);
242
242
  expect((err.details as { reason: string }).reason).toBe("billing_period_not_found");
243
243
  });
244
+
245
+ test("PascalCase entity name does not leak a leading underscore into the reason", () => {
246
+ const err = new NotFoundError("Invoice", 7);
247
+ expect((err.details as { reason: string }).reason).toBe("invoice_not_found");
248
+ });
244
249
  });
245
250
 
246
251
  describe("ConflictError + VersionConflictError", () => {