@cosmicdrift/kumiko-framework 0.8.1 → 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 CHANGED
@@ -1,5 +1,50 @@
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
+
24
+ ## 0.9.0
25
+
26
+ ### Patch Changes
27
+
28
+ - 51e22f5: Add deploy-template scaffolding (Sprint 9.6).
29
+
30
+ **New API:**
31
+
32
+ - `scaffoldDeploy({ appName, port?, githubOrg?, destination?, force? })` exported from `@cosmicdrift/kumiko-dev-server`. Generates `deploy/Dockerfile`, `deploy/Dockerfile.dockerignore`, and `deploy/migrate-step.sh` from canonical templates shipped with the package. Substitutes `{{appName}}`, `{{port}}`, `{{githubOrg}}` placeholders.
33
+ - New CLI command: `kumiko init-deploy --app <name> [--port <n>] [--github-org <org>] [--out <dir>] [--force]`.
34
+
35
+ The templates are extracted from publicstatus's production-tested `deploy/Dockerfile` (node-alpine build stage → bun-alpine runtime, drizzle migrations baked in, healthcheck wired). Refuses to overwrite existing files unless `--force` is passed so a tuned per-app Dockerfile isn't clobbered.
36
+
37
+ **Templates are a starting point, not a contract.** Apps should review and adjust:
38
+
39
+ - **Image tag** is hardcoded `:latest` in `migrate-step.sh.template`. Swap to `:${BUILD_SHA}` for atomic deploys.
40
+ - **DB defaults** in `migrate-step.sh.template` assume `db user = db name = appName`, host `db`, port `5432`. Adjust to your stack.
41
+ - **`COPY /app/seeds`** assumes the app uses ES-Operations seed migrations. Comment out if your app has no `seeds/` directory (otherwise `docker build` fails).
42
+ - **`docker build`-smoke-test:** the templates run untested against a non-publicstatus app-tree. Verify locally before pushing to CI.
43
+
44
+ **Deferred to Sprint 9.7+:** `.github/workflows/build-image.yml.suggested`, `pulumi/secrets-bootstrap.sh`, `pulumi/extraEnv.snippet.ts`.
45
+
46
+ **Plan-Doc drift (for 9.9 update):** Plan-Doc-Tabelle nennt `start.sh` (in-container migrate-then-run); diese Implementation liefert `migrate-step.sh` (host-side deploy-pipeline). Beide Konzepte sind gültig — Plan-Doc-Update sollte das klarstellen.
47
+
3
48
  ## 0.8.1
4
49
 
