@cosmicdrift/kumiko-framework 0.27.0 → 0.31.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 (36) hide show
  1. package/package.json +1 -1
  2. package/src/api/auth-routes.ts +6 -0
  3. package/src/api/server.ts +7 -1
  4. package/src/bun-db/index.ts +3 -1
  5. package/src/bun-db/query.ts +1 -1
  6. package/src/db/__tests__/collect-table-metas.test.ts +126 -0
  7. package/src/db/collect-table-metas.ts +81 -0
  8. package/src/db/feature-table-sources.ts +35 -0
  9. package/src/db/index.ts +5 -0
  10. package/src/engine/__tests__/engine.test.ts +29 -0
  11. package/src/engine/__tests__/registry.test.ts +75 -0
  12. package/src/engine/config-helpers.ts +2 -0
  13. package/src/engine/define-feature.ts +28 -0
  14. package/src/engine/feature-ast/extractors/index.ts +1 -0
  15. package/src/engine/feature-ast/extractors/round1.ts +22 -0
  16. package/src/engine/feature-ast/parse.ts +3 -0
  17. package/src/engine/feature-ast/patch.ts +3 -0
  18. package/src/engine/feature-ast/patcher.ts +5 -0
  19. package/src/engine/feature-ast/patterns.ts +183 -17
  20. package/src/engine/feature-ast/render.ts +7 -0
  21. package/src/engine/pattern-library/__tests__/library.test.ts +3 -0
  22. package/src/engine/pattern-library/library.ts +19 -0
  23. package/src/engine/registry.ts +37 -1
  24. package/src/engine/system-user.ts +10 -2
  25. package/src/engine/types/config.ts +16 -0
  26. package/src/engine/types/feature.ts +31 -4
  27. package/src/engine/types/index.ts +1 -0
  28. package/src/entrypoint/index.ts +8 -3
  29. package/src/errors/classes.ts +29 -1
  30. package/src/errors/i18n/de.yaml +4 -4
  31. package/src/errors/i18n/en.yaml +4 -4
  32. package/src/errors/index.ts +2 -0
  33. package/src/event-store/__tests__/perf.integration.test.ts +6 -3
  34. package/src/files/feature.ts +3 -0
  35. package/src/secrets/types.ts +3 -0
  36. package/src/stack/test-stack.ts +18 -31
@@ -76,6 +76,11 @@ import type { SourceLocation } from "./source-location";
76
76
  // generates them as pure data. Round-trip without code spans.
77
77
  // =============================================================================
78
78
 
