@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,388 @@
1
+ import { applyEntityEvent } from "../db/apply-entity-event";
2
+ import { assertBackingTableSuperset, buildEntityTableMeta } from "../db/entity-table-meta";
3
+ import { asEntityTableMeta } from "../db/query";
4
+ import { buildEntityTable } from "../db/table-builder";
5
+ import { type QnType, qualifyEntityName } from "./qualified-name";
6
+ import type {
7
+ AuthClaimsHookDef,
8
+ ClaimKeyDefinition,
9
+ ConfigKeyDefinition,
10
+ ConfigSeedDef,
11
+ EntityDefinition,
12
+ EntityProjectionExtension,
13
+ EventDef,
14
+ EventUpcastFn,
15
+ FeatureDefinition,
16
+ FeatureMetricDef,
17
+ HookPhase,
18
+ JobDefinition,
19
+ MultiStreamProjectionDefinition,
20
+ NavDefinition,
21
+ NotificationDefinition,
22
+ OwnedFn,
23
+ PhasedHook,
24
+ PostDeleteHookFn,
25
+ PostQueryHookFn,
26
+ PostSaveHookFn,
27
+ PreDeleteHookFn,
28
+ PreQueryHookFn,
29
+ PreSaveHookFn,
30
+ ProjectionDefinition,
31
+ QueryHandlerDef,
32
+ RawTableDef,
33
+ ReferenceDataDef,
34
+ RegistrarExtensionDef,
35
+ RegistrarExtensionRegistration,
36
+ RelationDefinition,
37
+ ScreenDefinition,
38
+ SearchPayloadContributorFn,
39
+ SecretKeyDefinition,
40
+ TreeActionDef,
41
+ UnmanagedTableDef,
42
+ WorkspaceDefinition,
43
+ WriteHandlerDef,
44
+ } from "./types";
45
+
46
+ export type IncomingRelation = {
47
+ sourceEntity: string;
48
+ relationName: string;
49
+ relation: RelationDefinition;
50
+ };
51
+
52
+ const IMPLICIT_PROJECTION_SUFFIX = "-entity" as const;
53
+
54
+ // Pro r.entity-Registration eine ImplicitProjection mit auto-generierten
55
+ // apply-Handlern für die 4 Auto-Verben. Live-Pfad geht durch
56
+ // EventStoreExecutor und schreibt direkt in die Tabelle; rebuildProjection
57
+ // nutzt diese Definition um aus Events zu replayen. Beide rufen dieselbe
58
+ // applyEntityEvent-Funktion → Live==Rebuild by-construction (verstärkt
59
+ // durch implicit-projection-equivalence.integration.ts).
60
+ export function buildImplicitProjection(
61
+ featureName: string,
62
+ entityName: string,
63
+ entity: EntityDefinition,
64
+ qualify: typeof qualifyEntityName,
65
+ backingTable?: unknown,
66
+ extensions: readonly EntityProjectionExtension[] = [],
67
+ ): ProjectionDefinition {
68
+ const name = qualify(featureName, "projection", `${entityName}${IMPLICIT_PROJECTION_SUFFIX}`);
69
+ // Backing table (r.entity(name, def, { table })) is the one physical table
70
+ // object shared by executor-writes, rebuild-replay, test-push and
71
+ // collectTableMetas — restoring the #255 invariant (test-push == generate).
72
+ // Validated as a superset of the field-derived columns so a field/table
73
+ // disagreement fails at boot, not as a silent thin-vs-rich row.
74
+ const drizzleTable =
75
+ backingTable !== undefined
76
+ ? resolveBackingTable(entityName, entity, backingTable)
77
+ : buildEntityTable(entityName, entity);
78
+ // applyEntityEvent gibt ApplyResult zurück; SingleStreamApplyFn erwartet
79
+ // Promise<void>. Im rebuild-Pfad ist die Row irrelevant — wir discarden.
80
+ const handler = async (
81
+ event: Parameters<ProjectionDefinition["apply"][string]>[0],
82
+ tx: Parameters<ProjectionDefinition["apply"][string]>[1],
83
+ ): Promise<void> => {
84
+ await applyEntityEvent(event, drizzleTable, entity, tx);
85
+ };
86
+ const apply: Record<string, ProjectionDefinition["apply"][string]> = {
87
+ [`${entityName}.created`]: handler,
88
+ [`${entityName}.updated`]: handler,
89
+ [`${entityName}.deleted`]: handler,
90
+ // forget/purge (Art. 17): hard-deletes the row even for softDelete entities.
91
+ // Registered for every entity so the erasure replays on rebuild.
92
+ [`${entityName}.forgotten`]: handler,
93
+ };
94
+ // Restore-Verb existiert nur für softDelete-Entities. Hard-Delete-
95
+ // Entities sollten keine restored-Events produzieren — würden sie es
96
+ // doch, würde applyEntityEvent intern als no-op laufen, aber wir
97
+ // registrieren den Handler gar nicht erst.
98
+ if (entity.softDelete) {
99
+ apply[`${entityName}.restored`] = handler;
100
+ }
101
+ // r.extendEntityProjection: merge extension applies into the rebuild
102
+ // replay. Collisions with lifecycle applies (or another extension) are
103
+ // authoring bugs — fail at boot, not by silently overwriting a handler.
104
+ const extraSources: string[] = [];
105
+ for (const ext of extensions) {
106
+ for (const [eventType, fn] of Object.entries(ext.apply)) {
107
+ if (apply[eventType]) {
108
+ throw new Error(
109
+ `Implicit projection "${name}": extendEntityProjection apply-key "${eventType}" ` +
110
+ `collides with an existing handler (entity lifecycle apply or another extension).`,
111
+ );
112
+ }
113
+ apply[eventType] = fn;
114
+ }
115
+ for (const s of ext.sources ?? []) {
116
+ if (s !== entityName && !extraSources.includes(s)) extraSources.push(s);
117
+ }
118
+ }
119
+ return {
120
+ name,
121
+ source: entityName,
122
+ ...(extraSources.length > 0 && { extraSources }),
123
+ table: drizzleTable,
124
+ apply,
125
+ isImplicit: true,
126
+ };
127
+ }
128
+
129
+ // Validates a r.entity backing table is a superset of the entity's field-
130
+ // derived columns, then hands it back as the projection table. The cast is a
131
+ // system-boundary reconstitution: the table is stored as `unknown` on
132
+ // FeatureDefinition only to keep drizzle out of the plain-data shape, and
133
+ // asEntityTableMeta confirms the kumiko-table shape at runtime.
134
+ function resolveBackingTable(
135
+ entityName: string,
136
+ entity: EntityDefinition,
137
+ backingTable: unknown,
138
+ ): ProjectionDefinition["table"] {
139
+ const tableMeta = asEntityTableMeta(backingTable);
140
+ if (!tableMeta) {
141
+ throw new Error(
142
+ `r.entity("${entityName}", …, { table }): the backing table carries no ` +
143
+ "EntityTableMeta — build it via table() / buildEntityTable.",
144
+ );
145
+ }
146
+ assertBackingTableSuperset(entityName, buildEntityTableMeta(entityName, entity), tableMeta);
147
+ return backingTable as ProjectionDefinition["table"];
148
+ }
149
+
150
+ // Local alias for readability — `qualifyEntityName` is the shared helper
151
+ // from qualified-name.ts, also used by validateBoot to keep ingest and
152
+ // validation in lockstep on the qualification rule. Module-scope: stateless,
153
+ // no RegistryState threading needed.
154
+ export const qualify = qualifyEntityName;
155
+
156
+ // Bundles every Map/Set/array/scalar createRegistry populates during ingest —
157
+ // hoisted to module scope out of createRegistry's former closure, so the
158
+ // populateX/validateX phase-functions below can read/write them explicitly
159
+ // instead of via implicit capture. Every field is held BY REFERENCE (the
160
+ // actual Map/Set/array instance, never a destructured copy) — populateX
161
+ // functions mutate the same instance across calls for the same registry
162
+ // build. See docs/plans/god-files-refactor.md for the by-reference invariant.
163
+ export type RegistryState = {
164
+ featureMap: Map<string, FeatureDefinition>;
165
+ entityMap: Map<string, EntityDefinition>;
166
+ relationMap: Map<string, Record<string, RelationDefinition>>;
167
+ writeHandlerMap: Map<string, WriteHandlerDef>;
168
+ queryHandlerMap: Map<string, QueryHandlerDef>;
169
+ preSaveHooks: Map<string, OwnedFn<PreSaveHookFn>[]>;
170
+ postSaveHooks: Map<string, PhasedHook<PostSaveHookFn>[]>;
171
+ preDeleteHooks: Map<string, PhasedHook<PreDeleteHookFn>[]>;
172
+ postDeleteHooks: Map<string, PhasedHook<PostDeleteHookFn>[]>;
173
+ preQueryHooks: Map<string, OwnedFn<PreQueryHookFn>[]>;
174
+ postQueryHooks: Map<string, OwnedFn<PostQueryHookFn>[]>;
175
+ entityPostSaveHooks: Map<string, PhasedHook<PostSaveHookFn>[]>;
176
+ entityPreDeleteHooks: Map<string, PhasedHook<PreDeleteHookFn>[]>;
177
+ entityPostDeleteHooks: Map<string, PhasedHook<PostDeleteHookFn>[]>;
178
+ entityPostQueryHooks: Map<string, OwnedFn<PostQueryHookFn>[]>;
179
+ searchPayloadExtensions: Map<string, OwnedFn<SearchPayloadContributorFn>[]>;
180
+ configKeyMap: Map<string, ConfigKeyDefinition>;
181
+ jobMap: Map<string, JobDefinition>;
182
+ notificationMap: Map<string, NotificationDefinition>;
183
+ notificationFeatureMap: Map<string, string>;
184
+ eventMap: Map<string, EventDef>;
185
+ eventUpcasterMap: Map<
186
+ string,
187
+ { readonly currentVersion: number; readonly chain: ReadonlyMap<number, EventUpcastFn> }
188
+ >;
189
+ handlerEntityMap: Map<string, string>;
190
+ handlerFeatureMap: Map<string, string>;
191
+ extensionMap: Map<string, RegistrarExtensionDef>;
192
+ extensionUsages: RegistrarExtensionRegistration[];
193
+ extensionSelectorMap: Map<string, string>;
194
+ allReferenceData: ReferenceDataDef[];
195
+ allConfigSeeds: ConfigSeedDef[];
196
+ mergedTranslations: Record<string, Record<string, string>>;
197
+ metricMap: Map<string, FeatureMetricDef & { readonly featureName: string }>;
198
+ secretKeyMap: Map<string, SecretKeyDefinition>;
199
+ projectionMap: Map<string, ProjectionDefinition>;
200
+ projectionsBySource: Map<string, ProjectionDefinition[]>;
201
+ multiStreamProjectionMap: Map<string, MultiStreamProjectionDefinition>;
202
+ multiStreamProjectionFeatureMap: Map<string, string>;
203
+ rawTableMap: Map<string, RawTableDef>;
204
+ unmanagedTableMap: Map<string, UnmanagedTableDef>;
205
+ physicalTableOwners: Map<
206
+ string,
207
+ { kind: "entity" | "unmanaged"; owner: string; featureName: string }
208
+ >;
209
+ authClaimsHooks: AuthClaimsHookDef[];
210
+ claimKeyMap: Map<string, ClaimKeyDefinition>;
211
+ screenMap: Map<string, ScreenDefinition>;
212
+ screenFeatureMap: Map<string, string>;
213
+ screensByEntity: Map<string, ScreenDefinition[]>;
214
+ navMap: Map<string, NavDefinition>;
215
+ navFeatureMap: Map<string, string>;
216
+ navsByParent: Map<string, NavDefinition[]>;
217
+ topLevelNavs: NavDefinition[];
218
+ workspaceMap: Map<string, WorkspaceDefinition>;
219
+ workspaceFeatureMap: Map<string, string>;
220
+ navsByWorkspace: Map<string, string[]>;
221
+ defaultWorkspace: WorkspaceDefinition | undefined;
222
+ treeActionsMap: Map<string, Readonly<Record<string, TreeActionDef>>>;
223
+ searchableFieldsCache: Map<string, readonly string[]>;
224
+ sortableFieldsCache: Map<string, readonly string[]>;
225
+ searchIncludesCache: Map<string, ReadonlyMap<string, readonly string[]>>;
226
+ incomingRelationsCache: Map<string, IncomingRelation[]>;
227
+ hasRateLimitedHandlerCached: boolean;
228
+ };
229
+
230
+ export function createInitialState(): RegistryState {
231
+ return {
232
+ featureMap: new Map(),
233
+ entityMap: new Map(),
234
+ relationMap: new Map(),
235
+ writeHandlerMap: new Map(),
236
+ queryHandlerMap: new Map(),
237
+ preSaveHooks: new Map(),
238
+ postSaveHooks: new Map(),
239
+ preDeleteHooks: new Map(),
240
+ postDeleteHooks: new Map(),
241
+ preQueryHooks: new Map(),
242
+ postQueryHooks: new Map(),
243
+ entityPostSaveHooks: new Map(),
244
+ entityPreDeleteHooks: new Map(),
245
+ entityPostDeleteHooks: new Map(),
246
+ entityPostQueryHooks: new Map(),
247
+ searchPayloadExtensions: new Map(),
248
+ configKeyMap: new Map(),
249
+ jobMap: new Map(),
250
+ notificationMap: new Map(),
251
+ notificationFeatureMap: new Map(),
252
+ eventMap: new Map(),
253
+ eventUpcasterMap: new Map(),
254
+ handlerEntityMap: new Map(),
255
+ handlerFeatureMap: new Map(),
256
+ extensionMap: new Map(),
257
+ extensionUsages: [],
258
+ extensionSelectorMap: new Map(),
259
+ allReferenceData: [],
260
+ allConfigSeeds: [],
261
+ mergedTranslations: {},
262
+ metricMap: new Map(),
263
+ secretKeyMap: new Map(),
264
+ projectionMap: new Map(),
265
+ projectionsBySource: new Map(),
266
+ multiStreamProjectionMap: new Map(),
267
+ multiStreamProjectionFeatureMap: new Map(),
268
+ rawTableMap: new Map(),
269
+ unmanagedTableMap: new Map(),
270
+ physicalTableOwners: new Map(),
271
+ authClaimsHooks: [],
272
+ claimKeyMap: new Map(),
273
+ screenMap: new Map(),
274
+ screenFeatureMap: new Map(),
275
+ screensByEntity: new Map(),
276
+ navMap: new Map(),
277
+ navFeatureMap: new Map(),
278
+ navsByParent: new Map(),
279
+ topLevelNavs: [],
280
+ workspaceMap: new Map(),
281
+ workspaceFeatureMap: new Map(),
282
+ navsByWorkspace: new Map(),
283
+ defaultWorkspace: undefined,
284
+ treeActionsMap: new Map(),
285
+ searchableFieldsCache: new Map(),
286
+ sortableFieldsCache: new Map(),
287
+ searchIncludesCache: new Map(),
288
+ incomingRelationsCache: new Map(),
289
+ hasRateLimitedHandlerCached: false,
290
+ };
291
+ }
292
+
293
+ // Filter hooks by phase and/or owning feature.
294
+ //
295
+ // - `phase === undefined` → any phase passes.
296
+ // - `effectiveFeatures === undefined` → ownership filter disabled.
297
+ // - hook.featureName === "*" or undefined → always passes ownership filter.
298
+ // "*" is reserved for extension-provided hooks that are invariant
299
+ // plumbing, not opt-in feature logic.
300
+ export function filterByPhase<TFn>(
301
+ list: readonly PhasedHook<TFn>[] | undefined,
302
+ phase: HookPhase | undefined,
303
+ effectiveFeatures?: ReadonlySet<string>,
304
+ ): readonly TFn[] {
305
+ if (!list || list.length === 0) return [];
306
+ const result: TFn[] = [];
307
+ for (const entry of list) {
308
+ if (phase !== undefined && entry.phase !== phase) continue;
309
+ if (!ownerEnabled(entry.featureName, effectiveFeatures)) continue;
310
+ result.push(entry.fn);
311
+ }
312
+ return result;
313
+ }
314
+
315
+ // Same ownership rule as filterByPhase, but for unphased hook lists
316
+ // (preSave, preQuery). Returns the raw fns ready for the lifecycle runner.
317
+ export function filterOwned<TFn>(
318
+ list: readonly OwnedFn<TFn>[] | undefined,
319
+ effectiveFeatures?: ReadonlySet<string>,
320
+ ): readonly TFn[] {
321
+ if (!list || list.length === 0) return [];
322
+ const result: TFn[] = [];
323
+ for (const entry of list) {
324
+ if (!ownerEnabled(entry.featureName, effectiveFeatures)) continue;
325
+ result.push(entry.fn);
326
+ }
327
+ return result;
328
+ }
329
+
330
+ function ownerEnabled(
331
+ owner: string | undefined,
332
+ effectiveFeatures: ReadonlySet<string> | undefined,
333
+ ): boolean {
334
+ if (!effectiveFeatures) return true;
335
+ if (owner === undefined || owner === "*") return true;
336
+ return effectiveFeatures.has(owner);
337
+ }
338
+
339
+ // Merge hooks without prefix (entity hooks). featureName is already on
340
+ // every hook entry (set by defineFeature), so there's no parallel
341
+ // bookkeeping — just append.
342
+ export function mergeHookList<T>(
343
+ map: Map<string, T[]>,
344
+ source: Readonly<Record<string, readonly T[]>> | undefined,
345
+ ): void {
346
+ // skip: optionaler entityHook-slot — features ohne postSave/preDelete/
347
+ // postDelete/postQuery lassen das slot undefined.
348
+ if (!source) return;
349
+ for (const [name, fns] of Object.entries(source)) {
350
+ const existing = map.get(name) ?? [];
351
+ existing.push(...fns);
352
+ map.set(name, existing);
353
+ }
354
+ }
355
+
356
+ // Merge hooks with feature prefix (handler hooks).
357
+ // Hook keys are handler QNs — hooks don't get their own QN, they're keyed by the handler they target.
358
+ // The hookQnType indicates whether the targeted handler is a write or query handler.
359
+ export function mergeHookListQualified<T>(
360
+ map: Map<string, T[]>,
361
+ source: Readonly<Record<string, readonly T[]>> | undefined,
362
+ featureName: string,
363
+ hookQnType: QnType,
364
+ ): void {
365
+ // skip: optionaler hook-slot — defineFeature materialisiert zwar alle
366
+ // Slots, aber hand-gebaute Definitionen an System-Grenzen (Fixtures,
367
+ // Partial-Boots, s. registry.test.ts) lassen sie weg. Leeres Record
368
+ // statt Object.entries(undefined)-Crash.
369
+ if (!source) return;
370
+ for (const [name, fns] of Object.entries(source)) {
371
+ const qualified = qualify(featureName, hookQnType, name);
372
+ const existing = map.get(qualified) ?? [];
373
+ existing.push(...fns);
374
+ map.set(qualified, existing);
375
+ }
376
+ }
377
+
378
+ /** Returns true if any entity in the feature has field-level access rules (read or write). */
379
+ export function hasFieldAccessRules(feature: FeatureDefinition): boolean {
380
+ for (const entity of Object.values(feature.entities ?? {})) {
381
+ for (const field of Object.values(entity.fields)) {
382
+ if (field.access?.read?.length || field.access?.write?.length) {
383
+ return true;
384
+ }
385
+ }
386
+ }
387
+ return false;
388
+ }