@cosmicdrift/kumiko-framework 0.146.3 → 0.147.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.
Files changed (68) hide show
  1. package/package.json +6 -2
  2. package/src/api/auth-routes.ts +32 -67
  3. package/src/api/routes.ts +1 -3
  4. package/src/bun-db/__tests__/PATTERN.md +0 -1
  5. package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +29 -3
  6. package/src/db/__tests__/schema-inspection.test.ts +7 -0
  7. package/src/db/event-store-executor-context.ts +398 -0
  8. package/src/db/event-store-executor-read.ts +276 -0
  9. package/src/db/event-store-executor-write.ts +615 -0
  10. package/src/db/event-store-executor.ts +29 -1166
  11. package/src/db/queries/shadow-swap.ts +3 -1
  12. package/src/db/schema-inspection.ts +1 -1
  13. package/src/engine/__tests__/boot-validator-dashboard.test.ts +10 -0
  14. package/src/engine/__tests__/engine.test.ts +45 -1
  15. package/src/engine/__tests__/event-migration-declarative.test.ts +6 -0
  16. package/src/engine/__tests__/membership-roles.test.ts +4 -10
  17. package/src/engine/__tests__/screen.test.ts +26 -0
  18. package/src/engine/boot-validator/entity-list-screens.ts +1 -1
  19. package/src/engine/boot-validator/gdpr-storage.ts +1 -1
  20. package/src/engine/boot-validator/index.ts +12 -8
  21. package/src/engine/boot-validator/nav.ts +120 -0
  22. package/src/engine/boot-validator/{screens-nav.ts → screens.ts} +12 -190
  23. package/src/engine/boot-validator/workspaces.ts +68 -0
  24. package/src/engine/define-feature.ts +77 -1011
  25. package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/feature.ts +9 -0
  26. package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/screens.ts +4 -0
  27. package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +18 -8
  28. package/src/engine/feature-ast/__tests__/parse.test.ts +291 -2
  29. package/src/engine/feature-ast/__tests__/patcher.test.ts +1 -1
  30. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +73 -0
  31. package/src/engine/feature-ast/extractors/round1.ts +3 -3
  32. package/src/engine/feature-ast/extractors/round4.ts +45 -10
  33. package/src/engine/feature-ast/extractors/shared.ts +64 -6
  34. package/src/engine/feature-ast/parse.ts +123 -10
  35. package/src/engine/feature-ast/patterns.ts +14 -7
  36. package/src/engine/feature-ast/render.ts +28 -7
  37. package/src/engine/feature-builder-state.ts +165 -0
  38. package/src/engine/feature-config-events-jobs.ts +303 -0
  39. package/src/engine/feature-entity-handlers.ts +161 -0
  40. package/src/engine/feature-ui-extensions.ts +413 -0
  41. package/src/engine/index.ts +0 -2
  42. package/src/engine/pattern-library/library.ts +44 -1131
  43. package/src/engine/pattern-library/mixed-schemas.ts +484 -0
  44. package/src/engine/pattern-library/opaque-schemas.ts +124 -0
  45. package/src/engine/pattern-library/shared-fields.ts +80 -0
  46. package/src/engine/pattern-library/static-schemas.ts +456 -0
  47. package/src/engine/registry-facade.ts +349 -0
  48. package/src/engine/registry-ingest.ts +473 -0
  49. package/src/engine/registry-state.ts +388 -0
  50. package/src/engine/registry-validate.ts +660 -0
  51. package/src/engine/registry.ts +79 -1695
  52. package/src/engine/types/screen.ts +4 -2
  53. package/src/engine/types/tree-node.ts +2 -5
  54. package/src/event-store/snapshot.ts +7 -7
  55. package/src/i18n/required-surface-keys.ts +2 -0
  56. package/src/jobs/job-runner.ts +1 -1
  57. package/src/observability/standard-metrics.ts +4 -3
  58. package/src/pipeline/dispatch-batch.ts +3 -3
  59. package/src/pipeline/dispatch-shared.ts +17 -10
  60. package/src/pipeline/event-dispatcher-admin.ts +289 -0
  61. package/src/pipeline/event-dispatcher-delivery.ts +264 -0
  62. package/src/pipeline/event-dispatcher.ts +28 -540
  63. package/src/pipeline/projection-rebuild.ts +1 -1
  64. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +95 -0
  65. package/src/stack/test-stack.ts +46 -1
  66. package/src/testing/boot-validator-fixture.ts +1 -2
  67. package/src/engine/__tests__/deep-link.test.ts +0 -35
  68. package/src/engine/deep-link.ts +0 -23
