@cosmicdrift/kumiko-framework 0.146.4 → 0.147.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -2
- package/src/api/auth-routes.ts +32 -67
- package/src/api/routes.ts +1 -3
- package/src/bun-db/__tests__/PATTERN.md +0 -1
- package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +29 -3
- package/src/db/__tests__/schema-inspection.test.ts +7 -0
- package/src/db/event-store-executor-context.ts +398 -0
- package/src/db/event-store-executor-read.ts +276 -0
- package/src/db/event-store-executor-write.ts +615 -0
- package/src/db/event-store-executor.ts +29 -1166
- package/src/db/queries/shadow-swap.ts +3 -1
- package/src/db/schema-inspection.ts +1 -1
- package/src/engine/__tests__/boot-validator-dashboard.test.ts +10 -0
- package/src/engine/__tests__/engine.test.ts +45 -1
- package/src/engine/__tests__/event-migration-declarative.test.ts +6 -0
- package/src/engine/__tests__/membership-roles.test.ts +4 -10
- package/src/engine/__tests__/screen.test.ts +26 -0
- package/src/engine/boot-validator/entity-list-screens.ts +1 -1
- package/src/engine/boot-validator/gdpr-storage.ts +1 -1
- package/src/engine/boot-validator/index.ts +12 -8
- package/src/engine/boot-validator/nav.ts +120 -0
- package/src/engine/boot-validator/{screens-nav.ts → screens.ts} +12 -190
- package/src/engine/boot-validator/workspaces.ts +68 -0
- package/src/engine/define-feature.ts +77 -1011
- package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/feature.ts +9 -0
- package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/screens.ts +4 -0
- package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +18 -8
- package/src/engine/feature-ast/__tests__/parse.test.ts +291 -2
- package/src/engine/feature-ast/__tests__/patcher.test.ts +1 -1
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +73 -0
- package/src/engine/feature-ast/extractors/round1.ts +3 -3
- package/src/engine/feature-ast/extractors/round4.ts +45 -10
- package/src/engine/feature-ast/extractors/shared.ts +64 -6
- package/src/engine/feature-ast/parse.ts +123 -10
- package/src/engine/feature-ast/patterns.ts +14 -7
- package/src/engine/feature-ast/render.ts +28 -7
- package/src/engine/feature-builder-state.ts +165 -0
- package/src/engine/feature-config-events-jobs.ts +303 -0
- package/src/engine/feature-entity-handlers.ts +161 -0
- package/src/engine/feature-ui-extensions.ts +413 -0
- package/src/engine/index.ts +0 -2
- package/src/engine/pattern-library/library.ts +44 -1131
- package/src/engine/pattern-library/mixed-schemas.ts +484 -0
- package/src/engine/pattern-library/opaque-schemas.ts +124 -0
- package/src/engine/pattern-library/shared-fields.ts +80 -0
- package/src/engine/pattern-library/static-schemas.ts +456 -0
- package/src/engine/registry-facade.ts +349 -0
- package/src/engine/registry-ingest.ts +473 -0
- package/src/engine/registry-state.ts +388 -0
- package/src/engine/registry-validate.ts +660 -0
- package/src/engine/registry.ts +79 -1695
- package/src/engine/types/screen.ts +4 -2
- package/src/engine/types/tree-node.ts +2 -5
- package/src/event-store/snapshot.ts +7 -7
- package/src/i18n/required-surface-keys.ts +2 -0
- package/src/jobs/job-runner.ts +1 -1
- package/src/observability/standard-metrics.ts +4 -3
- package/src/pipeline/dispatch-batch.ts +3 -3
- package/src/pipeline/dispatch-shared.ts +17 -10
- package/src/pipeline/event-dispatcher-admin.ts +289 -0
- package/src/pipeline/event-dispatcher-delivery.ts +264 -0
- package/src/pipeline/event-dispatcher.ts +28 -540
- package/src/pipeline/projection-rebuild.ts +1 -1
- package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +95 -0
- package/src/stack/test-stack.ts +46 -1
- package/src/testing/boot-validator-fixture.ts +1 -2
- package/src/engine/__tests__/deep-link.test.ts +0 -35
- 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
|
+
}
|