@cosmicdrift/kumiko-framework 0.24.1 → 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 (32) hide show
  1. package/package.json +1 -1
  2. package/src/bun-db/__tests__/extract-table-info.test.ts +22 -1
  3. package/src/bun-db/query.ts +5 -2
  4. package/src/db/__tests__/source-shadow-create.integration.test.ts +54 -0
  5. package/src/db/__tests__/sql-inventory.test.ts +26 -1
  6. package/src/db/__tests__/table-builder-indexes.test.ts +15 -0
  7. package/src/db/__tests__/tenant-db-where-merge.test.ts +81 -0
  8. package/src/db/entity-table-meta.ts +1 -1
  9. package/src/db/queries/event-store.ts +2 -7
  10. package/src/db/sql-inventory.ts +9 -0
  11. package/src/db/tenant-db.ts +5 -2
  12. package/src/engine/__tests__/boot-validator.test.ts +79 -0
  13. package/src/engine/__tests__/post-query-hook.test.ts +6 -6
  14. package/src/engine/__tests__/registry.test.ts +58 -0
  15. package/src/engine/__tests__/search-payload-extension.test.ts +49 -3
  16. package/src/engine/__tests__/unmanaged-table.test.ts +30 -1
  17. package/src/engine/boot-validator/api-ext.ts +1 -5
  18. package/src/engine/boot-validator/screens-nav.ts +18 -2
  19. package/src/engine/registry.ts +43 -12
  20. package/src/engine/types/fields.ts +2 -1
  21. package/src/engine/types/handlers.ts +1 -1
  22. package/src/engine/types/hooks.ts +4 -1
  23. package/src/engine/validate-projection-allowlist.ts +13 -3
  24. package/src/errors/__tests__/classes.test.ts +5 -0
  25. package/src/errors/classes.ts +4 -2
  26. package/src/files/__tests__/file-ref-entity.test.ts +34 -0
  27. package/src/pipeline/__tests__/dispatcher.test.ts +53 -0
  28. package/src/pipeline/dispatcher.ts +57 -57
  29. package/src/pipeline/system-hooks.ts +17 -5
  30. package/src/stack/table-helpers.ts +2 -2
  31. package/src/stack/test-stack.ts +3 -4
  32. package/src/errors/__tests__/field-issue-compat.test.ts +0 -16
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.24.1",
3
+ "version": "0.25.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -11,7 +11,7 @@
11
11
 
12
12
  import { describe, expect, test } from "bun:test";
13
13
  import { buildEntityTable } from "../../db/table-builder";
14
- import { extractTableInfo } from "../query";
14
+ import { extractTableInfo, resolveConflictColumns } from "../query";
15
15
 
16
16
  describe("extractTableInfo — EntityTableMeta discriminator is shadow-proof", () => {
17
17
  test("an entity field named `source` does not shadow the discriminator", () => {
@@ -49,3 +49,24 @@ describe("extractTableInfo — EntityTableMeta discriminator is shadow-proof", (
49
49
  expect(info.pgTypeOf("inserted_at")).toBe("timestamptz");
50
50
  });
51
51
  });
52
+
53
+ describe("resolveConflictColumns — shadow-proof PK inference", () => {
54
+ test("an entity field named `columns` does not shadow the inferred primary key", () => {
55
+ const table = buildEntityTable("thing", {
56
+ fields: { columns: { type: "text", required: true } },
57
+ });
58
+ const info = extractTableInfo(table);
59
+ // No explicit conflictKeys → infer the real PK (`id`) from the symbol-based
60
+ // meta. Reading `table.columns` directly would yield the `columns` field
61
+ // handle (the bug this guards), not the PK list.
62
+ expect(resolveConflictColumns(table, info, undefined)).toEqual(["id"]);
63
+ });
64
+
65
+ test("explicit conflictKeys are mapped through columnOf (control)", () => {
66
+ const table = buildEntityTable("thing", {
67
+ fields: { slug: { type: "text", required: true } },
68
+ });
69
+ const info = extractTableInfo(table);
70
+ expect(resolveConflictColumns(table, info, ["slug"])).toEqual(["slug"]);
71
+ });
72
+ });
@@ -50,6 +50,7 @@ function isEntityTableMeta(v: unknown): v is EntityTableMeta {
50
50
  typeof v === "object" &&
51
51
  typeof (v as EntityTableMeta).tableName === "string" &&
52
52
  Array.isArray((v as EntityTableMeta).columns) &&
53
+ Array.isArray((v as EntityTableMeta).indexes) &&
53
54
  ((v as EntityTableMeta).source === "managed" || (v as EntityTableMeta).source === "unmanaged")
54
55
  );
55
56
  }
@@ -356,7 +357,7 @@ export function coerceRow<T extends Record<string, unknown>>(row: T, info: Table
356
357
  ) {
357
358
  // Bun.SQL / some drivers return int4 as bigint or numeric string.
358
359
  // Drizzle coerced to number for numberField columns — match that.
359
- const n = typeof value === "bigint" ? Number(value) : Number(value);
360
+ const n = Number(value);
360
361
  if (!Number.isNaN(n)) coerced = n;
361
362
  }
362
363
  const fieldName = info.fieldOf(key);
@@ -693,7 +694,9 @@ function insertEntries(info: TableInfo, values: Record<string, unknown>): Insert
693
694
  });