@@ -0,0 +1,660 @@
1
+ import { configureEventPiiCatalog } from "../crypto/event-pii";
2
+ import type { RegistryState } from "./registry-state";
3
+ import { buildImplicitProjection, hasFieldAccessRules, qualify } from "./registry-state";
4
+ import {
5
+ buildSoftDeleteCleanupJob,
6
+ buildSoftDeleteCleanupSystemJob,
7
+ SOFT_DELETE_CLEANUP_JOB,
8
+ SOFT_DELETE_CLEANUP_SYSTEM_JOB,
9
+ SOFT_DELETE_GRACE_DAYS_KEY,
10
+ softDeleteGraceDaysConfig,
11
+ } from "./soft-delete-cleanup";
12
+ import type { EventPiiFields, EventUpcastFn, FeatureDefinition } from "./types";
13
+ import { HookPhases } from "./types";
14
+ import { resolveName } from "./types/handlers";
15
+
16
+ function allHandlerQns(state: RegistryState): ReadonlySet<string> {
17
+ return new Set([...state.writeHandlerMap.keys(), ...state.queryHandlerMap.keys()]);
18
+ }
19
+
20
+ // Pass 2 for workspaces: fold any nav-self-assigned QNs into their
21
+ // workspace's member list. Safe now that every feature (and therefore every
22
+ // workspace) is in state.workspaceMap.
23
+ export function finalizeWorkspaceNavMembership(state: RegistryState): void {
24
+ // workspace's member list. We can do this safely now that every feature
25
+ // (and therefore every workspace) is in state.workspaceMap. Cross-feature refs
26
+ // — a nav from feature A self-assigning to a workspace from feature B —
27
+ // resolve here because B's workspace was registered in pass 1 above.
28
+ // Dedup: a nav entry that's also in r.workspace({ nav: [...] }) shouldn't
29
+ // appear twice. Boot-validator catches dangling workspace ids.
30
+ for (const [navQn, navDef] of state.navMap) {
31
+ if (!navDef.workspaces || navDef.workspaces.length === 0) continue;
32
+ for (const wsQn of navDef.workspaces) {
33
+ const members = state.navsByWorkspace.get(wsQn);
34
+ if (members === undefined) continue; // dangling — boot-validator reports
35
+ if (!members.includes(navQn)) members.push(navQn);
36
+ }
37
+ }
38
+ }
39
+
40
+ // Build handler → entity mapping from feature declarations. Must happen before
41
+ // extension processing since extension preSave hooks need entity mappings.
42
+ export function populateHandlerEntityMappings(
43
+ state: RegistryState,
44
+ features: readonly FeatureDefinition[],
45
+ ): void {
46
+ // Build handler → entity mapping from feature declarations (filled by tryMapEntity
47
+ // in defineFeature via the "entityName:verb" colon convention).
48
+ // Must happen before extension processing since extension preSave hooks need entity mappings.
49
+ for (const feature of features) {
50
+ for (const [handlerName, entityName] of Object.entries(feature.handlerEntityMappings ?? {})) {
51
+ const writeQn = qualify(feature.name, "write", handlerName);
52
+ const queryQn = qualify(feature.name, "query", handlerName);
53
+ if (state.writeHandlerMap.has(writeQn)) {
54
+ state.handlerEntityMap.set(writeQn, entityName);
55
+ }
56
+ if (state.queryHandlerMap.has(queryQn)) {
57
+ state.handlerEntityMap.set(queryQn, entityName);
58
+ }
59
+ }
60
+ }
61
+ }
62
+
63
+ export function validateExtensionSelectors(state: RegistryState): void {
64
+ // Selector declarations point into the merged extension + config-key
65
+ // sets — a typo'd extension or dropped key must fail the boot, not
66
+ // silently un-gate readiness.
67
+ for (const [extensionName, qualifiedKey] of state.extensionSelectorMap) {
68
+ if (!state.extensionMap.has(extensionName)) {
69
+ throw new Error(
70
+ `extensionSelector("${extensionName}") declared but no feature ` +
71
+ `registers that extension via extendsRegistrar.`,
72
+ );
73
+ }
74
+ if (!state.configKeyMap.has(qualifiedKey)) {
75
+ throw new Error(
76
+ `extensionSelector("${extensionName}") points at unknown config key ` +
77
+ `"${qualifiedKey}" — no mounted feature declares it.`,
78
+ );
79
+ }
80
+ }
81
+ }
82
+
83
+ // Process extension usages: call onRegister, apply extendSchema, register hooks.
84
+ export function applyExtensionUsages(state: RegistryState): void {
85
+ // Process extension usages: call onRegister, apply extendSchema, register hooks
86
+ for (const usage of state.extensionUsages) {
87
+ const ext = state.extensionMap.get(usage.extensionName);
88
+ if (!ext) continue;
89
+
90
+ if (ext.onRegister) {
91
+ ext.onRegister(usage.entityName, usage.options);
92
+ }
93
+
94
+ // extendSchema: merge extra fields into entity definition
95
+ if (ext.extendSchema) {
96
+ const entity = state.entityMap.get(usage.entityName);
97
+ if (entity) {
98
+ const extraFields = ext.extendSchema(usage.entityName);
99
+ const merged = { ...entity, fields: { ...entity.fields, ...extraFields } };
100
+ state.entityMap.set(usage.entityName, merged);
101
+ }
102
+ }
103
+
104
+ // Extension hooks → entity hooks (fire for all writes on the entity).
105
+ // Extensions default to afterCommit phase (same default as r.hook).
106
+ //
107
+ // Owner "*" = always-enabled, not gated by feature-toggles. Extensions
108
+ // are plumbing (e.g. ownership) — the feature that declared them might
109
+ // itself be toggleable, but the extension-hook is conceptually part of
110
+ // the entity's invariants. If future requirements need extension hooks
111
+ // to also be gated, store the registering-feature on
112
+ // RegistrarExtensionRegistration and use that here.
113
+ const extOwner = "*";
114
+ if (ext.hooks) {
115
+ if (ext.hooks.postSave) {
116
+ const existing = state.entityPostSaveHooks.get(usage.entityName) ?? [];
117
+ existing.push({
118
+ fn: ext.hooks.postSave,
119
+ phase: HookPhases.afterCommit,
120
+ featureName: extOwner,
121
+ });
122
+ state.entityPostSaveHooks.set(usage.entityName, existing);
123
+ }
124
+ if (ext.hooks.preDelete) {
125
+ const existing = state.entityPreDeleteHooks.get(usage.entityName) ?? [];
126
+ existing.push({
127
+ fn: ext.hooks.preDelete,
128
+ phase: HookPhases.afterCommit,
129
+ featureName: extOwner,
130
+ });
131
+ state.entityPreDeleteHooks.set(usage.entityName, existing);
132
+ }
133
+ if (ext.hooks.postDelete) {
134
+ const existing = state.entityPostDeleteHooks.get(usage.entityName) ?? [];
135
+ existing.push({
136
+ fn: ext.hooks.postDelete,
137
+ phase: HookPhases.afterCommit,
138
+ featureName: extOwner,
139
+ });
140
+ state.entityPostDeleteHooks.set(usage.entityName, existing);
141
+ }
142
+ // preSave on extensions: store as handler hook for all CRUD handlers of this entity
143
+ if (ext.hooks.preSave) {
144
+ // Find all write handlers that belong to this entity via state.handlerEntityMap
145
+ for (const qualifiedHandler of state.writeHandlerMap.keys()) {
146
+ if (state.handlerEntityMap.get(qualifiedHandler) === usage.entityName) {
147
+ const existing = state.preSaveHooks.get(qualifiedHandler) ?? [];
148
+ existing.push({ fn: ext.hooks.preSave, featureName: extOwner });
149
+ state.preSaveHooks.set(qualifiedHandler, existing);
150
+ }
151
+ }
152
+ }
153
+ }
154
+ }
155
+ }
156
+
157
+ // Precompute: searchable/sortable fields.
158
+ export function buildSearchableSortableCaches(state: RegistryState): void {
159
+ // Precompute: searchable/sortable fields, search includes, incoming relations
160
+
161
+ for (const [name, entity] of state.entityMap) {
162
+ const searchable: string[] = [];
163
+ const sortable: string[] = [];
164
+ for (const [fieldName, field] of Object.entries(entity.fields)) {
165
+ if (field.type === "text" && field.searchable === true) searchable.push(fieldName);
166
+ if (field.type === "text" && field.sortable === true) sortable.push(fieldName);
167
+ if (field.type === "embedded") {
168
+ for (const [subName, subField] of Object.entries(field.schema)) {
169
+ if (subField.searchable === true) searchable.push(`${fieldName}_${subName}`);
170
+ }
171
+ }
172
+ }
173
+ state.searchableFieldsCache.set(name, searchable);
174
+ state.sortableFieldsCache.set(name, sortable);
175
+ }
176
+ }
177
+
178
+ // Implicit-Projection pro r.entity — see buildImplicitProjection doc above.
179
+ export function buildImplicitProjections(
180
+ state: RegistryState,
181
+ features: readonly FeatureDefinition[],
182
+ ): void {
183
+ // Implicit-Projection pro r.entity. Macht die Entity-Tabelle rebaubar
184
+ // ohne dass Apps eine explizite r.projection schreiben müssen.
185
+ // Naming-Convention: `<feature>:projection:<entityName>-entity` — der
186
+ // "-entity"-Suffix unterscheidet implicit von explicit-Projections und
187
+ // vermeidet Kollisionen wenn jemand z.B. eine Cross-Aggregate-Projection
188
+ // mit Entity-Name registriert.
189
+ for (const feature of features) {
190
+ // extendEntityProjection targets must exist as r.entity in the SAME
191
+ // feature — a typo'd entity name would otherwise vanish silently and the
192
+ // extension's events would still be wiped on rebuild.
193
+ for (const extEntity of Object.keys(feature.entityProjectionExtensions ?? {})) {
194
+ if (!feature.entities?.[extEntity]) {
195
+ throw new Error(
196
+ `[Feature ${feature.name}] extendEntityProjection("${extEntity}"): no r.entity ` +
197
+ `with that name in this feature. Declare the entity first — the extension ` +
198
+ `merges into its implicit projection.`,
199
+ );
200
+ }
201
+ }
202
+ for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
203
+ const def = buildImplicitProjection(
204
+ feature.name,
205
+ entityName,
206
+ entity,
207
+ qualify,
208
+ feature.entityTables?.[entityName],
209
+ feature.entityProjectionExtensions?.[entityName] ?? [],
210
+ );
211
+ if (state.projectionMap.has(def.name)) {
212
+ throw new Error(
213
+ `Implicit projection "${def.name}" kollidiert mit einer explizit registrierten r.projection. ` +
214
+ `Implicit-Projections werden für jede r.entity mit "-entity"-Suffix angelegt — ` +
215
+ `benenne deine explicit projection um (z.B. "<entity>-summary") um die Kollision aufzulösen.`,
216
+ );
217
+ }
218
+ state.projectionMap.set(def.name, def);
219
+ const existing = state.projectionsBySource.get(entityName) ?? [];
220
+ existing.push(def);
221
+ state.projectionsBySource.set(entityName, existing);
222
+ }
223
+ }
224
+ }
225
+
226
+ export function validateNoRawTableProjectionClash(state: RegistryState): void {
227
+ // Cross-cut: a r.rawTable() PgTable must not coincide with any
228
+ // registered projection's table. Silent dedupe via Set would mask a
229
+ // real authoring bug (two owners writing to the same physical table).
230
+ // Run after both passes so implicit projections are visible too.
231
+ const projectionTables = new Set<unknown>();
232
+ for (const proj of state.projectionMap.values()) projectionTables.add(proj.table);
233
+ for (const msp of state.multiStreamProjectionMap.values()) {
234
+ if (msp.table) projectionTables.add(msp.table);
235
+ }
236
+ for (const raw of state.rawTableMap.values()) {
237
+ if (projectionTables.has(raw.table)) {
238
+ throw new Error(
239
+ `r.rawTable "${raw.name}" (feature "${raw.featureName}") shares a Drizzle ` +
240
+ `PgTable with a registered projection. Pick one owner: r.entity() / ` +
241
+ `r.projection() for event-sourced reads, r.rawTable() for the bypass.`,
242
+ );
243
+ }
244
+ }
245
+ }
246
+
247
+ export function buildSearchIncludesAndIncomingRelations(state: RegistryState): void {
248
+ for (const [entityName, rels] of state.relationMap) {
249
+ const includes = new Map<string, readonly string[]>();
250
+ for (const [relName, rel] of Object.entries(rels)) {
251
+ if ((rel.type === "belongsTo" || rel.type === "manyToMany") && rel.searchInclude?.length) {
252
+ includes.set(relName, rel.searchInclude);
253
+ }
254
+ }
255
+ state.searchIncludesCache.set(entityName, includes);
256
+
257
+ // Build reverse index for incoming relations
258
+ for (const [relName, rel] of Object.entries(rels)) {
259
+ const existing = state.incomingRelationsCache.get(rel.target) ?? [];
260
+ existing.push({ sourceEntity: entityName, relationName: relName, relation: rel });
261
+ state.incomingRelationsCache.set(rel.target, existing);
262
+ }
263
+ }
264
+ }
265
+
266
+ export function validateFieldAccessHandlersAreEntityMapped(
267
+ state: RegistryState,
268
+ features: readonly FeatureDefinition[],
269
+ ): void {
270
+ // Validate: handlers in features with field-access rules must be entity-mapped.
271
+ // Without entity mapping, field-level access checks are silently skipped (security gap).
272
+ for (const feature of features) {
273
+ if (!hasFieldAccessRules(feature)) continue;
274
+
275
+ for (const handlerName of Object.keys(feature.writeHandlers ?? {})) {
276
+ const qualified = qualify(feature.name, "write", handlerName);
277
+ if (!state.handlerEntityMap.has(qualified)) {
278
+ throw new Error(
279
+ `Write handler "${qualified}" is not mapped to any entity, but feature "${feature.name}" has field-level access rules. ` +
280
+ `Name must follow "entity:verb" convention (e.g. "user:create") or use create/update/delete on a matching entity.`,
281
+ );
282
+ }
283
+ }
284
+
285
+ // Query handlers: only those with a colon must resolve (typo protection).
286
+ for (const handlerName of Object.keys(feature.queryHandlers ?? {})) {
287
+ if (!handlerName.includes(":")) continue;
288
+ const qualified = qualify(feature.name, "query", handlerName);
289
+ if (!state.handlerEntityMap.has(qualified)) {
290
+ throw new Error(
291
+ `Query handler "${qualified}" looks entity-bound but no matching entity exists. ` +
292
+ `Either fix the entity name, or use a name without colons for standalone queries.`,
293
+ );
294
+ }
295
+ }
296
+ }
297
+ }
298
+
299
+ export function validateRelationTargetsExist(state: RegistryState): void {
300
+ // Validate: all relation targets must reference existing entities
301
+ for (const [entityName, rels] of state.relationMap) {
302
+ for (const [relName, rel] of Object.entries(rels)) {
303
+ if (!state.entityMap.has(rel.target)) {
304
+ throw new Error(
305
+ `Relation "${entityName}.${relName}" targets entity "${rel.target}" which does not exist`,
306
+ );
307
+ }
308
+ }
309
+ }
310
+ }
311
+
312
+ export function validateEventMigrationVersions(
313
+ state: RegistryState,
314
+ features: readonly FeatureDefinition[],
315
+ ): void {
316
+ // Build + validate event upcaster chains. Run AFTER all features are
317
+ // ingested so r.eventMigration calls can reference events from any
318
+ // feature (same feature in practice, but the check stays lax for future
319
+ // cross-feature event packs).
320
+ for (const feature of features) {
321
+ for (const [shortName, migrations] of Object.entries(feature.eventMigrations ?? {})) {
322
+ const qualified = qualify(feature.name, "event", shortName);
323
+ const eventDef = state.eventMap.get(qualified);
324
+ if (!eventDef) {
325
+ throw new Error(
326
+ `Feature "${feature.name}" registered r.eventMigration for "${shortName}" ` +
327
+ `but no r.defineEvent exists for that name. Register the event first.`,
328
+ );
329
+ }
330
+ for (const m of migrations) {
331
+ if (m.toVersion > eventDef.version) {
332
+ throw new Error(
333
+ `Feature "${feature.name}" has r.eventMigration("${shortName}", ${m.fromVersion}, ${m.toVersion}) ` +
334
+ `but r.defineEvent declares only version ${eventDef.version}. ` +
335
+ `Bump the version in defineEvent to at least ${m.toVersion}, or remove the migration.`,
336
+ );
337
+ }
338
+ }
339
+ }
340
+ }
341
+ }
342
+
343
+ export function buildEventUpcasterChains(
344
+ state: RegistryState,
345
+ features: readonly FeatureDefinition[],
346
+ ): void {
347
+ // Stitch the upcaster chain per qualified event. At this point, gaps in
348
+ // the chain (e.g. defineEvent version=3 but only a 1→2 migration exists)
349
+ // are hard errors — they would silently hand a v2-shape payload to a
350
+ // consumer expecting v3 at runtime, which is the class of bug upcasters
351
+ // are supposed to prevent.
352
+ for (const [qualified, eventDef] of state.eventMap) {
353
+ const chainMap = new Map<number, EventUpcastFn>();
354
+ // Locate the feature that owns this event (to pick up its migrations).
355
+ for (const feature of features) {
356
+ for (const [shortName, migs] of Object.entries(feature.eventMigrations ?? {})) {
357
+ const candidateQn = qualify(feature.name, "event", shortName);
358
+ if (candidateQn !== qualified) continue;
359
+ for (const m of migs) chainMap.set(m.fromVersion, m.transform);
360
+ }
361
+ }
362
+ if (eventDef.version > 1) {
363
+ for (let v = 1; v < eventDef.version; v++) {
364
+ if (!chainMap.has(v)) {
365
+ throw new Error(
366
+ `Event "${qualified}" declares version ${eventDef.version} but no migration ` +
367
+ `covers the step v${v} → v${v + 1}. Register r.eventMigration("${qualified.split(":").pop() ?? qualified}", ${v}, ${v + 1}, transform) ` +
368
+ `so stored v${v} payloads can be upcast on read.`,
369
+ );
370
+ }
371
+ }
372
+ }
373
+ state.eventUpcasterMap.set(qualified, {
374
+ currentVersion: eventDef.version,
375
+ chain: chainMap,
376
+ });
377
+ }
378
+ }
379
+
380
+ export function validateProjectionApplyKeys(state: RegistryState): void {
381
+ // Validate: every projection's source must reference a registered entity.
382
+ // A typo ("unti" instead of "unit") would otherwise be a silent no-op —
383
+ // the projection is stored but never fires because no aggregateType ever
384
+ // matches. Fail at boot so the feature author sees it immediately.
385
+ //
386
+ // Same guard extends to apply-keys: a handler for "unit.creatd" (missing
387
+ // 'e') would silently never fire. Valid apply-keys are the auto-generated
388
+ // CRUD types per source entity PLUS every domain event registered via
389
+ // r.defineEvent — an apply-handler for a domain event is how a projection
390
+ // reacts to ctx.appendEvent writes on the same aggregate stream.
391
+ const AUTO_EVENT_VERBS = ["created", "updated", "deleted", "restored", "forgotten"] as const;
392
+ const allDomainEventNames = new Set(state.eventMap.keys());
393
+ for (const [projName, projDef] of state.projectionMap) {
394
+ const sources = Array.isArray(projDef.source) ? projDef.source : [projDef.source];
395
+ // extraSources (r.extendEntityProjection) sit in the rebuild filter, so
396
+ // their auto-verbs are legitimately observable apply-keys too.
397
+ const rebuildSources = [...sources, ...(projDef.extraSources ?? [])];
398
+ const validEventTypes = new Set<string>();
399
+ // Two source-modes are legal:
400
+ //
401
+ // (a) Registered entity (r.entity(src, ...)) — the "normal" case:
402
+ // auto-lifecycle events `<src>.created/.updated/.deleted/.restored`
403
+ // fire when the event-store-executor writes, and any domain-event
404
+ // (r.defineEvent) appended onto an aggregate of that type is
405
+ // observable too.
406
+ //
407
+ // (b) Events-only source — no r.entity registered, but at least one
408
+ // apply-key must be a domain-event (not a CRUD-verb on the source
409
+ // name). Use-case: features that own an append-only event-stream
410
+ // without a CRUD lifecycle, e.g. `deliveryAttempt` (each call to
411
+ // the delivery-service produces one event on a fresh aggregate)
412
+ // or `jobRun` (BullMQ-callback-driven lifecycle, no executor).
413
+ // A "Shape-Anchor"-entity is no longer needed for this case.
414
+ const isEventsOnlySource = !sources.every((src) => state.entityMap.has(src));
415
+ for (const src of rebuildSources) {
416
+ if (state.entityMap.has(src)) {
417
+ for (const verb of AUTO_EVENT_VERBS) validEventTypes.add(`${src}.${verb}`);
418
+ }
419
+ }
420
+ // Domain events are valid apply-keys for any projection. They arrive via
421
+ // ctx.appendEvent on a specific aggregate — the runtime matches by event
422
+ // type, so a projection can observe domain events whose aggregate matches
423
+ // one of its declared sources.
424
+ for (const domainEvt of allDomainEventNames) validEventTypes.add(domainEvt);
425
+
426
+ // In events-only mode, at least one apply-key MUST be a domain-event —
427
+ // otherwise the source is simply a typo (no events will ever fire).
428
+ if (isEventsOnlySource) {
429
+ const hasAnyDomainEvent = Object.keys(projDef.apply).some((k) => allDomainEventNames.has(k));
430
+ if (!hasAnyDomainEvent) {
431
+ const unregistered = sources.filter((src) => !state.entityMap.has(src));
432
+ throw new Error(
433
+ `Projection "${projName}" declares source(s) [${unregistered.join(", ")}] that are not registered entities, ` +
434
+ `and has no domain-event apply-keys. This is either a typo or a missing r.defineEvent registration. ` +
435
+ `Events-only projections need at least one apply-key from r.defineEvent; ` +
436
+ `CRUD-style projections need r.entity("${unregistered[0]}", ...).`,
437
+ );
438
+ }
439
+ }
440
+
441
+ for (const applyKey of Object.keys(projDef.apply)) {
442
+ if (!validEventTypes.has(applyKey)) {
443
+ throw new Error(
444
+ `Projection "${projName}" has an apply handler for "${applyKey}" but no such event ` +
445
+ `type exists for its source(s) [${sources.join(", ")}]. ` +
446
+ `Valid types: ${[...validEventTypes].join(", ")}. ` +
447
+ `Check for a typo — auto-verbs follow "<entity>.<verb>"; ` +
448
+ `domain events follow "<feature>:event:<short-name>" (see r.defineEvent).`,
449
+ );
450
+ }
451
+ }
452
+ }
453
+ }
454
+
455
+ export function validateRequiredFeatures(
456
+ state: RegistryState,
457
+ features: readonly FeatureDefinition[],
458
+ ): void {
459
+ // Validate: all required features must be registered
460
+ for (const feature of features) {
461
+ for (const required of feature.requires ?? []) {
462
+ if (!state.featureMap.has(required)) {
463
+ throw new Error(
464
+ `Feature "${feature.name}" requires feature "${required}" which is not registered`,
465
+ );
466
+ }
467
+ }
468
+ }
469
+ }
470
+
471
+ export function resolveNotificationTriggersAndRegisterHooks(state: RegistryState): void {
472
+ // Resolve notification triggers and register postSave hooks
473
+ // Done after all features are registered so cross-feature triggers work
474
+ const allHandlerNames = allHandlerQns(state);
475
+ for (const [qualifiedName, notifDef] of state.notificationMap) {
476
+ // Both maps are populated in lockstep — same key-set by construction.
477
+ const featureName = state.notificationFeatureMap.get(qualifiedName) as string; // @cast-boundary engine-bridge
478
+ // I'll try the easy path first: if the trigger is already a fully qualified QN
479
+ // (cross-feature), I take it as-is. Otherwise I qualify with the own feature —
480
+ // as a write handler first (the common case), then as a query. If nothing
481
+ // matches by then, it was a typo and I'll say so.
482
+ let triggerOn: string;
483
+ if (allHandlerNames.has(notifDef.trigger.on)) {
484
+ triggerOn = notifDef.trigger.on;
485
+ } else {
486
+ // Try as write handler first (most common), then query
487
+ const writeQn = qualify(featureName, "write", notifDef.trigger.on);
488
+ const queryQn = qualify(featureName, "query", notifDef.trigger.on);
489
+ if (allHandlerNames.has(writeQn)) {
490
+ triggerOn = writeQn;
491
+ } else if (allHandlerNames.has(queryQn)) {
492
+ triggerOn = queryQn;
493
+ } else {
494
+ throw new Error(
495
+ `Notification "${qualifiedName}" triggers on "${notifDef.trigger.on}" ` +
496
+ `but no handler with that name exists. ` +
497
+ `Tried: "${notifDef.trigger.on}", "${writeQn}", and "${queryQn}"`,
498
+ );
499
+ }
500
+ }
501
+ // Update the stored definition with resolved trigger
502
+ state.notificationMap.set(qualifiedName, { ...notifDef, trigger: { on: triggerOn } });
503
+
504
+ if (!state.postSaveHooks.has(triggerOn)) state.postSaveHooks.set(triggerOn, []);
505
+ state.postSaveHooks.get(triggerOn)?.push({
506
+ phase: HookPhases.afterCommit,
507
+ featureName,
508
+ fn: async (result, context) => {
509
+ if (!context.notify) {
510
+ context.log?.debug(
511
+ `notification ${qualifiedName}: skipping — no notify function configured on context`,
512
+ );
513
+ return;
514
+ }
515
+ const to = notifDef.recipient(result);
516
+ if (to === null) {
517
+ context.log?.debug(
518
+ `notification ${qualifiedName}: skipping — recipient resolver returned null for result ${result.id}`,
519
+ );
520
+ return;
521
+ }
522
+ const data = notifDef.data(result);
523
+ await context.notify(qualifiedName, { to, data });
524
+ },
525
+ });
526
+ }
527
+ }
528
+
529
+ export function validateLifecycleHookTargets(state: RegistryState): void {
530
+ // Validate: lifecycle hook targets must reference existing handlers
531
+ const allHandlers = allHandlerQns(state);
532
+ const lifecycleHookMaps = [
533
+ { map: state.preSaveHooks, phase: "preSave" },
534
+ { map: state.postSaveHooks, phase: "postSave" },
535
+ { map: state.preDeleteHooks, phase: "preDelete" },
536
+ { map: state.postDeleteHooks, phase: "postDelete" },
537
+ { map: state.preQueryHooks, phase: "preQuery" },
538
+ { map: state.postQueryHooks, phase: "postQuery" },
539
+ ] as const;
540
+
541
+ // I'd rather warn you now at boot than have you open a ticket three weeks from now
542
+ // saying "my hook isn't firing". One typo in the target and the thing goes silent.
543
+ for (const { map, phase } of lifecycleHookMaps) {
544
+ for (const hookTarget of map.keys()) {
545
+ if (!allHandlers.has(hookTarget)) {
546
+ throw new Error(
547
+ `${phase} hook targets "${hookTarget}" but no handler with that name exists. ` +
548
+ `Check for typos — the hook will never fire.`,
549
+ );
550
+ }
551
+ }
552
+ }
553
+ }
554
+
555
+ export function validateEntityHookTargets(
556
+ state: RegistryState,
557
+ features: readonly FeatureDefinition[],
558
+ ): void {
559
+ // Same logic for entity-keyed hooks — targets must reference existing entities.
560
+ // Memory `feedback_dead_hook_needs_second_consumer`: a typo silently registers
561
+ // and never fires. Validates all four entity-hook types (postSave/preDelete/
562
+ // postDelete/postQuery) — net cleanup of an existing antipattern, not a
563
+ // postQuery-special.
564
+ const allEntities = new Set<string>();
565
+ for (const feature of features) {
566
+ for (const entityName of Object.keys(feature.entities ?? {})) {
567
+ allEntities.add(entityName);
568
+ }
569
+ }
570
+ const entityHookMaps = [
571
+ { map: state.entityPostSaveHooks, phase: "postSave (entityHook)", kind: "hook" },
572
+ { map: state.entityPreDeleteHooks, phase: "preDelete (entityHook)", kind: "hook" },
573
+ { map: state.entityPostDeleteHooks, phase: "postDelete (entityHook)", kind: "hook" },
574
+ { map: state.entityPostQueryHooks, phase: "postQuery (entityHook)", kind: "hook" },
575
+ { map: state.searchPayloadExtensions, phase: "searchPayloadExtension", kind: "extension" },
576
+ ] as const;
577
+ for (const { map, phase, kind } of entityHookMaps) {
578
+ for (const entityName of map.keys()) {
579
+ if (!allEntities.has(entityName)) {
580
+ throw new Error(
581
+ `${phase} ${kind} targets entity "${entityName}" but no entity with that name exists. ` +
582
+ `Check for typos — the ${kind} will never fire.`,
583
+ );
584
+ }
585
+ }
586
+ }
587
+ }
588
+
589
+ export function validateJobTriggers(state: RegistryState): void {
590
+ // Validate: job event triggers must reference existing handlers.
591
+ // Multi-Trigger-Form: jeden Eintrag im Array gegen allHandlers prüfen,
592
+ // auch wenn nur einer fehlt fail-fast.
593
+ const allHandlers = allHandlerQns(state);
594
+ for (const [jobName, jobDef] of state.jobMap) {
595
+ if (!("on" in jobDef.trigger)) continue;
596
+ const triggerOn = jobDef.trigger.on;
597
+ const triggers = Array.isArray(triggerOn) ? triggerOn : [triggerOn];
598
+ for (const t of triggers) {
599
+ const rawName = resolveName(t);
600
+ if (allHandlers.has(rawName)) continue;
601
+ throw new Error(
602
+ `Job "${jobName}" triggers on "${rawName}" but no handler with that name exists`,
603
+ );
604
+ }
605
+ }
606
+ }
607
+
608
+ export function validateExtensionUsageTargets(state: RegistryState): void {
609
+ // Validate: extension usages must reference existing extensions
610
+ for (const usage of state.extensionUsages) {
611
+ if (!state.extensionMap.has(usage.extensionName)) {
612
+ throw new Error(
613
+ `Extension usage "${usage.extensionName}" on entity "${usage.entityName}" references an extension that does not exist`,
614
+ );
615
+ }
616
+ }
617
+ }
618
+
619
+ // Pre-compute: any handler with a rateLimit option?
620
+ export function computeHasRateLimitedHandler(state: RegistryState): void {
621
+ // Pre-compute: any handler with a rateLimit option? Keeps the boot
622
+ // path able to short-circuit the RateLimitResolver wiring (and its
623
+ // Lua-script registration on Redis) when nobody opted in.
624
+ state.hasRateLimitedHandlerCached = (() => {
625
+ for (const h of state.writeHandlerMap.values()) if (h.rateLimit !== undefined) return true;
626
+ for (const h of state.queryHandlerMap.values()) if (h.rateLimit !== undefined) return true;
627
+ return false;
628
+ })();
629
+ }
630
+
631
+ export function publishEventPiiCatalog(state: RegistryState): void {
632
+ // Publish the event-PII catalog (#799): append() — the single write funnel
633
+ // into kumiko_events — encrypts catalogued payload fields regardless of
634
+ // which path produced the event (ctx.appendEvent, MSP-apply, low-level
635
+ // append in delivery/jobs loggers).
636
+ const eventPiiCatalog = new Map<string, EventPiiFields>();
637
+ for (const [qualified, def] of state.eventMap) {
638
+ if (def.piiFields) eventPiiCatalog.set(qualified, def.piiFields);
639
+ }
640
+ configureEventPiiCatalog(eventPiiCatalog);
641
+ }
642
+
643
+ export function autoWireSoftDeleteJobs(state: RegistryState): void {
644
+ // Auto-wire the soft-delete cleanup cron + its grace-days config key when ANY
645
+ // entity opts into softDelete — the framework owns this machinery, no feature
646
+ // declares it (mirrors the auto restore-handler). Job-runner reads getAllJobs
647
+ // ungated; config-resolver reads getConfigKey → default. Reserved owner
648
+ // segment, guarded against a real-feature collision.
649
+ if ([...state.entityMap.values()].some((e) => e.softDelete)) {
650
+ if (!state.jobMap.has(SOFT_DELETE_CLEANUP_JOB)) {
651
+ state.jobMap.set(SOFT_DELETE_CLEANUP_JOB, buildSoftDeleteCleanupJob());
652
+ }
653
+ if (!state.jobMap.has(SOFT_DELETE_CLEANUP_SYSTEM_JOB)) {
654
+ state.jobMap.set(SOFT_DELETE_CLEANUP_SYSTEM_JOB, buildSoftDeleteCleanupSystemJob());
655
+ }
656
+ if (!state.configKeyMap.has(SOFT_DELETE_GRACE_DAYS_KEY)) {
657
+ state.configKeyMap.set(SOFT_DELETE_GRACE_DAYS_KEY, softDeleteGraceDaysConfig);
658
+ }
659
+ }
660
+ }