@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
@@ -1,88 +1,9 @@
1
- import { ZodObject, type ZodType, type z } from "zod";
2
- import type { EntityTableMeta } from "../db/entity-table-meta";
3
- import { toTableName } from "../db/table-builder";
4
- import { LifecycleHookTypes } from "./constants";
5
- import type { QueryHandlerDefinition, WriteHandlerDefinition } from "./define-handler";
6
- import { type RegisterEntityCrudOptions, registerEntityCrud } from "./entity-handlers";
7
- import { isKebabSegment, QnTypes, qn, toKebab } from "./qualified-name";
8
- import type {
9
- AccessRule,
10
- AuthClaimsFn,
11
- ClaimKeyDefinition,
12
- ClaimKeyHandle,
13
- ClaimKeyType,
14
- ConfigKeyDefinition,
15
- ConfigKeyHandle,
16
- ConfigKeyType,
17
- ConfigSeedDef,
18
- DeclarativeEventMigration,
19
- EntityDefinition,
20
- EntityProjectionExtension,
21
- EntityRef,
22
- EventDef,
23
- EventMigrationDef,
24
- EventPiiFields,
25
- EventUpcastFn,
26
- ExtensionSelectorDef,
27
- FeatureDefinition,
28
- FeatureMetricDef,
29
- FeatureRegistrar,
30
- HandlerRef,
31
- HookMap,
32
- HookPhase,
33
- JobDefinition,
34
- JobHandlerFn,
35
- LifecycleHookFn,
36
- LifecycleHookType,
37
- MetricOptions,
38
- MultiStreamProjectionDefinition,
39
- NameOrRef,
40
- NotificationDataFn,
41
- NotificationDefinition,
42
- NotificationRecipientFn,
43
- NotificationTemplateFn,
44
- OwnedFn,
45
- PhasedHook,
46
- PostDeleteHookFn,
47
- PostQueryHookFn,
48
- PostSaveHookFn,
49
- PreDeleteHookFn,
50
- ProjectionDefinition,
51
- QualifiedEventName,
52
- QueryHandlerDef,
53
- QueryHandlerFn,
54
- RateLimitOption,
55
- RawTableEntry,
56
- RawTableOptions,
57
- ReferenceDataDef,
58
- RegistrarExtensionDef,
59
- RegistrarExtensionRegistration,
60
- RelationDefinition,
61
- SearchPayloadContributorFn,
62
- SecretKeyDefinition,
63
- SecretKeyHandle,
64
- SecretOptions,
65
- TranslationKeys,
66
- TranslationsDef,
67
- TreeActionDef,
68
- TreeActionsHandle,
69
- UiHints,
70
- UnmanagedTableEntry,
71
- UnmanagedTableOptions,
72
- ValidationHookFn,
73
- WriteHandlerDef,
74
- WriteHandlerFn,
75
- } from "./types";
76
- import { HookPhases } from "./types";
1
+ import { createInitialFeatureBuilderState } from "./feature-builder-state";
2
+ import { buildConfigEventsJobsMethods } from "./feature-config-events-jobs";
3
+ import { buildEntityHandlerMethods } from "./feature-entity-handlers";
4
+ import { buildUiExtensionsMethods } from "./feature-ui-extensions";
5
+ import type { FeatureDefinition, FeatureRegistrar, HookMap, UiHints } from "./types";
77
6
  import type { RequiresApi } from "./types/feature";
78
- import { resolveName } from "./types/handlers";
79
- import type { HttpRouteDefinition } from "./types/http-route";
80
- import type { NavDefinition } from "./types/nav";
81
- import type { ScreenDefinition } from "./types/screen";
82
- import type { PipelineDef } from "./types/step";
83
- import type { WorkspaceDefinition } from "./types/workspace";
84
-
85
- const LIFECYCLE_TYPES = Object.values(LifecycleHookTypes);
86
7
 
87
8
  // `TExports` lets the setup callback hand back a typed object that
88
9
  // downstream features can import (e.g. `tenantFeature.exports.config`). The
@@ -96,120 +17,19 @@ const LIFECYCLE_TYPES = Object.values(LifecycleHookTypes);
96
17
  // for `ctx.appendEvent({ type: eventDef.name, ... })` lights up. Apps
97
18
  // that don't care can keep the default-string and use the wrapper-based
98
19
  // strict-mode (string-literal types per call-site) like before.