694
695
  }
695
696
 
696
- function resolveConflictColumns(
697
+ // Exported for the shadow-proof regression test — `table.columns` would be the
698
+ // drizzle handle (not the PK list) when an entity has a field named `columns`.
699
+ export function resolveConflictColumns(
697
700
  table: TableLike,
698
701
  info: TableInfo,
699
702
  conflictKeys: readonly string[] | undefined,
@@ -0,0 +1,54 @@
1
+ // End-to-end regression for the EntityTableMeta discriminator shadow.
2
+ //
3
+ // An entity field literally named `source` overwrote the `source:
4
+ // "managed"|"unmanaged"` discriminator on the table-meta. extractTableInfo
5
+ // then failed its meta check, fell into the dead drizzle branch, and typed the
6
+ // timestamptz base-columns as "timestamp with time zone" (getSQLType spelling).
7
+ // prepareValue only serializes Temporal.Instant for "timestamptz", so a raw
8
+ // Temporal reached postgres-js → "Cannot use valueOf" on every create.
9
+ //
10
+ // extract-table-info.test.ts pins the proximate cause (pgTypeOf stays
11
+ // "timestamptz"). This proves the actual create-path no longer crashes — the
12
+ // integration proof the unit test cannot give.
13
+
14
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
15
+ import { insertOne, selectMany } from "../../db/query";
16
+ import { buildEntityTable } from "../../db/table-builder";
17
+ import { createEntity, createTextField } from "../../engine";
18
+ import { setupTestStack, type TestStack, unsafeCreateEntityTable } from "../../stack";
19
+
20
+ const sourceEntity = createEntity({
21
+ table: "ssc_source",
22
+ fields: {
23
+ // `source` collides with the EntityTableMeta discriminator key.
24
+ source: createTextField({ required: true }),
25
+ },
26
+ });
27
+ const sourceTable = buildEntityTable("source-row", sourceEntity);
28
+
29
+ let stack: TestStack;
30
+
31
+ beforeAll(async () => {
32
+ stack = await setupTestStack({ features: [] });
33
+ await unsafeCreateEntityTable(stack.db, sourceEntity, "source-row");
34
+ });
35
+
36
+ afterAll(async () => stack?.cleanup());
37
+
38
+ describe("entity with a `source` field — create-path is shadow-proof", () => {
39
+ test("insertOne serializes the timestamptz inserted_at — no Temporal valueOf crash", async () => {
40
+ // Passing a real Temporal.Instant exercises the timestamptz serializer
41
+ // path that the shadow used to bypass. Pre-fix this threw "Cannot use
42
+ // valueOf"; post-fix the row persists and round-trips as a Temporal.Instant.
43
+ await insertOne(stack.db, sourceTable, {
44
+ source: "import",
45
+ tenantId: "00000000-0000-4000-8000-000000000001",
46
+ insertedAt: Temporal.Instant.from("2026-01-15T12:00:00Z"),
47
+ });
48
+
49
+ const rows = await selectMany(stack.db, sourceTable);
50
+ expect(rows).toHaveLength(1);
51
+ expect(rows[0]?.["source"]).toBe("import");
52
+ expect(rows[0]?.["insertedAt"]).toBeInstanceOf(Temporal.Instant);
53
+ });
54
+ });
@@ -1,5 +1,11 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
- import { formatReport, isRawSqlAllowed, joinPath, scanRepo } from "../sql-inventory";
2
+ import {
3
+ formatReport,
4
+ isRawSqlAllowed,
5
+ joinPath,
6
+ scanRepo,
7
+ toBaselineJson,
8
+ } from "../sql-inventory";
3
9
 
4
10
  const cleanups: string[] = [];
5
11
 
@@ -53,4 +59,23 @@ describe("sql-inventory", () => {
53
59
  expect(report.summary.byBucket.disallowed).toBeGreaterThanOrEqual(1);
54
60
  expect(formatReport(report)).toContain("sql inventory");
55
61
  });
62
+
63
+ test("toBaselineJson normalizes machine-specific root + scannedAt, keeps the rest", async () => {
64
+ const root = await tempRepo({
65
+ "packages/framework/src/handlers/bad.ts": `export async function y(db: unknown) {
66
+ return asRawClient(db).unsafe("DELETE FROM read_users");
67
+ }`,
68
+ });
69
+
70
+ const report = await scanRepo(root);
71
+ expect(report.root).toBe(root);
72
+ expect(report.scannedAt).not.toBe("");
73
+
74
+ const parsed = JSON.parse(toBaselineJson(report));
75
+ expect(parsed.root).toBe(".");
76
+ expect(parsed.scannedAt).toBe("");
77
+ expect(parsed.root).not.toContain(root);
78
+ expect(parsed.summary.disallowed).toBe(report.summary.disallowed);
79
+ expect(parsed.hits).toHaveLength(report.hits.length);
80
+ });
56
81
  });
