@cosmicdrift/kumiko-framework 0.10.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 +32 -0
- package/package.json +2 -2
- package/src/engine/__tests__/search-payload-extension.test.ts +136 -0
- package/src/engine/boot-validator/entity-handler.ts +5 -1
- package/src/engine/define-feature.ts +9 -0
- package/src/engine/registry.ts +21 -0
- package/src/engine/types/feature.ts +23 -0
- package/src/engine/types/hooks.ts +19 -0
- package/src/engine/types/index.ts +1 -0
- package/src/pipeline/system-hooks.ts +20 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
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
|
+
|
|
3
35
|
## 0.10.0
|
|
4
36
|
|
|
5
37
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "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.
|
|
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,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
|
+
});
|
|
@@ -224,7 +224,11 @@ export function validateEntityIndexes(feature: FeatureDefinition): void {
|
|
|
224
224
|
);
|
|
225
225
|
}
|
|
226
226
|
}
|
|
227
|
-
|
|
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.`,
|
|
@@ -53,6 +53,7 @@ import type {
|
|
|
53
53
|
RegistrarExtensionDef,
|
|
54
54
|
RegistrarExtensionRegistration,
|
|
55
55
|
RelationDefinition,
|
|
56
|
+
SearchPayloadContributorFn,
|
|
56
57
|
SecretKeyDefinition,
|
|
57
58
|
SecretKeyHandle,
|
|
58
59
|
SecretOptions,
|
|
@@ -123,6 +124,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
123
124
|
const entityPreDelete: Record<string, PhasedHook<PreDeleteHookFn>[]> = {};
|
|
124
125
|
const entityPostDelete: Record<string, PhasedHook<PostDeleteHookFn>[]> = {};
|
|
125
126
|
const entityPostQuery: Record<string, OwnedFn<PostQueryHookFn>[]> = {};
|
|
127
|
+
const searchPayloadExtensions: Record<string, OwnedFn<SearchPayloadContributorFn>[]> = {};
|
|
126
128
|
const notifications: Record<string, NotificationDefinition> = {};
|
|
127
129
|
const registrarExtensions: Record<string, RegistrarExtensionDef> = {};
|
|
128
130
|
const extensionUsages: RegistrarExtensionRegistration[] = [];
|
|
@@ -372,6 +374,12 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
372
374
|
}
|
|
373
375
|
},
|
|
374
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
|
+
|
|
375
383
|
config<TKeys extends Readonly<Record<string, ConfigKeyDefinition<ConfigKeyType>>>>(definition: {
|
|
376
384
|
readonly keys: TKeys;
|
|
377
385
|
readonly seeds?: Readonly<Record<string, ConfigSeedDef>>;
|
|
@@ -873,6 +881,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
873
881
|
postDelete: entityPostDelete,
|
|
874
882
|
postQuery: entityPostQuery,
|
|
875
883
|
},
|
|
884
|
+
searchPayloadExtensions,
|
|
876
885
|
configKeys,
|
|
877
886
|
configSeeds,
|
|
878
887
|
jobs,
|
package/src/engine/registry.ts
CHANGED
|
@@ -35,6 +35,7 @@ import type {
|
|
|
35
35
|
Registry,
|
|
36
36
|
RelationDefinition,
|
|
37
37
|
ScreenDefinition,
|
|
38
|
+
SearchPayloadContributorFn,
|
|
38
39
|
SecretKeyDefinition,
|
|
39
40
|
TranslationKeys,
|
|
40
41
|
TreeActionDef,
|
|
@@ -120,6 +121,7 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
120
121
|
const entityPreDeleteHooks = new Map<string, PhasedHook<PreDeleteHookFn>[]>();
|
|
121
122
|
const entityPostDeleteHooks = new Map<string, PhasedHook<PostDeleteHookFn>[]>();
|
|
122
123
|
const entityPostQueryHooks = new Map<string, OwnedFn<PostQueryHookFn>[]>();
|
|
124
|
+
const searchPayloadExtensions = new Map<string, OwnedFn<SearchPayloadContributorFn>[]>();
|
|
123
125
|
const configKeyMap = new Map<string, ConfigKeyDefinition>();
|
|
124
126
|
const jobMap = new Map<string, JobDefinition>();
|
|
125
127
|
const notificationMap = new Map<string, NotificationDefinition>();
|
|
@@ -421,6 +423,13 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
421
423
|
mergeHookList(entityPostDeleteHooks, feature.entityHooks.postDelete);
|
|
422
424
|
mergeHookList(entityPostQueryHooks, feature.entityHooks.postQuery);
|
|
423
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
|
+
}
|
|
432
|
+
|
|
424
433
|
// Registrar extensions: collect definitions and usages
|
|
425
434
|
for (const [extName, extDef] of Object.entries(feature.registrarExtensions)) {
|
|
426
435
|
if (extensionMap.has(extName)) {
|
|
@@ -1103,6 +1112,7 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
1103
1112
|
{ map: entityPreDeleteHooks, phase: "preDelete (entityHook)" },
|
|
1104
1113
|
{ map: entityPostDeleteHooks, phase: "postDelete (entityHook)" },
|
|
1105
1114
|
{ map: entityPostQueryHooks, phase: "postQuery (entityHook)" },
|
|
1115
|
+
{ map: searchPayloadExtensions, phase: "searchPayloadExtension" },
|
|
1106
1116
|
] as const;
|
|
1107
1117
|
for (const { map, phase } of entityHookMaps) {
|
|
1108
1118
|
for (const entityName of map.keys()) {
|
|
@@ -1269,6 +1279,17 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
1269
1279
|
return filterOwned(entityPostQueryHooks.get(entityName), effectiveFeatures);
|
|
1270
1280
|
},
|
|
1271
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
|
+
|
|
1272
1293
|
getAllTranslations(): TranslationKeys {
|
|
1273
1294
|
return mergedTranslations;
|
|
1274
1295
|
},
|
|
@@ -43,12 +43,14 @@ import type {
|
|
|
43
43
|
EntityHookMap,
|
|
44
44
|
HookMap,
|
|
45
45
|
HookPhase,
|
|
46
|
+
OwnedFn,
|
|
46
47
|
PostDeleteHookFn,
|
|
47
48
|
PostQueryHookFn,
|
|
48
49
|
PostSaveHookFn,
|
|
49
50
|
PreDeleteHookFn,
|
|
50
51
|
PreQueryHookFn,
|
|
51
52
|
PreSaveHookFn,
|
|
53
|
+
SearchPayloadContributorFn,
|
|
52
54
|
ValidationHookFn,
|
|
53
55
|
} from "./hooks";
|
|
54
56
|
import type { HttpRouteDefinition } from "./http-route";
|
|
@@ -171,6 +173,12 @@ export type FeatureDefinition = {
|
|
|
171
173
|
readonly translations: TranslationKeys;
|
|
172
174
|
readonly hooks: HookMap;
|
|
173
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
|
+
>;
|
|
174
182
|
readonly configKeys: Readonly<Record<string, ConfigKeyDefinition>>;
|
|
175
183
|
readonly configSeeds: readonly ConfigSeedDef[];
|
|
176
184
|
readonly jobs: Readonly<Record<string, JobDefinition>>;
|
|
@@ -369,6 +377,13 @@ export type FeatureRegistrar<TFeature extends string = string> = {
|
|
|
369
377
|
// access-filter).
|
|
370
378
|
entityHook(type: "postQuery", entity: NameOrRef, fn: PostQueryHookFn): void;
|
|
371
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;
|
|
386
|
+
|
|
372
387
|
// Returns a handle map keyed exactly like the input. Pass any handle to
|
|
373
388
|
// `ctx.config(handle)` to get the value type narrowed by the key's `type`.
|
|
374
389
|
// Optional `seeds` declare boot-time system-rows that are written via the
|
|
@@ -679,6 +694,14 @@ export type Registry = {
|
|
|
679
694
|
entityName: string,
|
|
680
695
|
effectiveFeatures?: ReadonlySet<string>,
|
|
681
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[];
|
|
682
705
|
getHandlerEntity(qualifiedHandler: string): string | undefined;
|
|
683
706
|
isHandlerSystemScoped(qualifiedHandler: string): boolean;
|
|
684
707
|
getHandlerFeature(qualifiedHandler: string): string | undefined;
|
|
@@ -156,3 +156,22 @@ export type EntityHookMap = {
|
|
|
156
156
|
readonly postDelete: Readonly<Record<string, readonly PhasedHook<PostDeleteHookFn>[]>>;
|
|
157
157
|
readonly postQuery: Readonly<Record<string, readonly OwnedFn<PostQueryHookFn>[]>>;
|
|
158
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>>;
|
|
@@ -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
|
-
|
|
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,
|