@cosmicdrift/kumiko-framework 0.28.0 → 0.31.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/api/auth-routes.ts +6 -0
- package/src/bun-db/index.ts +3 -1
- package/src/bun-db/query.ts +1 -1
- package/src/db/__tests__/collect-table-metas.test.ts +126 -0
- package/src/db/__tests__/table-builder-meta-lockstep.test.ts +62 -0
- package/src/db/collect-table-metas.ts +81 -0
- package/src/db/dialect.ts +4 -1
- package/src/db/feature-table-sources.ts +35 -0
- package/src/db/index.ts +5 -0
- package/src/db/table-builder.ts +10 -3
- package/src/engine/__tests__/registry.test.ts +75 -0
- package/src/engine/config-helpers.ts +2 -0
- package/src/engine/define-feature.ts +14 -0
- package/src/engine/feature-ast/patterns.ts +175 -17
- package/src/engine/registry.ts +37 -1
- package/src/engine/types/config.ts +16 -0
- package/src/engine/types/feature.ts +25 -4
- package/src/engine/types/index.ts +1 -0
- package/src/errors/classes.ts +29 -1
- package/src/errors/i18n/de.yaml +4 -4
- package/src/errors/i18n/en.yaml +4 -4
- package/src/errors/index.ts +2 -0
- package/src/event-store/__tests__/perf.integration.test.ts +6 -3
- package/src/secrets/types.ts +3 -0
- package/src/stack/test-stack.ts +13 -30
|
@@ -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,59 +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.
|
|
141
194
|
export type DescribePattern = {
|
|
142
195
|
readonly kind: "describe";
|
|
143
196
|
readonly source: SourceLocation;
|
|
144
197
|
readonly text: string;
|
|
145
198
|
};
|
|
146
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" })`.
|
|
147
204
|
export type MetricPattern = {
|
|
148
205
|
readonly kind: "metric";
|
|
149
206
|
readonly source: SourceLocation;
|
|
@@ -151,6 +208,10 @@ export type MetricPattern = {
|
|
|
151
208
|
readonly options: MetricOptions;
|
|
152
209
|
};
|
|
153
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.
|
|
154
215
|
export type SecretPattern = {
|
|
155
216
|
readonly kind: "secret";
|
|
156
217
|
readonly source: SourceLocation;
|
|
@@ -158,6 +219,12 @@ export type SecretPattern = {
|
|
|
158
219
|
readonly options: SecretOptions;
|
|
159
220
|
};
|
|
160
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).
|
|
161
228
|
export type ClaimKeyPattern = {
|
|
162
229
|
readonly kind: "claimKey";
|
|
163
230
|
readonly source: SourceLocation;
|
|
@@ -165,6 +232,12 @@ export type ClaimKeyPattern = {
|
|
|
165
232
|
readonly claimType: ClaimKeyType;
|
|
166
233
|
};
|
|
167
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.
|
|
168
241
|
export type ReferenceDataPattern = {
|
|
169
242
|
readonly kind: "referenceData";
|
|
170
243
|
readonly source: SourceLocation;
|
|
@@ -173,12 +246,23 @@ export type ReferenceDataPattern = {
|
|
|
173
246
|
readonly upsertKey?: string;
|
|
174
247
|
};
|
|
175
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)`.
|
|
176
254
|
export type ReadsConfigPattern = {
|
|
177
255
|
readonly kind: "readsConfig";
|
|
178
256
|
readonly source: SourceLocation;
|
|
179
257
|
readonly qualifiedKeys: readonly string[];
|
|
180
258
|
};
|
|
181
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.
|
|
182
266
|
export type UseExtensionPattern = {
|
|
183
267
|
readonly kind: "useExtension";
|
|
184
268
|
readonly source: SourceLocation;
|
|
@@ -187,11 +271,12 @@ export type UseExtensionPattern = {
|
|
|
187
271
|
readonly options?: Readonly<Record<string, unknown>>;
|
|
188
272
|
};
|
|
189
273
|
|
|
190
|
-
// r.treeActions({ ... }) —
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
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.
|
|
195
280
|
export type TreeActionsPattern = {
|
|
196
281
|
readonly kind: "treeActions";
|
|
197
282
|
readonly source: SourceLocation;
|
|
@@ -219,6 +304,11 @@ export type OpaquePropMap = Readonly<Record<string, SourceLocation>>;
|
|
|
219
304
|
export const SCREEN_OPAQUE_MARKER = "$opaque" as const;
|
|
220
305
|
export type ScreenOpaqueMarker = typeof SCREEN_OPAQUE_MARKER;
|
|
221
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`.
|
|
222
312
|
export type ScreenPattern = {
|
|
223
313
|
readonly kind: "screen";
|
|
224
314
|
readonly source: SourceLocation;
|
|
@@ -229,6 +319,9 @@ export type ScreenPattern = {
|
|
|
229
319
|
readonly opaqueProps: OpaquePropMap;
|
|
230
320
|
};
|
|
231
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.
|
|
232
325
|
export type WriteHandlerPattern = {
|
|
233
326
|
readonly kind: "writeHandler";
|
|
234
327
|
readonly source: SourceLocation;
|
|
@@ -245,6 +338,9 @@ export type WriteHandlerPattern = {
|
|
|
245
338
|
readonly unsafeSkipTransitionGuard?: boolean;
|
|
246
339
|
};
|
|
247
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.
|
|
248
344
|
export type QueryHandlerPattern = {
|
|
249
345
|
readonly kind: "queryHandler";
|
|
250
346
|
readonly source: SourceLocation;
|
|
@@ -255,6 +351,11 @@ export type QueryHandlerPattern = {
|
|
|
255
351
|
readonly rateLimit?: RateLimitOption;
|
|
256
352
|
};
|
|
257
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.
|
|
258
359
|
export type HookPattern = {
|
|
259
360
|
readonly kind: "hook";
|
|
260
361
|
readonly source: SourceLocation;
|
|
@@ -266,6 +367,11 @@ export type HookPattern = {
|
|
|
266
367
|
readonly phase?: HookPhase;
|
|
267
368
|
};
|
|
268
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.
|
|
269
375
|
export type EntityHookPattern = {
|
|
270
376
|
readonly kind: "entityHook";
|
|
271
377
|
readonly source: SourceLocation;
|
|
@@ -275,6 +381,13 @@ export type EntityHookPattern = {
|
|
|
275
381
|
readonly phase?: HookPhase;
|
|
276
382
|
};
|
|
277
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.
|
|
278
391
|
export type JobPattern = {
|
|
279
392
|
readonly kind: "job";
|
|
280
393
|
readonly source: SourceLocation;
|
|
@@ -285,6 +398,13 @@ export type JobPattern = {
|
|
|
285
398
|
readonly handlerBody: SourceLocation;
|
|
286
399
|
};
|
|
287
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.
|
|
288
408
|
export type NotificationPattern = {
|
|
289
409
|
readonly kind: "notification";
|
|
290
410
|
readonly source: SourceLocation;
|
|
@@ -296,22 +416,36 @@ export type NotificationPattern = {
|
|
|
296
416
|
readonly templates?: Readonly<Record<string, SourceLocation>>;
|
|
297
417
|
};
|
|
298
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).
|
|
299
426
|
export type AuthClaimsPattern = {
|
|
300
427
|
readonly kind: "authClaims";
|
|
301
428
|
readonly source: SourceLocation;
|
|
302
429
|
readonly fnBody: SourceLocation;
|
|
303
430
|
};
|
|
304
431
|
|
|
305
|
-
// r.tree(provider) —
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
//
|
|
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.
|
|
309
438
|
export type TreePattern = {
|
|
310
439
|
readonly kind: "tree";
|
|
311
440
|
readonly source: SourceLocation;
|
|
312
441
|
readonly providerBody: SourceLocation;
|
|
313
442
|
};
|
|
314
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.
|
|
315
449
|
export type HttpRoutePattern = {
|
|
316
450
|
readonly kind: "httpRoute";
|
|
317
451
|
readonly source: SourceLocation;
|
|
@@ -321,6 +455,11 @@ export type HttpRoutePattern = {
|
|
|
321
455
|
readonly handlerBody: SourceLocation;
|
|
322
456
|
};
|
|
323
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.
|
|
324
463
|
export type ProjectionPattern = {
|
|
325
464
|
readonly kind: "projection";
|
|
326
465
|
readonly source: SourceLocation;
|
|
@@ -332,6 +471,12 @@ export type ProjectionPattern = {
|
|
|
332
471
|
readonly applyBodies: Readonly<Record<string, SourceLocation>>;
|
|
333
472
|
};
|
|
334
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).
|
|
335
480
|
export type MultiStreamProjectionPattern = {
|
|
336
481
|
readonly kind: "multiStreamProjection";
|
|
337
482
|
readonly source: SourceLocation;
|
|
@@ -342,6 +487,12 @@ export type MultiStreamProjectionPattern = {
|
|
|
342
487
|
readonly delivery?: "shared" | "per-instance";
|
|
343
488
|
};
|
|
344
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.
|
|
345
496
|
export type DefineEventPattern = {
|
|
346
497
|
readonly kind: "defineEvent";
|
|
347
498
|
readonly source: SourceLocation;
|
|
@@ -350,6 +501,11 @@ export type DefineEventPattern = {
|
|
|
350
501
|
readonly version?: number;
|
|
351
502
|
};
|
|
352
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.
|
|
353
509
|
export type EventMigrationPattern = {
|
|
354
510
|
readonly kind: "eventMigration";
|
|
355
511
|
readonly source: SourceLocation;
|
|
@@ -359,6 +515,11 @@ export type EventMigrationPattern = {
|
|
|
359
515
|
readonly transformBody: SourceLocation;
|
|
360
516
|
};
|
|
361
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.
|
|
362
523
|
export type ExtendsRegistrarPattern = {
|
|
363
524
|
readonly kind: "extendsRegistrar";
|
|
364
525
|
readonly source: SourceLocation;
|
|
@@ -386,14 +547,7 @@ export type ExposesApiPattern = {
|
|
|
386
547
|
readonly apiName: string;
|
|
387
548
|
};
|
|
388
549
|
|
|
389
|
-
//
|
|
390
|
-
// Catch-all — r.* calls the visitor doesn't recognise. Designer renders
|
|
391
|
-
// "unknown call (cannot edit)", AI patcher leaves them unchanged. When
|
|
392
|
-
// an UnknownPattern shows up in the wild it's a signal that a new r.*
|
|
393
|
-
// API exists and needs its own pattern type here.
|
|
394
|
-
// =============================================================================
|
|
395
|
-
|
|
396
|
-
// r.envSchema(z.object({...})) — the env-vars contract for a feature.
|
|
550
|
+
// `r.envSchema(z.object({...}))` — the env-vars contract for a feature.
|
|
397
551
|
// Argument is a Zod-expression (computed); we keep the source-location of
|
|
398
552
|
// the schema body so Designer / AI render the raw TS code (opaque).
|
|
399
553
|
export type EnvSchemaPattern = {
|
|
@@ -402,6 +556,10 @@ export type EnvSchemaPattern = {
|
|
|
402
556
|
readonly schemaBody: SourceLocation;
|
|
403
557
|
};
|
|
404
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.
|
|
405
563
|
export type UnknownPattern = {
|
|
406
564
|
readonly kind: "unknown";
|
|
407
565
|
readonly source: SourceLocation;
|
package/src/engine/registry.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
},
|
|
@@ -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 ---
|
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
ConfigKeyHandle,
|
|
13
13
|
ConfigKeyType,
|
|
14
14
|
ConfigSeedDef,
|
|
15
|
+
ExtensionSelectorDef,
|
|
15
16
|
JobDefinition,
|
|
16
17
|
JobHandlerFn,
|
|
17
18
|
NotificationDataFn,
|
|
@@ -108,6 +109,10 @@ export type SecretKeyDefinition = {
|
|
|
108
109
|
readonly hint?: { readonly [locale: string]: string };
|
|
109
110
|
// Per-secret scope. v1 only "tenant" — user / system scopes ship in v2.
|
|
110
111
|
readonly scope: "tenant";
|
|
112
|
+
// Tenant must set this secret before the owning feature works. Surfaced
|
|
113
|
+
// by readiness:query:status; keep in sync with the missing-secret throw
|
|
114
|
+
// in the feature's build-fn.
|
|
115
|
+
readonly required?: boolean;
|
|
111
116
|
};
|
|
112
117
|
|
|
113
118
|
export type SecretOptions = Omit<SecretKeyDefinition, "shortName" | "qualifiedName">;
|
|
@@ -210,6 +215,7 @@ export type FeatureDefinition = {
|
|
|
210
215
|
readonly jobs: Readonly<Record<string, JobDefinition>>;
|
|
211
216
|
readonly registrarExtensions: Readonly<Record<string, RegistrarExtensionDef>>;
|
|
212
217
|
readonly extensionUsages: readonly RegistrarExtensionRegistration[];
|
|
218
|
+
readonly extensionSelectors: readonly ExtensionSelectorDef[];
|
|
213
219
|
/**
|
|
214
220
|
* Cross-feature API names this feature exposes via `r.exposesApi(name)`.
|
|
215
221
|
* Pure Marker-Deklaration — die echte Implementation wird als
|
|
@@ -346,7 +352,7 @@ export type FeatureRegistrar<TFeature extends string = string> = {
|
|
|
346
352
|
// Declare the feature as operator-togglable. `default` is the effective
|
|
347
353
|
// state when no global-toggle row exists. Must be called at most once per
|
|
348
354
|
// feature; calling on an always-on feature (e.g. auth/tenant/user) is a
|
|
349
|
-
// bug
|
|
355
|
+
// bug — and one nothing catches at boot, so don't.
|
|
350
356
|
toggleable(options: { default: boolean }): void;
|
|
351
357
|
|
|
352
358
|
entity(name: string, definition: EntityDefinition): EntityRef;
|
|
@@ -480,6 +486,17 @@ export type FeatureRegistrar<TFeature extends string = string> = {
|
|
|
480
486
|
|
|
481
487
|
useExtension(extensionName: string, entity: NameOrRef, options?: Record<string, unknown>): void;
|
|
482
488
|
|
|
489
|
+
/**
|
|
490
|
+
* Declares which config key selects the active provider under an
|
|
491
|
+
* extension point — called by the point-owning foundation (e.g.
|
|
492
|
+
* `r.extensionSelector("mailTransport", configKeys.provider)`).
|
|
493
|
+
* Readiness gating counts a provider-feature's `required` keys and
|
|
494
|
+
* secrets only while that provider is the selected one. Registry-build
|
|
495
|
+
* fails on duplicate declarations per extension and on selector keys
|
|
496
|
+
* that no mounted feature declares.
|
|
497
|
+
*/
|
|
498
|
+
extensionSelector(extensionName: string, key: { readonly name: string } | string): void;
|
|
499
|
+
|
|
483
500
|
/**
|
|
484
501
|
* Marker-Deklaration: dieses Feature stellt eine Cross-Feature-API
|
|
485
502
|
* unter dem genannten Namen bereit. Die eigentliche Implementation
|
|
@@ -555,8 +572,9 @@ export type FeatureRegistrar<TFeature extends string = string> = {
|
|
|
555
572
|
// rules are for).
|
|
556
573
|
authClaims(fn: AuthClaimsFn): void;
|
|
557
574
|
|
|
558
|
-
// Declare a claim key. Qualified name follows "<feature>:<
|
|
559
|
-
//
|
|
575
|
+
// Declare a claim key. Qualified name follows "<feature>:<shortName>" —
|
|
576
|
+
// NO kebab conversion (it would break the claim round-trip, unlike
|
|
577
|
+
// r.secret / r.config). Returns a
|
|
560
578
|
// typed handle so feature code can pass it to `readClaim(user, handle)`
|
|
561
579
|
// without retyping the qualified string and with the right narrowed
|
|
562
580
|
// return type.
|
|
@@ -593,7 +611,8 @@ export type FeatureRegistrar<TFeature extends string = string> = {
|
|
|
593
611
|
// Register an HTTP-route owned by this feature. The route is mounted
|
|
594
612
|
// outside the dispatcher pipeline (= außerhalb /api/write|query|batch),
|
|
595
613
|
// direkt an die app — Use-Case: RSS/Atom-Feeds, OG-Images, OpenAPI-Specs.
|
|
596
|
-
//
|
|
614
|
+
// Duplicate "method path"-Combinations are rejected per feature at setup
|
|
615
|
+
// time; there is no cross-feature check.
|
|
597
616
|
// Symmetric to queryHandler/writeHandler — Routes leben mit dem Feature,
|
|
598
617
|
// nicht im Bootstrap. Escape-hatch für nicht-feature-bound Routes
|
|
599
618
|
// bleibt runProdApp.extraRoutes.
|
|
@@ -788,6 +807,8 @@ export type Registry = {
|
|
|
788
807
|
>;
|
|
789
808
|
getExtension(name: string): RegistrarExtensionDef | undefined;
|
|
790
809
|
getExtensionUsages(extensionName: string): readonly RegistrarExtensionRegistration[];
|
|
810
|
+
// Extension point → selector config key, from r.extensionSelector calls.
|
|
811
|
+
getAllExtensionSelectors(): ReadonlyMap<string, string>;
|
|
791
812
|
getAllNotifications(): ReadonlyMap<string, NotificationDefinition>;
|
|
792
813
|
getAllReferenceData(): readonly ReferenceDataDef[];
|
|
793
814
|
// Look up projections by source-entity name. Empty list when no projection
|
package/src/errors/classes.ts
CHANGED
|
@@ -176,7 +176,9 @@ export type UnprocessableOpts = Pick<ErrorOpts, "i18nKey" | "i18nParams" | "caus
|
|
|
176
176
|
};
|
|
177
177
|
|
|
178
178
|
export class UnprocessableError extends KumikoError {
|
|
179
|
-
|
|
179
|
+
// Widened to `string` so subclasses (UnconfiguredError) can refine the
|
|
180
|
+
// value — same pattern as ConflictError above.
|
|
181
|
+
readonly code: string = "unprocessable";
|
|
180
182
|
readonly httpStatus = 422;
|
|
181
183
|
|
|
182
184
|
constructor(reason: string, opts?: UnprocessableOpts) {
|
|
@@ -190,6 +192,32 @@ export class UnprocessableError extends KumikoError {
|
|
|
190
192
|
}
|
|
191
193
|
}
|
|
192
194
|
|
|
195
|
+
// A required tenant-config key has no usable value yet. Same 422 as the
|
|
196
|
+
// parent, but a distinct code so clients can route the user to the settings
|
|
197
|
+
// screen instead of showing a generic business-rule error.
|
|
198
|
+
export type UnconfiguredDetails = {
|
|
199
|
+
readonly feature: string;
|
|
200
|
+
readonly key: string;
|
|
201
|
+
readonly hint?: string;
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
export class UnconfiguredError extends UnprocessableError {
|
|
205
|
+
override readonly code: string = "unconfigured";
|
|
206
|
+
|
|
207
|
+
constructor(details: UnconfiguredDetails, opts?: Pick<ErrorOpts, "i18nKey" | "cause">) {
|
|
208
|
+
super(
|
|
209
|
+
`${details.feature}: '${details.key}' is empty — tenant must configure it before use.${
|
|
210
|
+
details.hint ? ` ${details.hint}` : ""
|
|
211
|
+
}`,
|
|
212
|
+
{
|
|
213
|
+
i18nKey: opts?.i18nKey ?? "errors.unconfigured",
|
|
214
|
+
details,
|
|
215
|
+
...(opts?.cause && { cause: opts.cause }),
|
|
216
|
+
},
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
193
221
|
// Auto-wrap target for unexpected throws. Never exposes .details to the client
|
|
194
222
|
// — the serializer drops it. Stack + cause live in the log only.
|
|
195
223
|
export class InternalError extends KumikoError {
|