20
+
99
21
  export function defineFeature<const TName extends string, TExports = undefined>(
100
22
  name: TName,
101
23
  setup: (r: FeatureRegistrar<TName>) => TExports,
102
24
  ): FeatureDefinition & { readonly exports: TExports } {
103
- const requires: string[] = [];
104
- const optionalRequires: string[] = [];
105
- // Read-side projection-tables declared via r.requires.projection("table").
106
- // Boot-validator checks unsafeProjection-* step calls against this set.
107
- const requiredProjections = new Set<string>();
108
- // Tier-2 step kinds declared via r.requires.step("webhook.send"). Q9.
109
- const requiredSteps = new Set<string>();
110
- const entities: Record<string, EntityDefinition> = {};
111
- const entityTables: Record<string, unknown> = {};
112
- const relations: Record<string, Record<string, RelationDefinition>> = {};
113
- const writeHandlers: Record<string, WriteHandlerDef> = {};
114
- const queryHandlers: Record<string, QueryHandlerDef> = {};
115
- const validationHooks: Record<string, ValidationHookFn> = {};
116
- // preSave/preQuery stay unphased (owned-fn); postSave/preDelete/postDelete
117
- // are phased (owned-fn + phase). Each hook carries its owning feature so
118
- // the lifecycle pipeline can filter by effectiveFeatures without a parallel
119
- // bookkeeping structure.
120
- const lifecycleHooks: Record<string, Record<string, OwnedFn<LifecycleHookFn>[]>> = {};
121
- const phasedLifecycleHooks: Record<
122
- "postSave" | "preDelete" | "postDelete",
123
- Record<string, PhasedHook<LifecycleHookFn>[]>
124
- > = { postSave: {}, preDelete: {}, postDelete: {} };
125
- const configKeys: Record<string, ConfigKeyDefinition> = {};
126
- const configSeeds: ConfigSeedDef[] = [];
127
- const jobs: Record<string, JobDefinition> = {};
128
- const events: Record<string, { name: string; schema: ZodType; version: number }> = {};
129
- const eventMigrations: Record<string, EventMigrationDef[]> = {};
130
- const configReads: string[] = [];
131
- const entityPostSave: Record<string, PhasedHook<PostSaveHookFn>[]> = {};
132
- const entityPreDelete: Record<string, PhasedHook<PreDeleteHookFn>[]> = {};
133
- const entityPostDelete: Record<string, PhasedHook<PostDeleteHookFn>[]> = {};
134
- const entityPostQuery: Record<string, OwnedFn<PostQueryHookFn>[]> = {};
135
- const searchPayloadExtensions: Record<string, OwnedFn<SearchPayloadContributorFn>[]> = {};
136
- const notifications: Record<string, NotificationDefinition> = {};
137
- const registrarExtensions: Record<string, RegistrarExtensionDef> = {};
138
- const extensionUsages: RegistrarExtensionRegistration[] = [];
139
- const extensionSelectors: ExtensionSelectorDef[] = [];
140
- const exposedApis: Set<string> = new Set();
141
- const usedApis: Set<string> = new Set();
142
- const referenceData: ReferenceDataDef[] = [];
143
- const handlerEntityMappings: Record<string, string> = {};
144
- const metrics: Record<string, FeatureMetricDef> = {};
145
- const secretKeys: Record<string, SecretKeyDefinition> = {};
146
- const projections: Record<string, ProjectionDefinition> = {};
147
- const multiStreamProjections: Record<string, MultiStreamProjectionDefinition> = {};
148
- const entityProjectionExtensions: Record<string, EntityProjectionExtension[]> = {};
149
- const rawTables: Record<string, RawTableEntry> = {};
150
- const unmanagedTables: Record<string, UnmanagedTableEntry> = {};
151
- const authClaimsHooks: AuthClaimsFn[] = [];
152
- const claimKeys: Record<string, ClaimKeyDefinition> = {};
153
- const screens: Record<string, ScreenDefinition> = {};
154
- const navs: Record<string, NavDefinition> = {};
155
- const workspaces: Record<string, WorkspaceDefinition> = {};
156
- const httpRoutes: Record<string, HttpRouteDefinition> = {};
157
- let translations: TranslationKeys = {};
158
-
159
- for (const t of LIFECYCLE_TYPES) {
160
- lifecycleHooks[t] = {};
161
- }
162
-
163
- let isSystemScoped = false;
164
- let toggleableDefault: boolean | undefined;
165
- let description: string | undefined;
166
- let uiHints: UiHints | undefined;
167
- // Tree-Action-Slot — at-most-one per feature, only-once-guard im
168
- // registrar (siehe r.treeActions). Undefined wenn das Feature keine
169
- // Tree-Actions liefert (Zero-Whitelist-Filter).
170
- // Name-Collision-Sicherheit: object-literal-method-Names im registrar
171
- // sind keine bindings im closure-scope, daher kollidiert die `treeActions`
172
- // closure-let-var nicht mit der `treeActions(...)` registrar-Methode.
173
- let treeActions: Readonly<Record<string, TreeActionDef>> | undefined;
174
- // Optional Zod-schema for env-vars this feature reads at runtime,
175
- // declared via r.envSchema(). At-most-one per feature.
176
- let envSchema: z.ZodObject<z.ZodRawShape> | undefined;
177
-
178
- // Map handler name to entity via colon convention.
179
- // "task:create" → entity "task". Bare CRUD verbs (create/update/delete) map
180
- // when feature name matches an entity or the feature owns exactly one entity.
181
- const CRUD_VERBS = new Set(["create", "update", "delete"]);
182
-
183
- function tryMapEntity(handlerName: string): void {
184
- const colonIdx = handlerName.indexOf(":");
185
- if (colonIdx >= 0) {
186
- const candidate = handlerName.slice(0, colonIdx);
187
- if (entities[candidate]) {
188
- handlerEntityMappings[handlerName] = candidate;
189
- }
190
- // skip: colon-prefixed handler processed (mapped or not), bare CRUD path not applicable
191
- return;
192
- }
193
- if (CRUD_VERBS.has(handlerName)) {
194
- if (entities[name]) {
195
- handlerEntityMappings[handlerName] = name;
196
- // skip: feature-name entity match is the preferred mapping
197
- return;
198
- }
199
- const entityKeys = Object.keys(entities);
200
- if (entityKeys.length === 1) {
201
- handlerEntityMappings[handlerName] = entityKeys[0] as string;
202
- }
203
- }
204
- }
25
+ const state = createInitialFeatureBuilderState();
205
26
 
206
27
  const registrar: FeatureRegistrar<TName> = {
207
28
  systemScope(): void {
208
- isSystemScoped = true;
29
+ state.isSystemScoped = true;
209
30
  },
210
-
211
31
  describe(text: string): void {
212
- if (description !== undefined) {
32
+ if (state.description !== undefined) {
213
33
  throw new Error(
214
34
  `[Feature ${name}] r.describe() called twice — a feature's description is declared once`,
215
35
  );
@@ -217,863 +37,109 @@ export function defineFeature<const TName extends string, TExports = undefined>(
217
37
  if (typeof text !== "string" || text.trim().length === 0) {
218
38
  throw new Error(`[Feature ${name}] r.describe(): text must be a non-empty string`);
219
39
  }
220
- description = text.trim();
40
+ state.description = text.trim();
221
41
  },
222
-
223
42
  requires: (() => {
224
43
  const fn = (...featureNames: string[]) => {
225
- requires.push(...featureNames);
44
+ state.requires.push(...featureNames);
226
45
  };
227
46
  fn.projection = (tableName: string) => {
228
- requiredProjections.add(tableName);
47
+ state.requiredProjections.add(tableName);
229
48
  };
230
49
  fn.step = (stepKind: string) => {
231
- requiredSteps.add(stepKind);
50
+ state.requiredSteps.add(stepKind);
232
51
  };
233
52
  return fn as RequiresApi;
234
53
  })(),
235
-
236
54
  optionalRequires(...featureNames: string[]): void {
237
- optionalRequires.push(...featureNames);
55
+ state.optionalRequires.push(...featureNames);
238
56
  },
239
-
240
57
  toggleable(options: { default: boolean }): void {
241
- if (toggleableDefault !== undefined) {
58
+ if (state.toggleableDefault !== undefined) {
242
59
  throw new Error(
243
60
  `[Feature ${name}] r.toggleable() called twice — a feature's toggleable status is declared once`,
244
61
  );
245
62
  }
246
- toggleableDefault = options.default;
63
+ state.toggleableDefault = options.default;
247
64
  },
248
-
249
65
  uiHints(hints: UiHints): void {
250
- if (uiHints !== undefined) {
66
+ if (state.uiHints !== undefined) {
251
67
  throw new Error(`[Feature ${name}] r.uiHints() called twice — UI hints are declared once`);
252
68
  }
253
- uiHints = hints;
254
- },
255
-
256
- entity(
257
- entityName: string,
258
- definition: EntityDefinition,
259
- options?: { readonly table?: unknown },
260
- ): EntityRef {
261
- entities[entityName] = definition;
262
- if (options?.table !== undefined) entityTables[entityName] = options.table;
263
- return { name: entityName, table: definition.table ?? toTableName(entityName) };
264
- },
265
-
266
- crud(
267
- entityName: string,
268
- definition: EntityDefinition,
269
- options?: RegisterEntityCrudOptions,
270
- ): EntityRef {
271
- registerEntityCrud(registrar, entityName, definition, options);
272
- return { name: entityName, table: definition.table ?? toTableName(entityName) };
273
- },
274
-
275
- writeHandler<TName extends string, TSchema extends ZodType>(
276
- nameOrDef: string | WriteHandlerDefinition<TName, TSchema>,
277
- schema?: TSchema,
278
- handler?: WriteHandlerFn<z.infer<TSchema>>,
279
- options?: { access?: AccessRule; rateLimit?: RateLimitOption },
280
- ): HandlerRef {
281
- if (typeof nameOrDef === "object") {
282
- const def = nameOrDef;
283
- writeHandlers[def.name] = {
284
- name: def.name,
285
- schema: def.schema,
286
- // @cast-boundary engine-bridge — typed Dev-API's handler is
287
- // generic over the schema's parsed payload (`WriteEvent<output<TSchema>>`),
288
- // the storage form WriteHandlerFn carries `WriteEvent<unknown>`.
289
- // Function-arg variance: TS sees the typed handler as stricter
290
- // than the loose storage shape and rejects direct assignment.
291
- // The runtime value is identical — the cast crosses that boundary.
292
- // `satisfies` does not work here (it asserts assignability, which
293
- // is what fails). Explicit cast is the right tool.
294
- handler: def.handler as WriteHandlerFn,
295
- ...(def.access && { access: def.access }),
296
- ...(def.unsafeSkipTransitionGuard && { unsafeSkipTransitionGuard: true }),
297
- ...(def.rateLimit && { rateLimit: def.rateLimit }),
298
- // Forward the pipeline-build closure so boot-validators and
299
- // Designer/AI tooling can inspect the step list. Absent on
300
- // free-form handlers — defineWriteHandler only sets `perform`
301
- // when the author used the pipeline form. Variance cast
302
- // mirrors the handler-cast above: PipelineDef<output<TSchema>>
303
- // is stricter than PipelineDef<unknown> for the same reason.
304
- ...(def.perform !== undefined && {
305
- perform: def.perform as PipelineDef,
306
- }),
307
- };
308
- tryMapEntity(def.name);
309
- return { name: def.name };
310
- }
311
- if (!schema || !handler)
312
- throw new Error("writeHandler inline form requires schema + handler");
313
- writeHandlers[nameOrDef] = {
314
- name: nameOrDef,
315
- schema,
316
- handler: handler as WriteHandlerFn, // @cast-boundary engine-bridge
317
- ...(options?.access && { access: options.access }),
318
- ...(options?.rateLimit && { rateLimit: options.rateLimit }),
319
- };
320
- tryMapEntity(nameOrDef);
321
- return { name: nameOrDef };
322
- },
323
-
324
- queryHandler<TName extends string, TSchema extends ZodType>(
325
- nameOrDef: string | QueryHandlerDefinition<TName, TSchema>,
326
- schema?: TSchema,
327
- handler?: QueryHandlerFn<z.infer<TSchema>>,
328
- options?: { access?: AccessRule; rateLimit?: RateLimitOption },
329
- ): HandlerRef {
330
- if (typeof nameOrDef === "object") {
331
- const def = nameOrDef;
332
- queryHandlers[def.name] = {
333
- name: def.name,
334
- schema: def.schema,
335
- // @cast-boundary engine-bridge — typed Dev-API → erased internal storage
336
- handler: def.handler as QueryHandlerFn, // @cast-boundary engine-bridge
337
- ...(def.access && { access: def.access }),
338
- ...(def.rateLimit && { rateLimit: def.rateLimit }),
339
- };
340
- tryMapEntity(def.name);
341
- return { name: def.name };
342
- }
343
- if (!schema || !handler)
344
- throw new Error("queryHandler inline form requires schema + handler");
345
- queryHandlers[nameOrDef] = {
346
- name: nameOrDef,
347
- schema,
348
- handler: handler as QueryHandlerFn, // @cast-boundary engine-bridge
349
- ...(options?.access && { access: options.access }),
350
- ...(options?.rateLimit && { rateLimit: options.rateLimit }),
351
- };
352
- tryMapEntity(nameOrDef);
353
- return { name: nameOrDef };
354
- },
355
-
356
- relation(entityRef: NameOrRef, relationName: string, definition: RelationDefinition): void {
357
- const entityName = resolveName(entityRef);
358
- if (!relations[entityName]) relations[entityName] = {};
359
- relations[entityName][relationName] = definition;
360
- },
361
-
362
- hook(
363
- type: LifecycleHookType | "validation",
364
- target: NameOrRef | readonly NameOrRef[],
365
- fn: LifecycleHookFn | ValidationHookFn,
366
- options?: { phase?: HookPhase },
367
- ): void {
368
- const targets = Array.isArray(target) ? target : [target];
369
- const names = targets.map(resolveName);
370
-
371
- // Hook-fn casts unten alle: @cast-boundary engine-bridge
372
- // — typed Dev-API (LifecycleHookFn|ValidationHookFn) → erased Map<name, fn>.
373
- if (type === "validation") {
374
- for (const n of names) {
375
- validationHooks[n] = fn as ValidationHookFn; // @cast-boundary engine-bridge
376
- }
377
- // skip: validation hooks have no phase, stored and done
378
- return;
379
- }
380
-
381
- if (
382
- type === LifecycleHookTypes.preSave ||
383
- type === LifecycleHookTypes.preQuery ||
384
- type === LifecycleHookTypes.postQuery
385
- ) {
386
- if (!lifecycleHooks[type]) lifecycleHooks[type] = {};
387
- for (const n of names) {
388
- if (!lifecycleHooks[type][n]) lifecycleHooks[type][n] = [];
389
- lifecycleHooks[type][n].push({ fn: fn as LifecycleHookFn, featureName: name }); // @cast-boundary engine-bridge
390
- }
391
- // skip: pre/post-hooks without phase semantics, stored and done
392
- return;
393
- }
394
-
395
- // Phased storage. preDelete has no phase option (always inTransaction);
396
- // postSave/postDelete default to afterCommit.
397
- const phase =
398
- type === LifecycleHookTypes.preDelete
399
- ? HookPhases.inTransaction
400
- : (options?.phase ?? HookPhases.afterCommit);
401
- const bucket = phasedLifecycleHooks[type];
402
- for (const n of names) {
403
- if (!bucket[n]) bucket[n] = [];
404
- bucket[n].push({ fn: fn as LifecycleHookFn, phase, featureName: name }); // @cast-boundary engine-bridge
405
- }
406
- },
407
-
408
- entityHook(
409
- type: "postSave" | "preDelete" | "postDelete" | "postQuery",
410
- entityRef: NameOrRef,
411
- fn: LifecycleHookFn,
412
- options?: { phase?: HookPhase },
413
- ): void {
414
- const entityName = resolveName(entityRef);
415
- if (type === LifecycleHookTypes.postSave) {
416
- const phase = options?.phase ?? HookPhases.afterCommit;
417
- if (!entityPostSave[entityName]) entityPostSave[entityName] = [];
418
- entityPostSave[entityName].push({ fn: fn as PostSaveHookFn, phase, featureName: name }); // @cast-boundary engine-bridge
419
- } else if (type === LifecycleHookTypes.preDelete) {
420
- if (!entityPreDelete[entityName]) entityPreDelete[entityName] = [];
421
- entityPreDelete[entityName].push({
422
- fn: fn as PreDeleteHookFn, // @cast-boundary engine-bridge
423
- phase: HookPhases.inTransaction,
424
- featureName: name,
425
- });
426
- } else if (type === LifecycleHookTypes.postDelete) {
427
- const phase = options?.phase ?? HookPhases.afterCommit;
428
- if (!entityPostDelete[entityName]) entityPostDelete[entityName] = [];
429
- entityPostDelete[entityName].push({ fn: fn as PostDeleteHookFn, phase, featureName: name }); // @cast-boundary engine-bridge
430
- } else if (type === LifecycleHookTypes.postQuery) {
431
- // postQuery is unphased (no inTransaction/afterCommit semantics — fires
432
- // synchronously after query-handler-execute, before field-access-filter)
433
- if (!entityPostQuery[entityName]) entityPostQuery[entityName] = [];
434
- entityPostQuery[entityName].push({ fn: fn as PostQueryHookFn, featureName: name }); // @cast-boundary engine-bridge
435
- }
436
- },
437
-
438
- searchPayloadExtension(entityRef: NameOrRef, fn: SearchPayloadContributorFn): void {
439
- const entityName = resolveName(entityRef);
440
- if (!searchPayloadExtensions[entityName]) searchPayloadExtensions[entityName] = [];
441
- searchPayloadExtensions[entityName].push({ fn, featureName: name });
442
- },
443
-
444
- config<TKeys extends Readonly<Record<string, ConfigKeyDefinition<ConfigKeyType>>>>(definition: {
445
- readonly keys: TKeys;
446
- readonly seeds?: Readonly<Record<string, ConfigSeedDef>>;
447
- }): { readonly [K in keyof TKeys]: ConfigKeyHandle<TKeys[K]["type"]> } {
448
- // Qualify eagerly (same as defineEvent) so the handle name matches what
449
- // the registry stores — lazy qualification would break compile-time
450
- // autocomplete and hand-built test registries.
451
- const handles: Record<string, ConfigKeyHandle<ConfigKeyType>> = {};
452
- for (const [key, keyDef] of Object.entries(definition.keys)) {
453
- configKeys[key] = keyDef;
454
- handles[key] = {
455
- name: qn(toKebab(name), "config", toKebab(key)),
456
- type: keyDef.type,
457
- };
458
- }
459
- // Parse seeds: resolve qualified key names and validate scope
460
- if (definition.seeds) {
461
- for (const [shortKey, seedDef] of Object.entries(definition.seeds)) {
462
- const keyDef = definition.keys[shortKey];
463
- if (!keyDef) continue; // skip — boot-validator reports unknown keys
464
- const qualifiedKey = qn(toKebab(name), "config", toKebab(shortKey));
465
- const scope = seedDef.scope ?? keyDef.scope;
466
- configSeeds.push({
467
- key: qualifiedKey,
468
- value: seedDef.value,
469
- scope,
470
- tenantId: seedDef.tenantId,
471
- userId: seedDef.userId,
472
- });
473
- }
474
- }
475
- return handles as {
476
- readonly [K in keyof TKeys]: ConfigKeyHandle<TKeys[K]["type"]>;
477
- }; // @cast-boundary engine-bridge — Mapped-Type-Inference at config()-callsite
478
- },
479
-
480
- job(
481
- jobName: string,
482
- options: Omit<JobDefinition, "name" | "handler">,
483
- handler: JobHandlerFn,
484
- ): void {
485
- // Resolve NameOrRef(s) in trigger.on. Multi-Trigger-Form: Array
486
- // wird zu Array von resolved strings, Single bleibt single string —
487
- // job-runner unterscheidet anhand Array.isArray.
488
- const trigger =
489
- "on" in options.trigger
490
- ? {
491
- on: Array.isArray(options.trigger.on)
492
- ? options.trigger.on.map(resolveName)
493
- : resolveName(options.trigger.on as NameOrRef), // @cast-boundary engine-bridge
494
- }
495
- : options.trigger;
496
- jobs[jobName] = { ...options, trigger, name: jobName, handler };
497
- },
498
-
499
- notification(
500
- notificationName: string,
501
- definition: {
502
- readonly trigger: { readonly on: NameOrRef };
503
- readonly recipient: NotificationRecipientFn;
504
- readonly data: NotificationDataFn;
505
- readonly templates?: Readonly<Record<string, NotificationTemplateFn>>;
506
- },
507
- ): void {
508
- notifications[notificationName] = {
509
- name: notificationName,
510
- trigger: { on: resolveName(definition.trigger.on) },
511
- recipient: definition.recipient,
512
- data: definition.data,
513
- templates: definition.templates,
514
- };
515
- },
516
-
517
- translations(def: TranslationsDef): void {
518
- translations = { ...translations, ...def.keys };
519
- },
520
-
521
- defineEvent: <const TInner extends string, TPayload>(
522
- eventName: TInner,
523
- schema: ZodType<TPayload>,
524
- options?: { readonly version?: number; readonly piiFields?: EventPiiFields },
525
- ): EventDef<TPayload, QualifiedEventName<TName, TInner>> => {
526
- // Return the fully-qualified event name so callers can pass it
527
- // straight to ctx.appendEvent without hand-building the
528
- // "<feature>:event:<name>" shape. Registry keeps events keyed by
529
- // short name — qualification is the framework's job, not the feature
530
- // author's.
531
- //
532
- // The runtime kebab-step (`qn(toKebab(feature), …)`) is mirrored at
533
- // the type-level by `QualifiedEventName<TName, TInner>` so the
534
- // returned `name` carries the literal qualified shape that the
535
- // augmented `KumikoEventTypeMap` keys against.
536
- const qualified = qn(toKebab(name), "event", toKebab(eventName));
537
- const version = options?.version ?? 1;
538
- if (!Number.isInteger(version) || version < 1) {
539
- throw new Error(
540
- `[Feature ${name}] defineEvent("${eventName}"): version must be a positive integer, got ${String(version)}`,
541
- );
542
- }
543
- // piiFields misconfiguration is a boot-time error, not a silent
544
- // plaintext leak: both the pii field and its subjectField must exist
545
- // on the payload schema (checkable when the schema is a ZodObject).
546
- const piiFields = options?.piiFields;
547
- if (piiFields) {
548
- const shape = schema instanceof ZodObject ? schema.shape : undefined;
549
- for (const [field, spec] of Object.entries(piiFields)) {
550
- if (field === spec.subjectField) {
551
- throw new Error(
552
- `[Feature ${name}] defineEvent("${eventName}"): piiFields."${field}" cannot use itself as subjectField — the subject id is a plaintext pseudonymous fk, the pii field is the value it owns.`,
553
- );
554
- }
555
- for (const required of [field, spec.subjectField]) {
556
- if (shape && !(required in shape)) {
557
- throw new Error(
558
- `[Feature ${name}] defineEvent("${eventName}"): piiFields references "${required}" which is not a field of the payload schema.`,
559
- );
560
- }
561
- }
562
- }
563
- }
564
- // @cast-boundary engine-bridge — runtime-string mirrors the
565
- // template-literal-type via QualifiedEventName + toKebab. Both
566
- // sides are tested (CamelToKebab type-tests + scan-events kebab
567
- // tests), so the cast is a contract, not a typing-loss.
568
- const def: EventDef<TPayload, QualifiedEventName<TName, TInner>> = {
569
- name: qualified as QualifiedEventName<TName, TInner>,
570
- schema,
571
- version,
572
- ...(piiFields !== undefined && { piiFields }),
573
- };
574
- events[eventName] = def;
575
- return def;
576
- },
577
-
578
- eventMigration(
579
- eventName: string,
580
- fromVersion: number,
581
- toVersion: number,
582
- transform: EventUpcastFn | DeclarativeEventMigration,
583
- ): void {
584
- if (toVersion !== fromVersion + 1) {
585
- throw new Error(
586
- `[Feature ${name}] eventMigration("${eventName}", ${fromVersion}, ${toVersion}): ` +
587
- `only single-step migrations are allowed — toVersion must be fromVersion + 1. ` +
588
- `Chain larger jumps by registering each step separately.`,
589
- );
590
- }
591
- if (!Number.isInteger(fromVersion) || fromVersion < 1) {
592
- throw new Error(
593
- `[Feature ${name}] eventMigration("${eventName}", ...): fromVersion must be >= 1, got ${String(fromVersion)}`,
594
- );
595
- }
596
- const qualified = qn(toKebab(name), "event", toKebab(eventName));
597
- const list = eventMigrations[eventName] ?? [];
598
- if (list.some((m) => m.fromVersion === fromVersion)) {
599
- throw new Error(
600
- `[Feature ${name}] eventMigration("${eventName}", ${fromVersion}, ${toVersion}): ` +
601
- `a migration from v${fromVersion} is already registered. Each step may only be declared once.`,
602
- );
603
- }
604
- const transformFn =
605
- typeof transform === "function" ? transform : compileEventMigration(transform);
606
- list.push({ eventName: qualified, fromVersion, toVersion, transform: transformFn });
607
- eventMigrations[eventName] = list;
608
- },
609
-
610
- readsConfig(...qualifiedKeys: string[]): void {
611
- configReads.push(...qualifiedKeys);
612
- },
613
-
614
- referenceData(
615
- entityRef: NameOrRef,
616
- data: readonly Record<string, unknown>[],
617
- options?: { upsertKey?: string },
618
- ): void {
619
- referenceData.push({
620
- entityName: resolveName(entityRef),
621
- data,
622
- upsertKey: options?.upsertKey,
623
- });
624
- },
625
-
626
- extendsRegistrar(extensionName: string, def: RegistrarExtensionDef): void {
627
- registrarExtensions[extensionName] = def;
628
- },
629
-
630
- useExtension(
631
- extensionName: string,
632
- entityRef: NameOrRef,
633
- options?: Record<string, unknown>,
634
- ): void {
635
- extensionUsages.push({ extensionName, entityName: resolveName(entityRef), options });
636
- },
637
-
638
- extensionSelector(extensionName: string, key: { readonly name: string } | string): void {
639
- if (extensionSelectors.some((s) => s.extensionName === extensionName)) {
640
- throw new Error(
641
- `[Feature ${name}] extensionSelector("${extensionName}") declared twice — ` +
642
- `one selector key per extension point.`,
643
- );
644
- }
645
- const qualifiedKey = typeof key === "string" ? key : key.name;
646
- extensionSelectors.push({ extensionName, qualifiedKey });
647
- },
648
-
649
- /**
650
- * Marker-Deklaration: dieses Feature stellt eine Cross-Feature-API
651
- * unter dem genannten Namen bereit. Die eigentliche Implementation
652
- * wird separat als Query- oder Write-Handler unter dem QN-Pattern
653
- * registriert; r.exposesApi ist reine Boot-Check-Surface.
654
- *
655
- * Beispiel:
656
- * defineFeature("compliance-profiles", (r) => {
657
- * r.exposesApi("compliance.forTenant");
658
- * r.queryHandler({ name: "compliance:query:for-tenant", ... });
659
- * });
660
- * defineFeature("user-data-rights", (r) => {
661
- * r.requires("compliance-profiles");
662
- * r.usesApi("compliance.forTenant");
663
- * // ruft im Handler: ctx.callQuery("compliance:query:for-tenant", ...)
664
- * });
665
- */
666
- exposesApi(apiName: string): void {
667
- if (exposedApis.has(apiName)) {
668
- throw new Error(
669
- `[Feature ${name}] r.exposesApi("${apiName}") called twice — API names must be unique within a feature.`,
670
- );
671
- }
672
- exposedApis.add(apiName);
673
- },
674
-
675
- /**
676
- * Declares that this feature calls a cross-feature API. Boot-Validator
677
- * checkt dass irgendein anderes Feature `r.exposesApi(name)` macht und
678
- * dass dieses Feature `r.requires` darauf hat.
679
- */
680
- usesApi(apiName: string): void {
681
- usedApis.add(apiName);
682
- },
683
-
684
- metric(shortName: string, options: MetricOptions): void {
685
- if (metrics[shortName]) {
686
- throw new Error(
687
- `[Feature ${name}] Metric "${shortName}" already registered. ` +
688
- `Metric names must be unique per feature.`,
689
- );
690
- }
691
- metrics[shortName] = { shortName, ...options };
692
- },
693
-
694
- envSchema(schema: z.ZodObject<z.ZodRawShape>): void {
695
- if (envSchema !== undefined) {
696
- throw new Error(
697
- `[Feature ${name}] r.envSchema() called twice — declare one composed Zod-object per feature.`,
698
- );
699
- }
700
- envSchema = schema;
701
- },
702
-
703
- secret(shortName: string, options: SecretOptions): SecretKeyHandle {
704
- if (secretKeys[shortName]) {
705
- throw new Error(
706
- `[Feature ${name}] Secret "${shortName}" already registered. ` +
707
- `Secret key names must be unique per feature.`,
708
- );
709
- }
710
- // Qualified name follows the framework's "<feature>:<type>:<name>"
711
- // QN convention — same pattern config / jobs / events use. toKebab
712
- // handles the common input shapes ("stripe.apiKey" → "stripe-api-key")
713
- // so features can declare keys in their natural style without
714
- // thinking about kebab-case on every call.
715
- const qualifiedName = qn(toKebab(name), QnTypes.secret, toKebab(shortName));
716
- secretKeys[shortName] = {
717
- shortName,
718
- qualifiedName,
719
- ...options,
720
- };
721
- return { name: qualifiedName };
722
- },
723
-
724
- projection(definition: ProjectionDefinition): void {
725
- // Reject names that would blow up at registry-boot when we qualify them.
726
- // Catch it at the registration site so the stack trace points at the
727
- // feature file, not at framework internals.
728
- if (!isKebabSegment(definition.name)) {
729
- throw new Error(
730
- `[Feature ${name}] Projection name "${definition.name}" must be kebab-case ` +
731
- `(lowercase letters, digits, dashes; start with a letter). ` +
732
- `Got "${definition.name}" — try "${toKebab(definition.name).replace(/_/g, "-")}".`,
733
- );
734
- }
735
- if (projections[definition.name]) {
736
- throw new Error(
737
- `[Feature ${name}] Projection "${definition.name}" already registered. ` +
738
- `Projection names must be unique per feature.`,
739
- );
740
- }
741
- projections[definition.name] = definition;
742
- },
743
-
744
- multiStreamProjection(definition: MultiStreamProjectionDefinition): void {
745
- if (!isKebabSegment(definition.name)) {
746
- throw new Error(
747
- `[Feature ${name}] MultiStreamProjection name "${definition.name}" must be kebab-case ` +
748
- `(lowercase letters, digits, dashes; start with a letter). ` +
749
- `Got "${definition.name}" — try "${toKebab(definition.name).replace(/_/g, "-")}".`,
750
- );
751
- }
752
- if (multiStreamProjections[definition.name] || projections[definition.name]) {
753
- throw new Error(
754
- `[Feature ${name}] Projection name "${definition.name}" already registered. ` +
755
- `r.projection and r.multiStreamProjection share a namespace — pick a unique short name.`,
756
- );
757
- }
758
- if (Object.keys(definition.apply).length === 0) {
759
- throw new Error(
760
- `[Feature ${name}] MultiStreamProjection "${definition.name}" has no apply handlers. ` +
761
- `Declare at least one event type it reacts to, otherwise the dispatcher has nothing to route.`,
762
- );
763
- }
764
- multiStreamProjections[definition.name] = definition;
765
- },
766
-
767
- extendEntityProjection(entityName: string, extension: EntityProjectionExtension): void {
768
- if (Object.keys(extension.apply).length === 0) {
769
- throw new Error(
770
- `[Feature ${name}] extendEntityProjection("${entityName}") has no apply handlers. ` +
771
- `Declare at least one event type, otherwise the rebuild replay has nothing to do.`,
772
- );
773
- }
774
- // Entity existence + apply-key collisions are validated at registry
775
- // build — r.entity may legally be called after this in the same feature.
776
- const list = entityProjectionExtensions[entityName] ?? [];
777
- list.push(extension);
778
- entityProjectionExtensions[entityName] = list;
779
- },
780
-
781
- authClaims(fn: AuthClaimsFn): void {
782
- authClaimsHooks.push(fn);
783
- },
784
-
785
- screen(definition: ScreenDefinition): void {
786
- // Reject kebab-drift at registration-time so the stack trace points at
787
- // the feature file, not at registry-boot. Same guard pattern as
788
- // r.projection / r.multiStreamProjection.
789
- if (!isKebabSegment(definition.id)) {
790
- throw new Error(
791
- `[Feature ${name}] Screen id "${definition.id}" must be kebab-case ` +
792
- `(lowercase letters, digits, dashes; start with a letter). ` +
793
- `Got "${definition.id}" — try "${toKebab(definition.id).replace(/_/g, "-")}".`,
794
- );
795
- }
796
- if (screens[definition.id]) {
797
- throw new Error(
798
- `[Feature ${name}] Screen "${definition.id}" already registered. ` +
799
- `Screen ids must be unique per feature.`,
800
- );
801
- }
802
- screens[definition.id] = definition;
803
- },
804
-
805
- nav(definition: NavDefinition): void {
806
- // Reject kebab-drift at registration-time so the stack trace points at
807
- // the feature file, not at registry-boot. Same guard pattern as
808
- // r.projection / r.multiStreamProjection / r.screen.
809
- if (!isKebabSegment(definition.id)) {
810
- throw new Error(
811
- `[Feature ${name}] Nav id "${definition.id}" must be kebab-case ` +
812
- `(lowercase letters, digits, dashes; start with a letter). ` +
813
- `Got "${definition.id}" — try "${toKebab(definition.id).replace(/_/g, "-")}".`,
814
- );
815
- }
816
- if (navs[definition.id]) {
817
- throw new Error(
818
- `[Feature ${name}] Nav entry "${definition.id}" already registered. ` +
819
- `Nav ids must be unique per feature.`,
820
- );
821
- }
822
- navs[definition.id] = definition;
823
- },
824
-
825
- workspace(definition: WorkspaceDefinition): void {
826
- // Same kebab guard as r.screen / r.nav so authoring-time mistakes
827
- // surface at the feature file, not deep in registry boot.
828
- if (!isKebabSegment(definition.id)) {
829
- throw new Error(
830
- `[Feature ${name}] Workspace id "${definition.id}" must be kebab-case ` +
831
- `(lowercase letters, digits, dashes; start with a letter). ` +
832
- `Got "${definition.id}" — try "${toKebab(definition.id).replace(/_/g, "-")}".`,
833
- );
834
- }
835
- if (workspaces[definition.id]) {
836
- throw new Error(
837
- `[Feature ${name}] Workspace "${definition.id}" already registered. ` +
838
- `Workspace ids must be unique per feature.`,
839
- );
840
- }
841
- workspaces[definition.id] = definition;
842
- },
843
-
844
- httpRoute(definition: HttpRouteDefinition): void {
845
- // Path-Validation: muss mit "/" beginnen, keine /api/-Routes (die
846
- // sind dem Dispatcher reserviert; eine HTTP-Route die /api/foo
847
- // belegt, würde die Auth-Middleware umgehen ohne dass der Author
848
- // das ausgesprochen hat — bewusster Block).
849
- if (!definition.path.startsWith("/")) {
850
- throw new Error(
851
- `[Feature ${name}] httpRoute path "${definition.path}" must start with "/". ` +
852
- `Got "${definition.path}".`,
853
- );
854
- }
855
- if (definition.path === "/api" || definition.path.startsWith("/api/")) {
856
- throw new Error(
857
- `[Feature ${name}] httpRoute path "${definition.path}" is in the /api/* namespace ` +
858
- `which is reserved for the dispatcher (write/query/batch/auth/sse). ` +
859
- `Pick a different path or use r.queryHandler / r.writeHandler.`,
860
- );
861
- }
862
- const key = `${definition.method} ${definition.path}`;
863
- if (httpRoutes[key]) {
864
- throw new Error(
865
- `[Feature ${name}] HTTP-Route "${key}" already registered. ` +
866
- `method + path must be unique per feature.`,
867
- );
868
- }
869
- httpRoutes[key] = definition;
870
- },
871
-
872
- rawTable(rawTableName: string, table: unknown, options: RawTableOptions): void {
873
- // Same kebab guard as r.projection / r.screen / r.nav so authoring-time
874
- // mistakes surface at the feature file, not deep in registry boot.
875
- if (!isKebabSegment(rawTableName)) {
876
- throw new Error(
877
- `[Feature ${name}] Raw-table name "${rawTableName}" must be kebab-case ` +
878
- `(lowercase letters, digits, dashes; start with a letter). ` +
879
- `Got "${rawTableName}" — try "${toKebab(rawTableName).replace(/_/g, "-")}".`,
880
- );
881
- }
882
- if (rawTables[rawTableName]) {
883
- throw new Error(
884
- `[Feature ${name}] r.rawTable("${rawTableName}") already registered. ` +
885
- `Raw-table names must be unique per feature.`,
886
- );
887
- }
888
- // The `reason` is the marker that justifies the bypass — empty
889
- // strings would defeat the audit trail. Reject early so the
890
- // failure points at the feature file.
891
- if (typeof options.reason !== "string" || options.reason.trim().length === 0) {
892
- throw new Error(
893
- `[Feature ${name}] r.rawTable("${rawTableName}"): options.reason must be a ` +
894
- `non-empty string. The reason is the marker that justifies the bypass — ` +
895
- `if you can't write one, declare data via r.entity() instead.`,
896
- );
897
- }
898
- rawTables[rawTableName] = {
899
- name: rawTableName,
900
- table,
901
- reason: options.reason,
902
- };
903
- },
904
-
905
- unmanagedTable(meta: EntityTableMeta, options: UnmanagedTableOptions): void {
906
- // Name comes from the meta itself — apps already give the table a
907
- // name when calling defineUnmanagedTable, no need to repeat it.
908
- const tableName = meta.tableName;
909
- if (!isKebabSegment(tableName.replace(/_/g, "-"))) {
910
- // EntityTableMeta uses snake_case for tableName (matches Postgres
911
- // convention); we just guard against truly broken input.
912
- throw new Error(
913
- `[Feature ${name}] Unmanaged-table name "${tableName}" must be a ` +
914
- `valid identifier (lowercase letters, digits, underscores; start with a letter).`,
915
- );
916
- }
917
- if (unmanagedTables[tableName]) {
918
- throw new Error(
919
- `[Feature ${name}] r.unmanagedTable("${tableName}") already registered. ` +
920
- `Unmanaged-table names must be unique per feature.`,
921
- );
922
- }
923
- if (typeof options.reason !== "string" || options.reason.trim().length === 0) {
924
- throw new Error(
925
- `[Feature ${name}] r.unmanagedTable("${tableName}"): options.reason must be a ` +
926
- `non-empty string. The reason justifies the audit-trail bypass — ` +
927
- `if you can't write one, declare data via r.entity() instead.`,
928
- );
929
- }
930
- unmanagedTables[tableName] = {
931
- name: tableName,
932
- meta,
933
- reason: options.reason,
934
- ...(options.piiEncryptedOnWrite && { piiEncryptedOnWrite: true }),
935
- };
936
- },
937
-
938
- claimKey<T extends ClaimKeyType>(
939
- shortName: string,
940
- options: { readonly type: T },
941
- ): ClaimKeyHandle<T> {
942
- if (claimKeys[shortName]) {
943
- throw new Error(
944
- `[Feature ${name}] Claim key "${shortName}" already declared. ` +
945
- "Claim short-names must be unique per feature.",
946
- );
947
- }
948
- // Claim keys are NOT full QNs — the JWT shape is 2-segment
949
- // "<featureName>:<shortName>" (same as Translation keys), not
950
- // kebab-cased. The authClaims resolver prefixes with the raw
951
- // feature.name + the raw inner key the hook returns, so the handle's
952
- // `name` must match that literal string exactly for `readClaim` to
953
- // find the value. kebab-conversion here would break the round-trip.
954
- const qualifiedName = `${name}:${shortName}`;
955
- claimKeys[shortName] = {
956
- shortName,
957
- qualifiedName,
958
- type: options.type,
959
- };
960
- return { name: qualifiedName, type: options.type };
961
- },
962
-
963
- treeActions<const TActions extends Record<string, TreeActionDef>>(
964
- actions: TActions,
965
- ): TreeActionsHandle<TName, TActions> {
966
- // Only-once-guard: zweiter Aufruf ist Author-Bug, soll am
967
- // Feature-File aufschlagen (gleicher Stil wie r.toggleable).
968
- if (treeActions !== undefined) {
969
- throw new Error(
970
- `[Feature ${name}] r.treeActions() already called. ` +
971
- `Each feature may declare a single tree-actions schema.`,
972
- );
973
- }
974
- treeActions = actions;
975
- // Return typed handle für setup-export. Frozen damit Caller die
976
- // Map nicht nachträglich mutieren (würde Pattern-AST + Runtime-
977
- // Lookup divergieren lassen).
978
- return Object.freeze({
979
- id: name,
980
- treeActions: actions,
981
- });
69
+ state.uiHints = hints;
982
70
  },
71
+ ...buildEntityHandlerMethods(state, name, () => registrar),
72
+ ...buildConfigEventsJobsMethods(state, name),
73
+ ...buildUiExtensionsMethods(state, name),
983
74
  };
984
75
 
985
76
  const exports = setup(registrar) as TExports; // @cast-boundary engine-bridge
986
77
 
987
78
  return {
988
79
  name,
989
- ...(description !== undefined && { description }),
990
- systemScope: isSystemScoped,
80
+ ...(state.description !== undefined && { description: state.description }),
81
+ systemScope: state.isSystemScoped,
991
82
  exports,
992
- requires,
993
- optionalRequires,
994
- requiredProjections,
995
- requiredSteps,
996
- ...(toggleableDefault !== undefined && { toggleableDefault }),
997
- ...(uiHints !== undefined && { uiHints }),
998
- entities,
999
- entityTables,
1000
- relations,
1001
- writeHandlers,
1002
- queryHandlers,
1003
- translations,
83
+ requires: state.requires,
84
+ optionalRequires: state.optionalRequires,
85
+ requiredProjections: state.requiredProjections,
86
+ requiredSteps: state.requiredSteps,
87
+ ...(state.toggleableDefault !== undefined && { toggleableDefault: state.toggleableDefault }),
88
+ ...(state.uiHints !== undefined && { uiHints: state.uiHints }),
89
+ entities: state.entities,
90
+ entityTables: state.entityTables,
91
+ relations: state.relations,
92
+ writeHandlers: state.writeHandlers,
93
+ queryHandlers: state.queryHandlers,
94
+ translations: state.translations,
1004
95
  hooks: {
1005
- validation: validationHooks,
1006
- preSave: lifecycleHooks["preSave"] ?? {},
1007
- postSave: phasedLifecycleHooks.postSave,
1008
- preDelete: phasedLifecycleHooks.preDelete,
1009
- postDelete: phasedLifecycleHooks.postDelete,
1010
- preQuery: lifecycleHooks["preQuery"] ?? {},
1011
- postQuery: lifecycleHooks["postQuery"] ?? {},
96
+ validation: state.validationHooks,
97
+ preSave: state.lifecycleHooks["preSave"] ?? {},
98
+ postSave: state.phasedLifecycleHooks.postSave,
99
+ preDelete: state.phasedLifecycleHooks.preDelete,
100
+ postDelete: state.phasedLifecycleHooks.postDelete,
101
+ preQuery: state.lifecycleHooks["preQuery"] ?? {},
102
+ postQuery: state.lifecycleHooks["postQuery"] ?? {},
1012
103
  // @cast-boundary engine-bridge — die Hook-Registrierung erased die
1013
104
  // per-Slot-Signaturen zu LifecycleHookFn (Union, s. Cast in
1014
105
  // addLifecycleHook); die Branches dort sind die einzigen Producer und
1015
106
  // schreiben pro Slot typrichtig.
1016
107
  } as HookMap,
1017
108
  entityHooks: {
1018
- postSave: entityPostSave,
1019
- preDelete: entityPreDelete,
1020
- postDelete: entityPostDelete,
1021
- postQuery: entityPostQuery,
1022
- },
1023
- searchPayloadExtensions,
1024
- configKeys,
1025
- configSeeds,
1026
- jobs,
1027
- notifications,
1028
- registrarExtensions,
1029
- extensionUsages,
1030
- extensionSelectors,
1031
- exposedApis,
1032
- usedApis,
1033
- referenceData,
1034
- events,
1035
- eventMigrations,
1036
- configReads,
1037
- handlerEntityMappings,
1038
- metrics,
1039
- secretKeys,
1040
- projections,
1041
- entityProjectionExtensions,
1042
- multiStreamProjections,
1043
- authClaimsHooks,
1044
- claimKeys,
1045
- screens,
1046
- navs,
1047
- workspaces,
1048
- httpRoutes,
1049
- rawTables,
1050
- unmanagedTables,
1051
- ...(treeActions !== undefined && { treeActions }),
1052
- ...(envSchema !== undefined && { envSchema }),
1053
- };
1054
- }
1055
-
1056
- // Compile the declarative {rename, default, map} migration spec into an
1057
- // EventUpcastFn. Fixed order: rename → default → map.
1058
- function compileEventMigration(spec: DeclarativeEventMigration): EventUpcastFn {
1059
- return (payload) => {
1060
- if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
1061
- throw new Error("Declarative event migration expects an object payload");
1062
- }
1063
- // @cast-boundary parse — payload is guarded as a plain object above
1064
- const next = { ...(payload as Record<string, unknown>) };
1065
- for (const [from, to] of Object.entries(spec.rename ?? {})) {
1066
- if (from in next) {
1067
- next[to] = next[from];
1068
- delete next[from];
1069
- }
1070
- }
1071
- for (const [key, value] of Object.entries(spec.default ?? {})) {
1072
- if (!(key in next)) next[key] = value;
1073
- }
1074
- for (const [key, fn] of Object.entries(spec.map ?? {})) {
1075
- if (key in next) next[key] = fn(next[key]);
1076
- }
1077
- return next;
109
+ postSave: state.entityPostSave,
110
+ preDelete: state.entityPreDelete,
111
+ postDelete: state.entityPostDelete,
112
+ postQuery: state.entityPostQuery,
113
+ },
114
+ searchPayloadExtensions: state.searchPayloadExtensions,
115
+ configKeys: state.configKeys,
116
+ configSeeds: state.configSeeds,
117
+ jobs: state.jobs,
118
+ notifications: state.notifications,
119
+ registrarExtensions: state.registrarExtensions,
120
+ extensionUsages: state.extensionUsages,
121
+ extensionSelectors: state.extensionSelectors,
122
+ exposedApis: state.exposedApis,
123
+ usedApis: state.usedApis,
124
+ referenceData: state.referenceData,
125
+ events: state.events,
126
+ eventMigrations: state.eventMigrations,
127
+ configReads: state.configReads,
128
+ handlerEntityMappings: state.handlerEntityMappings,
129
+ metrics: state.metrics,
130
+ secretKeys: state.secretKeys,
131
+ projections: state.projections,
132
+ entityProjectionExtensions: state.entityProjectionExtensions,
133
+ multiStreamProjections: state.multiStreamProjections,
134
+ authClaimsHooks: state.authClaimsHooks,
135
+ claimKeys: state.claimKeys,
136
+ screens: state.screens,
137
+ navs: state.navs,
138
+ workspaces: state.workspaces,
139
+ httpRoutes: state.httpRoutes,
140
+ rawTables: state.rawTables,
141
+ unmanagedTables: state.unmanagedTables,
142
+ ...(state.treeActions !== undefined && { treeActions: state.treeActions }),
143
+ ...(state.envSchema !== undefined && { envSchema: state.envSchema }),
1078
144
  };
1079
145
  }