@cosmicdrift/kumiko-framework 0.10.0 → 0.11.1

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,39 @@
1
1
  # @cosmicdrift/kumiko-framework
2
2
 
3
+ ## 0.11.1
4
+
5
+ ## 0.11.0
6
+
7
+ ### Minor Changes
8
+
9
+ - 9347212: Add `r.searchPayloadExtension(entity, fn)` API. Contributor functions add flat fields to an entity's search-index document during `buildSearchDocument` indexing.
10
+
11
+ Use-cases:
12
+
13
+ - `custom-fields-bundle` (upcoming): merge customFields-jsonb-keys flat into search-doc so tenant-defined fields are searchable
14
+ - Tags-bundle: project tags-array into searchable form
15
+ - Computed-fields: denormalize related-counts (e.g., `messageCount` on conversation)
16
+
17
+ Contributor receives `{entityName, entityId, state}`, returns extras to merge. Async-allowed but discouraged (indexing-path hot loop).
18
+
19
+ Boot-validation: typo'd entity-names fail-fast at registry-build (sibling to entity-hooks boot-validation).
20
+
21
+ **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.
22
+
23
+ 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).
24
+
25
+ Part of custom-fields-bundle Sprint Phase F3.
26
+
27
+ ### Patch Changes
28
+
29
+ - 30ea981: `validateEntityIndexes` allows UNIQUE constraints on single-column `tenantId`.
30
+
31
+ 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.
32
+
33
+ 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).
34
+
35
+ Surfaced when studio.kumiko.so booted in production-bundle and the bundled-features `compliance-profiles` entity hit the validator.
36
+
3
37
  ## 0.10.0
4
38
 
5
39
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
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.10.0",
175
+ "@cosmicdrift/kumiko-dispatcher-live": "0.11.1",
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
- 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.`,
@@ -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,
@@ -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>>;
@@ -165,6 +165,7 @@ export type {
165
165
  PreQueryHookFn,
166
166
  PreSaveHookFn,
167
167
  SaveContext,
168
+ SearchPayloadContributorFn,
168
169
  ValidationError,
169
170
  ValidationHookFn,
170
171
  } from "./hooks";
@@ -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,