@@ -147,6 +147,21 @@ describe("validateBoot — entity.indexes", () => {
147
147
  expect(() => validateBoot([feature])).toThrow(/redundant/);
148
148
  });
149
149
 
150
+ test("single-column UNIQUE auf tenantId ist erlaubt (1:1-Constraint)", () => {
151
+ // Der `&& !def.unique`-Branch in entity-handler.ts: ein UNIQUE-Index auf
152
+ // tenantId allein ist kein redundanter Read-Index, sondern ein
153
+ // 1:1-Constraint (genau ein Row pro Tenant). Nur die NON-unique-Variante
154
+ // oben ist redundant — beide Hälften der Bedingung sind damit gepinnt,
155
+ // sonst fliegt der Branch beim nächsten Revert/Refactor stumm raus.
156
+ const feature = defineFeature("widgetFeature", (r) => {
157
+ r.entity("widget", {
158
+ fields: { title: createTextField({}) },
159
+ indexes: [{ unique: true, columns: ["tenantId"] }],
160
+ });
161
+ });
162
+ expect(() => validateBoot([feature])).not.toThrow();
163
+ });
164
+
150
165
  test("composite mit tenantId ist OK (z.B. für unique über 3 Cols)", () => {
151
166
  const feature = defineFeature("widgetFeature", (r) => {
152
167
  r.entity("widget", {
@@ -0,0 +1,81 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createEntity, createTextField } from "../../engine";
3
+ import { testTenantId } from "../../stack";
4
+ import { buildEntityTable } from "../table-builder";
5
+ import { createTenantDb } from "../tenant-db";
6
+
7
+ // Tenant-isolation: a caller-supplied `where.tenantId` must NEVER override the
8
+ // enforced tenant scope. These tests drive the real query-builder against a
9
+ // recording fake runner and assert on the emitted SQL + bound values — no
10
+ // Postgres needed because the bug lives entirely in the WHERE-object merge.
11
+
12
+ const entity = createEntity({
13
+ table: "merge_items",
14
+ fields: { name: createTextField({ required: true }) },
15
+ });
16
+ const table = buildEntityTable("mergeItem", entity);
17
+
18
+ const own = testTenantId(1);
19
+ const foreign = testTenantId(2);
20
+
21
+ type Captured = { sql: string; values: readonly unknown[] };
22
+
23
+ function recordingDb(captured: Captured[]) {
24
+ return {
25
+ unsafe: async (sql: string, values: readonly unknown[]) => {
26
+ captured.push({ sql, values });
27
+ return [] as unknown[];
28
+ },
29
+ };
30
+ }
31
+
32
+ describe("tenant-db WHERE merge — caller cannot override tenant scope", () => {
33
+ test("selectMany ignores a foreign where.tenantId and keeps the IN-scope filter", async () => {
34
+ const captured: Captured[] = [];
35
+ const tdb = createTenantDb(recordingDb(captured), own);
36
+
37
+ await tdb.selectMany(table, { tenantId: foreign });
38
+
39
+ expect(captured).toHaveLength(1);
40
+ // The enforced scope is an IN over [own, SYSTEM_TENANT_ID]; the foreign id
41
+ // must not appear as a sole-equality predicate (the pre-fix bug).
42
+ expect(captured[0]?.sql).toMatch(/tenant_id" IN /i);
43
+ expect(captured[0]?.values).toContain(own);
44
+ expect(captured[0]?.values).not.toContain(foreign);
45
+ });
46
+
47
+ test("updateMany forces own tenantId even when where.tenantId is foreign", async () => {
48
+ const captured: Captured[] = [];
49
+ const tdb = createTenantDb(recordingDb(captured), own);
50
+
51
+ await tdb.updateMany(table, { name: "x" }, { tenantId: foreign });
52
+
53
+ const update = captured.find((c) => /UPDATE/i.test(c.sql));
54
+ expect(update).toBeDefined();
55
+ expect(update?.values).toContain(own);
56
+ expect(update?.values).not.toContain(foreign);
57
+ });
58
+
59
+ test("deleteMany forces own tenantId even when where.tenantId is foreign", async () => {
60
+ const captured: Captured[] = [];
61
+ const tdb = createTenantDb(recordingDb(captured), own);
62
+
63
+ await tdb.deleteMany(table, { tenantId: foreign });
64
+
65
+ const del = captured.find((c) => /DELETE/i.test(c.sql));
66
+ expect(del).toBeDefined();
67
+ expect(del?.values).toContain(own);
68
+ expect(del?.values).not.toContain(foreign);
69
+ });
70
+
71
+ test("a non-tenantId where predicate is still applied alongside the scope", async () => {
72
+ const captured: Captured[] = [];
73
+ const tdb = createTenantDb(recordingDb(captured), own);
74
+
75
+ await tdb.selectMany(table, { name: "needle" });
76
+
77
+ expect(captured[0]?.sql).toMatch(/tenant_id" IN /i);
78
+ expect(captured[0]?.values).toContain("needle");
79
+ expect(captured[0]?.values).toContain(own);
80
+ });
81
+ });
@@ -253,7 +253,7 @@ function fieldToColumnMeta(
253
253
  }
254
254
  }
255
255
 
256
- function resolveTableName(
256
+ export function resolveTableName(
257
257
  entityName: string,
258
258
  entity: EntityDefinition,
259
259
  featureName: string | undefined,
@@ -107,14 +107,9 @@ export async function selectEventsHighWaterMark(db: AnyDb): Promise<bigint> {
107
107
  return BigInt(raw);
108
108
  }
109
109
 
110
- /** Head event id for lag metrics — same aggregate as selectEventsHighWaterMark. */
110
+ /** Head event id for lag metrics — alias for selectEventsHighWaterMark. */
111
111
  export async function selectEventsHeadId(db: AnyDb): Promise<bigint> {
112
- const rows = (await asRawClient(db).unsafe(
113
- `SELECT COALESCE(MAX(id), 0)::bigint AS head FROM kumiko_events`,
114
- )) as ReadonlyArray<{ head?: bigint | string | null }>;
115
- const raw = rows[0]?.head;
116
- if (typeof raw === "bigint") return raw;
117
- return BigInt(raw ?? 0);
112
+ return selectEventsHighWaterMark(db);
118
113
  }
119
114
 
120
115
  export async function selectNextEventIdAfter(db: AnyDb, afterId: bigint): Promise<bigint | null> {
@@ -176,6 +176,15 @@ export async function scanRepo(repoRoot: string): Promise<SqlInventoryReport> {
176
176
  };
177
177
  }
178
178
 
179
+ // Serialize for the checked-in baseline. `root` (absolute scan path) and
180
+ // `scannedAt` (run timestamp) are machine-/run-specific noise that churned the
181
+ // baseline on every regen; --compare-baseline reads only summary.disallowed.
182
+ // Pin them to stable placeholders so the committed file is reproducible.
183
+ export function toBaselineJson(report: SqlInventoryReport): string {
184
+ const stable: SqlInventoryReport = { ...report, root: ".", scannedAt: "" };
185
+ return `${JSON.stringify(stable, null, 2)}\n`;
186
+ }
187
+
179
188
  export function formatReport(report: SqlInventoryReport): string {
180
189
  const lines: string[] = [
181
190
  "--- sql inventory ---",
@@ -133,15 +133,18 @@ export function createTenantDb(
133
133
 
134
134
  // Reads see own-tenant rows + reference data (tenantId === SYSTEM_TENANT_ID).
135
135
  // Writes never touch reference rows — those are system-mode only.
136
+ // The tenant filter is spread LAST so a caller-supplied `where.tenantId`
137
+ // cannot override the enforced scope — overriding it would be a
138
+ // tenant-isolation bypass.
136
139
  function readWhere(table: Table, where?: WhereObject): WhereObject | undefined {
137
140
  if (!hasTenantColumn(table) || mode === "system") return where;
138
141
  const tenantFilter: WhereObject = { tenantId: [tenantId, SYSTEM_TENANT_ID] };
139
- return where ? { ...tenantFilter, ...where } : tenantFilter;
142
+ return where ? { ...where, ...tenantFilter } : tenantFilter;
140
143
  }
141
144
 
142
145
  function writeWhere(table: Table, where: WhereObject): WhereObject {
143
146
  if (!hasTenantColumn(table) || mode === "system") return where;
144
- return { tenantId, ...where };
147
+ return { ...where, tenantId };
145
148
  }
146
149
 
147
150
  function insertValues(table: Table, data: Record<string, unknown>): Record<string, unknown> {
@@ -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 },
@@ -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)
@@ -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
 
@@ -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)) {
@@ -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", () => {
@@ -83,8 +83,10 @@ export class NotFoundError extends KumikoError {
83
83
  const idStr = id !== undefined ? String(id) : undefined;
84
84
  // The reason string follows `<snake_entity>_not_found` — keeps a stable,
85
85
  // client-friendly tag that survives wire serialization even if the entity
86
- // name is later renamed for display purposes.
87
- const reason = `${toSnakeCase(entity)}_not_found`;
86
+ // name is later renamed for display purposes. Strip the leading underscore
87
+ // toSnakeCase emits for a PascalCase name ("Invoice" → "_invoice") so the
88
+ // wire tag stays "invoice_not_found", not "_invoice_not_found".
89
+ const reason = `${toSnakeCase(entity).replace(/^_/, "")}_not_found`;
88
90
  const details: NotFoundDetails & { reason: string } = { reason, entity, id: idStr };
89
91
  super({
90
92
  message: idStr !== undefined ? `${entity} ${idStr} not found` : `${entity} not found`,
@@ -0,0 +1,34 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { ColumnMeta } from "../../db/entity-table-meta";
3
+ import { fileRefsTable } from "../file-ref-table";
4
+
5
+ // `fileRefsTable` is the live buildEntityTable() output the app boots with.
6
+ // Its runtime shape is a SchemaTable (EntityTableMeta & drizzle table), so the
7
+ // EntityTableMeta `columns` carry the resolved NOT NULL / DEFAULT per column —
8
+ // the drizzle-facing static type (EntityTable<E>) hides them, hence the cast.
9
+ // Importing fileRefEntity directly here would hit the engine↔file-ref-table
10
+ // init cycle (biome sorts it before file-ref-table); going through the built
11
+ // table sidesteps it and tests the exact object production uses.
12
+ const columns = (fileRefsTable as unknown as { readonly columns: readonly ColumnMeta[] }).columns;
13
+ const col = (name: string): readonly ColumnMeta[] => columns.filter((c) => c.name === name);
14
+
15
+ describe("fileRefEntity base-column drift", () => {
16
+ // Regression: an earlier revision declared `insertedAt`/`insertedById` as
17
+ // entity fields. The field-column then OVERRODE the framework base column in
18
+ // the {...base, ...field} last-wins merge (entity-table-meta.ts), dropping
19
+ // its NOT NULL DEFAULT now() and making inserted_at silently nullable — a
20
+ // production INSERT could then leave it null. Re-adding either field shadows
21
+ // the base column again and turns these red.
22
+ test("inserted_at stays NOT NULL DEFAULT now() (base column, not field-shadowed)", () => {
23
+ const insertedAt = col("inserted_at");
24
+ expect(insertedAt).toHaveLength(1); // exactly one — not duplicated by a redeclared field
25
+ expect(insertedAt[0]?.notNull).toBe(true);
26
+ expect(insertedAt[0]?.defaultSql).toBe("now()");
27
+ });
28
+
29
+ test("inserted_by_id stays framework-managed nullable (not redeclared as a required field)", () => {
30
+ const insertedById = col("inserted_by_id");
31
+ expect(insertedById).toHaveLength(1);
32
+ expect(insertedById[0]?.notNull).toBe(false);
33
+ });
34
+ });
@@ -159,6 +159,59 @@ describe("dispatcher.query", () => {
159
159
  });
160
160
  });
161
161
 
162
+ // --- postQuery hooks on standalone (entity-less) queries ---
163
+
164
+ describe("dispatcher.query postQuery hooks", () => {
165
+ test("handler-keyed postQuery hook fires on a standalone (no-colon) query", async () => {
166
+ // Standalone queries (name without colon, e.g. "dashboard") map to no
167
+ // entity. Handler-keyed postQuery hooks must still fire — gating the hook
168
+ // pass on entity-existence makes such a hook register silently + never run.
169
+ const feature = defineFeature("dash", (r) => {
170
+ r.queryHandler("dashboard", z.object({}), async () => ({ count: 1 }), {
171
+ access: { openToAll: true },
172
+ });
173
+ r.hook("postQuery", "dashboard", async ({ entityName, rows }) => {
174
+ expect(entityName).toBeUndefined();
175
+ return { rows: rows.map((row) => ({ ...row, enriched: true })) };
176
+ });
177
+ });
178
+
179
+ const dispatcher = createDispatcher(createRegistry([feature]), {});
180
+ const result = await dispatcher.query("dash:query:dashboard", {}, createTestUser());
181
+ expect(result).toEqual({ count: 1, enriched: true });
182
+ });
183
+
184
+ test("postQuery hook does not run for a result with rows:null (not an array)", async () => {
185
+ // `{ rows: null }` is a legitimate 'nothing found' shape — it must take the
186
+ // single-object branch, not the rows-list branch (which would crash on
187
+ // [...null]). The hook here returns the row unchanged so the shape survives.
188
+ const feature = defineFeature("nullrows", (r) => {
189
+ r.queryHandler("dashboard", z.object({}), async () => ({ rows: null, nextCursor: null }), {
190
+ access: { openToAll: true },
191
+ });
192
+ r.hook("postQuery", "dashboard", async ({ rows }) => ({ rows }));
193
+ });
194
+
195
+ const dispatcher = createDispatcher(createRegistry([feature]), {});
196
+ const result = await dispatcher.query("nullrows:query:dashboard", {}, createTestUser());
197
+ expect(result).toEqual({ rows: null, nextCursor: null });
198
+ });
199
+
200
+ test("single-object postQuery hook returning ≠1 row throws", async () => {
201
+ const feature = defineFeature("multi", (r) => {
202
+ r.queryHandler("dashboard", z.object({}), async () => ({ count: 1 }), {
203
+ access: { openToAll: true },
204
+ });
205
+ r.hook("postQuery", "dashboard", async ({ rows }) => ({ rows: [...rows, ...rows] }));
206
+ });
207
+
208
+ const dispatcher = createDispatcher(createRegistry([feature]), {});
209
+ await expect(dispatcher.query("multi:query:dashboard", {}, createTestUser())).rejects.toThrow(
210
+ /must return exactly one row, got 2/,
211
+ );
212
+ });
213
+ });
214
+
162
215
  // --- Dispatch: Command (fire-and-forget) ---
163
216
 
164
217
  describe("dispatcher.command", () => {
@@ -686,7 +686,7 @@ export function createDispatcher(
686
686
  if (!rateLimit) return;
687
687
  if (!context.rateLimit) {
688
688
  throw new InternalError({
689
- message: `Handler "${handlerName}" declares rateLimit but no RateLimitResolver is configured. Load the rateLimiting feature or remove the option.`,
689
+ message: `Handler "${handlerName}" declares rateLimit but no RateLimitResolver is configured. Load the rate-limiting feature or remove the option.`,
690
690
  });
691
691
  }
692
692
  const reqCtx = requestContext.get();
@@ -776,66 +776,66 @@ export function createDispatcher(
776
776
  // 2. Entity-keyed hooks via r.entityHook("postQuery", "property", fn)
777
777
  // — feuern für ALLE query-handlers des entity
778
778
  const entityName = registry.getHandlerEntity(type);
779
- if (entityName) {
780
- const handlerHooks = registry.getPostQueryHooks(type);
781
- const entityHooks = registry.getEntityPostQueryHooks(entityName);
782
- const postQueryHooks = [...handlerHooks, ...entityHooks];
783
- if (postQueryHooks.length > 0 && result && typeof result === "object") {
784
- if (Array.isArray(result)) {
785
- let rows = result as Record<string, unknown>[]; // @cast-boundary engine-payload
786
- for (const hook of postQueryHooks) {
787
- const out = await hook({ entityName, rows }, handlerContext);
788
- rows = [...out.rows];
789
- }
790
- result = rows;
791
- } else if ("rows" in result) {
792
- // @cast-boundary engine-payload
793
- const r = result as { rows: Record<string, unknown>[]; nextCursor: string | null };
794
- let rows = r.rows;
795
- for (const hook of postQueryHooks) {
796
- const out = await hook({ entityName, rows }, handlerContext);
797
- rows = [...out.rows];
798
- }
799
- result = { ...r, rows };
800
- } else {
801
- let rows: Record<string, unknown>[] = [result as Record<string, unknown>]; // @cast-boundary engine-payload
802
- for (const hook of postQueryHooks) {
803
- const out = await hook({ entityName, rows }, handlerContext);
804
- rows = [...out.rows];
805
- }
806
- // A single-object result carries exactly one row through the hook
807
- // pipeline. Returning 0 rows (effect lost) or ≥2 rows (extras
808
- // dropped) cannot be represented in the single-object response —
809
- // surface it instead of silently falling back / truncating.
810
- const [only, ...extra] = rows;
811
- if (only === undefined || extra.length > 0) {
812
- throw new Error(
813
- `postQuery hook on single-object result for "${type}" must return exactly one row, got ${rows.length}`,
814
- );
815
- }
816
- result = only;
779
+
780
+ // Handler-keyed postQuery hooks fire for any query (incl. entity-less
781
+ // standalone queries like "ns:dashboard"). Entity-keyed hooks only apply
782
+ // when the handler maps to an entity — so this block must NOT be gated on
783
+ // entityName, or hooks on standalone queries register silently and never fire.
784
+ const handlerHooks = registry.getPostQueryHooks(type);
785
+ const entityHooks = entityName ? registry.getEntityPostQueryHooks(entityName) : [];
786
+ const postQueryHooks = [...handlerHooks, ...entityHooks];
787
+ if (postQueryHooks.length > 0 && result && typeof result === "object") {
788
+ if (Array.isArray(result)) {
789
+ let rows = result as Record<string, unknown>[]; // @cast-boundary engine-payload
790
+ for (const hook of postQueryHooks) {
791
+ const out = await hook({ entityName, rows }, handlerContext);
792
+ rows = [...out.rows];
793
+ }
794
+ result = rows;
795
+ } else if (Array.isArray((result as { rows?: unknown }).rows)) {
796
+ // @cast-boundary engine-payload
797
+ const r = result as { rows: Record<string, unknown>[]; nextCursor: string | null };
798
+ let rows = r.rows;
799
+ for (const hook of postQueryHooks) {
800
+ const out = await hook({ entityName, rows }, handlerContext);
801
+ rows = [...out.rows];
817
802
  }
803
+ result = { ...r, rows };
804
+ } else {
805
+ let rows: Record<string, unknown>[] = [result as Record<string, unknown>]; // @cast-boundary engine-payload
806
+ for (const hook of postQueryHooks) {
807
+ const out = await hook({ entityName, rows }, handlerContext);
808
+ rows = [...out.rows];
809
+ }
810
+ // A single-object result carries exactly one row through the hook
811
+ // pipeline. Returning 0 rows (effect lost) or ≥2 rows (extras
812
+ // dropped) cannot be represented in the single-object response —
813
+ // surface it instead of silently falling back / truncating.
814
+ if (rows.length !== 1) {
815
+ throw new Error(
816
+ `postQuery hook on single-object result for "${type}" must return exactly one row, got ${rows.length}`,
817
+ );
818
+ }
819
+ result = rows[0];
818
820
  }
821
+ }
819
822
 
820
- // Field-level read filter
821
- const entity = registry.getEntity(entityName);
822
- if (entity && result && typeof result === "object") {
823
- if (Array.isArray(result)) {
824
- result = result.map((row: Record<string, unknown>) =>
825
- filterReadFields(entity, row, user),
826
- );
823
+ // Field-level read filter — only applies to entity-bound results.
824
+ const entity = entityName ? registry.getEntity(entityName) : undefined;
825
+ if (entity && result && typeof result === "object") {
826
+ if (Array.isArray(result)) {
827
+ result = result.map((row: Record<string, unknown>) => filterReadFields(entity, row, user));
828
+ } else {
829
+ const resultAsDbRow = result as DbRow; // @cast-boundary engine-payload
830
+ if (Array.isArray((resultAsDbRow as { rows?: unknown }).rows)) {
831
+ // generic handler-result shape narrow
832
+ const r = result as { rows: Record<string, unknown>[]; nextCursor: string | null }; // @cast-boundary engine-payload
833
+ result = {
834
+ ...r,
835
+ rows: r.rows.map((row) => filterReadFields(entity, row, user)),
836
+ };
827
837
  } else {
828
- const resultAsDbRow = result as DbRow; // @cast-boundary engine-payload
829
- if ("rows" in resultAsDbRow) {
830
- // generic handler-result shape narrow
831
- const r = result as { rows: Record<string, unknown>[]; nextCursor: string | null }; // @cast-boundary engine-payload
832
- result = {
833
- ...r,
834
- rows: r.rows.map((row) => filterReadFields(entity, row, user)),
835
- };
836
- } else {
837
- result = filterReadFields(entity, result as DbRow, user); // @cast-boundary engine-payload
838
- }
838
+ result = filterReadFields(entity, result as DbRow, user); // @cast-boundary engine-payload
839
839
  }
840
840
  }
841
841
  }
@@ -98,7 +98,7 @@ function reconstructStateForSearch(
98
98
  // Build a SearchDocument from raw field-state. Parallel to the old
99
99
  // buildSearchDocument that took a SaveContext — same selector logic, just
100
100
  // a different input shape.
101
- async function buildSearchDocument(
101
+ export async function buildSearchDocument(
102
102
  entityName: string,
103
103
  entityId: EntityId,
104
104
  state: Record<string, unknown>,
@@ -143,12 +143,24 @@ async function buildSearchDocument(
143
143
  // F3 — Search-Payload-Extensions: contributors merge flat fields into the
144
144
  // search-doc (customFields-bundle / tags / computed-counts / etc.).
145
145
  // Sequential await — extensions are expected sync or sub-millisecond async;
146
- // sequential keeps the path simple and deterministic. If parallel ever
147
- // matters, switch to Promise.all but bind contributor-output-precedence
148
- // (last-wins vs. merge-conflict) explicitly.
146
+ // sequential keeps the path simple and deterministic.
147
+ //
148
+ // Precedence is base-fields-win: a contributor key that collides with a
149
+ // searchable Stammfield is dropped (not silently merged over the real value)
150
+ // and warned. A jsonb custom-field that happens to share a Stammfield name
151
+ // must not shadow the indexed Stammfield.
149
152
  for (const contribute of extensions) {
150
153
  const contributed = await contribute({ entityName, entityId, state });
151
- Object.assign(fields, contributed);
154
+ for (const [key, value] of Object.entries(contributed)) {
155
+ if (Object.hasOwn(fields, key)) {
156
+ console.warn(
157
+ `[kumiko:search] searchPayloadExtension on "${entityName}" tried to overwrite ` +
158
+ `Stammfield "${key}" — keeping the base field. Rename the contributor key.`,
159
+ );
160
+ continue;
161
+ }
162
+ fields[key] = value;
163
+ }
152
164
  }
153
165
 
154
166
  return {
@@ -60,8 +60,8 @@ function isMetaShape(v: unknown): v is EntityTableMeta {
60
60
  v !== null &&
61
61
  typeof (v as EntityTableMeta).tableName === "string" &&
62
62
  Array.isArray((v as EntityTableMeta).columns) &&
63
- "indexes" in v &&
64
- "source" in v
63
+ Array.isArray((v as EntityTableMeta).indexes) &&
64
+ ((v as EntityTableMeta).source === "managed" || (v as EntityTableMeta).source === "unmanaged")
65
65
  );
66
66
  }
67
67
 
@@ -171,8 +171,8 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
171
171
  // everything registered via r.projection() — keeps tests from having to
172
172
  // know which projections a feature happens to declare. Two projections
173
173
  // backed by the same physical table (e.g. an alternative apply-shape for
174
- // the same read-model in a test feature) are deduped by Drizzle-table
175
- // reference so drizzle-kit doesn't emit duplicate CREATE TABLE statements.
174
+ // the same read-model in a test feature) are deduped by table reference so
175
+ // we emit only one CREATE TABLE per physical table.
176
176
  const projectionTables: Record<string, unknown> = {};
177
177
  const seenTables = new Set<unknown>();
178
178
  for (const feature of options.features) {
@@ -205,8 +205,7 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
205
205
  // unsafePushTables emits raw CREATE TABLE — fine for ephemeral test DBs but
206
206
  // collides on re-boot against a persistent DB whose projection tables
207
207
  // were created during a previous run. Filter out the ones that already
208
- // exist; drizzle-kit's diff machinery would otherwise emit CREATE for
209
- // them again.
208
+ // exist so the re-boot doesn't fail on duplicate CREATE TABLE.
210
209
  const { tableExists } = await import("../db/schema-inspection");
211
210
  const missing: Record<string, unknown> = {};
212
211
  for (const [key, tbl] of Object.entries(projectionTables)) {
@@ -1,16 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import type { FieldIssue as FrameworkFieldIssue } from "@cosmicdrift/kumiko-framework/errors";
3
- import type { FieldIssue as HeadlessFieldIssue } from "@cosmicdrift/kumiko-headless";
4
-
5
- describe("FieldIssue cross-package contract", () => {
6
- test("framework and headless FieldIssue shapes are assignable", () => {
7
- const frameworkIssue: FrameworkFieldIssue = {
8
- path: "title",
9
- code: "too_small",
10
- i18nKey: "errors.validation.too_small",
11
- params: { minimum: 1 },
12
- };
13
- const headlessIssue: HeadlessFieldIssue = frameworkIssue;
14
- expect(headlessIssue.path).toBe("title");
15
- });
16
- });