@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.
- package/package.json +1 -1
- package/src/api/auth-routes.ts +6 -0
- package/src/api/server.ts +7 -1
- 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/collect-table-metas.ts +81 -0
- package/src/db/feature-table-sources.ts +35 -0
- package/src/db/index.ts +5 -0
- package/src/engine/__tests__/engine.test.ts +29 -0
- package/src/engine/__tests__/registry.test.ts +75 -0
- package/src/engine/config-helpers.ts +2 -0
- package/src/engine/define-feature.ts +28 -0
- package/src/engine/feature-ast/extractors/index.ts +1 -0
- package/src/engine/feature-ast/extractors/round1.ts +22 -0
- package/src/engine/feature-ast/parse.ts +3 -0
- package/src/engine/feature-ast/patch.ts +3 -0
- package/src/engine/feature-ast/patcher.ts +5 -0
- package/src/engine/feature-ast/patterns.ts +183 -17
- package/src/engine/feature-ast/render.ts +7 -0
- package/src/engine/pattern-library/__tests__/library.test.ts +3 -0
- package/src/engine/pattern-library/library.ts +19 -0
- package/src/engine/registry.ts +37 -1
- package/src/engine/system-user.ts +10 -2
- package/src/engine/types/config.ts +16 -0
- package/src/engine/types/feature.ts +31 -4
- package/src/engine/types/index.ts +1 -0
- package/src/entrypoint/index.ts +8 -3
- 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/files/feature.ts +3 -0
- package/src/secrets/types.ts +3 -0
- 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({ ... }) —
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
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) —
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
//
|
|
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,
|
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
|
},
|
|
@@ -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
|
-
|
|
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 ---
|