@cosmicdrift/kumiko-framework 0.146.4 → 0.147.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 +6 -2
- package/src/api/auth-routes.ts +32 -67
- package/src/api/routes.ts +1 -3
- package/src/bun-db/__tests__/PATTERN.md +0 -1
- package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +29 -3
- package/src/db/__tests__/schema-inspection.test.ts +7 -0
- package/src/db/event-store-executor-context.ts +398 -0
- package/src/db/event-store-executor-read.ts +276 -0
- package/src/db/event-store-executor-write.ts +615 -0
- package/src/db/event-store-executor.ts +29 -1166
- package/src/db/queries/shadow-swap.ts +3 -1
- package/src/db/schema-inspection.ts +1 -1
- package/src/engine/__tests__/boot-validator-dashboard.test.ts +10 -0
- package/src/engine/__tests__/engine.test.ts +45 -1
- package/src/engine/__tests__/event-migration-declarative.test.ts +6 -0
- package/src/engine/__tests__/membership-roles.test.ts +4 -10
- package/src/engine/__tests__/screen.test.ts +26 -0
- package/src/engine/boot-validator/entity-list-screens.ts +1 -1
- package/src/engine/boot-validator/gdpr-storage.ts +1 -1
- package/src/engine/boot-validator/index.ts +12 -8
- package/src/engine/boot-validator/nav.ts +120 -0
- package/src/engine/boot-validator/{screens-nav.ts → screens.ts} +12 -190
- package/src/engine/boot-validator/workspaces.ts +68 -0
- package/src/engine/define-feature.ts +77 -1011
- package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/feature.ts +9 -0
- package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/screens.ts +4 -0
- package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +18 -8
- package/src/engine/feature-ast/__tests__/parse.test.ts +291 -2
- package/src/engine/feature-ast/__tests__/patcher.test.ts +1 -1
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +73 -0
- package/src/engine/feature-ast/extractors/round1.ts +3 -3
- package/src/engine/feature-ast/extractors/round4.ts +45 -10
- package/src/engine/feature-ast/extractors/shared.ts +64 -6
- package/src/engine/feature-ast/parse.ts +123 -10
- package/src/engine/feature-ast/patterns.ts +14 -7
- package/src/engine/feature-ast/render.ts +28 -7
- package/src/engine/feature-builder-state.ts +165 -0
- package/src/engine/feature-config-events-jobs.ts +303 -0
- package/src/engine/feature-entity-handlers.ts +161 -0
- package/src/engine/feature-ui-extensions.ts +413 -0
- package/src/engine/index.ts +0 -2
- package/src/engine/pattern-library/library.ts +44 -1131
- package/src/engine/pattern-library/mixed-schemas.ts +484 -0
- package/src/engine/pattern-library/opaque-schemas.ts +124 -0
- package/src/engine/pattern-library/shared-fields.ts +80 -0
- package/src/engine/pattern-library/static-schemas.ts +456 -0
- package/src/engine/registry-facade.ts +349 -0
- package/src/engine/registry-ingest.ts +473 -0
- package/src/engine/registry-state.ts +388 -0
- package/src/engine/registry-validate.ts +660 -0
- package/src/engine/registry.ts +79 -1695
- package/src/engine/types/screen.ts +4 -2
- package/src/engine/types/tree-node.ts +2 -5
- package/src/event-store/snapshot.ts +7 -7
- package/src/i18n/required-surface-keys.ts +2 -0
- package/src/jobs/job-runner.ts +1 -1
- package/src/observability/standard-metrics.ts +4 -3
- package/src/pipeline/dispatch-batch.ts +3 -3
- package/src/pipeline/dispatch-shared.ts +17 -10
- package/src/pipeline/event-dispatcher-admin.ts +289 -0
- package/src/pipeline/event-dispatcher-delivery.ts +264 -0
- package/src/pipeline/event-dispatcher.ts +28 -540
- package/src/pipeline/projection-rebuild.ts +1 -1
- package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +95 -0
- package/src/stack/test-stack.ts +46 -1
- package/src/testing/boot-validator-fixture.ts +1 -2
- package/src/engine/__tests__/deep-link.test.ts +0 -35
- package/src/engine/deep-link.ts +0 -23
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
import { resolveTableName } from "../db/entity-table-meta";
|
|
2
|
+
import { buildMetricName, validateMetricName } from "../observability";
|
|
3
|
+
import type { RegistryState } from "./registry-state";
|
|
4
|
+
import { mergeHookList, mergeHookListQualified, qualify } from "./registry-state";
|
|
5
|
+
import type { FeatureDefinition } from "./types";
|
|
6
|
+
|
|
7
|
+
// Feature registration + entities (globally-unique, physical-table-checked) + relations
|
|
8
|
+
// (additive per entity, duplicate-per-name guarded).
|
|
9
|
+
export function populateFeatureCore(state: RegistryState, feature: FeatureDefinition): void {
|
|
10
|
+
if (state.featureMap.has(feature.name)) {
|
|
11
|
+
throw new Error(`Duplicate feature: "${feature.name}"`);
|
|
12
|
+
}
|
|
13
|
+
state.featureMap.set(feature.name, feature);
|
|
14
|
+
|
|
15
|
+
// Entities: NOT prefixed — entity names must be globally unique
|
|
16
|
+
for (const [name, entity] of Object.entries(feature.entities ?? {})) {
|
|
17
|
+
if (state.entityMap.has(name)) {
|
|
18
|
+
throw new Error(`Duplicate entity: "${name}" (registered by multiple features)`);
|
|
19
|
+
}
|
|
20
|
+
state.entityMap.set(name, entity);
|
|
21
|
+
const physical = resolveTableName(name, entity, feature.name);
|
|
22
|
+
const clash = state.physicalTableOwners.get(physical);
|
|
23
|
+
if (clash?.kind === "unmanaged") {
|
|
24
|
+
throw new Error(
|
|
25
|
+
`Entity "${name}" (feature "${feature.name}") has physical table "${physical}" which ` +
|
|
26
|
+
`collides with r.unmanagedTable("${physical}") (feature "${clash.featureName}"). ` +
|
|
27
|
+
`Pick a different tableName — both would emit CREATE TABLE "${physical}".`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
// Entity-vs-entity ist genauso fatal: zwei Entities mit explizitem,
|
|
31
|
+
// identischem tableName überschrieben sich hier vorher still —
|
|
32
|
+
// doppeltes CREATE TABLE bzw. eine Projektion frisst die andere.
|
|
33
|
+
if (clash?.kind === "entity") {
|
|
34
|
+
throw new Error(
|
|
35
|
+
`Entity "${name}" (feature "${feature.name}") has physical table "${physical}" which ` +
|
|
36
|
+
`collides with entity "${clash.owner}" (feature "${clash.featureName}"). ` +
|
|
37
|
+
`Pick a different tableName — both would project into "${physical}".`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
state.physicalTableOwners.set(physical, {
|
|
41
|
+
kind: "entity",
|
|
42
|
+
owner: name,
|
|
43
|
+
featureName: feature.name,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Relations: entityName (not prefixed)
|
|
48
|
+
for (const [entityName, rels] of Object.entries(feature.relations ?? {})) {
|
|
49
|
+
const existing = state.relationMap.get(entityName) ?? {};
|
|
50
|
+
for (const [relName, relDef] of Object.entries(rels)) {
|
|
51
|
+
if (existing[relName]) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`Duplicate relation: "${entityName}.${relName}" (registered by multiple features)`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
existing[relName] = relDef;
|
|
57
|
+
}
|
|
58
|
+
state.relationMap.set(entityName, existing);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Write + query handlers: qualified scope:type:name, duplicate-guarded.
|
|
63
|
+
export function populateHandlers(state: RegistryState, feature: FeatureDefinition): void {
|
|
64
|
+
// Write handlers: scope:write:name
|
|
65
|
+
for (const [name, handler] of Object.entries(feature.writeHandlers ?? {})) {
|
|
66
|
+
const qualified = qualify(feature.name, "write", name);
|
|
67
|
+
if (state.writeHandlerMap.has(qualified)) {
|
|
68
|
+
throw new Error(`Duplicate write handler: "${qualified}" (registered by multiple features)`);
|
|
69
|
+
}
|
|
70
|
+
state.writeHandlerMap.set(qualified, { ...handler, name: qualified });
|
|
71
|
+
state.handlerFeatureMap.set(qualified, feature.name);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Query handlers: scope:query:name
|
|
75
|
+
for (const [name, handler] of Object.entries(feature.queryHandlers ?? {})) {
|
|
76
|
+
const qualified = qualify(feature.name, "query", name);
|
|
77
|
+
if (state.queryHandlerMap.has(qualified)) {
|
|
78
|
+
throw new Error(`Duplicate query handler: "${qualified}" (registered by multiple features)`);
|
|
79
|
+
}
|
|
80
|
+
state.queryHandlerMap.set(qualified, { ...handler, name: qualified });
|
|
81
|
+
state.handlerFeatureMap.set(qualified, feature.name);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Config keys: scope:config:name, duplicate-guarded.
|
|
86
|
+
export function populateConfigKeys(state: RegistryState, feature: FeatureDefinition): void {
|
|
87
|
+
// Config keys: scope:config:name
|
|
88
|
+
for (const [key, keyDef] of Object.entries(feature.configKeys ?? {})) {
|
|
89
|
+
const qualifiedKey = qualify(feature.name, "config", key);
|
|
90
|
+
if (state.configKeyMap.has(qualifiedKey)) {
|
|
91
|
+
throw new Error(`Duplicate config key: "${qualifiedKey}" (registered by multiple features)`);
|
|
92
|
+
}
|
|
93
|
+
state.configKeyMap.set(qualifiedKey, keyDef);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Jobs (runIn-pinned, duplicate-guarded) + notifications (trigger resolved later).
|
|
98
|
+
export function populateJobsAndNotifications(
|
|
99
|
+
state: RegistryState,
|
|
100
|
+
feature: FeatureDefinition,
|
|
101
|
+
): void {
|
|
102
|
+
// Jobs: scope:job:name
|
|
103
|
+
for (const [name, jobDef] of Object.entries(feature.jobs ?? {})) {
|
|
104
|
+
const qualifiedName = qualify(feature.name, "job", name);
|
|
105
|
+
if (state.jobMap.has(qualifiedName)) {
|
|
106
|
+
throw new Error(`Duplicate job: "${qualifiedName}" (registered by multiple features)`);
|
|
107
|
+
}
|
|
108
|
+
// runIn runtime-check. TS's JobRunIn = Exclude<RunIn, "both"> already
|
|
109
|
+
// rejects "both" at compile time, but dynamically-constructed jobs
|
|
110
|
+
// (serialized config, plugin authors using `as any`) could slip it
|
|
111
|
+
// past the type system. Fail loud — "both" for jobs would mean "fan
|
|
112
|
+
// out to both lane-queues", which over-delivers; the routing assumes
|
|
113
|
+
// exactly one target queue per dispatch.
|
|
114
|
+
// @cast-boundary schema-walk — defensive runtime-check against bypassed type-system
|
|
115
|
+
const runIn = (jobDef as { runIn?: unknown }).runIn;
|
|
116
|
+
if (runIn !== undefined && runIn !== "api" && runIn !== "worker") {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`Invalid runIn "${String(runIn)}" on job "${qualifiedName}" — jobs must be pinned to a single lane ("api" or "worker"). "both" is not allowed because BullMQ queues are lane-scoped.`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
state.jobMap.set(qualifiedName, { ...jobDef, name: qualifiedName });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Notifications: scope:notify:name
|
|
125
|
+
for (const [name, notifDef] of Object.entries(feature.notifications ?? {})) {
|
|
126
|
+
const qualifiedName = qualify(feature.name, "notify", name);
|
|
127
|
+
state.notificationMap.set(qualifiedName, {
|
|
128
|
+
...notifDef,
|
|
129
|
+
name: qualifiedName,
|
|
130
|
+
trigger: { on: notifDef.trigger.on },
|
|
131
|
+
});
|
|
132
|
+
state.notificationFeatureMap.set(qualifiedName, feature.name);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Events: scope:event:name. Upcaster chains stitched after full ingest (see validateEventUpcasters).
|
|
137
|
+
export function populateEvents(state: RegistryState, feature: FeatureDefinition): void {
|
|
138
|
+
// Events: scope:event:name. Migrations stay keyed by feature+short-name
|
|
139
|
+
// in the FeatureDefinition and get stitched into the state.eventUpcasterMap
|
|
140
|
+
// below (after ALL features are ingested) so cross-feature validation has
|
|
141
|
+
// the complete picture.
|
|
142
|
+
for (const [eventName, eventDef] of Object.entries(feature.events ?? {})) {
|
|
143
|
+
const qualified = qualify(feature.name, "event", eventName);
|
|
144
|
+
state.eventMap.set(qualified, { ...eventDef, name: qualified });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Translations prefixed with featureName: (i18next namespace convention).
|
|
149
|
+
export function populateTranslations(state: RegistryState, feature: FeatureDefinition): void {
|
|
150
|
+
// Translations prefixed with featureName: (i18next namespace convention)
|
|
151
|
+
for (const [key, value] of Object.entries(feature.translations ?? {})) {
|
|
152
|
+
state.mergedTranslations[`${feature.name}:${key}`] = value;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Lifecycle hooks (handler-targeted, qualified) + entity hooks (entity-targeted,
|
|
157
|
+
// unprefixed) + search-payload-extensions (additive per entity).
|
|
158
|
+
export function populateHooks(state: RegistryState, feature: FeatureDefinition): void {
|
|
159
|
+
// Lifecycle hooks: keyed by handler QN. featureName rides along on each
|
|
160
|
+
// hook entry — defineFeature sets it, the registry just appends.
|
|
161
|
+
// Save/delete hooks target write handlers, query hooks target query handlers.
|
|
162
|
+
mergeHookListQualified(state.preSaveHooks, feature.hooks?.preSave, feature.name, "write");
|
|
163
|
+
mergeHookListQualified(state.postSaveHooks, feature.hooks?.postSave, feature.name, "write");
|
|
164
|
+
mergeHookListQualified(state.preDeleteHooks, feature.hooks?.preDelete, feature.name, "write");
|
|
165
|
+
mergeHookListQualified(state.postDeleteHooks, feature.hooks?.postDelete, feature.name, "write");
|
|
166
|
+
mergeHookListQualified(state.preQueryHooks, feature.hooks?.preQuery, feature.name, "query");
|
|
167
|
+
mergeHookListQualified(state.postQueryHooks, feature.hooks?.postQuery, feature.name, "query");
|
|
168
|
+
|
|
169
|
+
// Entity hooks: NOT prefixed, keyed by entity name
|
|
170
|
+
mergeHookList(state.entityPostSaveHooks, feature.entityHooks?.postSave);
|
|
171
|
+
mergeHookList(state.entityPreDeleteHooks, feature.entityHooks?.preDelete);
|
|
172
|
+
mergeHookList(state.entityPostDeleteHooks, feature.entityHooks?.postDelete);
|
|
173
|
+
mergeHookList(state.entityPostQueryHooks, feature.entityHooks?.postQuery);
|
|
174
|
+
|
|
175
|
+
// F3 search-payload-extensions: per-entity contributors merged additively
|
|
176
|
+
for (const [entityName, contributors] of Object.entries(feature.searchPayloadExtensions ?? {})) {
|
|
177
|
+
const existing = state.searchPayloadExtensions.get(entityName) ?? [];
|
|
178
|
+
for (const c of contributors) existing.push(c);
|
|
179
|
+
state.searchPayloadExtensions.set(entityName, existing);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Registrar extension definitions + usages + selectors + reference-data + config-seeds.
|
|
184
|
+
export function populateExtensionsAndSeeds(state: RegistryState, feature: FeatureDefinition): void {
|
|
185
|
+
// Registrar extensions: collect definitions and usages
|
|
186
|
+
for (const [extName, extDef] of Object.entries(feature.registrarExtensions ?? {})) {
|
|
187
|
+
if (state.extensionMap.has(extName)) {
|
|
188
|
+
throw new Error(
|
|
189
|
+
`Duplicate registrar extension: "${extName}" (registered by multiple features)`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
state.extensionMap.set(extName, extDef);
|
|
193
|
+
}
|
|
194
|
+
// Annotate the owner so consumers (readiness gating) can map a
|
|
195
|
+
// registration back to the feature's config keys + secrets.
|
|
196
|
+
state.extensionUsages.push(
|
|
197
|
+
...(feature.extensionUsages ?? []).map((u) => ({ ...u, featureName: feature.name })),
|
|
198
|
+
);
|
|
199
|
+
for (const sel of feature.extensionSelectors ?? []) {
|
|
200
|
+
if (state.extensionSelectorMap.has(sel.extensionName)) {
|
|
201
|
+
throw new Error(
|
|
202
|
+
`Duplicate extension selector for "${sel.extensionName}" ` +
|
|
203
|
+
`(feature "${feature.name}") — one owning feature declares the selector.`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
state.extensionSelectorMap.set(sel.extensionName, sel.qualifiedKey);
|
|
207
|
+
}
|
|
208
|
+
state.allReferenceData.push(...(feature.referenceData ?? []));
|
|
209
|
+
state.allConfigSeeds.push(...(feature.configSeeds ?? []));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Metrics (name-validated, globally-unique) + secret keys (already qualified).
|
|
213
|
+
export function populateMetricsAndSecrets(state: RegistryState, feature: FeatureDefinition): void {
|
|
214
|
+
// Metrics: validate + qualify per feature. Collisions across features are
|
|
215
|
+
// rejected here — two features can't both register "created_total" under
|
|
216
|
+
// different shapes (labels/type) because the resulting fully qualified
|
|
217
|
+
// names differ, but same short+feature combo would already fail in
|
|
218
|
+
// defineFeature. This loop catches cross-feature/extension edge cases.
|
|
219
|
+
for (const [shortName, def] of Object.entries(feature.metrics ?? {})) {
|
|
220
|
+
const fullName = buildMetricName(feature.name, shortName);
|
|
221
|
+
validateMetricName(fullName, def.type);
|
|
222
|
+
if (state.metricMap.has(fullName)) {
|
|
223
|
+
throw new Error(
|
|
224
|
+
`[Kumiko Observability] Metric "${fullName}" registered multiple times ` +
|
|
225
|
+
`(Feature: ${feature.name}). Metric names must be globally unique.`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
state.metricMap.set(fullName, { ...def, featureName: feature.name });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Secret keys: already qualified during defineFeature (same "<feature>:<short>"
|
|
232
|
+
// convention used elsewhere). Reject cross-feature duplicates — extensions
|
|
233
|
+
// could theoretically register on another feature's namespace.
|
|
234
|
+
for (const def of Object.values(feature.secretKeys ?? {})) {
|
|
235
|
+
if (state.secretKeyMap.has(def.qualifiedName)) {
|
|
236
|
+
throw new Error(
|
|
237
|
+
`[Kumiko Secrets] Secret key "${def.qualifiedName}" registered multiple times. ` +
|
|
238
|
+
"Secret names must be globally unique across features.",
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
state.secretKeyMap.set(def.qualifiedName, def);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Explicit + multi-stream projections (source-entity indexed) + raw tables +
|
|
246
|
+
// unmanaged tables (both cross-feature-uniqueness-by-physical-name guarded).
|
|
247
|
+
export function populateProjectionsAndTables(
|
|
248
|
+
state: RegistryState,
|
|
249
|
+
feature: FeatureDefinition,
|
|
250
|
+
): void {
|
|
251
|
+
// Projections: qualified by feature name. Build the source-entity index so
|
|
252
|
+
// the event-store-executor can fetch matching projections in O(1) per write.
|
|
253
|
+
for (const [projName, projDef] of Object.entries(feature.projections ?? {})) {
|
|
254
|
+
const qualified = qualify(feature.name, "projection", projName);
|
|
255
|
+
if (state.projectionMap.has(qualified)) {
|
|
256
|
+
throw new Error(`Duplicate projection: "${qualified}" (registered by multiple features)`);
|
|
257
|
+
}
|
|
258
|
+
const stored = { ...projDef, name: qualified };
|
|
259
|
+
state.projectionMap.set(qualified, stored);
|
|
260
|
+
const sources = Array.isArray(projDef.source) ? projDef.source : [projDef.source];
|
|
261
|
+
for (const src of sources) {
|
|
262
|
+
const existing = state.projectionsBySource.get(src) ?? [];
|
|
263
|
+
existing.push(stored);
|
|
264
|
+
state.projectionsBySource.set(src, existing);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Multi-stream projections: qualified + stored for later wiring into
|
|
269
|
+
// event-dispatcher. Namespace is shared with single-stream projections —
|
|
270
|
+
// defineFeature already catches name collisions inside one feature, but
|
|
271
|
+
// we also guard the cross-feature case here.
|
|
272
|
+
for (const [mspName, mspDef] of Object.entries(feature.multiStreamProjections ?? {})) {
|
|
273
|
+
const qualified = qualify(feature.name, "projection", mspName);
|
|
274
|
+
if (state.projectionMap.has(qualified) || state.multiStreamProjectionMap.has(qualified)) {
|
|
275
|
+
throw new Error(`Duplicate projection: "${qualified}" (registered by multiple features)`);
|
|
276
|
+
}
|
|
277
|
+
// runIn runtime-check. TS's RunIn union already enforces the three
|
|
278
|
+
// values at compile time; this guards dynamically-constructed MSPs
|
|
279
|
+
// (config-driven, plugin authors) that could slip a typo through.
|
|
280
|
+
// @cast-boundary schema-walk — defensive runtime-check against bypassed type-system
|
|
281
|
+
const mspRunIn = (mspDef as { runIn?: unknown }).runIn;
|
|
282
|
+
if (
|
|
283
|
+
mspRunIn !== undefined &&
|
|
284
|
+
mspRunIn !== "api" &&
|
|
285
|
+
mspRunIn !== "worker" &&
|
|
286
|
+
mspRunIn !== "both"
|
|
287
|
+
) {
|
|
288
|
+
throw new Error(
|
|
289
|
+
`Invalid runIn "${String(mspRunIn)}" on MSP "${qualified}" — must be "api", "worker", or "both".`,
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
state.multiStreamProjectionMap.set(qualified, { ...mspDef, name: qualified });
|
|
293
|
+
state.multiStreamProjectionFeatureMap.set(qualified, feature.name);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Raw tables: aggregated by feature-local short name (unprefixed —
|
|
297
|
+
// these bypass the qualified-name namespace because they have no
|
|
298
|
+
// event-stream binding to disambiguate). Reject cross-feature
|
|
299
|
+
// duplicates at boot so the dev-server doesn't race two CREATE TABLE
|
|
300
|
+
// statements that target the same physical table name.
|
|
301
|
+
for (const [rawName, rawDef] of Object.entries(feature.rawTables ?? {})) {
|
|
302
|
+
const existing = state.rawTableMap.get(rawName);
|
|
303
|
+
if (existing) {
|
|
304
|
+
throw new Error(
|
|
305
|
+
`Raw-table "${rawName}" registered by both feature "${existing.featureName}" and ` +
|
|
306
|
+
`"${feature.name}". Pick a feature-prefixed name to disambiguate.`,
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
state.rawTableMap.set(rawName, { ...rawDef, featureName: feature.name });
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Unmanaged tables — same cross-feature uniqueness invariant as rawTables.
|
|
313
|
+
// Two features registering the same physical tableName would race two
|
|
314
|
+
// CREATE TABLE statements via migrate-runner.
|
|
315
|
+
for (const [umName, umDef] of Object.entries(feature.unmanagedTables ?? {})) {
|
|
316
|
+
const existing = state.unmanagedTableMap.get(umName);
|
|
317
|
+
if (existing) {
|
|
318
|
+
throw new Error(
|
|
319
|
+
`Unmanaged-table "${umName}" registered by both feature "${existing.featureName}" and ` +
|
|
320
|
+
`"${feature.name}". Pick a feature-prefixed tableName to disambiguate.`,
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
const physicalClash = state.physicalTableOwners.get(umName);
|
|
324
|
+
if (physicalClash?.kind === "entity") {
|
|
325
|
+
throw new Error(
|
|
326
|
+
`Unmanaged-table "${umName}" (feature "${feature.name}") collides with the physical ` +
|
|
327
|
+
`table of entity "${physicalClash.owner}" (feature "${physicalClash.featureName}"). ` +
|
|
328
|
+
`Pick a different tableName — both would emit CREATE TABLE "${umName}".`,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
const piiFields = umDef.meta.piiSubjectFields ?? [];
|
|
332
|
+
if (piiFields.length > 0 && !umDef.piiEncryptedOnWrite) {
|
|
333
|
+
throw new Error(
|
|
334
|
+
`Unmanaged-table "${umName}" (feature "${feature.name}") has PII-annotated fields ` +
|
|
335
|
+
`(${piiFields.join(", ")}) but direct writes bypass the executor's PII encryption. ` +
|
|
336
|
+
`Encrypt those fields before every insert/update (encryptPiiFieldValues) and declare ` +
|
|
337
|
+
`{ piiEncryptedOnWrite: true }, or drop the subject annotations.`,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
state.physicalTableOwners.set(umName, {
|
|
341
|
+
kind: "unmanaged",
|
|
342
|
+
owner: umName,
|
|
343
|
+
featureName: feature.name,
|
|
344
|
+
});
|
|
345
|
+
state.unmanagedTableMap.set(umName, { ...umDef, featureName: feature.name });
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Claim keys + auth-claims hooks (declaredShortNames threads the auto-prefix
|
|
350
|
+
// warning-set from claim-key declarations into the hooks registered right after —
|
|
351
|
+
// reordered next to each other; originally separated by the screens/nav/workspace
|
|
352
|
+
// block below, which has no dependency on either).
|
|
353
|
+
export function populateClaimsAndAuth(state: RegistryState, feature: FeatureDefinition): void {
|
|
354
|
+
// Claim keys: aggregated by qualified name. Two features cannot collide
|
|
355
|
+
// here (qualified by feature name), but we still guard for explicit
|
|
356
|
+
// correctness — the only way to hit this is a hand-built FeatureDefinition
|
|
357
|
+
// bypassing defineFeature's per-feature duplicate check.
|
|
358
|
+
const declaredShortNames = new Set<string>();
|
|
359
|
+
for (const def of Object.values(feature.claimKeys ?? {})) {
|
|
360
|
+
if (state.claimKeyMap.has(def.qualifiedName)) {
|
|
361
|
+
throw new Error(
|
|
362
|
+
`[Kumiko ClaimKeys] Claim key "${def.qualifiedName}" registered multiple times. ` +
|
|
363
|
+
"Claim short-names must be globally unique across features.",
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
state.claimKeyMap.set(def.qualifiedName, def);
|
|
367
|
+
declaredShortNames.add(def.shortName);
|
|
368
|
+
}
|
|
369
|
+
// Auth-claims hooks: order of registration is preserved. Feature name is
|
|
370
|
+
// captured alongside so the resolver can apply the auto-prefix at merge
|
|
371
|
+
// time — the feature author never ships pre-prefixed keys.
|
|
372
|
+
//
|
|
373
|
+
// If the feature declared ANY claim keys, every hook from that feature
|
|
374
|
+
// gets the declaredShortNames set attached. The resolver uses it to warn
|
|
375
|
+
// on undeclared inner-keys (typo / rename drift). Features that don't
|
|
376
|
+
// declare claimKeys skip the check entirely — it's opt-in.
|
|
377
|
+
const declaredKeys = declaredShortNames.size > 0 ? declaredShortNames : undefined;
|
|
378
|
+
for (const fn of feature.authClaimsHooks ?? []) {
|
|
379
|
+
state.authClaimsHooks.push({
|
|
380
|
+
featureName: feature.name,
|
|
381
|
+
fn,
|
|
382
|
+
...(declaredKeys && { declaredKeys }),
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Screens + nav (qualified, entity/parent indexed) + workspaces (nav membership
|
|
388
|
+
// pass 1 — pass 2 folds self-assigned nav entries in after full ingest, see
|
|
389
|
+
// finalizeWorkspaceNavMembership) + tree-actions (at-most-one per feature).
|
|
390
|
+
export function populateScreensNavWorkspaces(
|
|
391
|
+
state: RegistryState,
|
|
392
|
+
feature: FeatureDefinition,
|
|
393
|
+
): void {
|
|
394
|
+
// Screens: qualified + stored. Uniqueness per-feature is enforced in
|
|
395
|
+
// defineFeature; cross-feature collisions are impossible because the
|
|
396
|
+
// qualified name includes the feature-prefix. The separate state.featureMap
|
|
397
|
+
// entry lets the nav resolver pause screens owned by disabled features
|
|
398
|
+
// in O(1) without walking every screen.
|
|
399
|
+
for (const [screenId, screenDef] of Object.entries(feature.screens ?? {})) {
|
|
400
|
+
const qualified = qualify(feature.name, "screen", screenId);
|
|
401
|
+
// Stored version overwrites `id` with the qualified name so callers
|
|
402
|
+
// never need a reverse index (NavDef → qn) during tree-walking.
|
|
403
|
+
// Same pattern as state.writeHandlerMap/state.projectionMap/state.multiStreamProjectionMap
|
|
404
|
+
// (see `{ ...def, name: qualified }` above). Feature-side
|
|
405
|
+
// `feature.screens[shortId]` keeps the short id — only the registry
|
|
406
|
+
// surface flips.
|
|
407
|
+
const stored = { ...screenDef, id: qualified };
|
|
408
|
+
state.screenMap.set(qualified, stored);
|
|
409
|
+
state.screenFeatureMap.set(qualified, feature.name);
|
|
410
|
+
// entity-Index nur für Screens die direkt an einer Entity hängen.
|
|
411
|
+
// entityList/entityEdit haben `entity`; custom + actionForm haben
|
|
412
|
+
// keinen entity-Bezug (custom ist opaque, actionForm hat inline
|
|
413
|
+
// fields ohne Entity-Reference).
|
|
414
|
+
if (stored.type === "entityList" || stored.type === "entityEdit") {
|
|
415
|
+
const existing = state.screensByEntity.get(stored.entity) ?? [];
|
|
416
|
+
existing.push(stored);
|
|
417
|
+
state.screensByEntity.set(stored.entity, existing);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Nav entries: same qualification pattern as screens. The parent/screen
|
|
422
|
+
// refs are boot-validated below (after all features are ingested, so
|
|
423
|
+
// cross-feature parents can resolve). parent-index is built in the same
|
|
424
|
+
// loop because `parent` refers to a qualified name that doesn't need
|
|
425
|
+
// resolution — just string equality with whatever's in the target
|
|
426
|
+
// entry's QN.
|
|
427
|
+
for (const [navId, navDef] of Object.entries(feature.navs ?? {})) {
|
|
428
|
+
const qualified = qualify(feature.name, "nav", navId);
|
|
429
|
+
// See screens above — stored version carries the qualified id so
|
|
430
|
+
// resolveNavigation can recurse via getNavsByParent(child.id) without
|
|
431
|
+
// hand-building a reverse index.
|
|
432
|
+
const stored = { ...navDef, id: qualified };
|
|
433
|
+
state.navMap.set(qualified, stored);
|
|
434
|
+
state.navFeatureMap.set(qualified, feature.name);
|
|
435
|
+
if (stored.parent === undefined) {
|
|
436
|
+
state.topLevelNavs.push(stored);
|
|
437
|
+
} else {
|
|
438
|
+
const existing = state.navsByParent.get(stored.parent) ?? [];
|
|
439
|
+
existing.push(stored);
|
|
440
|
+
state.navsByParent.set(stored.parent, existing);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Workspaces: same qualification pattern as nav/screen. Step one stores
|
|
445
|
+
// the workspace itself + its explicit nav list; step two (after every
|
|
446
|
+
// feature has been ingested) folds nav-self-assigned QNs into the same
|
|
447
|
+
// member list. Doing it in two passes keeps cross-feature workspace
|
|
448
|
+
// refs valid — a nav entry can self-assign to a workspace whose feature
|
|
449
|
+
// hasn't been ingested yet.
|
|
450
|
+
for (const [wsId, wsDef] of Object.entries(feature.workspaces ?? {})) {
|
|
451
|
+
const qualified = qualify(feature.name, "workspace", wsId);
|
|
452
|
+
const stored = { ...wsDef, id: qualified };
|
|
453
|
+
state.workspaceMap.set(qualified, stored);
|
|
454
|
+
state.workspaceFeatureMap.set(qualified, feature.name);
|
|
455
|
+
// Seed the membership list with the workspace's explicit nav refs in
|
|
456
|
+
// declaration order. Boot-validator checks the QNs resolve.
|
|
457
|
+
state.navsByWorkspace.set(qualified, [...(stored.nav ?? [])]);
|
|
458
|
+
if (stored.default === true) {
|
|
459
|
+
// Boot-validator enforces uniqueness; here we just remember the
|
|
460
|
+
// first one and let validateBoot complain if there's a second.
|
|
461
|
+
if (state.defaultWorkspace === undefined) {
|
|
462
|
+
state.defaultWorkspace = stored;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Tree-Actions slot — at-most-one per feature (only-once-guard im
|
|
468
|
+
// registrar). Erased Map für Runtime-Lookup; compile-time-typed
|
|
469
|
+
// Surface läuft über FeatureDefinition.exports (TreeActionsHandle).
|
|
470
|
+
if (feature.treeActions !== undefined) {
|
|
471
|
+
state.treeActionsMap.set(feature.name, feature.treeActions);
|
|
472
|
+
}
|
|
473
|
+
}
|