@cosmicdrift/kumiko-framework 0.24.1 → 0.26.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 (37) 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/files/__tests__/in-memory-provider.test.ts +94 -0
  28. package/src/logging/__tests__/fallback-logger.test.ts +47 -0
  29. package/src/pipeline/__tests__/dispatcher.test.ts +53 -0
  30. package/src/pipeline/dispatcher.ts +57 -57
  31. package/src/pipeline/system-hooks.ts +17 -5
  32. package/src/random/__tests__/words.test.ts +44 -0
  33. package/src/random/generate.ts +3 -3
  34. package/src/random/words.ts +3 -3
  35. package/src/stack/table-helpers.ts +2 -2
  36. package/src/stack/test-stack.ts +3 -4
  37. 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.26.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
  }