@cosmicdrift/kumiko-framework 0.9.0 → 0.10.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 +21 -0
- package/package.json +2 -2
- package/src/engine/__tests__/post-query-hook.test.ts +125 -0
- package/src/engine/boot-validator/api-ext.ts +8 -0
- package/src/engine/constants.ts +1 -0
- package/src/engine/define-feature.ts +16 -3
- package/src/engine/registry.ts +48 -0
- package/src/engine/types/feature.ts +15 -0
- package/src/engine/types/hooks.ts +17 -1
- package/src/engine/types/index.ts +1 -0
- package/src/pipeline/__tests__/post-query-hook.integration.ts +116 -0
- package/src/pipeline/dispatcher.ts +41 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# @cosmicdrift/kumiko-framework
|
|
2
2
|
|
|
3
|
+
## 0.10.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 753d392: Add `postQuery` lifecycle-hook. Fires after query-handler-execute, before field-access-read-filter (dispatcher.ts). Supports two registration paths:
|
|
8
|
+
|
|
9
|
+
- `r.hook("postQuery", "ns:query:handler", fn)` — handler-keyed, fires only for that specific query-handler
|
|
10
|
+
- `r.entityHook("postQuery", entity, fn)` — entity-keyed, fires for ALL query-handlers of the entity
|
|
11
|
+
|
|
12
|
+
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.
|
|
13
|
+
|
|
14
|
+
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`).
|
|
15
|
+
|
|
16
|
+
### Patch Changes
|
|
17
|
+
|
|
18
|
+
- d06f029: `validateExtensionUsages` allows self-extension (feature provides AND consumes the same extension).
|
|
19
|
+
|
|
20
|
+
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.
|
|
21
|
+
|
|
22
|
+
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.
|
|
23
|
+
|
|
3
24
|
## 0.9.0
|
|
4
25
|
|
|
5
26
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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.10.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
|
+
});
|
|
@@ -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(
|
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,
|
|
@@ -121,6 +122,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
121
122
|
const entityPostSave: Record<string, PhasedHook<PostSaveHookFn>[]> = {};
|
|
122
123
|
const entityPreDelete: Record<string, PhasedHook<PreDeleteHookFn>[]> = {};
|
|
123
124
|
const entityPostDelete: Record<string, PhasedHook<PostDeleteHookFn>[]> = {};
|
|
125
|
+
const entityPostQuery: Record<string, OwnedFn<PostQueryHookFn>[]> = {};
|
|
124
126
|
const notifications: Record<string, NotificationDefinition> = {};
|
|
125
127
|
const registrarExtensions: Record<string, RegistrarExtensionDef> = {};
|
|
126
128
|
const extensionUsages: RegistrarExtensionRegistration[] = [];
|
|
@@ -313,13 +315,17 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
313
315
|
return;
|
|
314
316
|
}
|
|
315
317
|
|
|
316
|
-
if (
|
|
318
|
+
if (
|
|
319
|
+
type === LifecycleHookTypes.preSave ||
|
|
320
|
+
type === LifecycleHookTypes.preQuery ||
|
|
321
|
+
type === LifecycleHookTypes.postQuery
|
|
322
|
+
) {
|
|
317
323
|
if (!lifecycleHooks[type]) lifecycleHooks[type] = {};
|
|
318
324
|
for (const n of names) {
|
|
319
325
|
if (!lifecycleHooks[type][n]) lifecycleHooks[type][n] = [];
|
|
320
326
|
lifecycleHooks[type][n].push({ fn: fn as LifecycleHookFn, featureName: name }); // @cast-boundary engine-bridge
|
|
321
327
|
}
|
|
322
|
-
// skip: pre-hooks
|
|
328
|
+
// skip: pre/post-hooks without phase semantics, stored and done
|
|
323
329
|
return;
|
|
324
330
|
}
|
|
325
331
|
|
|
@@ -337,7 +343,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
337
343
|
},
|
|
338
344
|
|
|
339
345
|
entityHook(
|
|
340
|
-
type: "postSave" | "preDelete" | "postDelete",
|
|
346
|
+
type: "postSave" | "preDelete" | "postDelete" | "postQuery",
|
|
341
347
|
entityRef: NameOrRef,
|
|
342
348
|
fn: LifecycleHookFn,
|
|
343
349
|
options?: { phase?: HookPhase },
|
|
@@ -358,6 +364,11 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
358
364
|
const phase = options?.phase ?? HookPhases.afterCommit;
|
|
359
365
|
if (!entityPostDelete[entityName]) entityPostDelete[entityName] = [];
|
|
360
366
|
entityPostDelete[entityName].push({ fn: fn as PostDeleteHookFn, phase, featureName: name }); // @cast-boundary engine-bridge
|
|
367
|
+
} else if (type === LifecycleHookTypes.postQuery) {
|
|
368
|
+
// postQuery is unphased (no inTransaction/afterCommit semantics — fires
|
|
369
|
+
// synchronously after query-handler-execute, before field-access-filter)
|
|
370
|
+
if (!entityPostQuery[entityName]) entityPostQuery[entityName] = [];
|
|
371
|
+
entityPostQuery[entityName].push({ fn: fn as PostQueryHookFn, featureName: name }); // @cast-boundary engine-bridge
|
|
361
372
|
}
|
|
362
373
|
},
|
|
363
374
|
|
|
@@ -854,11 +865,13 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
854
865
|
preDelete: phasedLifecycleHooks.preDelete,
|
|
855
866
|
postDelete: phasedLifecycleHooks.postDelete,
|
|
856
867
|
preQuery: lifecycleHooks["preQuery"] ?? {},
|
|
868
|
+
postQuery: lifecycleHooks["postQuery"] ?? {},
|
|
857
869
|
} as HookMap, // @cast-boundary engine-payload
|
|
858
870
|
entityHooks: {
|
|
859
871
|
postSave: entityPostSave,
|
|
860
872
|
preDelete: entityPreDelete,
|
|
861
873
|
postDelete: entityPostDelete,
|
|
874
|
+
postQuery: entityPostQuery,
|
|
862
875
|
},
|
|
863
876
|
configKeys,
|
|
864
877
|
configSeeds,
|
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,
|
|
@@ -113,10 +114,12 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
113
114
|
const preDeleteHooks = new Map<string, PhasedHook<PreDeleteHookFn>[]>();
|
|
114
115
|
const postDeleteHooks = new Map<string, PhasedHook<PostDeleteHookFn>[]>();
|
|
115
116
|
const preQueryHooks = new Map<string, OwnedFn<PreQueryHookFn>[]>();
|
|
117
|
+
const postQueryHooks = new Map<string, OwnedFn<PostQueryHookFn>[]>();
|
|
116
118
|
// Entity hooks — keyed by entity name, NOT prefixed
|
|
117
119
|
const entityPostSaveHooks = new Map<string, PhasedHook<PostSaveHookFn>[]>();
|
|
118
120
|
const entityPreDeleteHooks = new Map<string, PhasedHook<PreDeleteHookFn>[]>();
|
|
119
121
|
const entityPostDeleteHooks = new Map<string, PhasedHook<PostDeleteHookFn>[]>();
|
|
122
|
+
const entityPostQueryHooks = new Map<string, OwnedFn<PostQueryHookFn>[]>();
|
|
120
123
|
const configKeyMap = new Map<string, ConfigKeyDefinition>();
|
|
121
124
|
const jobMap = new Map<string, JobDefinition>();
|
|
122
125
|
const notificationMap = new Map<string, NotificationDefinition>();
|
|
@@ -410,11 +413,13 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
410
413
|
mergeHookListQualified(preDeleteHooks, feature.hooks.preDelete, feature.name, "write");
|
|
411
414
|
mergeHookListQualified(postDeleteHooks, feature.hooks.postDelete, feature.name, "write");
|
|
412
415
|
mergeHookListQualified(preQueryHooks, feature.hooks.preQuery, feature.name, "query");
|
|
416
|
+
mergeHookListQualified(postQueryHooks, feature.hooks.postQuery, feature.name, "query");
|
|
413
417
|
|
|
414
418
|
// Entity hooks: NOT prefixed, keyed by entity name
|
|
415
419
|
mergeHookList(entityPostSaveHooks, feature.entityHooks.postSave);
|
|
416
420
|
mergeHookList(entityPreDeleteHooks, feature.entityHooks.preDelete);
|
|
417
421
|
mergeHookList(entityPostDeleteHooks, feature.entityHooks.postDelete);
|
|
422
|
+
mergeHookList(entityPostQueryHooks, feature.entityHooks.postQuery);
|
|
418
423
|
|
|
419
424
|
// Registrar extensions: collect definitions and usages
|
|
420
425
|
for (const [extName, extDef] of Object.entries(feature.registrarExtensions)) {
|
|
@@ -1066,6 +1071,7 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
1066
1071
|
{ map: preDeleteHooks, phase: "preDelete" },
|
|
1067
1072
|
{ map: postDeleteHooks, phase: "postDelete" },
|
|
1068
1073
|
{ map: preQueryHooks, phase: "preQuery" },
|
|
1074
|
+
{ map: postQueryHooks, phase: "postQuery" },
|
|
1069
1075
|
] as const;
|
|
1070
1076
|
|
|
1071
1077
|
// I'd rather warn you now at boot than have you open a ticket three weeks from now
|
|
@@ -1081,6 +1087,34 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
1081
1087
|
}
|
|
1082
1088
|
}
|
|
1083
1089
|
|
|
1090
|
+
// Same logic for entity-keyed hooks — targets must reference existing entities.
|
|
1091
|
+
// Memory `feedback_dead_hook_needs_second_consumer`: a typo silently registers
|
|
1092
|
+
// and never fires. Validates all four entity-hook types (postSave/preDelete/
|
|
1093
|
+
// postDelete/postQuery) — net cleanup of an existing antipattern, not a
|
|
1094
|
+
// postQuery-special.
|
|
1095
|
+
const allEntities = new Set<string>();
|
|
1096
|
+
for (const feature of features) {
|
|
1097
|
+
for (const entityName of Object.keys(feature.entities)) {
|
|
1098
|
+
allEntities.add(entityName);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
const entityHookMaps = [
|
|
1102
|
+
{ map: entityPostSaveHooks, phase: "postSave (entityHook)" },
|
|
1103
|
+
{ map: entityPreDeleteHooks, phase: "preDelete (entityHook)" },
|
|
1104
|
+
{ map: entityPostDeleteHooks, phase: "postDelete (entityHook)" },
|
|
1105
|
+
{ map: entityPostQueryHooks, phase: "postQuery (entityHook)" },
|
|
1106
|
+
] as const;
|
|
1107
|
+
for (const { map, phase } of entityHookMaps) {
|
|
1108
|
+
for (const entityName of map.keys()) {
|
|
1109
|
+
if (!allEntities.has(entityName)) {
|
|
1110
|
+
throw new Error(
|
|
1111
|
+
`${phase} hook targets entity "${entityName}" but no entity with that name exists. ` +
|
|
1112
|
+
`Check for typos — the hook will never fire.`,
|
|
1113
|
+
);
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1084
1118
|
// Validate: job event triggers must reference existing handlers.
|
|
1085
1119
|
// Multi-Trigger-Form: jeden Eintrag im Array gegen allHandlers prüfen,
|
|
1086
1120
|
// auch wenn nur einer fehlt fail-fast.
|
|
@@ -1196,6 +1230,13 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
1196
1230
|
return filterOwned(preQueryHooks.get(name), effectiveFeatures);
|
|
1197
1231
|
},
|
|
1198
1232
|
|
|
1233
|
+
getPostQueryHooks(
|
|
1234
|
+
name: string,
|
|
1235
|
+
effectiveFeatures?: ReadonlySet<string>,
|
|
1236
|
+
): readonly PostQueryHookFn[] {
|
|
1237
|
+
return filterOwned(postQueryHooks.get(name), effectiveFeatures);
|
|
1238
|
+
},
|
|
1239
|
+
|
|
1199
1240
|
// Entity hooks — fire for all writes on an entity
|
|
1200
1241
|
getEntityPostSaveHooks(
|
|
1201
1242
|
entityName: string,
|
|
@@ -1221,6 +1262,13 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
1221
1262
|
return filterByPhase(entityPostDeleteHooks.get(entityName), phase, effectiveFeatures);
|
|
1222
1263
|
},
|
|
1223
1264
|
|
|
1265
|
+
getEntityPostQueryHooks(
|
|
1266
|
+
entityName: string,
|
|
1267
|
+
effectiveFeatures?: ReadonlySet<string>,
|
|
1268
|
+
): readonly PostQueryHookFn[] {
|
|
1269
|
+
return filterOwned(entityPostQueryHooks.get(entityName), effectiveFeatures);
|
|
1270
|
+
},
|
|
1271
|
+
|
|
1224
1272
|
getAllTranslations(): TranslationKeys {
|
|
1225
1273
|
return mergedTranslations;
|
|
1226
1274
|
},
|
|
@@ -44,6 +44,7 @@ import type {
|
|
|
44
44
|
HookMap,
|
|
45
45
|
HookPhase,
|
|
46
46
|
PostDeleteHookFn,
|
|
47
|
+
PostQueryHookFn,
|
|
47
48
|
PostSaveHookFn,
|
|
48
49
|
PreDeleteHookFn,
|
|
49
50
|
PreQueryHookFn,
|
|
@@ -347,6 +348,7 @@ export type FeatureRegistrar<TFeature extends string = string> = {
|
|
|
347
348
|
options?: { phase?: HookPhase },
|
|
348
349
|
): void;
|
|
349
350
|
hook(type: "preQuery", target: RefOrRefs, fn: PreQueryHookFn): void;
|
|
351
|
+
hook(type: "postQuery", target: RefOrRefs, fn: PostQueryHookFn): void;
|
|
350
352
|
|
|
351
353
|
entityHook(
|
|
352
354
|
type: "postSave",
|
|
@@ -361,6 +363,11 @@ export type FeatureRegistrar<TFeature extends string = string> = {
|
|
|
361
363
|
fn: PostDeleteHookFn,
|
|
362
364
|
options?: { phase?: HookPhase },
|
|
363
365
|
): void;
|
|
366
|
+
// postQuery-entityHook: fires for ALL query-handlers of this entity (e.g.,
|
|
367
|
+
// for customFields-bundle to merge custom-fields-jsonb into every read).
|
|
368
|
+
// No phase semantics (synchronous after handler-execute, before field-
|
|
369
|
+
// access-filter).
|
|
370
|
+
entityHook(type: "postQuery", entity: NameOrRef, fn: PostQueryHookFn): void;
|
|
364
371
|
|
|
365
372
|
// Returns a handle map keyed exactly like the input. Pass any handle to
|
|
366
373
|
// `ctx.config(handle)` to get the value type narrowed by the key's `type`.
|
|
@@ -649,6 +656,10 @@ export type Registry = {
|
|
|
649
656
|
name: string,
|
|
650
657
|
effectiveFeatures?: ReadonlySet<string>,
|
|
651
658
|
): readonly PreQueryHookFn[];
|
|
659
|
+
getPostQueryHooks(
|
|
660
|
+
name: string,
|
|
661
|
+
effectiveFeatures?: ReadonlySet<string>,
|
|
662
|
+
): readonly PostQueryHookFn[];
|
|
652
663
|
getEntityPostSaveHooks(
|
|
653
664
|
entityName: string,
|
|
654
665
|
phase?: HookPhase,
|
|
@@ -664,6 +675,10 @@ export type Registry = {
|
|
|
664
675
|
phase?: HookPhase,
|
|
665
676
|
effectiveFeatures?: ReadonlySet<string>,
|
|
666
677
|
): readonly PostDeleteHookFn[];
|
|
678
|
+
getEntityPostQueryHooks(
|
|
679
|
+
entityName: string,
|
|
680
|
+
effectiveFeatures?: ReadonlySet<string>,
|
|
681
|
+
): readonly PostQueryHookFn[];
|
|
667
682
|
getHandlerEntity(qualifiedHandler: string): string | undefined;
|
|
668
683
|
isHandlerSystemScoped(qualifiedHandler: string): boolean;
|
|
669
684
|
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,12 @@ 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
|
};
|
|
@@ -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)) {
|