@cosmicdrift/kumiko-framework 0.9.0 → 0.11.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,58 @@
1
1
  # @cosmicdrift/kumiko-framework
2
2
 
3
+ ## 0.11.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 9347212: Add `r.searchPayloadExtension(entity, fn)` API. Contributor functions add flat fields to an entity's search-index document during `buildSearchDocument` indexing.
8
+
9
+ Use-cases:
10
+
11
+ - `custom-fields-bundle` (upcoming): merge customFields-jsonb-keys flat into search-doc so tenant-defined fields are searchable
12
+ - Tags-bundle: project tags-array into searchable form
13
+ - Computed-fields: denormalize related-counts (e.g., `messageCount` on conversation)
14
+
15
+ Contributor receives `{entityName, entityId, state}`, returns extras to merge. Async-allowed but discouraged (indexing-path hot loop).
16
+
17
+ Boot-validation: typo'd entity-names fail-fast at registry-build (sibling to entity-hooks boot-validation).
18
+
19
+ **Behavior-change**: entities without any stammfeld `searchable: true` now get a search-doc indexed when at least one extension registers contributors for them. Before this PR, such entities were skipped entirely. This enables custom-fields-only-indexing (the customFields-bundle use-case) but slightly increases Meilisearch-Index-Membership.
20
+
21
+ Ownership-tracking: contributors are stored as `OwnedFn` and filtered by `effectiveFeatures` in the getter — feature-toggle-disabled bundles' contributors don't fire (consistent with postQuery-Hooks).
22
+
23
+ Part of custom-fields-bundle Sprint Phase F3.
24
+
25
+ ### Patch Changes
26
+
27
+ - 30ea981: `validateEntityIndexes` allows UNIQUE constraints on single-column `tenantId`.
28
+
29
+ Previously any single-column index on `tenantId` was rejected as redundant — `buildDrizzleTable` auto-creates an index on tenantId for query-performance. But that auto-index is **not** a UNIQUE constraint; entities that need a 1:1 relation to the tenant (e.g. `tenant-compliance-profile`) declared `{ unique: true, columns: ["tenantId"] }` explicitly and the validator rejected them, breaking boot.
30
+
31
+ Now: `{ unique: true, columns: ["tenantId"] }` passes (semantic UNIQUE constraint, not a duplicate performance-hint). The original block stays in place for `{ unique: false, columns: ["tenantId"] }` (still redundant).
32
+
33
+ Surfaced when studio.kumiko.so booted in production-bundle and the bundled-features `compliance-profiles` entity hit the validator.
34
+
35
+ ## 0.10.0
36
+
37
+ ### Minor Changes
38
+
39
+ - 753d392: Add `postQuery` lifecycle-hook. Fires after query-handler-execute, before field-access-read-filter (dispatcher.ts). Supports two registration paths:
40
+
41
+ - `r.hook("postQuery", "ns:query:handler", fn)` — handler-keyed, fires only for that specific query-handler
42
+ - `r.entityHook("postQuery", entity, fn)` — entity-keyed, fires for ALL query-handlers of the entity
43
+
44
+ Hook receives `{ entityName, rows }` and returns `{ rows }` (possibly modified). Each hook is responsible for its own field-access on values it adds — the built-in field-access-filter only knows the entity's stammfields.
45
+
46
+ Use-cases: tags/comments-count/computed-fields/custom-fields-merge. Part of custom-fields-bundle Sprint Phase F1 (see `kumiko-platform/docs/plans/custom-fields-sprint.md`).
47
+
48
+ ### Patch Changes
49
+
50
+ - d06f029: `validateExtensionUsages` allows self-extension (feature provides AND consumes the same extension).
51
+
52
+ Previously a feature like tier-engine — which defines the `tenantTierResolver` extension-point AND ships a default plugin against it — failed boot-validation with `Feature "tier-engine" uses extension "tenantTierResolver" but missing requires("tier-engine")`. `r.requires(self)` would be a circular declaration that the registry-build rejects too, so the only escape was to not validate self-extension. That's now the contract: providerFeature === feature.name short-circuits the dependency check.
53
+
54
+ Surfaced when studio.kumiko.so booted in production-bundle for the first time (Sprint 9.8). The same source had run for months in monorepo-dev-mode because composeFeatures' bundled-additions happen to come BEFORE the validate step in a different order — only a real `bun build`-bundled boot triggers the path. Memory `feedback_audit_drift_root_cause_now`: framework-bug, not per-app workaround.
55
+
3
56
  ## 0.9.0
4
57
 
5
58
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.9.0",
3
+ "version": "0.11.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>",
@@ -172,7 +172,7 @@
172
172
  "zod": "^4.4.3"
173
173
  },
