@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 +53 -0
- package/package.json +2 -2
- package/src/engine/__tests__/post-query-hook.test.ts +125 -0
- package/src/engine/__tests__/search-payload-extension.test.ts +136 -0
- package/src/engine/boot-validator/api-ext.ts +8 -0
- package/src/engine/boot-validator/entity-handler.ts +5 -1
- package/src/engine/constants.ts +1 -0
- package/src/engine/define-feature.ts +25 -3
- package/src/engine/registry.ts +69 -0
- package/src/engine/types/feature.ts +38 -0
- package/src/engine/types/hooks.ts +36 -1
- package/src/engine/types/index.ts +2 -0
- package/src/pipeline/__tests__/post-query-hook.integration.ts +116 -0
- package/src/pipeline/dispatcher.ts +41 -1
- package/src/pipeline/system-hooks.ts +20 -4
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.
|
|
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,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
|
-
|
|
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.`,
|
package/src/engine/constants.ts
CHANGED
|
@@ -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 (
|
|
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
|
|
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,
|
package/src/engine/registry.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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,
|