5
50
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.8.1",
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.8.1",
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(
@@ -41,6 +41,7 @@ export const LifecycleHookTypes = {
41
41
  preDelete: "preDelete",
42
42
  postDelete: "postDelete",
43
43
  preQuery: "preQuery",
44
+ postQuery: "postQuery",
44
45
  } as const;
45
46
 
46
47
  export type LifecycleHookType = (typeof LifecycleHookTypes)[keyof typeof LifecycleHookTypes];
@@ -39,6 +39,7 @@ import type {
39
39
  OwnedFn,
40
40
  PhasedHook,
41
41
  PostDeleteHookFn,
42
+ PostQueryHookFn,
42
43
  PostSaveHookFn,
43
44
  PreDeleteHookFn,
44
45
  ProjectionDefinition,
@@ -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 (type === LifecycleHookTypes.preSave || type === LifecycleHookTypes.preQuery) {
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 have no phase, stored and done
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,
@@ -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
  };
@@ -158,6 +158,7 @@ export type {
158
158
  PhasedHook,
159
159
  PostDeleteBatchHookFn,
160
160
  PostDeleteHookFn,
161
+ PostQueryHookFn,
161
162
  PostSaveBatchHookFn,
162
163
  PostSaveHookFn,
163
164
  PreDeleteHookFn,
@@ -0,0 +1,116 @@
1
+ // F1 postQuery-Hook integration test — verifies firing through the real
2
+ // dispatcher pipeline (handler-keyed + entity-keyed, order, shape-variants).
3
+ //
4
+ // Covers advisor-gaps from F1-self-review:
5
+ // - integration test against real dispatcher (gap #1)
6
+ // - order: handler-keyed fires BEFORE entity-keyed (gap #2)
7
+ // - {rows}-shape (the most common case for list-handlers) (gap #3)
8
+ //
9
+ // Memory `feedback_no_fake_dispatcher`: real HTTP-Calls via setupTestStack,
10
+ // nicht createTestDispatcher.
11
+
12
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
13
+ import { z } from "zod";
14
+ import { createEntity, createTextField, defineFeature } from "../../engine";
15
+ import type { PostQueryHookFn } from "../../engine/types";
16
+ import { setupTestStack, type TestStack, TestUsers } from "../../stack";
17
+
18
+ // --- Fixture entity ---
19
+
20
+ const widgetEntity = createEntity({
21
+ table: "read_post_query_widgets",
22
+ fields: {
23
+ name: createTextField({ required: true }),
24
+ },
25
+ });
26
+
27
+ // --- Track hook-firing-order (module-level for test inspection) ---
28
+
29
+ const firingOrder: string[] = [];
30
+
31
+ // --- Feature with handler-keyed + entity-keyed postQuery-Hooks ---
32
+
33
+ const handlerKeyedHook: PostQueryHookFn = async ({ rows }) => {
34
+ firingOrder.push("handler-keyed");
35
+ return {
36
+ rows: rows.map((row) => ({ ...row, viaHandler: true })),
37
+ };
38
+ };
39
+
40
+ const entityKeyedHook: PostQueryHookFn = async ({ rows }) => {
41
+ firingOrder.push("entity-keyed");
42
+ return {
43
+ rows: rows.map((row) => ({ ...row, viaEntity: true })),
44
+ };
45
+ };
46
+
47
+ const postQueryFeature = defineFeature("postquerytest", (r) => {
48
+ const widget = r.entity("widget", widgetEntity);
49
+
50
+ // Returns 2 rows in {rows}-Shape (the dominant case for list-handlers)
51
+ r.queryHandler(
52
+ "widget:list",
53
+ z.object({}),
54
+ async () => ({
55
+ rows: [
56
+ { id: "w1", name: "Alpha" },
57
+ { id: "w2", name: "Beta" },
58
+ ],
59
+ nextCursor: null,
60
+ }),
61
+ { access: { openToAll: true } },
62
+ );
63
+
64
+ // Handler-keyed: fires only for widget:list
65
+ r.hook("postQuery", "widget:list", handlerKeyedHook);
66
+
67
+ // Entity-keyed: fires for ALL query-handlers of widget-entity
68
+ r.entityHook("postQuery", widget, entityKeyedHook);
69
+ });
70
+
71
+ // --- Test stack ---
72
+
73
+ let stack: TestStack;
74
+ const admin = TestUsers.admin;
75
+
76
+ beforeAll(async () => {
77
+ stack = await setupTestStack({ features: [postQueryFeature], systemHooks: [] });
78
+ });
79
+
80
+ afterAll(async () => {
81
+ await stack.cleanup();
82
+ });
83
+
84
+ // --- Tests ---
85
+
86
+ describe("postQuery-Hook integration through dispatcher", () => {
87
+ test("handler-keyed and entity-keyed hooks both fire, modify rows, in handler-then-entity order", async () => {
88
+ firingOrder.length = 0;
89
+
90
+ const result = await stack.http.queryOk<{
91
+ rows: Array<{ id: string; name: string; viaHandler?: boolean; viaEntity?: boolean }>;
92
+ nextCursor: string | null;
93
+ }>("postquerytest:query:widget:list", {}, admin);
94
+
95
+ // Both hooks fired
96
+ expect(firingOrder).toEqual(["handler-keyed", "entity-keyed"]);
97
+
98
+ // Both hooks' mutations land in the response
99
+ expect(result.rows).toHaveLength(2);
100
+ expect(result.rows[0]).toMatchObject({
101
+ id: "w1",
102
+ name: "Alpha",
103
+ viaHandler: true,
104
+ viaEntity: true,
105
+ });
106
+ expect(result.rows[1]).toMatchObject({
107
+ id: "w2",
108
+ name: "Beta",
109
+ viaHandler: true,
110
+ viaEntity: true,
111
+ });
112
+
113
+ // {rows}-Shape preserved through hook-pipeline
114
+ expect(result.nextCursor).toBeNull();
115
+ });
116
+ });
@@ -774,9 +774,49 @@ export function createDispatcher(
774
774
  const handlerContext = buildHandlerContext(type, user, tx);
775
775
  let result = await handler.handler({ type, payload: parsed.data, user }, handlerContext);
776
776
 
777
- // Field-level read filter
777
+ // postQuery-Hooks: fire BEFORE field-access-filter so hooks see raw data
778
+ // and can merge custom-fields/computed-counts/tags/etc. Each hook is
779
+ // responsible for its own field-access on values it adds (the filter
780
+ // below only knows the entity's stammfields).
781
+ //
782
+ // Two firing-pfade kombiniert in dieser Reihenfolge:
783
+ // 1. Handler-keyed hooks via r.hook("postQuery", "ns:query:list", fn)
784
+ // — feuern nur für genau diesen handler
785
+ // 2. Entity-keyed hooks via r.entityHook("postQuery", "property", fn)
786
+ // — feuern für ALLE query-handlers des entity
778
787
  const entityName = registry.getHandlerEntity(type);
779
788
  if (entityName) {
789
+ const handlerHooks = registry.getPostQueryHooks(type);
790
+ const entityHooks = registry.getEntityPostQueryHooks(entityName);
791
+ const postQueryHooks = [...handlerHooks, ...entityHooks];
792
+ if (postQueryHooks.length > 0 && result && typeof result === "object") {
793
+ if (Array.isArray(result)) {
794
+ let rows = result as Record<string, unknown>[]; // @cast-boundary engine-payload
795
+ for (const hook of postQueryHooks) {
796
+ const out = await hook({ entityName, rows }, handlerContext);
797
+ rows = [...out.rows];
798
+ }
799
+ result = rows;
800
+ } else if ("rows" in result) {
801
+ // @cast-boundary engine-payload
802
+ const r = result as { rows: Record<string, unknown>[]; nextCursor: string | null };
803
+ let rows = r.rows;
804
+ for (const hook of postQueryHooks) {
805
+ const out = await hook({ entityName, rows }, handlerContext);
806
+ rows = [...out.rows];
807
+ }
808
+ result = { ...r, rows };
809
+ } else {
810
+ let rows: Record<string, unknown>[] = [result as Record<string, unknown>]; // @cast-boundary engine-payload
811
+ for (const hook of postQueryHooks) {
812
+ const out = await hook({ entityName, rows }, handlerContext);
813
+ rows = [...out.rows];
814
+ }
815
+ result = rows[0] ?? result;
816
+ }
817
+ }
818
+
819
+ // Field-level read filter
780
820
  const entity = registry.getEntity(entityName);
781
821
  if (entity && result && typeof result === "object") {
782
822
  if (Array.isArray(result)) {