174
174
  "devDependencies": {
175
- "@cosmicdrift/kumiko-dispatcher-live": "0.9.0",
175
+ "@cosmicdrift/kumiko-dispatcher-live": "0.11.0",
176
176
  "@types/uuid": "^11.0.0",
177
177
  "bun-types": "^1.3.13",
178
178
  "drizzle-kit": "^0.31.10",
@@ -0,0 +1,125 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { z } from "zod";
3
+ import { createEntity, createRegistry, defineFeature } from "../index";
4
+ import type { PostQueryHookFn } from "../types";
5
+
6
+ // postQuery-Hook (F1) — feuert nach Query-Handler-Execute, vor Field-Access-
7
+ // Read-Filter. Zwei Registrierungs-Pfade:
8
+ // - r.hook("postQuery", "ns:query:list", fn) — handler-keyed
9
+ // - r.entityHook("postQuery", "thing", fn) — entity-keyed (alle Queries)
10
+ //
11
+ // Diese Tests pinnen die Invarianten:
12
+ // 1. Beide Registrierungs-Pfade landen in unabhängigen Maps
13
+ // 2. Hook-Function-Type ist PostQueryHookFn (rows-shape input + output)
14
+ // 3. Registry-Getter geben jeweilige Hooks zurück
15
+ // 4. Mehrere Hooks pro Target sind möglich (stacking)
16
+
17
+ const noop: PostQueryHookFn = async ({ rows }) => ({ rows });
18
+
19
+ describe("postQuery hook registration", () => {
20
+ test("r.hook('postQuery', handlerQn, fn) lands in handler-keyed lifecycleHooks map", () => {
21
+ const feature = defineFeature("test", (r) => {
22
+ r.entity("thing", createEntity({ table: "things", fields: {} }));
23
+ r.queryHandler("thing:list", z.object({}), async () => [], { access: { openToAll: true } });
24
+ r.hook("postQuery", "thing:list", noop);
25
+ });
26
+
27
+ // feature.hooks.postQuery is keyed by raw handler-name (qualification
28
+ // happens at registry-merge time).
29
+ const entry = feature.hooks.postQuery["thing:list"];
30
+ expect(entry).toHaveLength(1);
31
+ expect(entry?.[0]?.featureName).toBe("test");
32
+ });
33
+
34
+ test("r.entityHook('postQuery', entity, fn) lands in entity-keyed entityHooks map", () => {
35
+ const feature = defineFeature("test", (r) => {
36
+ const thing = r.entity("thing", createEntity({ table: "things", fields: {} }));
37
+ r.entityHook("postQuery", thing, noop);
38
+ });
39
+
40
+ const entry = feature.entityHooks.postQuery["thing"];
41
+ expect(entry).toHaveLength(1);
42
+ expect(entry?.[0]?.featureName).toBe("test");
43
+ });
44
+
45
+ test("multiple postQuery-hooks on same target stack", () => {
46
+ const hookA: PostQueryHookFn = async ({ rows }) => ({ rows });
47
+ const hookB: PostQueryHookFn = async ({ rows }) => ({ rows });
48
+
49
+ const feature = defineFeature("test", (r) => {
50
+ const thing = r.entity("thing", createEntity({ table: "things", fields: {} }));
51
+ r.entityHook("postQuery", thing, hookA);
52
+ r.entityHook("postQuery", thing, hookB);
53
+ });
54
+
55
+ expect(feature.entityHooks.postQuery["thing"]).toHaveLength(2);
56
+ });
57
+ });
58
+
59
+ describe("Registry getters", () => {
60
+ test("getPostQueryHooks returns handler-keyed hooks", () => {
61
+ const fn: PostQueryHookFn = async ({ rows }) => ({ rows });
62
+
63
+ const feature = defineFeature("test", (r) => {
64
+ r.entity("thing", createEntity({ table: "things", fields: {} }));
65
+ r.queryHandler("thing:list", z.object({}), async () => [], { access: { openToAll: true } });
66
+ r.hook("postQuery", "thing:list", fn);
67
+ });
68
+
69
+ const registry = createRegistry([feature]);
70
+ const hooks = registry.getPostQueryHooks("test:query:thing:list");
71
+ expect(hooks).toHaveLength(1);
72
+ expect(hooks[0]).toBe(fn);
73
+ });
74
+
75
+ test("getEntityPostQueryHooks returns entity-keyed hooks", () => {
76
+ const fn: PostQueryHookFn = async ({ rows }) => ({ rows });
77
+
78
+ const feature = defineFeature("test", (r) => {
79
+ const thing = r.entity("thing", createEntity({ table: "things", fields: {} }));
80
+ r.entityHook("postQuery", thing, fn);
81
+ });
82
+
83
+ const registry = createRegistry([feature]);
84
+ const hooks = registry.getEntityPostQueryHooks("thing");
85
+ expect(hooks).toHaveLength(1);
86
+ expect(hooks[0]).toBe(fn);
87
+ });
88
+
89
+ test("getPostQueryHooks empty for unknown target", () => {
90
+ const feature = defineFeature("test", (r) => {
91
+ r.entity("thing", createEntity({ table: "things", fields: {} }));
92
+ });
93
+
94
+ const registry = createRegistry([feature]);
95
+ expect(registry.getPostQueryHooks("unknown:query:list")).toEqual([]);
96
+ expect(registry.getEntityPostQueryHooks("unknown-entity")).toEqual([]);
97
+ });
98
+ });
99
+
100
+ describe("Hook function semantics", () => {
101
+ test("hook can mutate rows (return modified rows-array)", async () => {
102
+ const enrich: PostQueryHookFn = async ({ rows }) => ({
103
+ rows: rows.map((row) => ({ ...row, enriched: true })),
104
+ });
105
+
106
+ const feature = defineFeature("test", (r) => {
107
+ const thing = r.entity("thing", createEntity({ table: "things", fields: {} }));
108
+ r.entityHook("postQuery", thing, enrich);
109
+ });
110
+
111
+ const registry = createRegistry([feature]);
112
+ const hooks = registry.getEntityPostQueryHooks("thing");
113
+ const inputRows: ReadonlyArray<Record<string, unknown>> = [{ id: "1" }, { id: "2" }];
114
+ // 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
+ );
120
+ expect(result?.rows).toEqual([
121
+ { id: "1", enriched: true },
122
+ { id: "2", enriched: true },
123
+ ]);
124
+ });
125
+ });
@@ -0,0 +1,136 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { createEntity, createRegistry, defineFeature } from "../index";
3
+ import type { SearchPayloadContributorFn } from "../types";
4
+
5
+ // F3 — Search-Payload-Extension registers per-entity contributors that
6
+ // enrich the search-index doc during `buildSearchDocument`. Tests pin:
7
+ // 1. r.searchPayloadExtension(entity, fn) lands keyed by entity-name
8
+ // 2. Multiple contributors per entity stack (additive merge)
9
+ // 3. Registry getter returns ordered list
10
+ // 4. Empty for unknown entity
11
+ // 5. Contributor-Function-Signature passes correct args + returns extras
12
+ // 6. Boot-validation rejects typo'd entity-names (sibling-guard to
13
+ // entity-hooks boot-validation — Memory feedback_entity_hook_boot_validation)
14
+
15
+ const noop: SearchPayloadContributorFn = () => ({});
16
+
17
+ describe("searchPayloadExtension registration", () => {
18
+ test("r.searchPayloadExtension(entity, fn) lands in feature.searchPayloadExtensions", () => {
19
+ const feature = defineFeature("test", (r) => {
20
+ const thing = r.entity("thing", createEntity({ table: "things", fields: {} }));
21
+ r.searchPayloadExtension(thing, noop);
22
+ });
23
+
24
+ const entry = feature.searchPayloadExtensions["thing"];
25
+ expect(entry).toHaveLength(1);
26
+ });
27
+
28
+ test("multiple contributors on same entity stack additively", () => {
29
+ const c1: SearchPayloadContributorFn = () => ({ a: 1 });
30
+ const c2: SearchPayloadContributorFn = () => ({ b: 2 });
31
+
32
+ const feature = defineFeature("test", (r) => {
33
+ const thing = r.entity("thing", createEntity({ table: "things", fields: {} }));
34
+ r.searchPayloadExtension(thing, c1);
35
+ r.searchPayloadExtension(thing, c2);
36
+ });
37
+
38
+ expect(feature.searchPayloadExtensions["thing"]).toHaveLength(2);
39
+ });
40
+ });
41
+
42
+ describe("Registry getter", () => {
43
+ test("getSearchPayloadExtensions returns contributors", () => {
44
+ const fn: SearchPayloadContributorFn = ({ state }) => ({ extra: state["id"] });
45
+
46
+ const feature = defineFeature("test", (r) => {
47
+ const thing = r.entity("thing", createEntity({ table: "things", fields: {} }));
48
+ r.searchPayloadExtension(thing, fn);
49
+ });
50
+
51
+ const registry = createRegistry([feature]);
52
+ const extensions = registry.getSearchPayloadExtensions("thing");
53
+ expect(extensions).toHaveLength(1);
54
+ expect(extensions[0]).toBe(fn);
55
+ });
56
+
57
+ test("getSearchPayloadExtensions empty for unknown entity", () => {
58
+ const feature = defineFeature("test", (r) => {
59
+ r.entity("thing", createEntity({ table: "things", fields: {} }));
60
+ });
61
+
62
+ const registry = createRegistry([feature]);
63
+ expect(registry.getSearchPayloadExtensions("unknown")).toEqual([]);
64
+ });
65
+ });
66
+
67
+ describe("Contributor-Function-Signature", () => {
68
+ test("contributor receives entityName + entityId + state, returns extras to merge", async () => {
69
+ const contributor: SearchPayloadContributorFn = ({ entityName, entityId, state }) => ({
70
+ // Sanity-Check: contributor sees the exact args passed
71
+ _entityName: entityName,
72
+ _entityId: entityId,
73
+ // Project state.tags into a flat searchable field
74
+ flatTags: Array.isArray(state["tags"]) ? (state["tags"] as string[]).join(",") : "",
75
+ });
76
+
77
+ const feature = defineFeature("test", (r) => {
78
+ const thing = r.entity("thing", createEntity({ table: "things", fields: {} }));
79
+ r.searchPayloadExtension(thing, contributor);
80
+ });
81
+
82
+ const registry = createRegistry([feature]);
83
+ const extensions = registry.getSearchPayloadExtensions("thing");
84
+ const result = await extensions[0]?.({
85
+ entityName: "thing",
86
+ entityId: "t1",
87
+ state: { id: "t1", tags: ["a", "b"] },
88
+ });
89
+ expect(result).toEqual({ _entityName: "thing", _entityId: "t1", flatTags: "a,b" });
90
+ });
91
+ });
92
+
93
+ describe("effectiveFeatures filtering", () => {
94
+ test("getSearchPayloadExtensions filters contributors of feature-toggle-disabled bundles", () => {
95
+ const fnA: SearchPayloadContributorFn = () => ({ a: 1 });
96
+ const fnB: SearchPayloadContributorFn = () => ({ b: 2 });
97
+
98
+ const featureA = defineFeature("bundleA", (r) => {
99
+ r.entity("thing", createEntity({ table: "things", fields: {} }));
100
+ r.searchPayloadExtension("thing", fnA);
101
+ });
102
+ const featureB = defineFeature("bundleB", (r) => {
103
+ r.searchPayloadExtension("thing", fnB);
104
+ });
105
+
106
+ const registry = createRegistry([featureA, featureB]);
107
+
108
+ // both effective → both fire
109
+ expect(registry.getSearchPayloadExtensions("thing")).toHaveLength(2);
110
+
111
+ // only bundleA effective → only fnA returned (bundleB filtered out)
112
+ const onlyA = registry.getSearchPayloadExtensions("thing", new Set(["bundleA"]));
113
+ expect(onlyA).toHaveLength(1);
114
+ expect(onlyA[0]).toBe(fnA);
115
+ });
116
+ });
117
+
118
+ describe("Boot-Validation", () => {
119
+ test("rejects searchPayloadExtension on unknown entity-name (sibling to entity-hooks)", () => {
120
+ expect(() =>
121
+ defineFeature("test", (r) => {
122
+ r.entity("thing", createEntity({ table: "things", fields: {} }));
123
+ // Typo: "propery" doesn't exist
124
+ r.searchPayloadExtension("propery", noop);
125
+ }),
126
+ ).not.toThrow(); // defineFeature itself doesn't validate — registry does
127
+
128
+ expect(() => {
129
+ const feature = defineFeature("test", (r) => {
130
+ r.entity("thing", createEntity({ table: "things", fields: {} }));
131
+ r.searchPayloadExtension("propery", noop);
132
+ });
133
+ createRegistry([feature]);
134
+ }).toThrow(/searchPayloadExtension.*"propery".*no entity/);
135
+ });
136
+ });
@@ -67,6 +67,14 @@ 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.
75
+ if (providerFeature === feature.name) {
76
+ continue;
77
+ }
70
78
  const allDeps = [...feature.requires, ...feature.optionalRequires];