79
+ // `r.entity(name, definition)` — declares an event-sourced entity: field
80
+ // schema plus search/sort and PII metadata as one declarative object. The
81
+ // framework derives the aggregate table, CRUD events, and the read-side
82
+ // projection from it at boot. Fully static — the Designer renders it as a
83
+ // form, the AI patcher edits it as pure data.
79
84
  export type EntityPattern = {
80
85
  readonly kind: "entity";
81
86
  readonly source: SourceLocation;
@@ -83,6 +88,13 @@ export type EntityPattern = {
83
88
  readonly definition: EntityDefinition;
84
89
  };
85
90
 
91
+ // `r.relation(entity, relationName, definition)` — attaches a named
92
+ // relationship to an entity: `belongsTo`, `hasMany`, or `manyToMany`
93
+ // (discriminated by `type`). Each variant carries the target entity plus
94
+ // its own extras: foreign key or join table, cascade behaviour (`onDelete`
95
+ // — parent-side only, not on `belongsTo`), search includes, opt-in
96
+ // `nestedWrite` expansion. Boot-validation checks that every target
97
+ // resolves to a registered entity (cross-feature targets allowed).
86
98
  export type RelationPattern = {
87
99
  readonly kind: "relation";
88
100
  readonly source: SourceLocation;
@@ -91,53 +103,104 @@ export type RelationPattern = {
91
103
  readonly definition: RelationDefinition;
92
104
  };
93
105
 
106
+ // `r.nav(definition)` — registers a nav entry under the feature-local short
107
+ // id (qualified to `<feature>:nav:<id>`). Boot-validation checks that the
108
+ // referenced `screen` and `parent` exist (cross-feature QNs allowed) and
109
+ // that parent chains contain no cycles.
94
110
  export type NavPattern = {
95
111
  readonly kind: "nav";
96
112
  readonly source: SourceLocation;
97
113
  readonly definition: NavDefinition;
98
114
  };
99
115
 
116
+ // `r.workspace(definition)` — registers a workspace, a persona-/role-scoped
117
+ // UI surface (qualified to `<feature>:workspace:<id>`). Pure UI composition;
118
+ // boot-validation checks that nav refs exist and that at most one workspace
119
+ // per app declares `default: true`.
100
120
  export type WorkspacePattern = {
101
121
  readonly kind: "workspace";
102
122
  readonly source: SourceLocation;
103
123
  readonly definition: WorkspaceDefinition;
104
124
  };
105
125
 
126
+ // `r.config({ keys, seeds? })` — declares per-tenant config keys and returns
127
+ // a handle map; passing a handle to `ctx.config(handle)` narrows the value
128
+ // type by the key's declared `type`. Optional `seeds` write boot-time rows
129
+ // via the event-store executor (system-tenant by default, explicit
130
+ // `tenantId` per seed) — idempotent, skipped when the stream already exists.
106
131
  export type ConfigPattern = {
107
132
  readonly kind: "config";
108
133
  readonly source: SourceLocation;
109
134
  readonly keys: Readonly<Record<string, ConfigKeyDefinition<ConfigKeyType>>>;
110
135
  };
111
136
 
137
+ // `r.translations({ keys })` — registers locale-keyed string maps
138
+ // (`key → { locale → text }`). The registry namespaces every key by feature
139
+ // (`<feature>:<key>`), so short keys never collide across features;
140
+ // `createI18n` consumes the merged map with default-locale fallback.
141
+ // Multiple calls per feature merge, last write wins per key.
112
142
  export type TranslationsPattern = {
113
143
  readonly kind: "translations";
114
144
  readonly source: SourceLocation;
115
145
  readonly keys: TranslationKeys;
116
146
  };
117
147
 
148
+ // `r.requires(...featureNames)` — hard dependency on other features; boot
149
+ // fails when one is missing from the app composition. Callable-plus-
150
+ // namespace: `r.requires.projection(table)` allow-lists a read-side table
151
+ // for pipeline-step writes, `r.requires.step(kind)` opts into Tier-2 step
152
+ // kinds.
118
153
  export type RequiresPattern = {
119
154
  readonly kind: "requires";
120
155
  readonly source: SourceLocation;
121
156
  readonly featureNames: readonly string[];
122
157
  };
123
158
 
159
+ // `r.optionalRequires(...featureNames)` — soft dependency: the feature
160
+ // integrates with the named features when they are mounted but boots fine
161
+ // without them. For cross-cutting integrations (audit, notifications) that
162
+ // degrade gracefully.
124
163
  export type OptionalRequiresPattern = {
125
164
  readonly kind: "optionalRequires";
126
165
  readonly source: SourceLocation;
127
166
  readonly featureNames: readonly string[];
128
167
  };
129
168
 
169
+ // `r.systemScope()` — switches the feature's `TenantDb` to system mode: no
170
+ // tenant filter on reads/updates/deletes, and INSERT treats `tenantId` as a
171
+ // default the handler may override (tenant mode forces it). For features
172
+ // whose aggregates span tenants, e.g. user management or platform
173
+ // operations. Marker call — no arguments.
130
174
  export type SystemScopePattern = {
131
175
  readonly kind: "systemScope";
132
176
  readonly source: SourceLocation;
133
177
  };
134
178
 
179
+ // `r.toggleable({ default })` — declares the feature operator-switchable via
180
+ // the feature-toggles bundled feature; `default` is the effective state when
181
+ // no global-toggle row exists. At most once per feature. Don't declare it on
182
+ // always-on core features (auth, tenant, user) — that is a bug, and nothing
183
+ // catches it at boot.
135
184
  export type ToggleablePattern = {
136
185
  readonly kind: "toggleable";
137
186
  readonly source: SourceLocation;
138
187
  readonly default: boolean;
139
188
  };
140
189
 
190
+ // `r.describe(text)` — the one-to-three-sentence docs lead for the feature
191
+ // ("what it does + when you need it"). At most once per feature, must be
192
+ // non-empty. Flows through the feature manifest into the generated
193
+ // feature-reference pages.
194
+ export type DescribePattern = {
195
+ readonly kind: "describe";
196
+ readonly source: SourceLocation;
197
+ readonly text: string;
198
+ };
199
+
200
+ // `r.metric(shortName, options)` — declares a metric under its short name
201
+ // (without the `kumiko_<feature>_` prefix; the framework qualifies it at
202
+ // boot and validates snake_case + type suffix). Runtime usage:
203
+ // `ctx.metrics.inc("created_total", { status: "new" })`.
141
204
  export type MetricPattern = {
142
205
  readonly kind: "metric";
143
206
  readonly source: SourceLocation;
@@ -145,6 +208,10 @@ export type MetricPattern = {
145
208
  readonly options: MetricOptions;
146
209
  };
147
210
 
211
+ // `r.secret(shortName, options)` — declares a tenant-scoped secret key,
212
+ // qualified to `<feature>:secret:<kebab-short>` via the QN helper. Returns a
213
+ // typed handle for `ctx.secrets.get`, so feature code never retypes the
214
+ // qualified string — same ergonomics as `r.config` handles.
148
215
  export type SecretPattern = {
149
216
  readonly kind: "secret";
150
217
  readonly source: SourceLocation;
@@ -152,6 +219,12 @@ export type SecretPattern = {
152
219
  readonly options: SecretOptions;
153
220
  };
154
221
 
222
+ // `r.claimKey(shortName, { type })` — declares a session-claim key,
223
+ // qualified to `<feature>:<shortName>` (no kebab conversion — it would
224
+ // break the claim round-trip), and returns a typed handle for
225
+ // `readClaim(user, handle)`. Declaring keys also enables typo-drift
226
+ // protection: `r.authClaims` hooks returning an undeclared inner key log a
227
+ // warning (the claim still lands in the JWT — this is not strict mode).
155
228
  export type ClaimKeyPattern = {
156
229
  readonly kind: "claimKey";
157
230
  readonly source: SourceLocation;
@@ -159,6 +232,12 @@ export type ClaimKeyPattern = {
159
232
  readonly claimType: ClaimKeyType;
160
233
  };
161
234
 
235
+ // `r.referenceData(entity, rows, options?)` — declares static lookup rows
236
+ // for an entity, upserted by `seedReferenceData` (the app or dev-server
237
+ // calls it at boot — not the framework itself): insert or update by
238
+ // `upsertKey` — which defaults to the first field of the first row, so
239
+ // declare it explicitly — and never delete. New rows land under
240
+ // `SYSTEM_TENANT_ID`, i.e. global reference data, not tenant rows.
162
241
  export type ReferenceDataPattern = {
163
242
  readonly kind: "referenceData";
164
243
  readonly source: SourceLocation;
@@ -167,12 +246,23 @@ export type ReferenceDataPattern = {
167
246
  readonly upsertKey?: string;
168
247
  };
169
248
 
249
+ // `r.readsConfig(...qualifiedKeys)` — declares that this feature reads
250
+ // config keys owned by other features, in dot notation
251
+ // (`featureName.shortKey`). Boot-validation throws when a declared key does
252
+ // not exist anywhere. Purely declarative beyond that boot-time safety net —
253
+ // runtime reads still go through `ctx.config(handle)`.
170
254
  export type ReadsConfigPattern = {
171
255
  readonly kind: "readsConfig";
172
256
  readonly source: SourceLocation;
173
257
  readonly qualifiedKeys: readonly string[];
174
258
  };
175
259
 
260
+ // `r.useExtension(extensionName, entity, options?)` — opts an entity into a
261
+ // registrar extension declared via `r.extendsRegistrar`: runs its
262
+ // `onRegister`, merges its extra schema fields, and installs its entity
263
+ // hooks at registry build time. The `options` bag is passed verbatim to
264
+ // `onRegister` (per-entity configuration). Boot-validation throws when the
265
+ // extension name does not exist.
176
266
  export type UseExtensionPattern = {
177
267
  readonly kind: "useExtension";
178
268
  readonly source: SourceLocation;
@@ -181,11 +271,12 @@ export type UseExtensionPattern = {
181
271
  readonly options?: Readonly<Record<string, unknown>>;
182
272
  };
183
273
 
184
- // r.treeActions({ ... }) — Schema-Map für Visual-Tree-Action-Verben.
185
- // Static: Args sind Type-Samples (kein Runtime-Validator), Designer
186
- // rendert das als nested form pro Action. Compile-Time-Validation
187
- // passiert via setup-export-Handle (TreeActionsHandle), nicht über
188
- // dieses Patterndas hier ist reine Runtime-Repräsentation.
274
+ // `r.treeActions({ ... })`the schema map for visual-tree action verbs
275
+ // (action name definition with optional typed args). Static: args are
276
+ // type samples, not runtime validators; the Designer renders a nested form
277
+ // per action. Compile-time validation happens via the exported
278
+ // `TreeActionsHandle`, not through this pattern this is the erased
279
+ // runtime representation.
189
280
  export type TreeActionsPattern = {
190
281
  readonly kind: "treeActions";
191
282
  readonly source: SourceLocation;
@@ -213,6 +304,11 @@ export type OpaquePropMap = Readonly<Record<string, SourceLocation>>;
213
304
  export const SCREEN_OPAQUE_MARKER = "$opaque" as const;
214
305
  export type ScreenOpaqueMarker = typeof SCREEN_OPAQUE_MARKER;
215
306
 
307
+ // `r.screen(definition)` — registers a screen under the feature-local short
308
+ // id (qualified to `<feature>:screen:<id>`). Boot-validation checks that
309
+ // entity-bound screens reference a registered entity and that column/form
310
+ // field refs name real fields. Closure-valued props (visibility conditions,
311
+ // row-action payloads, custom renderers) stay opaque — see `opaqueProps`.
216
312
  export type ScreenPattern = {
217
313
  readonly kind: "screen";
218
314
  readonly source: SourceLocation;
@@ -223,6 +319,9 @@ export type ScreenPattern = {
223
319
  readonly opaqueProps: OpaquePropMap;
224
320
  };
225
321
 
322
+ // `r.writeHandler(...)` — registers a command handler: name, Zod input
323
+ // schema, handler closure, plus optional `access` and `rateLimit` rules.
324
+ // The header is declarative; schema and body stay opaque source spans.
226
325
  export type WriteHandlerPattern = {
227
326
  readonly kind: "writeHandler";
228
327
  readonly source: SourceLocation;
@@ -239,6 +338,9 @@ export type WriteHandlerPattern = {
239
338
  readonly unsafeSkipTransitionGuard?: boolean;
240
339
  };
241
340
 
341
+ // `r.queryHandler(...)` — registers a read handler: name, Zod input schema,
342
+ // handler closure, plus optional `access` and `rateLimit` rules. Read-side
343
+ // counterpart of `r.writeHandler` with the same header/body split.
242
344
  export type QueryHandlerPattern = {
243
345
  readonly kind: "queryHandler";
244
346
  readonly source: SourceLocation;
@@ -249,6 +351,11 @@ export type QueryHandlerPattern = {
249
351
  readonly rateLimit?: RateLimitOption;
250
352
  };
251
353
 
354
+ // `r.hook(type, target, fn, options?)` — attaches a lifecycle hook
355
+ // (`validation`, `preSave`, `postSave`, `preDelete`, `postDelete`,
356
+ // `preQuery`, `postQuery`) to one or more target handlers. Post-hooks
357
+ // accept a `phase` option; `preDelete` always runs in-transaction — it
358
+ // guards the delete. The hook body is an opaque code span.
252
359
  export type HookPattern = {
253
360
  readonly kind: "hook";
254
361
  readonly source: SourceLocation;
@@ -260,6 +367,11 @@ export type HookPattern = {
260
367
  readonly phase?: HookPhase;
261
368
  };
262
369
 
370
+ // `r.entityHook(type, entity, fn, options?)` — like `r.hook`, but bound to
371
+ // an entity instead of individual handlers: `postSave`, `preDelete`, and
372
+ // `postDelete` fire on every matching write. The runtime API additionally
373
+ // accepts `postQuery` (fires for all query-handlers of the entity), but
374
+ // this pattern type only represents the three write-side hooks.
263
375
  export type EntityHookPattern = {
264
376
  readonly kind: "entityHook";
265
377
  readonly source: SourceLocation;
@@ -269,6 +381,13 @@ export type EntityHookPattern = {
269
381
  readonly phase?: HookPhase;
270
382
  };
271
383
 
384
+ // `r.job(name, options, handler)` — registers a background job, qualified
385
+ // to `<feature>:job:<short>` and executed on a BullMQ queue outside the
386
+ // request pipeline. `trigger` is `{ on: handlerRef(s) }` (fires after the
387
+ // handler commits), `{ cron: "..." }`, or `{ manual: true }`; options cover
388
+ // concurrency modes, retries/backoff, timeout, `perTenant` fan-out, and the
389
+ // `runIn` lane (`api`/`worker`). The handler body stays an opaque code
390
+ // span.
272
391
  export type JobPattern = {
273
392
  readonly kind: "job";
274
393
  readonly source: SourceLocation;
@@ -279,6 +398,13 @@ export type JobPattern = {
279
398
  readonly handlerBody: SourceLocation;
280
399
  };
281
400
 
401
+ // `r.notification(name, definition)` — declarative notification template,
402
+ // qualified to `<feature>:notify:<short>`. At registry build it becomes an
403
+ // after-commit postSave hook on the trigger handler that calls
404
+ // `ctx.notify(name, { to, data })`: `recipient` picks userId(s), a tenant
405
+ // broadcast, or `null` to skip; `data` builds the payload; per-channel
406
+ // `templates` (email, in-app, push) render it. Delivered by the `delivery`
407
+ // bundled feature — declare `r.requires("delivery")` alongside.
282
408
  export type NotificationPattern = {
283
409
  readonly kind: "notification";
284
410
  readonly source: SourceLocation;
@@ -290,22 +416,36 @@ export type NotificationPattern = {
290
416
  readonly templates?: Readonly<Record<string, SourceLocation>>;
291
417
  };
292
418
 
419
+ // `r.authClaims(fn)` — contributes claims into `SessionUser.claims` whenever
420
+ // a session is issued (login AND tenant switch — claims are recomputed to
421
+ // avoid stale leaks across tenancies).
422
+ // Multiple hooks merge; keys are auto-prefixed `<feature>:<key>`, so
423
+ // cross-feature collisions are impossible by construction. Best-effort by
424
+ // design: a throwing hook logs and drops only that feature's claims — login
425
+ // still succeeds (identity facts are convenience, not access gates).
293
426
  export type AuthClaimsPattern = {
294
427
  readonly kind: "authClaims";
295
428
  readonly source: SourceLocation;
296
429
  readonly fnBody: SourceLocation;
297
430
  };
298
431
 
299
- // r.tree(provider) — Top-Level-Tree-Provider-Function. Closure-only,
300
- // kein Header-Form. Designer rendert als read-only Code-Block, AI-
301
- // Patcher überschreibt span verbatim. Konsistent mit r.authClaims
302
- // auch da ist die Function-Body die einzige Information.
432
+ // `r.tree(provider)`the feature's top-level tree provider: a subscribe
433
+ // function (emit-fn in, unsubscribe-fn out) that feeds workspaces with
434
+ // `navigation: "tree"`. Features without `r.tree()` are invisible there
435
+ // provider presence IS the filter, there is no workspace mapping.
436
+ // Closure-only, no header form: the Designer renders a read-only code
437
+ // block, the AI patcher overwrites the span verbatim.
303
438
  export type TreePattern = {
304
439
  readonly kind: "tree";
305
440
  readonly source: SourceLocation;
306
441
  readonly providerBody: SourceLocation;
307
442
  };
308
443
 
444
+ // `r.httpRoute(definition)` — mounts an HTTP route owned by the feature,
445
+ // outside the dispatcher pipeline (not under `/api/write|query|batch`) —
446
+ // for RSS/Atom feeds, OG images, OpenAPI specs and similar. Duplicate
447
+ // method+path pairs are rejected per feature at setup time; nothing checks
448
+ // across features.
309
449
  export type HttpRoutePattern = {
310
450
  readonly kind: "httpRoute";
311
451
  readonly source: SourceLocation;
@@ -315,6 +455,11 @@ export type HttpRoutePattern = {
315
455
  readonly handlerBody: SourceLocation;
316
456
  };
317
457
 
458
+ // `r.projection(definition)` — registers a read-side projection driven by
459
+ // events of one or more source entities. Apply functions run inside the
460
+ // event-store's transaction, so the projection stays consistent with the
461
+ // events that feed it. Apply bodies are opaque code spans keyed by event
462
+ // type.
318
463
  export type ProjectionPattern = {
319
464
  readonly kind: "projection";
320
465
  readonly source: SourceLocation;
@@ -326,6 +471,12 @@ export type ProjectionPattern = {
326
471
  readonly applyBodies: Readonly<Record<string, SourceLocation>>;
327
472
  };
328
473
 
474
+ // `r.multiStreamProjection(definition)` — registers a cross-aggregate async
475
+ // projection. The event-dispatcher owns delivery via a dedicated cursor:
476
+ // at-least-once, strictly ordered by event id — handlers must be idempotent.
477
+ // For views spanning many aggregate types (billing summaries, audit views,
478
+ // saga state); omit the table for pure side-effect consumers (notifications,
479
+ // webhooks, external-system sync).
329
480
  export type MultiStreamProjectionPattern = {
330
481
  readonly kind: "multiStreamProjection";
331
482
  readonly source: SourceLocation;
@@ -336,6 +487,12 @@ export type MultiStreamProjectionPattern = {
336
487
  readonly delivery?: "shared" | "per-instance";
337
488
  };
338
489
 
490
+ // `r.defineEvent(name, schema, options?)` — registers an event payload
491
+ // shape and returns the qualified `EventDef`, so callers pass `.name` to
492
+ // `ctx.appendEvent` instead of hand-building `<feature>:event:<short>`.
493
+ // `options.version` declares the CURRENT schema generation (default 1);
494
+ // bump it together with an `r.eventMigration` step — the framework refuses
495
+ // to boot if the chain from 1 to the current version has gaps.
339
496
  export type DefineEventPattern = {
340
497
  readonly kind: "defineEvent";
341
498
  readonly source: SourceLocation;
@@ -344,6 +501,11 @@ export type DefineEventPattern = {
344
501
  readonly version?: number;
345
502
  };
346
503
 
504
+ // `r.eventMigration(eventName, fromVersion, toVersion, transform)` —
505
+ // registers a step-wise payload upcast for event-schema evolution.
506
+ // `toVersion` must be `fromVersion + 1`; chain larger jumps step by step.
507
+ // Transforms are pure old-payload-in/new-payload-out functions and run once
508
+ // per READ, not once per event persisted — keep them cheap.
347
509
  export type EventMigrationPattern = {
348
510
  readonly kind: "eventMigration";
349
511
  readonly source: SourceLocation;
@@ -353,6 +515,11 @@ export type EventMigrationPattern = {
353
515
  readonly transformBody: SourceLocation;
354
516
  };
355
517
 
518
+ // `r.extendsRegistrar(extensionName, def)` — declares a named, globally
519
+ // unique extension point that other features opt into per entity via
520
+ // `r.useExtension`. The def can contribute `onRegister`, extra schema
521
+ // fields (`extendSchema`), and entity hooks; wiring happens at registry
522
+ // build time.
356
523
  export type ExtendsRegistrarPattern = {
357
524
  readonly kind: "extendsRegistrar";
358
525
  readonly source: SourceLocation;
@@ -380,14 +547,7 @@ export type ExposesApiPattern = {
380
547
  readonly apiName: string;
381
548
  };
382
549
 
383
- // =============================================================================
384
- // Catch-all — r.* calls the visitor doesn't recognise. Designer renders
385
- // "unknown call (cannot edit)", AI patcher leaves them unchanged. When
386
- // an UnknownPattern shows up in the wild it's a signal that a new r.*
387
- // API exists and needs its own pattern type here.
388
- // =============================================================================
389
-
390
- // r.envSchema(z.object({...})) — the env-vars contract for a feature.
550
+ // `r.envSchema(z.object({...}))` — the env-vars contract for a feature.
391
551
  // Argument is a Zod-expression (computed); we keep the source-location of
392
552
  // the schema body so Designer / AI render the raw TS code (opaque).
393
553
  export type EnvSchemaPattern = {
@@ -396,6 +556,10 @@ export type EnvSchemaPattern = {
396
556
  readonly schemaBody: SourceLocation;
397
557
  };
398
558
 
559
+ // Catch-all — r.* calls the visitor doesn't recognise. Designer renders
560
+ // "unknown call (cannot edit)", AI patcher leaves them unchanged. When
561
+ // an UnknownPattern shows up in the wild it's a signal that a new r.*
562
+ // API exists and needs its own pattern type here.
399
563
  export type UnknownPattern = {
400
564
  readonly kind: "unknown";
401
565
  readonly source: SourceLocation;
@@ -419,6 +583,7 @@ export type FeaturePattern =
419
583
  | OptionalRequiresPattern
420
584
  | SystemScopePattern
421
585
  | ToggleablePattern
586
+ | DescribePattern
422
587
  | MetricPattern
423
588
  | SecretPattern
424
589
  | ClaimKeyPattern
@@ -476,6 +641,7 @@ export function getEditability(pattern: FeaturePattern): Editability {
476
641
  case "optionalRequires":
477
642
  case "systemScope":
478
643
  case "toggleable":
644
+ case "describe":
479
645
  case "metric":
480
646
  case "secret":
481
647
  case "claimKey":
@@ -21,6 +21,7 @@ import type {
21
21
  ClaimKeyPattern,
22
22
  ConfigPattern,
23
23
  DefineEventPattern,
24
+ DescribePattern,
24
25
  EntityHookPattern,
25
26
  EntityPattern,
26
27
  EnvSchemaPattern,
@@ -77,6 +78,8 @@ export function renderPattern(pattern: FeaturePattern): string {
77
78
  return renderSystemScope(pattern);
78
79
  case "toggleable":
79
80
  return renderToggleable(pattern);
81
+ case "describe":
82
+ return renderDescribe(pattern);
80
83
  case "entity":
81
84
  return renderEntity(pattern);
82
85
  case "relation":
@@ -232,6 +235,10 @@ function renderToggleable(p: ToggleablePattern): string {
232
235
  return `r.toggleable({ default: ${p.default} });`;
233
236
  }
234
237
 
238
+ function renderDescribe(p: DescribePattern): string {
239
+ return `r.describe(${JSON.stringify(p.text)});`;
240
+ }
241
+
235
242
  function renderEntity(p: EntityPattern): string {
236
243
  // Inline `name` into the definition object — canonical Object-Form
237
244
  // is a single arg with name-as-property.
@@ -30,6 +30,7 @@ const ALL_KINDS: FeaturePatternKind[] = [
30
30
  "readsConfig",
31
31
  "systemScope",
32
32
  "toggleable",
33
+ "describe",
33
34
  "entity",
34
35
  "relation",
35
36
  "nav",
@@ -197,6 +198,8 @@ function makePlaceholderPattern(kind: FeaturePatternKind): FeaturePattern {
197
198
  return { kind, source: PLACEHOLDER_LOC };
198
199
  case "toggleable":
199
200
  return { kind, source: PLACEHOLDER_LOC, default: false };
201
+ case "describe":
202
+ return { kind, source: PLACEHOLDER_LOC, text: "x" };
200
203
  case "entity":
201
204
  return { kind, source: PLACEHOLDER_LOC, entityName: "x", definition: { fields: {} } };
202
205
  case "relation":
@@ -176,6 +176,24 @@ const toggleableSchema: PatternFormSchema = {
176
176
  ],
177
177
  };
178
178
 
179
+ const describeSchema: PatternFormSchema = {
180
+ kind: "describe",
181
+ label: { en: "Description", de: "Beschreibung" },
182
+ summary: { en: "One-to-three-sentence docs-lead: what the feature does + when you need it." },
183
+ category: "meta",
184
+ editability: "static",
185
+ singleton: true,
186
+ fields: [
187
+ {
188
+ path: "text",
189
+ label: { en: "Text", de: "Text" },
190
+ input: "textarea",
191
+ required: true,
192
+ placeholder: "Stores per-tenant widgets and exposes CRUD handlers for them.",
193
+ },
194
+ ],
195
+ };
196
+
179
197
  const entitySchema: PatternFormSchema = {
180
198
  kind: "entity",
181
199
  label: { en: "Entity", de: "Entität" },
@@ -1142,6 +1160,7 @@ export const PATTERN_LIBRARY: Readonly<Record<FeaturePatternKind, PatternFormSch
1142
1160
  readsConfig: readsConfigSchema,
1143
1161
  systemScope: systemScopeSchema,
1144
1162
  toggleable: toggleableSchema,
1163
+ describe: describeSchema,
1145
1164
  entity: entitySchema,
1146
1165
  relation: relationSchema,
1147
1166
  nav: navSchema,
@@ -143,6 +143,7 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
143
143
  const handlerFeatureMap = new Map<string, string>();
144
144
  const extensionMap = new Map<string, RegistrarExtensionDef>();
145
145
  const extensionUsages: RegistrarExtensionRegistration[] = [];
146
+ const extensionSelectorMap = new Map<string, string>();
146
147
  const allReferenceData: ReferenceDataDef[] = [];
147
148
  const allConfigSeeds: ConfigSeedDef[] = [];
148
149
  const mergedTranslations: Record<string, Record<string, string>> = {};
@@ -471,7 +472,20 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
471
472
  }
472
473
  extensionMap.set(extName, extDef);
473
474
  }
474
- extensionUsages.push(...(feature.extensionUsages ?? []));
475
+ // Annotate the owner so consumers (readiness gating) can map a
476
+ // registration back to the feature's config keys + secrets.
477
+ extensionUsages.push(
478
+ ...(feature.extensionUsages ?? []).map((u) => ({ ...u, featureName: feature.name })),
479
+ );
480
+ for (const sel of feature.extensionSelectors ?? []) {
481
+ if (extensionSelectorMap.has(sel.extensionName)) {
482
+ throw new Error(
483
+ `Duplicate extension selector for "${sel.extensionName}" ` +
484
+ `(feature "${feature.name}") — one owning feature declares the selector.`,
485
+ );
486
+ }
487
+ extensionSelectorMap.set(sel.extensionName, sel.qualifiedKey);
488
+ }
475
489
  allReferenceData.push(...(feature.referenceData ?? []));
476
490
  allConfigSeeds.push(...(feature.configSeeds ?? []));
477
491
 
@@ -742,6 +756,24 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
742
756
  }
743
757
  }
744
758
 
759
+ // Selector declarations point into the merged extension + config-key
760
+ // sets — a typo'd extension or dropped key must fail the boot, not
761
+ // silently un-gate readiness.
762
+ for (const [extensionName, qualifiedKey] of extensionSelectorMap) {
763
+ if (!extensionMap.has(extensionName)) {
764
+ throw new Error(
765
+ `extensionSelector("${extensionName}") declared but no feature ` +
766
+ `registers that extension via extendsRegistrar.`,
767
+ );
768
+ }
769
+ if (!configKeyMap.has(qualifiedKey)) {
770
+ throw new Error(
771
+ `extensionSelector("${extensionName}") points at unknown config key ` +
772
+ `"${qualifiedKey}" — no mounted feature declares it.`,
773
+ );
774
+ }
775
+ }
776
+
745
777
  // Process extension usages: call onRegister, apply extendSchema, register hooks
746
778
  for (const usage of extensionUsages) {
747
779
  const ext = extensionMap.get(usage.extensionName);
@@ -1411,6 +1443,10 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
1411
1443
  return extensionUsages.filter((u) => u.extensionName === extensionName);
1412
1444
  },
1413
1445
 
1446
+ getAllExtensionSelectors(): ReadonlyMap<string, string> {
1447
+ return extensionSelectorMap;
1448
+ },
1449
+
1414
1450
  getAllNotifications(): ReadonlyMap<string, NotificationDefinition> {
1415
1451
  return notificationMap;
1416
1452
  },
@@ -7,11 +7,19 @@ import type { TenantId } from "./types/identifiers";
7
7
  export const SYSTEM_USER_ID = "00000000-0000-0000-0000-000000000000";
8
8
  export const SYSTEM_ROLE = "system" as const;
9
9
 
10
- export function createSystemUser(tenantId: TenantId): SessionUser {
10
+ // extraRoles: hasAccess kennt keinen System-Bypass — Handler gaten auf
11
+ // explizite Rollen. Caller, die Handler mit z.B. SystemAdmin-Gate erreichen
12
+ // müssen (extraRoutes.dispatchSystemWrite → billing-foundation
13
+ // process-event), geben die Rolle hier zusätzlich mit; createdBy bleibt
14
+ // SYSTEM_USER_ID, der Audit-Trail zeigt weiterhin System.
15
+ export function createSystemUser(
16
+ tenantId: TenantId,
17
+ extraRoles: readonly string[] = [],
18
+ ): SessionUser {
11
19
  return {
12
20
  id: SYSTEM_USER_ID,
13
21
  tenantId,
14
- roles: [SYSTEM_ROLE],
22
+ roles: [SYSTEM_ROLE, ...extraRoles],
15
23
  };
16
24
  }
17
25
 
@@ -86,6 +86,11 @@ export type ConfigKeyDefinition<T extends ConfigKeyType = ConfigKeyType> = {
86
86
  // Nicht kombinierbar mit encrypted (Boot-Reject) — encrypted Keys
87
87
  // werden nicht transient aus Query-Strings heraus gelesen.
88
88
  readonly allowPerRequest?: boolean;
89
+ // Tenant must supply a real value before the owning feature works — for
90
+ // text keys an empty/whitespace value counts as unset. Surfaced by
91
+ // config:query:readiness; keep in sync with the feature's requireNonEmpty
92
+ // calls in its build-fn.
93
+ readonly required?: boolean;
89
94
  };
90
95
 
91
96
  export type ConfigDefinition = {
@@ -350,6 +355,17 @@ export type RegistrarExtensionRegistration = {
350
355
  readonly extensionName: string;
351
356
  readonly entityName: string;
352
357
  readonly options?: Record<string, unknown> | undefined;
358
+ // Owning feature — annotated by the registry at merge time so consumers
359
+ // (readiness gating) can map a registration back to the feature's keys.
360
+ readonly featureName?: string;
361
+ };
362
+
363
+ // Declared by the extension-point-owning foundation via r.extensionSelector:
364
+ // "which provider under <extensionName> is active is chosen by <qualifiedKey>".
365
+ // Readiness counts a provider-feature's required keys only when selected.
366
+ export type ExtensionSelectorDef = {
367
+ readonly extensionName: string;
368
+ readonly qualifiedKey: string;
353
369
  };
354
370
 
355
371
  // --- Reference Data ---