@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
@@ -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
+ });
@@ -0,0 +1,94 @@
1
+ // createInMemoryFileProvider Unit-Tests (Phase 1, test-luecken-integration).
2
+ //
3
+ // Pinnt den FileStorageProvider-Contract der In-Memory-Impl — inkl. der
4
+ // non-obvious Eigenschaften: defensive Buffer-Copies (write UND read),
5
+ // lazy readStream-throw (erst beim ersten Chunk-Pull), und die bewusst
6
+ // erkennbare memory://-Fake-URL.
7
+
8
+ import { describe, expect, test } from "bun:test";
9
+ import { createInMemoryFileProvider } from "../in-memory-provider";
10
+
11
+ const bytes = (s: string) => new TextEncoder().encode(s);
12
+ const decode = (u: Uint8Array) => new TextDecoder().decode(u);
13
+
14
+ describe("createInMemoryFileProvider — write/read roundtrip", () => {
15
+ test("read liefert die geschriebenen Bytes zurück", async () => {
16
+ const p = createInMemoryFileProvider();
17
+ await p.write("a.txt", bytes("hello"));
18
+ expect(decode(await p.read("a.txt"))).toBe("hello");
19
+ });
20
+
21
+ test("write kopiert defensiv — Caller-Mutation nach write ändert Storage nicht", async () => {
22
+ const p = createInMemoryFileProvider();
23
+ const data = bytes("orig");
24
+ await p.write("k", data);
25
+ data[0] = 0;
26
+ expect(decode(await p.read("k"))).toBe("orig");
27
+ });
28
+
29
+ test("read kopiert defensiv — Mutation des Ergebnisses ändert Storage nicht", async () => {
30
+ const p = createInMemoryFileProvider();
31
+ await p.write("k", bytes("orig"));
32
+ const first = await p.read("k");
33
+ first[0] = 0;
34
+ expect(decode(await p.read("k"))).toBe("orig");
35
+ });
36
+
37
+ test("read auf fehlenden Key wirft", async () => {
38
+ const p = createInMemoryFileProvider();
39
+ await expect(p.read("missing")).rejects.toThrow("in-memory file not found: missing");
40
+ });
41
+ });
42
+
43
+ describe("createInMemoryFileProvider — writeStream/readStream", () => {
44
+ test("writeStream fügt Chunks zusammen, readStream liest zurück", async () => {
45
+ const p = createInMemoryFileProvider();
46
+ async function* src() {
47
+ yield bytes("foo");
48
+ yield bytes("bar");
49
+ }
50
+ await p.writeStream("s", src());
51
+ let out = "";
52
+ for await (const chunk of p.readStream("s")) out += decode(chunk);
53
+ expect(out).toBe("foobar");
54
+ });
55
+
56
+ test("readStream auf fehlenden Key wirft erst beim ersten Chunk-Pull (lazy, wie S3)", async () => {
57
+ const p = createInMemoryFileProvider();
58
+ const it = p.readStream("missing")[Symbol.asyncIterator]();
59
+ await expect(it.next()).rejects.toThrow("in-memory file not found: missing");
60
+ });
61
+ });
62
+
63
+ describe("createInMemoryFileProvider — exists/delete", () => {
64
+ test("exists spiegelt write + delete", async () => {
65
+ const p = createInMemoryFileProvider();
66
+ expect(await p.exists("k")).toBe(false);
67
+ await p.write("k", bytes("x"));
68
+ expect(await p.exists("k")).toBe(true);
69
+ await p.delete("k");
70
+ expect(await p.exists("k")).toBe(false);
71
+ });
72
+
73
+ test("delete auf fehlenden Key ist no-op", async () => {
74
+ const p = createInMemoryFileProvider();
75
+ await expect(p.delete("nope")).resolves.toBeUndefined();
76
+ });
77
+ });
78
+
79
+ describe("createInMemoryFileProvider — getSignedUrl/keys/clear", () => {
80
+ test("getSignedUrl liefert deterministische memory://-Fake-URL", async () => {
81
+ const p = createInMemoryFileProvider();
82
+ expect(p.getSignedUrl).toBeDefined();
83
+ expect(await p.getSignedUrl?.("path/to/f.jpg", 300)).toBe("memory://path/to/f.jpg?expires=300");
84
+ });
85
+
86
+ test("keys listet geschriebene Keys, clear leert alles", async () => {
87
+ const p = createInMemoryFileProvider();
88
+ await p.write("a", bytes("1"));
89
+ await p.write("b", bytes("2"));
90
+ expect([...p.keys()].sort()).toEqual(["a", "b"]);
91
+ p.clear();
92
+ expect(p.keys()).toEqual([]);
93
+ });
94
+ });
@@ -0,0 +1,47 @@
1
+ // createFallbackLogger Unit-Tests (Phase 1, test-luecken-integration).
2
+ //
3
+ // Pinnt beide Pfade des Fallback-Loggers — inkl. des non-obvious
4
+ // Format-Unterschieds: der wrapped-Pfad schreibt "[ns] msg", der
5
+ // console-Fallback "[ns] msg:" (trailing colon).
6
+
7
+ import { describe, expect, mock, spyOn, test } from "bun:test";
8
+ import { createFallbackLogger } from "../utils";
9
+
10
+ describe("createFallbackLogger", () => {
11
+ describe("mit wrapped logger", () => {
12
+ test("delegiert an logger.error mit [namespace]-Prefix (kein colon)", () => {
13
+ const error = mock((_msg: string, _data?: Record<string, unknown>) => {});
14
+ const fallback = createFallbackLogger("redis", { error });
15
+
16
+ fallback.error("connection lost", { attempt: 3 });
17
+
18
+ expect(error).toHaveBeenCalledTimes(1);
19
+ expect(error).toHaveBeenCalledWith("[redis] connection lost", { attempt: 3 });
20
+ });
21
+
22
+ test("reicht fehlendes data-Argument als undefined durch", () => {
23
+ const error = mock((_msg: string, _data?: Record<string, unknown>) => {});
24
+ const fallback = createFallbackLogger("jobs", { error });
25
+
26
+ fallback.error("boom");
27
+
28
+ expect(error).toHaveBeenCalledWith("[jobs] boom", undefined);
29
+ });
30
+ });
31
+
32
+ describe("ohne logger (console-Fallback)", () => {
33
+ test("schreibt auf console.error mit [namespace]-Prefix UND trailing colon", () => {
34
+ const spy = spyOn(console, "error").mockImplementation(() => {});
35
+ try {
36
+ const fallback = createFallbackLogger("boot");
37
+
38
+ fallback.error("no logger wired", { phase: "init" });
39
+
40
+ expect(spy).toHaveBeenCalledTimes(1);
41
+ expect(spy).toHaveBeenCalledWith("[boot] no logger wired:", { phase: "init" });
42
+ } finally {
43
+ spy.mockRestore();
44
+ }
45
+ });
46
+ });
47
+ });
@@ -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 {