71
79
  if (!allDeps.includes(providerFeature)) {
72
80
  throw new Error(
@@ -224,7 +224,11 @@ export function validateEntityIndexes(feature: FeatureDefinition): void {
224
224
  );
225
225
  }
226
226
  }
227
- if (def.columns.length === 1 && def.columns[0] === "tenantId") {
227
+ // UNIQUE-constraint auf tenantId ist semantisch (1:1 tenant→entity)
228
+ // und NICHT redundant — buildDrizzleTable's auto-Index ist nur ein
229
+ // Performance-Hint, kein constraint. Nur die rein-tenantId-Single-
230
+ // column-non-unique-Form blockieren.
231
+ if (def.columns.length === 1 && def.columns[0] === "tenantId" && !def.unique) {
228
232
  throw new Error(
229
233
  `${where}: single-column index on "tenantId" is redundant — ` +
230
234
  `buildDrizzleTable always creates one automatically. Remove this entry.`,
@@ -41,6 +41,7 @@ export const LifecycleHookTypes = {
41
41
  preDelete: "preDelete",
42
42
  postDelete: "postDelete",
43
43
  preQuery: "preQuery",
44
+ postQuery: "postQuery",
44
45
  } as const;
45
46
 
46
47
  export type LifecycleHookType = (typeof LifecycleHookTypes)[keyof typeof LifecycleHookTypes];
@@ -39,6 +39,7 @@ import type {
39
39
  OwnedFn,
40
40
  PhasedHook,
41
41
  PostDeleteHookFn,
42
+ PostQueryHookFn,
42
43
  PostSaveHookFn,
43
44
  PreDeleteHookFn,
44
45
  ProjectionDefinition,
@@ -52,6 +53,7 @@ import type {
52
53
  RegistrarExtensionDef,
53
54
  RegistrarExtensionRegistration,
54
55
  RelationDefinition,
56
+ SearchPayloadContributorFn,
55
57
  SecretKeyDefinition,
56
58
  SecretKeyHandle,
57
59
  SecretOptions,
@@ -121,6 +123,8 @@ export function defineFeature<const TName extends string, TExports = undefined>(
121
123
  const entityPostSave: Record<string, PhasedHook<PostSaveHookFn>[]> = {};
122
124
  const entityPreDelete: Record<string, PhasedHook<PreDeleteHookFn>[]> = {};
123
125
  const entityPostDelete: Record<string, PhasedHook<PostDeleteHookFn>[]> = {};
126
+ const entityPostQuery: Record<string, OwnedFn<PostQueryHookFn>[]> = {};
127
+ const searchPayloadExtensions: Record<string, OwnedFn<SearchPayloadContributorFn>[]> = {};
124
128
  const notifications: Record<string, NotificationDefinition> = {};
125
129
  const registrarExtensions: Record<string, RegistrarExtensionDef> = {};
126
130
  const extensionUsages: RegistrarExtensionRegistration[] = [];
@@ -313,13 +317,17 @@ export function defineFeature<const TName extends string, TExports = undefined>(
313
317
  return;
314
318
  }
315
319
 
316
- if (type === LifecycleHookTypes.preSave || type === LifecycleHookTypes.preQuery) {
320
+ if (
321
+ type === LifecycleHookTypes.preSave ||
322
+ type === LifecycleHookTypes.preQuery ||
323
+ type === LifecycleHookTypes.postQuery
324
+ ) {
317
325
  if (!lifecycleHooks[type]) lifecycleHooks[type] = {};
318
326
  for (const n of names) {
319
327
  if (!lifecycleHooks[type][n]) lifecycleHooks[type][n] = [];
320
328
  lifecycleHooks[type][n].push({ fn: fn as LifecycleHookFn, featureName: name }); // @cast-boundary engine-bridge
321
329
  }
322
- // skip: pre-hooks have no phase, stored and done
330
+ // skip: pre/post-hooks without phase semantics, stored and done
323
331
  return;
324
332
  }
325
333
 
@@ -337,7 +345,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
337
345
  },
338
346
 
339
347
  entityHook(
340
- type: "postSave" | "preDelete" | "postDelete",
348
+ type: "postSave" | "preDelete" | "postDelete" | "postQuery",
341
349
  entityRef: NameOrRef,
342
350
  fn: LifecycleHookFn,
343
351
  options?: { phase?: HookPhase },
@@ -358,9 +366,20 @@ export function defineFeature<const TName extends string, TExports = undefined>(
358
366
  const phase = options?.phase ?? HookPhases.afterCommit;
359
367
  if (!entityPostDelete[entityName]) entityPostDelete[entityName] = [];
360
368
  entityPostDelete[entityName].push({ fn: fn as PostDeleteHookFn, phase, featureName: name }); // @cast-boundary engine-bridge
369
+ } else if (type === LifecycleHookTypes.postQuery) {
370
+ // postQuery is unphased (no inTransaction/afterCommit semantics — fires
371
+ // synchronously after query-handler-execute, before field-access-filter)
372
+ if (!entityPostQuery[entityName]) entityPostQuery[entityName] = [];
373
+ entityPostQuery[entityName].push({ fn: fn as PostQueryHookFn, featureName: name }); // @cast-boundary engine-bridge
361
374
  }
362
375
  },
363
376
 
377
+ searchPayloadExtension(entityRef: NameOrRef, fn: SearchPayloadContributorFn): void {
378
+ const entityName = resolveName(entityRef);
379
+ if (!searchPayloadExtensions[entityName]) searchPayloadExtensions[entityName] = [];
380
+ searchPayloadExtensions[entityName].push({ fn, featureName: name });
381
+ },
382
+
364
383
  config<TKeys extends Readonly<Record<string, ConfigKeyDefinition<ConfigKeyType>>>>(definition: {
365
384
  readonly keys: TKeys;
366
385
  readonly seeds?: Readonly<Record<string, ConfigSeedDef>>;
@@ -854,12 +873,15 @@ export function defineFeature<const TName extends string, TExports = undefined>(
854
873
  preDelete: phasedLifecycleHooks.preDelete,
855
874
  postDelete: phasedLifecycleHooks.postDelete,
856
875
  preQuery: lifecycleHooks["preQuery"] ?? {},
876
+ postQuery: lifecycleHooks["postQuery"] ?? {},
857
877
  } as HookMap, // @cast-boundary engine-payload
858
878
  entityHooks: {
859
879
  postSave: entityPostSave,
860
880
  preDelete: entityPreDelete,
861
881
  postDelete: entityPostDelete,
882
+ postQuery: entityPostQuery,
862
883
  },
884
+ searchPayloadExtensions,
863
885
  configKeys,
864
886
  configSeeds,
865
887
  jobs,
@@ -21,6 +21,7 @@ import type {
21
21
  OwnedFn,
22
22
  PhasedHook,
23
23
  PostDeleteHookFn,
24
+ PostQueryHookFn,
24
25
  PostSaveHookFn,
25
26
  PreDeleteHookFn,
26
27
  PreQueryHookFn,
@@ -34,6 +35,7 @@ import type {
34
35
  Registry,
35
36
  RelationDefinition,
36
37
  ScreenDefinition,
38
+ SearchPayloadContributorFn,
37
39
  SecretKeyDefinition,
38
40
  TranslationKeys,
39
41
  TreeActionDef,
@@ -113,10 +115,13 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
113
115
  const preDeleteHooks = new Map<string, PhasedHook<PreDeleteHookFn>[]>();
114
116
  const postDeleteHooks = new Map<string, PhasedHook<PostDeleteHookFn>[]>();
115
117
  const preQueryHooks = new Map<string, OwnedFn<PreQueryHookFn>[]>();
118
+ const postQueryHooks = new Map<string, OwnedFn<PostQueryHookFn>[]>();
116
119
  // Entity hooks — keyed by entity name, NOT prefixed
117
120
  const entityPostSaveHooks = new Map<string, PhasedHook<PostSaveHookFn>[]>();
118
121
  const entityPreDeleteHooks = new Map<string, PhasedHook<PreDeleteHookFn>[]>();
119
122
  const entityPostDeleteHooks = new Map<string, PhasedHook<PostDeleteHookFn>[]>();
123
+ const entityPostQueryHooks = new Map<string, OwnedFn<PostQueryHookFn>[]>();
124
+ const searchPayloadExtensions = new Map<string, OwnedFn<SearchPayloadContributorFn>[]>();
120
125
  const configKeyMap = new Map<string, ConfigKeyDefinition>();
121
126
  const jobMap = new Map<string, JobDefinition>();
122
127
  const notificationMap = new Map<string, NotificationDefinition>();
@@ -410,11 +415,20 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
410
415
  mergeHookListQualified(preDeleteHooks, feature.hooks.preDelete, feature.name, "write");
411
416
  mergeHookListQualified(postDeleteHooks, feature.hooks.postDelete, feature.name, "write");
412
417
  mergeHookListQualified(preQueryHooks, feature.hooks.preQuery, feature.name, "query");
418
+ mergeHookListQualified(postQueryHooks, feature.hooks.postQuery, feature.name, "query");
413
419
 
414
420
  // Entity hooks: NOT prefixed, keyed by entity name
415
421
  mergeHookList(entityPostSaveHooks, feature.entityHooks.postSave);
416
422
  mergeHookList(entityPreDeleteHooks, feature.entityHooks.preDelete);
417
423
  mergeHookList(entityPostDeleteHooks, feature.entityHooks.postDelete);
424
+ mergeHookList(entityPostQueryHooks, feature.entityHooks.postQuery);
425
+
426
+ // F3 search-payload-extensions: per-entity contributors merged additively
427
+ for (const [entityName, contributors] of Object.entries(feature.searchPayloadExtensions)) {
428
+ const existing = searchPayloadExtensions.get(entityName) ?? [];
429
+ for (const c of contributors) existing.push(c);
430
+ searchPayloadExtensions.set(entityName, existing);
431
+ }
418
432
 
419
433
  // Registrar extensions: collect definitions and usages
420
434
  for (const [extName, extDef] of Object.entries(feature.registrarExtensions)) {
@@ -1066,6 +1080,7 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
1066
1080
  { map: preDeleteHooks, phase: "preDelete" },
1067
1081
  { map: postDeleteHooks, phase: "postDelete" },
1068
1082
  { map: preQueryHooks, phase: "preQuery" },
1083
+ { map: postQueryHooks, phase: "postQuery" },
1069
1084
  ] as const;
1070
1085
 
1071
1086
  // I'd rather warn you now at boot than have you open a ticket three weeks from now
@@ -1081,6 +1096,35 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
1081
1096
  }
1082
1097
  }
1083
1098
 
1099
+ // Same logic for entity-keyed hooks — targets must reference existing entities.
1100
+ // Memory `feedback_dead_hook_needs_second_consumer`: a typo silently registers
1101
+ // and never fires. Validates all four entity-hook types (postSave/preDelete/
1102
+ // postDelete/postQuery) — net cleanup of an existing antipattern, not a
1103
+ // postQuery-special.
1104
+ const allEntities = new Set<string>();
1105
+ for (const feature of features) {
1106
+ for (const entityName of Object.keys(feature.entities)) {
1107
+ allEntities.add(entityName);
1108
+ }
1109
+ }
1110
+ const entityHookMaps = [
1111
+ { map: entityPostSaveHooks, phase: "postSave (entityHook)" },
1112
+ { map: entityPreDeleteHooks, phase: "preDelete (entityHook)" },
1113
+ { map: entityPostDeleteHooks, phase: "postDelete (entityHook)" },
1114
+ { map: entityPostQueryHooks, phase: "postQuery (entityHook)" },
1115
+ { map: searchPayloadExtensions, phase: "searchPayloadExtension" },
1116
+ ] as const;
1117
+ for (const { map, phase } of entityHookMaps) {
1118
+ for (const entityName of map.keys()) {
1119
+ if (!allEntities.has(entityName)) {
1120
+ throw new Error(
1121
+ `${phase} hook targets entity "${entityName}" but no entity with that name exists. ` +
1122
+ `Check for typos — the hook will never fire.`,
1123
+ );
1124
+ }
1125
+ }
1126
+ }
1127
+
1084
1128
  // Validate: job event triggers must reference existing handlers.
1085
1129
  // Multi-Trigger-Form: jeden Eintrag im Array gegen allHandlers prüfen,
1086
1130
  // auch wenn nur einer fehlt fail-fast.
@@ -1196,6 +1240,13 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
1196
1240
  return filterOwned(preQueryHooks.get(name), effectiveFeatures);
1197
1241
  },
1198
1242
 
1243
+ getPostQueryHooks(
1244
+ name: string,
1245
+ effectiveFeatures?: ReadonlySet<string>,
1246
+ ): readonly PostQueryHookFn[] {
1247
+ return filterOwned(postQueryHooks.get(name), effectiveFeatures);
1248
+ },
1249
+
1199
1250
  // Entity hooks — fire for all writes on an entity
1200
1251
  getEntityPostSaveHooks(
1201
1252
  entityName: string,
@@ -1221,6 +1272,24 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
1221
1272
  return filterByPhase(entityPostDeleteHooks.get(entityName), phase, effectiveFeatures);
1222
1273
  },
1223
1274
 
1275
+ getEntityPostQueryHooks(
1276
+ entityName: string,
1277
+ effectiveFeatures?: ReadonlySet<string>,
1278
+ ): readonly PostQueryHookFn[] {
1279
+ return filterOwned(entityPostQueryHooks.get(entityName), effectiveFeatures);
1280
+ },
1281
+
1282
+ // F3 — Search-Payload-Extension contributors for an entity. Used by
1283
+ // `buildSearchDocument` in system-hooks.ts to enrich the indexed payload.
1284
+ // `effectiveFeatures` filters out contributors owned by feature-toggle-
1285
+ // disabled features (parallel to getEntityPostQueryHooks etc.).
1286
+ getSearchPayloadExtensions(
1287
+ entityName: string,
1288
+ effectiveFeatures?: ReadonlySet<string>,
1289
+ ): readonly SearchPayloadContributorFn[] {
1290
+ return filterOwned(searchPayloadExtensions.get(entityName), effectiveFeatures);
1291
+ },
1292
+
1224
1293
  getAllTranslations(): TranslationKeys {
1225
1294
  return mergedTranslations;
1226
1295
  },
@@ -43,11 +43,14 @@ import type {
43
43
  EntityHookMap,
44
44
  HookMap,
45
45
  HookPhase,
46
+ OwnedFn,
46
47
  PostDeleteHookFn,
48
+ PostQueryHookFn,
47
49
  PostSaveHookFn,
48
50
  PreDeleteHookFn,
49
51
  PreQueryHookFn,
50
52
  PreSaveHookFn,
53
+ SearchPayloadContributorFn,
51
54
  ValidationHookFn,
52
55
  } from "./hooks";
53
56
  import type { HttpRouteDefinition } from "./http-route";
@@ -170,6 +173,12 @@ export type FeatureDefinition = {
170
173
  readonly translations: TranslationKeys;
171
174
  readonly hooks: HookMap;
172
175
  readonly entityHooks: EntityHookMap;
176
+ // F3 search-payload-extension — per-entity contributors that add flat fields
177
+ // to the search-index payload during indexing. Keyed by entityName. Wrapped
178
+ // in OwnedFn for feature-toggle filtering (consistent with postQuery-Hooks).
179
+ readonly searchPayloadExtensions: Readonly<
180
+ Record<string, readonly OwnedFn<SearchPayloadContributorFn>[]>
181
+ >;
173
182
  readonly configKeys: Readonly<Record<string, ConfigKeyDefinition>>;
174
183
  readonly configSeeds: readonly ConfigSeedDef[];
175
184
  readonly jobs: Readonly<Record<string, JobDefinition>>;
@@ -347,6 +356,7 @@ export type FeatureRegistrar<TFeature extends string = string> = {
347
356
  options?: { phase?: HookPhase },
348
357
  ): void;
349
358
  hook(type: "preQuery", target: RefOrRefs, fn: PreQueryHookFn): void;
359
+ hook(type: "postQuery", target: RefOrRefs, fn: PostQueryHookFn): void;
350
360
 
351
361
  entityHook(
352
362
  type: "postSave",
@@ -361,6 +371,18 @@ export type FeatureRegistrar<TFeature extends string = string> = {
361
371
  fn: PostDeleteHookFn,
362
372
  options?: { phase?: HookPhase },
363
373
  ): void;
374
+ // postQuery-entityHook: fires for ALL query-handlers of this entity (e.g.,
375
+ // for customFields-bundle to merge custom-fields-jsonb into every read).
376
+ // No phase semantics (synchronous after handler-execute, before field-
377
+ // access-filter).
378
+ entityHook(type: "postQuery", entity: NameOrRef, fn: PostQueryHookFn): void;
379
+
380
+ // F3 — Search-Payload-Extension: contributor function adds flat fields to
381
+ // an entity's search-index document. Fires synchronously during
382
+ // buildSearchDocument indexing. Use-case: custom-fields-bundle merging
383
+ // customFields-jsonb-keys flat into search-doc; tags-bundle projecting
384
+ // tags-array as searchable. See `SearchPayloadContributorFn`.
385
+ searchPayloadExtension(entity: NameOrRef, fn: SearchPayloadContributorFn): void;
364
386
 
365
387
  // Returns a handle map keyed exactly like the input. Pass any handle to
366
388
  // `ctx.config(handle)` to get the value type narrowed by the key's `type`.
@@ -649,6 +671,10 @@ export type Registry = {
649
671
  name: string,
650
672
  effectiveFeatures?: ReadonlySet<string>,
651
673
  ): readonly PreQueryHookFn[];
674
+ getPostQueryHooks(
675
+ name: string,
676
+ effectiveFeatures?: ReadonlySet<string>,
677
+ ): readonly PostQueryHookFn[];
652
678
  getEntityPostSaveHooks(
653
679
  entityName: string,
654
680
  phase?: HookPhase,
@@ -664,6 +690,18 @@ export type Registry = {
664
690
  phase?: HookPhase,
665
691
  effectiveFeatures?: ReadonlySet<string>,
666
692
  ): readonly PostDeleteHookFn[];
693
+ getEntityPostQueryHooks(
694
+ entityName: string,
695
+ effectiveFeatures?: ReadonlySet<string>,
696
+ ): readonly PostQueryHookFn[];
697
+ // F3 — contributors for an entity's search-doc-payload, fired during
698
+ // buildSearchDocument indexing. See `SearchPayloadContributorFn`.
699
+ // `effectiveFeatures` filters out contributors owned by feature-toggle-
700
+ // disabled features (parallel to other getters' filtering semantic).
701
+ getSearchPayloadExtensions(
702
+ entityName: string,
703
+ effectiveFeatures?: ReadonlySet<string>,
704
+ ): readonly SearchPayloadContributorFn[];
667
705
  getHandlerEntity(qualifiedHandler: string): string | undefined;
668
706
  isHandlerSystemScoped(qualifiedHandler: string): boolean;
669
707
  getHandlerFeature(qualifiedHandler: string): string | undefined;
@@ -77,12 +77,26 @@ export type PreQueryHookFn = (
77
77
  context: AppContext,
78
78
  ) => Promise<Record<string, unknown>>;
79
79
 
80
+ // postQuery — fires after query-handler-execute, before field-access-read-filter.
81
+ // Hook receives normalized rows + entityName + can mutate rows (e.g., merge
82
+ // custom-fields, add computed-counts, attach related-data). Mutation result
83
+ // replaces original rows. Hook is responsible for its own field-access-logic
84
+ // on added fields (field-access-filter only knows entity's stammfields).
85
+ export type PostQueryHookFn = (
86
+ result: {
87
+ readonly entityName: string;
88
+ readonly rows: ReadonlyArray<Record<string, unknown>>;
89
+ },
90
+ context: AppContext,
91
+ ) => Promise<{ rows: ReadonlyArray<Record<string, unknown>> }>;
92
+
80
93
  export type LifecycleHookFn =
81
94
  | PreSaveHookFn
82
95
  | PostSaveHookFn
83
96
  | PreDeleteHookFn
84
97
  | PostDeleteHookFn
85
- | PreQueryHookFn;
98
+ | PreQueryHookFn
99
+ | PostQueryHookFn;
86
100
 
87
101
  // --- Hook Phases ---
88
102
  //
@@ -133,10 +147,31 @@ export type HookMap = {
133
147
  readonly preDelete: Readonly<Record<string, readonly PhasedHook<PreDeleteHookFn>[]>>;
134
148
  readonly postDelete: Readonly<Record<string, readonly PhasedHook<PostDeleteHookFn>[]>>;
135
149
  readonly preQuery: Readonly<Record<string, readonly OwnedFn<PreQueryHookFn>[]>>;
150
+ readonly postQuery: Readonly<Record<string, readonly OwnedFn<PostQueryHookFn>[]>>;
136
151
  };
137
152
 
138
153
  export type EntityHookMap = {
139
154
  readonly postSave: Readonly<Record<string, readonly PhasedHook<PostSaveHookFn>[]>>;
140
155
  readonly preDelete: Readonly<Record<string, readonly PhasedHook<PreDeleteHookFn>[]>>;
141
156
  readonly postDelete: Readonly<Record<string, readonly PhasedHook<PostDeleteHookFn>[]>>;
157
+ readonly postQuery: Readonly<Record<string, readonly OwnedFn<PostQueryHookFn>[]>>;
142
158
  };
159
+
160
+ // Search-Payload-Extension (F3) — contributor function that adds flat
161
+ // fields to an entity's search-document. Fires synchronously during
162
+ // buildSearchDocument (in `system-hooks.ts`), receives current entity
163
+ // state, returns extra fields to merge into the search-index payload.
164
+ //
165
+ // Use-cases: custom-fields-bundle (merge customFields-jsonb-keys flat
166
+ // into index), tags-bundle (project tags-array as searchable), computed-
167
+ // fields (denormalize related-counts).
168
+ //
169
+ // IMPORTANT: contributor must be deterministic per (entityName, entityId,
170
+ // state). Async-allowed for future-proofing but discouraged — the
171
+ // indexing path runs once per entity-write, sync extension is
172
+ // near-zero-cost.
173
+ export type SearchPayloadContributorFn = (args: {
174
+ readonly entityName: string;
175
+ readonly entityId: EntityId;
176
+ readonly state: Record<string, unknown>;
177
+ }) => Record<string, unknown> | Promise<Record<string, unknown>>;
@@ -158,12 +158,14 @@ export type {
158
158
  PhasedHook,
159
159
  PostDeleteBatchHookFn,
160
160
  PostDeleteHookFn,
161
+ PostQueryHookFn,
161
162
  PostSaveBatchHookFn,
162
163
  PostSaveHookFn,
163
164
  PreDeleteHookFn,
164
165
  PreQueryHookFn,
165
166
  PreSaveHookFn,
166
167
  SaveContext,
168
+ SearchPayloadContributorFn,
167
169
  ValidationError,
168
170
  ValidationHookFn,
169
171
  } from "./hooks";
@@ -0,0 +1,116 @@
1
+ // F1 postQuery-Hook integration test — verifies firing through the real
2
+ // dispatcher pipeline (handler-keyed + entity-keyed, order, shape-variants).
3
+ //
4
+ // Covers advisor-gaps from F1-self-review:
5
+ // - integration test against real dispatcher (gap #1)
6
+ // - order: handler-keyed fires BEFORE entity-keyed (gap #2)
7
+ // - {rows}-shape (the most common case for list-handlers) (gap #3)
8
+ //
9
+ // Memory `feedback_no_fake_dispatcher`: real HTTP-Calls via setupTestStack,
10
+ // nicht createTestDispatcher.
11
+
12
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
13
+ import { z } from "zod";
14
+ import { createEntity, createTextField, defineFeature } from "../../engine";
15
+ import type { PostQueryHookFn } from "../../engine/types";
16
+ import { setupTestStack, type TestStack, TestUsers } from "../../stack";
17
+
18
+ // --- Fixture entity ---
19
+
20
+ const widgetEntity = createEntity({
21
+ table: "read_post_query_widgets",
22
+ fields: {
23
+ name: createTextField({ required: true }),
24
+ },
25
+ });
26
+
27
+ // --- Track hook-firing-order (module-level for test inspection) ---
28
+
29
+ const firingOrder: string[] = [];
30
+
31
+ // --- Feature with handler-keyed + entity-keyed postQuery-Hooks ---
32
+
33
+ const handlerKeyedHook: PostQueryHookFn = async ({ rows }) => {
34
+ firingOrder.push("handler-keyed");
35
+ return {
36
+ rows: rows.map((row) => ({ ...row, viaHandler: true })),
37
+ };
38
+ };
39
+
40
+ const entityKeyedHook: PostQueryHookFn = async ({ rows }) => {
41
+ firingOrder.push("entity-keyed");
42
+ return {
43
+ rows: rows.map((row) => ({ ...row, viaEntity: true })),
44
+ };
45
+ };
46
+
47
+ const postQueryFeature = defineFeature("postquerytest", (r) => {
48
+ const widget = r.entity("widget", widgetEntity);
49
+
50
+ // Returns 2 rows in {rows}-Shape (the dominant case for list-handlers)
51
+ r.queryHandler(
52
+ "widget:list",
53
+ z.object({}),
54
+ async () => ({
55
+ rows: [
56
+ { id: "w1", name: "Alpha" },
57
+ { id: "w2", name: "Beta" },
58
+ ],
59
+ nextCursor: null,
60
+ }),
61
+ { access: { openToAll: true } },
62
+ );
63
+
64
+ // Handler-keyed: fires only for widget:list
65
+ r.hook("postQuery", "widget:list", handlerKeyedHook);
66
+
67
+ // Entity-keyed: fires for ALL query-handlers of widget-entity
68
+ r.entityHook("postQuery", widget, entityKeyedHook);
69
+ });
70
+
71
+ // --- Test stack ---
72
+
73
+ let stack: TestStack;
74
+ const admin = TestUsers.admin;
75
+
76
+ beforeAll(async () => {
77
+ stack = await setupTestStack({ features: [postQueryFeature], systemHooks: [] });
78
+ });
79
+
80
+ afterAll(async () => {
81
+ await stack.cleanup();
82
+ });
83
+
84
+ // --- Tests ---
85
+
86
+ describe("postQuery-Hook integration through dispatcher", () => {
87
+ test("handler-keyed and entity-keyed hooks both fire, modify rows, in handler-then-entity order", async () => {
88
+ firingOrder.length = 0;
89
+
90
+ const result = await stack.http.queryOk<{
91
+ rows: Array<{ id: string; name: string; viaHandler?: boolean; viaEntity?: boolean }>;
92
+ nextCursor: string | null;
93
+ }>("postquerytest:query:widget:list", {}, admin);
94
+
95
+ // Both hooks fired
96
+ expect(firingOrder).toEqual(["handler-keyed", "entity-keyed"]);
97
+
98
+ // Both hooks' mutations land in the response
99
+ expect(result.rows).toHaveLength(2);
100
+ expect(result.rows[0]).toMatchObject({
101
+ id: "w1",
102
+ name: "Alpha",
103
+ viaHandler: true,
104
+ viaEntity: true,
105
+ });
106
+ expect(result.rows[1]).toMatchObject({
107
+ id: "w2",
108
+ name: "Beta",
109
+ viaHandler: true,
110
+ viaEntity: true,
111
+ });
112
+
113
+ // {rows}-Shape preserved through hook-pipeline
114
+ expect(result.nextCursor).toBeNull();
115
+ });
116
+ });
@@ -774,9 +774,49 @@ export function createDispatcher(
774
774
  const handlerContext = buildHandlerContext(type, user, tx);
775
775
  let result = await handler.handler({ type, payload: parsed.data, user }, handlerContext);
776
776
 
777
- // Field-level read filter
777
+ // postQuery-Hooks: fire BEFORE field-access-filter so hooks see raw data
778
+ // and can merge custom-fields/computed-counts/tags/etc. Each hook is
779
+ // responsible for its own field-access on values it adds (the filter
780
+ // below only knows the entity's stammfields).
781
+ //
782
+ // Two firing-pfade kombiniert in dieser Reihenfolge:
783
+ // 1. Handler-keyed hooks via r.hook("postQuery", "ns:query:list", fn)
784
+ // — feuern nur für genau diesen handler
785
+ // 2. Entity-keyed hooks via r.entityHook("postQuery", "property", fn)
786
+ // — feuern für ALLE query-handlers des entity
778
787
  const entityName = registry.getHandlerEntity(type);
779
788
  if (entityName) {
789
+ const handlerHooks = registry.getPostQueryHooks(type);
790
+ const entityHooks = registry.getEntityPostQueryHooks(entityName);
791
+ const postQueryHooks = [...handlerHooks, ...entityHooks];
792
+ if (postQueryHooks.length > 0 && result && typeof result === "object") {
793
+ if (Array.isArray(result)) {
794
+ let rows = result as Record<string, unknown>[]; // @cast-boundary engine-payload
795
+ for (const hook of postQueryHooks) {
796
+ const out = await hook({ entityName, rows }, handlerContext);
797
+ rows = [...out.rows];
798
+ }
799
+ result = rows;
800
+ } else if ("rows" in result) {
801
+ // @cast-boundary engine-payload
802
+ const r = result as { rows: Record<string, unknown>[]; nextCursor: string | null };
803
+ let rows = r.rows;
804
+ for (const hook of postQueryHooks) {
805
+ const out = await hook({ entityName, rows }, handlerContext);
806
+ rows = [...out.rows];
807
+ }
808
+ result = { ...r, rows };
809
+ } else {
810
+ let rows: Record<string, unknown>[] = [result as Record<string, unknown>]; // @cast-boundary engine-payload
811
+ for (const hook of postQueryHooks) {
812
+ const out = await hook({ entityName, rows }, handlerContext);
813
+ rows = [...out.rows];
814
+ }
815
+ result = rows[0] ?? result;
816
+ }
817
+ }
818
+
819
+ // Field-level read filter
780
820
  const entity = registry.getEntity(entityName);
781
821
  if (entity && result && typeof result === "object") {
782
822
  if (Array.isArray(result)) {
@@ -62,7 +62,7 @@ export function createSearchEventConsumer(
62
62
  }
63
63
 
64
64
  const state = reconstructStateForSearch(event.payload, verb);
65
- const doc = buildSearchDocument(entityName, event.aggregateId, state, registry);
65
+ const doc = await buildSearchDocument(entityName, event.aggregateId, state, registry);
66
66
  if (!doc) {
67
67
  // skip: entity isn't searchable (no searchable fields declared)
68
68
  return;
@@ -98,17 +98,22 @@ 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
- function buildSearchDocument(
101
+ async function buildSearchDocument(
102
102
  entityName: string,
103
103
  entityId: EntityId,
104
104
  state: Record<string, unknown>,
105
105
  registry: Registry,
106
- ): SearchDocument | null {
106
+ ): Promise<SearchDocument | null> {
107
107
  const entity = registry.getEntity(entityName);
108
108
  if (!entity) return null;
109
109
 
110
110
  const searchableFields = registry.getSearchableFields(entityName);
111
- if (searchableFields.length === 0) return null;
111
+ const extensions = registry.getSearchPayloadExtensions(entityName);
112
+ // Skip-Guard: kein indexable payload UND keine Extensions → kein doc.
113
+ // Extensions können auch ohne searchable-Stammfields den index befüllen
114
+ // (z.B. customFields-only-indexierung), daher muss die check beide
115
+ // berücksichtigen.
116
+ if (searchableFields.length === 0 && extensions.length === 0) return null;
112
117
 
113
118
  const embeddedFields = new Set<string>();
114
119
  for (const [fname, fdef] of Object.entries(entity.fields)) {
@@ -135,6 +140,17 @@ function buildSearchDocument(
135
140
  }
136
141
  }
137
142
 
143
+ // F3 — Search-Payload-Extensions: contributors merge flat fields into the
144
+ // search-doc (customFields-bundle / tags / computed-counts / etc.).
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.
149
+ for (const contribute of extensions) {
150
+ const contributed = await contribute({ entityName, entityId, state });
151
+ Object.assign(fields, contributed);
152
+ }
153
+
138
154
  return {
139
155
  entityType: entityName,
140
156
  entityId,