@cosmicdrift/kumiko-framework 0.146.4 → 0.147.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
package/src/engine/registry.ts
CHANGED
|
@@ -1,1706 +1,90 @@
|
|
|
1
|
-
import { configureEventPiiCatalog } from "../crypto/event-pii";
|
|
2
|
-
import { applyEntityEvent } from "../db/apply-entity-event";
|
|
3
|
-
import {
|
|
4
|
-
assertBackingTableSuperset,
|
|
5
|
-
buildEntityTableMeta,
|
|
6
|
-
resolveTableName,
|
|
7
|
-
} from "../db/entity-table-meta";
|
|
8
|
-
import { asEntityTableMeta } from "../db/query";
|
|
9
|
-
import { buildEntityTable } from "../db/table-builder";
|
|
10
|
-
import { buildMetricName, validateMetricName } from "../observability";
|
|
11
1
|
import { validateExtensionPreSaveWiring } from "./boot-validator/entity-handler";
|
|
12
|
-
import {
|
|
2
|
+
import { buildRegistryFacade } from "./registry-facade";
|
|
13
3
|
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
Registry,
|
|
54
|
-
RelationDefinition,
|
|
55
|
-
ScreenDefinition,
|
|
56
|
-
SearchPayloadContributorFn,
|
|
57
|
-
SecretKeyDefinition,
|
|
58
|
-
TranslationKeys,
|
|
59
|
-
TreeActionDef,
|
|
60
|
-
UnmanagedTableDef,
|
|
61
|
-
WorkspaceDefinition,
|
|
62
|
-
WriteHandlerDef,
|
|
63
|
-
} from "./types";
|
|
64
|
-
import { HookPhases } from "./types";
|
|
65
|
-
import { resolveName } from "./types/handlers";
|
|
66
|
-
|
|
67
|
-
type IncomingRelation = {
|
|
68
|
-
sourceEntity: string;
|
|
69
|
-
relationName: string;
|
|
70
|
-
relation: RelationDefinition;
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
const IMPLICIT_PROJECTION_SUFFIX = "-entity" as const;
|
|
74
|
-
|
|
75
|
-
// Pro r.entity-Registration eine ImplicitProjection mit auto-generierten
|
|
76
|
-
// apply-Handlern für die 4 Auto-Verben. Live-Pfad geht durch
|
|
77
|
-
// EventStoreExecutor und schreibt direkt in die Tabelle; rebuildProjection
|
|
78
|
-
// nutzt diese Definition um aus Events zu replayen. Beide rufen dieselbe
|
|
79
|
-
// applyEntityEvent-Funktion → Live==Rebuild by-construction (verstärkt
|
|
80
|
-
// durch implicit-projection-equivalence.integration.ts).
|
|
81
|
-
function buildImplicitProjection(
|
|
82
|
-
featureName: string,
|
|
83
|
-
entityName: string,
|
|
84
|
-
entity: EntityDefinition,
|
|
85
|
-
qualify: typeof qualifyEntityName,
|
|
86
|
-
backingTable?: unknown,
|
|
87
|
-
extensions: readonly EntityProjectionExtension[] = [],
|
|
88
|
-
): ProjectionDefinition {
|
|
89
|
-
const name = qualify(featureName, "projection", `${entityName}${IMPLICIT_PROJECTION_SUFFIX}`);
|
|
90
|
-
// Backing table (r.entity(name, def, { table })) is the one physical table
|
|
91
|
-
// object shared by executor-writes, rebuild-replay, test-push and
|
|
92
|
-
// collectTableMetas — restoring the #255 invariant (test-push == generate).
|
|
93
|
-
// Validated as a superset of the field-derived columns so a field/table
|
|
94
|
-
// disagreement fails at boot, not as a silent thin-vs-rich row.
|
|
95
|
-
const drizzleTable =
|
|
96
|
-
backingTable !== undefined
|
|
97
|
-
? resolveBackingTable(entityName, entity, backingTable)
|
|
98
|
-
: buildEntityTable(entityName, entity);
|
|
99
|
-
// applyEntityEvent gibt ApplyResult zurück; SingleStreamApplyFn erwartet
|
|
100
|
-
// Promise<void>. Im rebuild-Pfad ist die Row irrelevant — wir discarden.
|
|
101
|
-
const handler = async (
|
|
102
|
-
event: Parameters<ProjectionDefinition["apply"][string]>[0],
|
|
103
|
-
tx: Parameters<ProjectionDefinition["apply"][string]>[1],
|
|
104
|
-
): Promise<void> => {
|
|
105
|
-
await applyEntityEvent(event, drizzleTable, entity, tx);
|
|
106
|
-
};
|
|
107
|
-
const apply: Record<string, ProjectionDefinition["apply"][string]> = {
|
|
108
|
-
[`${entityName}.created`]: handler,
|
|
109
|
-
[`${entityName}.updated`]: handler,
|
|
110
|
-
[`${entityName}.deleted`]: handler,
|
|
111
|
-
// forget/purge (Art. 17): hard-deletes the row even for softDelete entities.
|
|
112
|
-
// Registered for every entity so the erasure replays on rebuild.
|
|
113
|
-
[`${entityName}.forgotten`]: handler,
|
|
114
|
-
};
|
|
115
|
-
// Restore-Verb existiert nur für softDelete-Entities. Hard-Delete-
|
|
116
|
-
// Entities sollten keine restored-Events produzieren — würden sie es
|
|
117
|
-
// doch, würde applyEntityEvent intern als no-op laufen, aber wir
|
|
118
|
-
// registrieren den Handler gar nicht erst.
|
|
119
|
-
if (entity.softDelete) {
|
|
120
|
-
apply[`${entityName}.restored`] = handler;
|
|
121
|
-
}
|
|
122
|
-
// r.extendEntityProjection: merge extension applies into the rebuild
|
|
123
|
-
// replay. Collisions with lifecycle applies (or another extension) are
|
|
124
|
-
// authoring bugs — fail at boot, not by silently overwriting a handler.
|
|
125
|
-
const extraSources: string[] = [];
|
|
126
|
-
for (const ext of extensions) {
|
|
127
|
-
for (const [eventType, fn] of Object.entries(ext.apply)) {
|
|
128
|
-
if (apply[eventType]) {
|
|
129
|
-
throw new Error(
|
|
130
|
-
`Implicit projection "${name}": extendEntityProjection apply-key "${eventType}" ` +
|
|
131
|
-
`collides with an existing handler (entity lifecycle apply or another extension).`,
|
|
132
|
-
);
|
|
133
|
-
}
|
|
134
|
-
apply[eventType] = fn;
|
|
135
|
-
}
|
|
136
|
-
for (const s of ext.sources ?? []) {
|
|
137
|
-
if (s !== entityName && !extraSources.includes(s)) extraSources.push(s);
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
return {
|
|
141
|
-
name,
|
|
142
|
-
source: entityName,
|
|
143
|
-
...(extraSources.length > 0 && { extraSources }),
|
|
144
|
-
table: drizzleTable,
|
|
145
|
-
apply,
|
|
146
|
-
isImplicit: true,
|
|
147
|
-
};
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// Validates a r.entity backing table is a superset of the entity's field-
|
|
151
|
-
// derived columns, then hands it back as the projection table. The cast is a
|
|
152
|
-
// system-boundary reconstitution: the table is stored as `unknown` on
|
|
153
|
-
// FeatureDefinition only to keep drizzle out of the plain-data shape, and
|
|
154
|
-
// asEntityTableMeta confirms the kumiko-table shape at runtime.
|
|
155
|
-
function resolveBackingTable(
|
|
156
|
-
entityName: string,
|
|
157
|
-
entity: EntityDefinition,
|
|
158
|
-
backingTable: unknown,
|
|
159
|
-
): ProjectionDefinition["table"] {
|
|
160
|
-
const tableMeta = asEntityTableMeta(backingTable);
|
|
161
|
-
if (!tableMeta) {
|
|
162
|
-
throw new Error(
|
|
163
|
-
`r.entity("${entityName}", …, { table }): the backing table carries no ` +
|
|
164
|
-
"EntityTableMeta — build it via table() / buildEntityTable.",
|
|
165
|
-
);
|
|
166
|
-
}
|
|
167
|
-
assertBackingTableSuperset(entityName, buildEntityTableMeta(entityName, entity), tableMeta);
|
|
168
|
-
return backingTable as ProjectionDefinition["table"];
|
|
169
|
-
}
|
|
4
|
+
populateClaimsAndAuth,
|
|
5
|
+
populateConfigKeys,
|
|
6
|
+
populateEvents,
|
|
7
|
+
populateExtensionsAndSeeds,
|
|
8
|
+
populateFeatureCore,
|
|
9
|
+
populateHandlers,
|
|
10
|
+
populateHooks,
|
|
11
|
+
populateJobsAndNotifications,
|
|
12
|
+
populateMetricsAndSecrets,
|
|
13
|
+
populateProjectionsAndTables,
|
|
14
|
+
populateScreensNavWorkspaces,
|
|
15
|
+
populateTranslations,
|
|
16
|
+
} from "./registry-ingest";
|
|
17
|
+
import { createInitialState } from "./registry-state";
|
|
18
|
+
import {
|
|
19
|
+
applyExtensionUsages,
|
|
20
|
+
autoWireSoftDeleteJobs,
|
|
21
|
+
buildEventUpcasterChains,
|
|
22
|
+
buildImplicitProjections,
|
|
23
|
+
buildSearchableSortableCaches,
|
|
24
|
+
buildSearchIncludesAndIncomingRelations,
|
|
25
|
+
computeHasRateLimitedHandler,
|
|
26
|
+
finalizeWorkspaceNavMembership,
|
|
27
|
+
populateHandlerEntityMappings,
|
|
28
|
+
publishEventPiiCatalog,
|
|
29
|
+
resolveNotificationTriggersAndRegisterHooks,
|
|
30
|
+
validateEntityHookTargets,
|
|
31
|
+
validateEventMigrationVersions,
|
|
32
|
+
validateExtensionSelectors,
|
|
33
|
+
validateExtensionUsageTargets,
|
|
34
|
+
validateFieldAccessHandlersAreEntityMapped,
|
|
35
|
+
validateJobTriggers,
|
|
36
|
+
validateLifecycleHookTargets,
|
|
37
|
+
validateNoRawTableProjectionClash,
|
|
38
|
+
validateProjectionApplyKeys,
|
|
39
|
+
validateRelationTargetsExist,
|
|
40
|
+
validateRequiredFeatures,
|
|
41
|
+
} from "./registry-validate";
|
|
42
|
+
import type { FeatureDefinition, Registry } from "./types";
|
|
170
43
|
|
|
171
44
|
// This is where the magic happens. By "magic" I mean: precomputed maps.
|
|
172
45
|
// I build everything once at boot (hooks, relations, searchable fields, ...)
|
|
173
46
|
// so nothing has to iterate over objects at runtime. O(1) instead of O(n*m).
|
|
174
47
|
export function createRegistry(features: readonly FeatureDefinition[]): Registry {
|
|
175
|
-
const
|
|
176
|
-
const entityMap = new Map<string, EntityDefinition>();
|
|
177
|
-
const relationMap = new Map<string, Record<string, RelationDefinition>>();
|
|
178
|
-
const writeHandlerMap = new Map<string, WriteHandlerDef>();
|
|
179
|
-
const queryHandlerMap = new Map<string, QueryHandlerDef>();
|
|
180
|
-
// Hook storage. Every entry carries its owning feature (on the OwnedFn /
|
|
181
|
-
// PhasedHook shape), so the lifecycle pipeline can skip hooks whose
|
|
182
|
-
// feature is globally disabled without a parallel bookkeeping map.
|
|
183
|
-
// featureName === "*" = always fire (extension-provided invariants).
|
|
184
|
-
const preSaveHooks = new Map<string, OwnedFn<PreSaveHookFn>[]>();
|
|
185
|
-
const postSaveHooks = new Map<string, PhasedHook<PostSaveHookFn>[]>();
|
|
186
|
-
const preDeleteHooks = new Map<string, PhasedHook<PreDeleteHookFn>[]>();
|
|
187
|
-
const postDeleteHooks = new Map<string, PhasedHook<PostDeleteHookFn>[]>();
|
|
188
|
-
const preQueryHooks = new Map<string, OwnedFn<PreQueryHookFn>[]>();
|
|
189
|
-
const postQueryHooks = new Map<string, OwnedFn<PostQueryHookFn>[]>();
|
|
190
|
-
// Entity hooks — keyed by entity name, NOT prefixed
|
|
191
|
-
const entityPostSaveHooks = new Map<string, PhasedHook<PostSaveHookFn>[]>();
|
|
192
|
-
const entityPreDeleteHooks = new Map<string, PhasedHook<PreDeleteHookFn>[]>();
|
|
193
|
-
const entityPostDeleteHooks = new Map<string, PhasedHook<PostDeleteHookFn>[]>();
|
|
194
|
-
const entityPostQueryHooks = new Map<string, OwnedFn<PostQueryHookFn>[]>();
|
|
195
|
-
const searchPayloadExtensions = new Map<string, OwnedFn<SearchPayloadContributorFn>[]>();
|
|
196
|
-
const configKeyMap = new Map<string, ConfigKeyDefinition>();
|
|
197
|
-
const jobMap = new Map<string, JobDefinition>();
|
|
198
|
-
const notificationMap = new Map<string, NotificationDefinition>();
|
|
199
|
-
const notificationFeatureMap = new Map<string, string>(); // qualifiedName → featureName
|
|
200
|
-
const eventMap = new Map<string, EventDef>();
|
|
201
|
-
// Schema-migration chain per qualified event name. Built at boot after all
|
|
202
|
-
// features are ingested, then exposed via getEventUpcasters(). Readers of
|
|
203
|
-
// the events-table (projection rebuild, future aggregate loaders) walk the
|
|
204
|
-
// chain to upcast stored payloads to the current shape at read time.
|
|
205
|
-
const eventUpcasterMap = new Map<
|
|
206
|
-
string,
|
|
207
|
-
{ readonly currentVersion: number; readonly chain: ReadonlyMap<number, EventUpcastFn> }
|
|
208
|
-
>();
|
|
209
|
-
// Handler → entity mapping (populated from entities + handler name convention)
|
|
210
|
-
const handlerEntityMap = new Map<string, string>();
|
|
211
|
-
// Handler → feature mapping (for systemScope check)
|
|
212
|
-
const handlerFeatureMap = new Map<string, string>();
|
|
213
|
-
const extensionMap = new Map<string, RegistrarExtensionDef>();
|
|
214
|
-
const extensionUsages: RegistrarExtensionRegistration[] = [];
|
|
215
|
-
const extensionSelectorMap = new Map<string, string>();
|
|
216
|
-
const allReferenceData: ReferenceDataDef[] = [];
|
|
217
|
-
const allConfigSeeds: ConfigSeedDef[] = [];
|
|
218
|
-
const mergedTranslations: Record<string, Record<string, string>> = {};
|
|
219
|
-
// Metric registry — keyed by fully qualified name (kumiko_<feature>_<short>).
|
|
220
|
-
// Boot-time validation rejects bad names; dashboards then safely rely on shape.
|
|
221
|
-
const metricMap = new Map<string, FeatureMetricDef & { readonly featureName: string }>();
|
|
222
|
-
// Feature-declared secrets. Keyed by qualified name ("<feature>:<short>").
|
|
223
|
-
// The map is the source of truth for ops-UIs, the rotation job, and any
|
|
224
|
-
// boot validation that wants to reject a secrets.get for an unknown key.
|
|
225
|
-
const secretKeyMap = new Map<string, SecretKeyDefinition>();
|
|
226
|
-
// Projections — full list keyed by qualified name AND a source-entity index
|
|
227
|
-
// the executor consults on every write. Index is precomputed so the hot path
|
|
228
|
-
// does a single Map.get, never a scan.
|
|
229
|
-
const projectionMap = new Map<string, ProjectionDefinition>();
|
|
230
|
-
const projectionsBySource = new Map<string, ProjectionDefinition[]>();
|
|
231
|
-
// Multi-stream projections — cross-aggregate, async via event-dispatcher.
|
|
232
|
-
// One qualified name per MSP; each becomes its own EventConsumer with a
|
|
233
|
-
// dedicated cursor in kumiko_event_consumers.
|
|
234
|
-
const multiStreamProjectionMap = new Map<string, MultiStreamProjectionDefinition>();
|
|
235
|
-
// qualified-MSP-name → owning-feature name. Used by the event-dispatcher
|
|
236
|
-
// to pause consumers whose feature is globally disabled.
|
|
237
|
-
const multiStreamProjectionFeatureMap = new Map<string, string>();
|
|
238
|
-
// Raw tables — declared via r.rawTable(). Bypass the projection registry,
|
|
239
|
-
// so they have no qualified-name namespace and no source-entity index.
|
|
240
|
-
// Keyed by the feature-local short name; cross-feature uniqueness is
|
|
241
|
-
// enforced at ingest below (collisions would race two CREATE TABLE
|
|
242
|
-
// statements at the same physical name and break boot).
|
|
243
|
-
const rawTableMap = new Map<string, RawTableDef>();
|
|
244
|
-
// Unmanaged tables — declared via r.unmanagedTable() (EntityTableMeta).
|
|
245
|
-
// Cousin of rawTables: same uniqueness-by-tableName invariant, different
|
|
246
|
-
// storage shape (post-drizzle migrate-runner consumes EntityTableMeta).
|
|
247
|
-
const unmanagedTableMap = new Map<string, UnmanagedTableDef>();
|
|
248
|
-
// Final physical table names (entity-derived + unmanaged) → owner. Catches
|
|
249
|
-
// a collision between an r.unmanagedTable() tableName and an r.entity()
|
|
250
|
-
// physical name at boot instead of as a duplicate CREATE TABLE in migrate.
|
|
251
|
-
const physicalTableOwners = new Map<
|
|
252
|
-
string,
|
|
253
|
-
{ kind: "entity" | "unmanaged"; owner: string; featureName: string }
|
|
254
|
-
>();
|
|
255
|
-
// Auth-claims hooks — tagged with featureName so the login resolver can
|
|
256
|
-
// auto-prefix each hook's returned keys with "<feature>:".
|
|
257
|
-
const authClaimsHooks: AuthClaimsHookDef[] = [];
|
|
258
|
-
// Feature-declared claim keys. Keyed by qualified name ("<feature>:<short>").
|
|
259
|
-
// Used by readClaim callers to introspect; the resolver reads it via the
|
|
260
|
-
// declaredKeys set on each AuthClaimsHookDef (pre-built per feature below).
|
|
261
|
-
const claimKeyMap = new Map<string, ClaimKeyDefinition>();
|
|
262
|
-
// Screens — keyed by qualified name ("<feature>:screen:<id>"). One map for
|
|
263
|
-
// lookup + a parallel featureMap so the nav-resolver can gate screens by
|
|
264
|
-
// effective-features without scanning. `screensByEntity` pre-groups the
|
|
265
|
-
// entity-bound screens (entityList / entityEdit) by their entity name so
|
|
266
|
-
// ui-core's Schema-driven view-model builders don't need to scan
|
|
267
|
-
// getAllScreens() for every render.
|
|
268
|
-
const screenMap = new Map<string, ScreenDefinition>();
|
|
269
|
-
const screenFeatureMap = new Map<string, string>();
|
|
270
|
-
const screensByEntity = new Map<string, ScreenDefinition[]>();
|
|
271
|
-
// Nav entries — same shape as screenMap. Tree assembly happens in ui-core
|
|
272
|
-
// at render time; the engine just stores the flat list and its owners.
|
|
273
|
-
// `navsByParent` pre-groups children by their parent's QN so
|
|
274
|
-
// resolveNavigation does O(n) passes, not O(n²) parent-filters. Top-level
|
|
275
|
-
// entries (no parent) sit in the separate `topLevelNavs` list.
|
|
276
|
-
const navMap = new Map<string, NavDefinition>();
|
|
277
|
-
const navFeatureMap = new Map<string, string>();
|
|
278
|
-
const navsByParent = new Map<string, NavDefinition[]>();
|
|
279
|
-
const topLevelNavs: NavDefinition[] = [];
|
|
280
|
-
|
|
281
|
-
// Workspaces — stored verbatim, plus a parallel feature-owner map and a
|
|
282
|
-
// pre-computed nav-membership map. Membership merges two sources at boot:
|
|
283
|
-
// 1. r.workspace({ nav: [...] }) — explicit list on the workspace
|
|
284
|
-
// 2. r.nav({ workspaces: [...] }) — self-assignment on the nav entry
|
|
285
|
-
// Order matters for the switcher: workspace-declared QNs come first (in
|
|
286
|
-
// declaration order), then nav-self-assigned ones (in registration order).
|
|
287
|
-
// Duplicates are deduped — a nav entry listed in both shows up once.
|
|
288
|
-
const workspaceMap = new Map<string, WorkspaceDefinition>();
|
|
289
|
-
const workspaceFeatureMap = new Map<string, string>();
|
|
290
|
-
const navsByWorkspace = new Map<string, string[]>();
|
|
291
|
-
let defaultWorkspace: WorkspaceDefinition | undefined;
|
|
292
|
-
|
|
293
|
-
// Tree-Actions — keyed by declaring feature name (NOT qualified; ein
|
|
294
|
-
// Feature liefert genau eine erased Action-Map (compile-time-typed
|
|
295
|
-
// Variante geht über setup-export-handle, siehe
|
|
296
|
-
// FeatureRegistrar.treeActions docs).
|
|
297
|
-
const treeActionsMap = new Map<string, Readonly<Record<string, TreeActionDef>>>();
|
|
298
|
-
|
|
299
|
-
// Local alias for readability — `qualifyEntityName` is the shared helper
|
|
300
|
-
// from qualified-name.ts, also used by validateBoot to keep ingest and
|
|
301
|
-
// validation in lockstep on the qualification rule.
|
|
302
|
-
const qualify = qualifyEntityName;
|
|
303
|
-
|
|
304
|
-
// Filter hooks by phase and/or owning feature.
|
|
305
|
-
//
|
|
306
|
-
// - `phase === undefined` → any phase passes.
|
|
307
|
-
// - `effectiveFeatures === undefined` → ownership filter disabled.
|
|
308
|
-
// - hook.featureName === "*" or undefined → always passes ownership filter.
|
|
309
|
-
// "*" is reserved for extension-provided hooks that are invariant
|
|
310
|
-
// plumbing, not opt-in feature logic.
|
|
311
|
-
function filterByPhase<TFn>(
|
|
312
|
-
list: readonly PhasedHook<TFn>[] | undefined,
|
|
313
|
-
phase: HookPhase | undefined,
|
|
314
|
-
effectiveFeatures?: ReadonlySet<string>,
|
|
315
|
-
): readonly TFn[] {
|
|
316
|
-
if (!list || list.length === 0) return [];
|
|
317
|
-
const result: TFn[] = [];
|
|
318
|
-
for (const entry of list) {
|
|
319
|
-
if (phase !== undefined && entry.phase !== phase) continue;
|
|
320
|
-
if (!ownerEnabled(entry.featureName, effectiveFeatures)) continue;
|
|
321
|
-
result.push(entry.fn);
|
|
322
|
-
}
|
|
323
|
-
return result;
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
// Same ownership rule as filterByPhase, but for unphased hook lists
|
|
327
|
-
// (preSave, preQuery). Returns the raw fns ready for the lifecycle runner.
|
|
328
|
-
function filterOwned<TFn>(
|
|
329
|
-
list: readonly OwnedFn<TFn>[] | undefined,
|
|
330
|
-
effectiveFeatures?: ReadonlySet<string>,
|
|
331
|
-
): readonly TFn[] {
|
|
332
|
-
if (!list || list.length === 0) return [];
|
|
333
|
-
const result: TFn[] = [];
|
|
334
|
-
for (const entry of list) {
|
|
335
|
-
if (!ownerEnabled(entry.featureName, effectiveFeatures)) continue;
|
|
336
|
-
result.push(entry.fn);
|
|
337
|
-
}
|
|
338
|
-
return result;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
function ownerEnabled(
|
|
342
|
-
owner: string | undefined,
|
|
343
|
-
effectiveFeatures: ReadonlySet<string> | undefined,
|
|
344
|
-
): boolean {
|
|
345
|
-
if (!effectiveFeatures) return true;
|
|
346
|
-
if (owner === undefined || owner === "*") return true;
|
|
347
|
-
return effectiveFeatures.has(owner);
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
// Merge hooks without prefix (entity hooks). featureName is already on
|
|
351
|
-
// every hook entry (set by defineFeature), so there's no parallel
|
|
352
|
-
// bookkeeping — just append.
|
|
353
|
-
function mergeHookList<T>(
|
|
354
|
-
map: Map<string, T[]>,
|
|
355
|
-
source: Readonly<Record<string, readonly T[]>> | undefined,
|
|
356
|
-
): void {
|
|
357
|
-
// skip: optionaler entityHook-slot — features ohne postSave/preDelete/
|
|
358
|
-
// postDelete/postQuery lassen das slot undefined.
|
|
359
|
-
if (!source) return;
|
|
360
|
-
for (const [name, fns] of Object.entries(source)) {
|
|
361
|
-
const existing = map.get(name) ?? [];
|
|
362
|
-
existing.push(...fns);
|
|
363
|
-
map.set(name, existing);
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
// Merge hooks with feature prefix (handler hooks).
|
|
368
|
-
// Hook keys are handler QNs — hooks don't get their own QN, they're keyed by the handler they target.
|
|
369
|
-
// The hookQnType indicates whether the targeted handler is a write or query handler.
|
|
370
|
-
function mergeHookListQualified<T>(
|
|
371
|
-
map: Map<string, T[]>,
|
|
372
|
-
source: Readonly<Record<string, readonly T[]>> | undefined,
|
|
373
|
-
featureName: string,
|
|
374
|
-
hookQnType: QnType,
|
|
375
|
-
): void {
|
|
376
|
-
// skip: optionaler hook-slot — defineFeature materialisiert zwar alle
|
|
377
|
-
// Slots, aber hand-gebaute Definitionen an System-Grenzen (Fixtures,
|
|
378
|
-
// Partial-Boots, s. registry.test.ts) lassen sie weg. Leeres Record
|
|
379
|
-
// statt Object.entries(undefined)-Crash.
|
|
380
|
-
if (!source) return;
|
|
381
|
-
for (const [name, fns] of Object.entries(source)) {
|
|
382
|
-
const qualified = qualify(featureName, hookQnType, name);
|
|
383
|
-
const existing = map.get(qualified) ?? [];
|
|
384
|
-
existing.push(...fns);
|
|
385
|
-
map.set(qualified, existing);
|
|
386
|
-
}
|
|
387
|
-
}
|
|
48
|
+
const state = createInitialState();
|
|
388
49
|
|
|
389
50
|
for (const feature of features) {
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
if (clash?.kind === "entity") {
|
|
414
|
-
throw new Error(
|
|
415
|
-
`Entity "${name}" (feature "${feature.name}") has physical table "${physical}" which ` +
|
|
416
|
-
`collides with entity "${clash.owner}" (feature "${clash.featureName}"). ` +
|
|
417
|
-
`Pick a different tableName — both would project into "${physical}".`,
|
|
418
|
-
);
|
|
419
|
-
}
|
|
420
|
-
physicalTableOwners.set(physical, { kind: "entity", owner: name, featureName: feature.name });
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
// Relations: entityName (not prefixed)
|
|
424
|
-
for (const [entityName, rels] of Object.entries(feature.relations ?? {})) {
|
|
425
|
-
const existing = relationMap.get(entityName) ?? {};
|
|
426
|
-
for (const [relName, relDef] of Object.entries(rels)) {
|
|
427
|
-
if (existing[relName]) {
|
|
428
|
-
throw new Error(
|
|
429
|
-
`Duplicate relation: "${entityName}.${relName}" (registered by multiple features)`,
|
|
430
|
-
);
|
|
431
|
-
}
|
|
432
|
-
existing[relName] = relDef;
|
|
433
|
-
}
|
|
434
|
-
relationMap.set(entityName, existing);
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
// Write handlers: scope:write:name
|
|
438
|
-
for (const [name, handler] of Object.entries(feature.writeHandlers ?? {})) {
|
|
439
|
-
const qualified = qualify(feature.name, "write", name);
|
|
440
|
-
if (writeHandlerMap.has(qualified)) {
|
|
441
|
-
throw new Error(
|
|
442
|
-
`Duplicate write handler: "${qualified}" (registered by multiple features)`,
|
|
443
|
-
);
|
|
444
|
-
}
|
|
445
|
-
writeHandlerMap.set(qualified, { ...handler, name: qualified });
|
|
446
|
-
handlerFeatureMap.set(qualified, feature.name);
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
// Query handlers: scope:query:name
|
|
450
|
-
for (const [name, handler] of Object.entries(feature.queryHandlers ?? {})) {
|
|
451
|
-
const qualified = qualify(feature.name, "query", name);
|
|
452
|
-
if (queryHandlerMap.has(qualified)) {
|
|
453
|
-
throw new Error(
|
|
454
|
-
`Duplicate query handler: "${qualified}" (registered by multiple features)`,
|
|
455
|
-
);
|
|
456
|
-
}
|
|
457
|
-
queryHandlerMap.set(qualified, { ...handler, name: qualified });
|
|
458
|
-
handlerFeatureMap.set(qualified, feature.name);
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
// Config keys: scope:config:name
|
|
462
|
-
for (const [key, keyDef] of Object.entries(feature.configKeys ?? {})) {
|
|
463
|
-
const qualifiedKey = qualify(feature.name, "config", key);
|
|
464
|
-
if (configKeyMap.has(qualifiedKey)) {
|
|
465
|
-
throw new Error(
|
|
466
|
-
`Duplicate config key: "${qualifiedKey}" (registered by multiple features)`,
|
|
467
|
-
);
|
|
468
|
-
}
|
|
469
|
-
configKeyMap.set(qualifiedKey, keyDef);
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
// Jobs: scope:job:name
|
|
473
|
-
for (const [name, jobDef] of Object.entries(feature.jobs ?? {})) {
|
|
474
|
-
const qualifiedName = qualify(feature.name, "job", name);
|
|
475
|
-
if (jobMap.has(qualifiedName)) {
|
|
476
|
-
throw new Error(`Duplicate job: "${qualifiedName}" (registered by multiple features)`);
|
|
477
|
-
}
|
|
478
|
-
// runIn runtime-check. TS's JobRunIn = Exclude<RunIn, "both"> already
|
|
479
|
-
// rejects "both" at compile time, but dynamically-constructed jobs
|
|
480
|
-
// (serialized config, plugin authors using `as any`) could slip it
|
|
481
|
-
// past the type system. Fail loud — "both" for jobs would mean "fan
|
|
482
|
-
// out to both lane-queues", which over-delivers; the routing assumes
|
|
483
|
-
// exactly one target queue per dispatch.
|
|
484
|
-
// @cast-boundary schema-walk — defensive runtime-check against bypassed type-system
|
|
485
|
-
const runIn = (jobDef as { runIn?: unknown }).runIn;
|
|
486
|
-
if (runIn !== undefined && runIn !== "api" && runIn !== "worker") {
|
|
487
|
-
throw new Error(
|
|
488
|
-
`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.`,
|
|
489
|
-
);
|
|
490
|
-
}
|
|
491
|
-
jobMap.set(qualifiedName, { ...jobDef, name: qualifiedName });
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
// Notifications: scope:notify:name
|
|
495
|
-
for (const [name, notifDef] of Object.entries(feature.notifications ?? {})) {
|
|
496
|
-
const qualifiedName = qualify(feature.name, "notify", name);
|
|
497
|
-
notificationMap.set(qualifiedName, {
|
|
498
|
-
...notifDef,
|
|
499
|
-
name: qualifiedName,
|
|
500
|
-
trigger: { on: notifDef.trigger.on },
|
|
501
|
-
});
|
|
502
|
-
notificationFeatureMap.set(qualifiedName, feature.name);
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
// Events: scope:event:name. Migrations stay keyed by feature+short-name
|
|
506
|
-
// in the FeatureDefinition and get stitched into the eventUpcasterMap
|
|
507
|
-
// below (after ALL features are ingested) so cross-feature validation has
|
|
508
|
-
// the complete picture.
|
|
509
|
-
for (const [eventName, eventDef] of Object.entries(feature.events ?? {})) {
|
|
510
|
-
const qualified = qualify(feature.name, "event", eventName);
|
|
511
|
-
eventMap.set(qualified, { ...eventDef, name: qualified });
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
// Translations prefixed with featureName: (i18next namespace convention)
|
|
515
|
-
for (const [key, value] of Object.entries(feature.translations ?? {})) {
|
|
516
|
-
mergedTranslations[`${feature.name}:${key}`] = value;
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
// Lifecycle hooks: keyed by handler QN. featureName rides along on each
|
|
520
|
-
// hook entry — defineFeature sets it, the registry just appends.
|
|
521
|
-
// Save/delete hooks target write handlers, query hooks target query handlers.
|
|
522
|
-
mergeHookListQualified(preSaveHooks, feature.hooks?.preSave, feature.name, "write");
|
|
523
|
-
mergeHookListQualified(postSaveHooks, feature.hooks?.postSave, feature.name, "write");
|
|
524
|
-
mergeHookListQualified(preDeleteHooks, feature.hooks?.preDelete, feature.name, "write");
|
|
525
|
-
mergeHookListQualified(postDeleteHooks, feature.hooks?.postDelete, feature.name, "write");
|
|
526
|
-
mergeHookListQualified(preQueryHooks, feature.hooks?.preQuery, feature.name, "query");
|
|
527
|
-
mergeHookListQualified(postQueryHooks, feature.hooks?.postQuery, feature.name, "query");
|
|
528
|
-
|
|
529
|
-
// Entity hooks: NOT prefixed, keyed by entity name
|
|
530
|
-
mergeHookList(entityPostSaveHooks, feature.entityHooks?.postSave);
|
|
531
|
-
mergeHookList(entityPreDeleteHooks, feature.entityHooks?.preDelete);
|
|
532
|
-
mergeHookList(entityPostDeleteHooks, feature.entityHooks?.postDelete);
|
|
533
|
-
mergeHookList(entityPostQueryHooks, feature.entityHooks?.postQuery);
|
|
534
|
-
|
|
535
|
-
// F3 search-payload-extensions: per-entity contributors merged additively
|
|
536
|
-
for (const [entityName, contributors] of Object.entries(
|
|
537
|
-
feature.searchPayloadExtensions ?? {},
|
|
538
|
-
)) {
|
|
539
|
-
const existing = searchPayloadExtensions.get(entityName) ?? [];
|
|
540
|
-
for (const c of contributors) existing.push(c);
|
|
541
|
-
searchPayloadExtensions.set(entityName, existing);
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
// Registrar extensions: collect definitions and usages
|
|
545
|
-
for (const [extName, extDef] of Object.entries(feature.registrarExtensions ?? {})) {
|
|
546
|
-
if (extensionMap.has(extName)) {
|
|
547
|
-
throw new Error(
|
|
548
|
-
`Duplicate registrar extension: "${extName}" (registered by multiple features)`,
|
|
549
|
-
);
|
|
550
|
-
}
|
|
551
|
-
extensionMap.set(extName, extDef);
|
|
552
|
-
}
|
|
553
|
-
// Annotate the owner so consumers (readiness gating) can map a
|
|
554
|
-
// registration back to the feature's config keys + secrets.
|
|
555
|
-
extensionUsages.push(
|
|
556
|
-
...(feature.extensionUsages ?? []).map((u) => ({ ...u, featureName: feature.name })),
|
|
557
|
-
);
|
|
558
|
-
for (const sel of feature.extensionSelectors ?? []) {
|
|
559
|
-
if (extensionSelectorMap.has(sel.extensionName)) {
|
|
560
|
-
throw new Error(
|
|
561
|
-
`Duplicate extension selector for "${sel.extensionName}" ` +
|
|
562
|
-
`(feature "${feature.name}") — one owning feature declares the selector.`,
|
|
563
|
-
);
|
|
564
|
-
}
|
|
565
|
-
extensionSelectorMap.set(sel.extensionName, sel.qualifiedKey);
|
|
566
|
-
}
|
|
567
|
-
allReferenceData.push(...(feature.referenceData ?? []));
|
|
568
|
-
allConfigSeeds.push(...(feature.configSeeds ?? []));
|
|
569
|
-
|
|
570
|
-
// Metrics: validate + qualify per feature. Collisions across features are
|
|
571
|
-
// rejected here — two features can't both register "created_total" under
|
|
572
|
-
// different shapes (labels/type) because the resulting fully qualified
|
|
573
|
-
// names differ, but same short+feature combo would already fail in
|
|
574
|
-
// defineFeature. This loop catches cross-feature/extension edge cases.
|
|
575
|
-
for (const [shortName, def] of Object.entries(feature.metrics ?? {})) {
|
|
576
|
-
const fullName = buildMetricName(feature.name, shortName);
|
|
577
|
-
validateMetricName(fullName, def.type);
|
|
578
|
-
if (metricMap.has(fullName)) {
|
|
579
|
-
throw new Error(
|
|
580
|
-
`[Kumiko Observability] Metric "${fullName}" registered multiple times ` +
|
|
581
|
-
`(Feature: ${feature.name}). Metric names must be globally unique.`,
|
|
582
|
-
);
|
|
583
|
-
}
|
|
584
|
-
metricMap.set(fullName, { ...def, featureName: feature.name });
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
// Secret keys: already qualified during defineFeature (same "<feature>:<short>"
|
|
588
|
-
// convention used elsewhere). Reject cross-feature duplicates — extensions
|
|
589
|
-
// could theoretically register on another feature's namespace.
|
|
590
|
-
for (const def of Object.values(feature.secretKeys ?? {})) {
|
|
591
|
-
if (secretKeyMap.has(def.qualifiedName)) {
|
|
592
|
-
throw new Error(
|
|
593
|
-
`[Kumiko Secrets] Secret key "${def.qualifiedName}" registered multiple times. ` +
|
|
594
|
-
"Secret names must be globally unique across features.",
|
|
595
|
-
);
|
|
596
|
-
}
|
|
597
|
-
secretKeyMap.set(def.qualifiedName, def);
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
// Projections: qualified by feature name. Build the source-entity index so
|
|
601
|
-
// the event-store-executor can fetch matching projections in O(1) per write.
|
|
602
|
-
for (const [projName, projDef] of Object.entries(feature.projections ?? {})) {
|
|
603
|
-
const qualified = qualify(feature.name, "projection", projName);
|
|
604
|
-
if (projectionMap.has(qualified)) {
|
|
605
|
-
throw new Error(`Duplicate projection: "${qualified}" (registered by multiple features)`);
|
|
606
|
-
}
|
|
607
|
-
const stored = { ...projDef, name: qualified };
|
|
608
|
-
projectionMap.set(qualified, stored);
|
|
609
|
-
const sources = Array.isArray(projDef.source) ? projDef.source : [projDef.source];
|
|
610
|
-
for (const src of sources) {
|
|
611
|
-
const existing = projectionsBySource.get(src) ?? [];
|
|
612
|
-
existing.push(stored);
|
|
613
|
-
projectionsBySource.set(src, existing);
|
|
614
|
-
}
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
// Multi-stream projections: qualified + stored for later wiring into
|
|
618
|
-
// event-dispatcher. Namespace is shared with single-stream projections —
|
|
619
|
-
// defineFeature already catches name collisions inside one feature, but
|
|
620
|
-
// we also guard the cross-feature case here.
|
|
621
|
-
for (const [mspName, mspDef] of Object.entries(feature.multiStreamProjections ?? {})) {
|
|
622
|
-
const qualified = qualify(feature.name, "projection", mspName);
|
|
623
|
-
if (projectionMap.has(qualified) || multiStreamProjectionMap.has(qualified)) {
|
|
624
|
-
throw new Error(`Duplicate projection: "${qualified}" (registered by multiple features)`);
|
|
625
|
-
}
|
|
626
|
-
// runIn runtime-check. TS's RunIn union already enforces the three
|
|
627
|
-
// values at compile time; this guards dynamically-constructed MSPs
|
|
628
|
-
// (config-driven, plugin authors) that could slip a typo through.
|
|
629
|
-
// @cast-boundary schema-walk — defensive runtime-check against bypassed type-system
|
|
630
|
-
const mspRunIn = (mspDef as { runIn?: unknown }).runIn;
|
|
631
|
-
if (
|
|
632
|
-
mspRunIn !== undefined &&
|
|
633
|
-
mspRunIn !== "api" &&
|
|
634
|
-
mspRunIn !== "worker" &&
|
|
635
|
-
mspRunIn !== "both"
|
|
636
|
-
) {
|
|
637
|
-
throw new Error(
|
|
638
|
-
`Invalid runIn "${String(mspRunIn)}" on MSP "${qualified}" — must be "api", "worker", or "both".`,
|
|
639
|
-
);
|
|
640
|
-
}
|
|
641
|
-
multiStreamProjectionMap.set(qualified, { ...mspDef, name: qualified });
|
|
642
|
-
multiStreamProjectionFeatureMap.set(qualified, feature.name);
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
// Raw tables: aggregated by feature-local short name (unprefixed —
|
|
646
|
-
// these bypass the qualified-name namespace because they have no
|
|
647
|
-
// event-stream binding to disambiguate). Reject cross-feature
|
|
648
|
-
// duplicates at boot so the dev-server doesn't race two CREATE TABLE
|
|
649
|
-
// statements that target the same physical table name.
|
|
650
|
-
for (const [rawName, rawDef] of Object.entries(feature.rawTables ?? {})) {
|
|
651
|
-
const existing = rawTableMap.get(rawName);
|
|
652
|
-
if (existing) {
|
|
653
|
-
throw new Error(
|
|
654
|
-
`Raw-table "${rawName}" registered by both feature "${existing.featureName}" and ` +
|
|
655
|
-
`"${feature.name}". Pick a feature-prefixed name to disambiguate.`,
|
|
656
|
-
);
|
|
657
|
-
}
|
|
658
|
-
rawTableMap.set(rawName, { ...rawDef, featureName: feature.name });
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
// Unmanaged tables — same cross-feature uniqueness invariant as rawTables.
|
|
662
|
-
// Two features registering the same physical tableName would race two
|
|
663
|
-
// CREATE TABLE statements via migrate-runner.
|
|
664
|
-
for (const [umName, umDef] of Object.entries(feature.unmanagedTables ?? {})) {
|
|
665
|
-
const existing = unmanagedTableMap.get(umName);
|
|
666
|
-
if (existing) {
|
|
667
|
-
throw new Error(
|
|
668
|
-
`Unmanaged-table "${umName}" registered by both feature "${existing.featureName}" and ` +
|
|
669
|
-
`"${feature.name}". Pick a feature-prefixed tableName to disambiguate.`,
|
|
670
|
-
);
|
|
671
|
-
}
|
|
672
|
-
const physicalClash = physicalTableOwners.get(umName);
|
|
673
|
-
if (physicalClash?.kind === "entity") {
|
|
674
|
-
throw new Error(
|
|
675
|
-
`Unmanaged-table "${umName}" (feature "${feature.name}") collides with the physical ` +
|
|
676
|
-
`table of entity "${physicalClash.owner}" (feature "${physicalClash.featureName}"). ` +
|
|
677
|
-
`Pick a different tableName — both would emit CREATE TABLE "${umName}".`,
|
|
678
|
-
);
|
|
679
|
-
}
|
|
680
|
-
const piiFields = umDef.meta.piiSubjectFields ?? [];
|
|
681
|
-
if (piiFields.length > 0 && !umDef.piiEncryptedOnWrite) {
|
|
682
|
-
throw new Error(
|
|
683
|
-
`Unmanaged-table "${umName}" (feature "${feature.name}") has PII-annotated fields ` +
|
|
684
|
-
`(${piiFields.join(", ")}) but direct writes bypass the executor's PII encryption. ` +
|
|
685
|
-
`Encrypt those fields before every insert/update (encryptPiiFieldValues) and declare ` +
|
|
686
|
-
`{ piiEncryptedOnWrite: true }, or drop the subject annotations.`,
|
|
687
|
-
);
|
|
688
|
-
}
|
|
689
|
-
physicalTableOwners.set(umName, {
|
|
690
|
-
kind: "unmanaged",
|
|
691
|
-
owner: umName,
|
|
692
|
-
featureName: feature.name,
|
|
693
|
-
});
|
|
694
|
-
unmanagedTableMap.set(umName, { ...umDef, featureName: feature.name });
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
// Claim keys: aggregated by qualified name. Two features cannot collide
|
|
698
|
-
// here (qualified by feature name), but we still guard for explicit
|
|
699
|
-
// correctness — the only way to hit this is a hand-built FeatureDefinition
|
|
700
|
-
// bypassing defineFeature's per-feature duplicate check.
|
|
701
|
-
const declaredShortNames = new Set<string>();
|
|
702
|
-
for (const def of Object.values(feature.claimKeys ?? {})) {
|
|
703
|
-
if (claimKeyMap.has(def.qualifiedName)) {
|
|
704
|
-
throw new Error(
|
|
705
|
-
`[Kumiko ClaimKeys] Claim key "${def.qualifiedName}" registered multiple times. ` +
|
|
706
|
-
"Claim short-names must be globally unique across features.",
|
|
707
|
-
);
|
|
708
|
-
}
|
|
709
|
-
claimKeyMap.set(def.qualifiedName, def);
|
|
710
|
-
declaredShortNames.add(def.shortName);
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
// Screens: qualified + stored. Uniqueness per-feature is enforced in
|
|
714
|
-
// defineFeature; cross-feature collisions are impossible because the
|
|
715
|
-
// qualified name includes the feature-prefix. The separate featureMap
|
|
716
|
-
// entry lets the nav resolver pause screens owned by disabled features
|
|
717
|
-
// in O(1) without walking every screen.
|
|
718
|
-
for (const [screenId, screenDef] of Object.entries(feature.screens ?? {})) {
|
|
719
|
-
const qualified = qualify(feature.name, "screen", screenId);
|
|
720
|
-
// Stored version overwrites `id` with the qualified name so callers
|
|
721
|
-
// never need a reverse index (NavDef → qn) during tree-walking.
|
|
722
|
-
// Same pattern as writeHandlerMap/projectionMap/multiStreamProjectionMap
|
|
723
|
-
// (see `{ ...def, name: qualified }` above). Feature-side
|
|
724
|
-
// `feature.screens[shortId]` keeps the short id — only the registry
|
|
725
|
-
// surface flips.
|
|
726
|
-
const stored = { ...screenDef, id: qualified };
|
|
727
|
-
screenMap.set(qualified, stored);
|
|
728
|
-
screenFeatureMap.set(qualified, feature.name);
|
|
729
|
-
// entity-Index nur für Screens die direkt an einer Entity hängen.
|
|
730
|
-
// entityList/entityEdit haben `entity`; custom + actionForm haben
|
|
731
|
-
// keinen entity-Bezug (custom ist opaque, actionForm hat inline
|
|
732
|
-
// fields ohne Entity-Reference).
|
|
733
|
-
if (stored.type === "entityList" || stored.type === "entityEdit") {
|
|
734
|
-
const existing = screensByEntity.get(stored.entity) ?? [];
|
|
735
|
-
existing.push(stored);
|
|
736
|
-
screensByEntity.set(stored.entity, existing);
|
|
737
|
-
}
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
// Nav entries: same qualification pattern as screens. The parent/screen
|
|
741
|
-
// refs are boot-validated below (after all features are ingested, so
|
|
742
|
-
// cross-feature parents can resolve). parent-index is built in the same
|
|
743
|
-
// loop because `parent` refers to a qualified name that doesn't need
|
|
744
|
-
// resolution — just string equality with whatever's in the target
|
|
745
|
-
// entry's QN.
|
|
746
|
-
for (const [navId, navDef] of Object.entries(feature.navs ?? {})) {
|
|
747
|
-
const qualified = qualify(feature.name, "nav", navId);
|
|
748
|
-
// See screens above — stored version carries the qualified id so
|
|
749
|
-
// resolveNavigation can recurse via getNavsByParent(child.id) without
|
|
750
|
-
// hand-building a reverse index.
|
|
751
|
-
const stored = { ...navDef, id: qualified };
|
|
752
|
-
navMap.set(qualified, stored);
|
|
753
|
-
navFeatureMap.set(qualified, feature.name);
|
|
754
|
-
if (stored.parent === undefined) {
|
|
755
|
-
topLevelNavs.push(stored);
|
|
756
|
-
} else {
|
|
757
|
-
const existing = navsByParent.get(stored.parent) ?? [];
|
|
758
|
-
existing.push(stored);
|
|
759
|
-
navsByParent.set(stored.parent, existing);
|
|
760
|
-
}
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
// Workspaces: same qualification pattern as nav/screen. Step one stores
|
|
764
|
-
// the workspace itself + its explicit nav list; step two (after every
|
|
765
|
-
// feature has been ingested) folds nav-self-assigned QNs into the same
|
|
766
|
-
// member list. Doing it in two passes keeps cross-feature workspace
|
|
767
|
-
// refs valid — a nav entry can self-assign to a workspace whose feature
|
|
768
|
-
// hasn't been ingested yet.
|
|
769
|
-
for (const [wsId, wsDef] of Object.entries(feature.workspaces ?? {})) {
|
|
770
|
-
const qualified = qualify(feature.name, "workspace", wsId);
|
|
771
|
-
const stored = { ...wsDef, id: qualified };
|
|
772
|
-
workspaceMap.set(qualified, stored);
|
|
773
|
-
workspaceFeatureMap.set(qualified, feature.name);
|
|
774
|
-
// Seed the membership list with the workspace's explicit nav refs in
|
|
775
|
-
// declaration order. Boot-validator checks the QNs resolve.
|
|
776
|
-
navsByWorkspace.set(qualified, [...(stored.nav ?? [])]);
|
|
777
|
-
if (stored.default === true) {
|
|
778
|
-
// Boot-validator enforces uniqueness; here we just remember the
|
|
779
|
-
// first one and let validateBoot complain if there's a second.
|
|
780
|
-
if (defaultWorkspace === undefined) {
|
|
781
|
-
defaultWorkspace = stored;
|
|
782
|
-
}
|
|
783
|
-
}
|
|
784
|
-
}
|
|
785
|
-
|
|
786
|
-
// Tree-Actions slot — at-most-one per feature (only-once-guard im
|
|
787
|
-
// registrar). Erased Map für Runtime-Lookup; compile-time-typed
|
|
788
|
-
// Surface läuft über FeatureDefinition.exports (TreeActionsHandle).
|
|
789
|
-
if (feature.treeActions !== undefined) {
|
|
790
|
-
treeActionsMap.set(feature.name, feature.treeActions);
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
// Auth-claims hooks: order of registration is preserved. Feature name is
|
|
794
|
-
// captured alongside so the resolver can apply the auto-prefix at merge
|
|
795
|
-
// time — the feature author never ships pre-prefixed keys.
|
|
796
|
-
//
|
|
797
|
-
// If the feature declared ANY claim keys, every hook from that feature
|
|
798
|
-
// gets the declaredShortNames set attached. The resolver uses it to warn
|
|
799
|
-
// on undeclared inner-keys (typo / rename drift). Features that don't
|
|
800
|
-
// declare claimKeys skip the check entirely — it's opt-in.
|
|
801
|
-
const declaredKeys = declaredShortNames.size > 0 ? declaredShortNames : undefined;
|
|
802
|
-
for (const fn of feature.authClaimsHooks ?? []) {
|
|
803
|
-
authClaimsHooks.push({
|
|
804
|
-
featureName: feature.name,
|
|
805
|
-
fn,
|
|
806
|
-
...(declaredKeys && { declaredKeys }),
|
|
807
|
-
});
|
|
808
|
-
}
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
// Pass 2 for workspaces: fold any nav-self-assigned QNs into their
|
|
812
|
-
// workspace's member list. We can do this safely now that every feature
|
|
813
|
-
// (and therefore every workspace) is in workspaceMap. Cross-feature refs
|
|
814
|
-
// — a nav from feature A self-assigning to a workspace from feature B —
|
|
815
|
-
// resolve here because B's workspace was registered in pass 1 above.
|
|
816
|
-
// Dedup: a nav entry that's also in r.workspace({ nav: [...] }) shouldn't
|
|
817
|
-
// appear twice. Boot-validator catches dangling workspace ids.
|
|
818
|
-
for (const [navQn, navDef] of navMap) {
|
|
819
|
-
if (!navDef.workspaces || navDef.workspaces.length === 0) continue;
|
|
820
|
-
for (const wsQn of navDef.workspaces) {
|
|
821
|
-
const members = navsByWorkspace.get(wsQn);
|
|
822
|
-
if (members === undefined) continue; // dangling — boot-validator reports
|
|
823
|
-
if (!members.includes(navQn)) members.push(navQn);
|
|
824
|
-
}
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
// Build handler → entity mapping from feature declarations (filled by tryMapEntity
|
|
828
|
-
// in defineFeature via the "entityName:verb" colon convention).
|
|
829
|
-
// Must happen before extension processing since extension preSave hooks need entity mappings.
|
|
830
|
-
for (const feature of features) {
|
|
831
|
-
for (const [handlerName, entityName] of Object.entries(feature.handlerEntityMappings ?? {})) {
|
|
832
|
-
const writeQn = qualify(feature.name, "write", handlerName);
|
|
833
|
-
const queryQn = qualify(feature.name, "query", handlerName);
|
|
834
|
-
if (writeHandlerMap.has(writeQn)) {
|
|
835
|
-
handlerEntityMap.set(writeQn, entityName);
|
|
836
|
-
}
|
|
837
|
-
if (queryHandlerMap.has(queryQn)) {
|
|
838
|
-
handlerEntityMap.set(queryQn, entityName);
|
|
839
|
-
}
|
|
840
|
-
}
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
// Selector declarations point into the merged extension + config-key
|
|
844
|
-
// sets — a typo'd extension or dropped key must fail the boot, not
|
|
845
|
-
// silently un-gate readiness.
|
|
846
|
-
for (const [extensionName, qualifiedKey] of extensionSelectorMap) {
|
|
847
|
-
if (!extensionMap.has(extensionName)) {
|
|
848
|
-
throw new Error(
|
|
849
|
-
`extensionSelector("${extensionName}") declared but no feature ` +
|
|
850
|
-
`registers that extension via extendsRegistrar.`,
|
|
851
|
-
);
|
|
852
|
-
}
|
|
853
|
-
if (!configKeyMap.has(qualifiedKey)) {
|
|
854
|
-
throw new Error(
|
|
855
|
-
`extensionSelector("${extensionName}") points at unknown config key ` +
|
|
856
|
-
`"${qualifiedKey}" — no mounted feature declares it.`,
|
|
857
|
-
);
|
|
858
|
-
}
|
|
859
|
-
}
|
|
860
|
-
|
|
861
|
-
// Process extension usages: call onRegister, apply extendSchema, register hooks
|
|
862
|
-
for (const usage of extensionUsages) {
|
|
863
|
-
const ext = extensionMap.get(usage.extensionName);
|
|
864
|
-
if (!ext) continue;
|
|
865
|
-
|
|
866
|
-
if (ext.onRegister) {
|
|
867
|
-
ext.onRegister(usage.entityName, usage.options);
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
// extendSchema: merge extra fields into entity definition
|
|
871
|
-
if (ext.extendSchema) {
|
|
872
|
-
const entity = entityMap.get(usage.entityName);
|
|
873
|
-
if (entity) {
|
|
874
|
-
const extraFields = ext.extendSchema(usage.entityName);
|
|
875
|
-
const merged = { ...entity, fields: { ...entity.fields, ...extraFields } };
|
|
876
|
-
entityMap.set(usage.entityName, merged);
|
|
877
|
-
}
|
|
878
|
-
}
|
|
879
|
-
|
|
880
|
-
// Extension hooks → entity hooks (fire for all writes on the entity).
|
|
881
|
-
// Extensions default to afterCommit phase (same default as r.hook).
|
|
882
|
-
//
|
|
883
|
-
// Owner "*" = always-enabled, not gated by feature-toggles. Extensions
|
|
884
|
-
// are plumbing (e.g. ownership) — the feature that declared them might
|
|
885
|
-
// itself be toggleable, but the extension-hook is conceptually part of
|
|
886
|
-
// the entity's invariants. If future requirements need extension hooks
|
|
887
|
-
// to also be gated, store the registering-feature on
|
|
888
|
-
// RegistrarExtensionRegistration and use that here.
|
|
889
|
-
const extOwner = "*";
|
|
890
|
-
if (ext.hooks) {
|
|
891
|
-
if (ext.hooks.postSave) {
|
|
892
|
-
const existing = entityPostSaveHooks.get(usage.entityName) ?? [];
|
|
893
|
-
existing.push({
|
|
894
|
-
fn: ext.hooks.postSave,
|
|
895
|
-
phase: HookPhases.afterCommit,
|
|
896
|
-
featureName: extOwner,
|
|
897
|
-
});
|
|
898
|
-
entityPostSaveHooks.set(usage.entityName, existing);
|
|
899
|
-
}
|
|
900
|
-
if (ext.hooks.preDelete) {
|
|
901
|
-
const existing = entityPreDeleteHooks.get(usage.entityName) ?? [];
|
|
902
|
-
existing.push({
|
|
903
|
-
fn: ext.hooks.preDelete,
|
|
904
|
-
phase: HookPhases.afterCommit,
|
|
905
|
-
featureName: extOwner,
|
|
906
|
-
});
|
|
907
|
-
entityPreDeleteHooks.set(usage.entityName, existing);
|
|
908
|
-
}
|
|
909
|
-
if (ext.hooks.postDelete) {
|
|
910
|
-
const existing = entityPostDeleteHooks.get(usage.entityName) ?? [];
|
|
911
|
-
existing.push({
|
|
912
|
-
fn: ext.hooks.postDelete,
|
|
913
|
-
phase: HookPhases.afterCommit,
|
|
914
|
-
featureName: extOwner,
|
|
915
|
-
});
|
|
916
|
-
entityPostDeleteHooks.set(usage.entityName, existing);
|
|
917
|
-
}
|
|
918
|
-
// preSave on extensions: store as handler hook for all CRUD handlers of this entity
|
|
919
|
-
if (ext.hooks.preSave) {
|
|
920
|
-
// Find all write handlers that belong to this entity via handlerEntityMap
|
|
921
|
-
for (const qualifiedHandler of writeHandlerMap.keys()) {
|
|
922
|
-
if (handlerEntityMap.get(qualifiedHandler) === usage.entityName) {
|
|
923
|
-
const existing = preSaveHooks.get(qualifiedHandler) ?? [];
|
|
924
|
-
existing.push({ fn: ext.hooks.preSave, featureName: extOwner });
|
|
925
|
-
preSaveHooks.set(qualifiedHandler, existing);
|
|
926
|
-
}
|
|
927
|
-
}
|
|
928
|
-
}
|
|
929
|
-
}
|
|
930
|
-
}
|
|
931
|
-
|
|
932
|
-
// Precompute: searchable/sortable fields, search includes, incoming relations
|
|
933
|
-
const searchableFieldsCache = new Map<string, readonly string[]>();
|
|
934
|
-
const sortableFieldsCache = new Map<string, readonly string[]>();
|
|
935
|
-
const searchIncludesCache = new Map<string, ReadonlyMap<string, readonly string[]>>();
|
|
936
|
-
const incomingRelationsCache = new Map<string, IncomingRelation[]>();
|
|
937
|
-
|
|
938
|
-
for (const [name, entity] of entityMap) {
|
|
939
|
-
const searchable: string[] = [];
|
|
940
|
-
const sortable: string[] = [];
|
|
941
|
-
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
942
|
-
if (field.type === "text" && field.searchable === true) searchable.push(fieldName);
|
|
943
|
-
if (field.type === "text" && field.sortable === true) sortable.push(fieldName);
|
|
944
|
-
if (field.type === "embedded") {
|
|
945
|
-
for (const [subName, subField] of Object.entries(field.schema)) {
|
|
946
|
-
if (subField.searchable === true) searchable.push(`${fieldName}_${subName}`);
|
|
947
|
-
}
|
|
948
|
-
}
|
|
949
|
-
}
|
|
950
|
-
searchableFieldsCache.set(name, searchable);
|
|
951
|
-
sortableFieldsCache.set(name, sortable);
|
|
952
|
-
}
|
|
953
|
-
|
|
954
|
-
// Implicit-Projection pro r.entity. Macht die Entity-Tabelle rebaubar
|
|
955
|
-
// ohne dass Apps eine explizite r.projection schreiben müssen.
|
|
956
|
-
// Naming-Convention: `<feature>:projection:<entityName>-entity` — der
|
|
957
|
-
// "-entity"-Suffix unterscheidet implicit von explicit-Projections und
|
|
958
|
-
// vermeidet Kollisionen wenn jemand z.B. eine Cross-Aggregate-Projection
|
|
959
|
-
// mit Entity-Name registriert.
|
|
960
|
-
for (const feature of features) {
|
|
961
|
-
// extendEntityProjection targets must exist as r.entity in the SAME
|
|
962
|
-
// feature — a typo'd entity name would otherwise vanish silently and the
|
|
963
|
-
// extension's events would still be wiped on rebuild.
|
|
964
|
-
for (const extEntity of Object.keys(feature.entityProjectionExtensions ?? {})) {
|
|
965
|
-
if (!feature.entities?.[extEntity]) {
|
|
966
|
-
throw new Error(
|
|
967
|
-
`[Feature ${feature.name}] extendEntityProjection("${extEntity}"): no r.entity ` +
|
|
968
|
-
`with that name in this feature. Declare the entity first — the extension ` +
|
|
969
|
-
`merges into its implicit projection.`,
|
|
970
|
-
);
|
|
971
|
-
}
|
|
972
|
-
}
|
|
973
|
-
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
974
|
-
const def = buildImplicitProjection(
|
|
975
|
-
feature.name,
|
|
976
|
-
entityName,
|
|
977
|
-
entity,
|
|
978
|
-
qualify,
|
|
979
|
-
feature.entityTables?.[entityName],
|
|
980
|
-
feature.entityProjectionExtensions?.[entityName] ?? [],
|
|
981
|
-
);
|
|
982
|
-
if (projectionMap.has(def.name)) {
|
|
983
|
-
throw new Error(
|
|
984
|
-
`Implicit projection "${def.name}" kollidiert mit einer explizit registrierten r.projection. ` +
|
|
985
|
-
`Implicit-Projections werden für jede r.entity mit "-entity"-Suffix angelegt — ` +
|
|
986
|
-
`benenne deine explicit projection um (z.B. "<entity>-summary") um die Kollision aufzulösen.`,
|
|
987
|
-
);
|
|
988
|
-
}
|
|
989
|
-
projectionMap.set(def.name, def);
|
|
990
|
-
const existing = projectionsBySource.get(entityName) ?? [];
|
|
991
|
-
existing.push(def);
|
|
992
|
-
projectionsBySource.set(entityName, existing);
|
|
993
|
-
}
|
|
994
|
-
}
|
|
995
|
-
|
|
996
|
-
// Cross-cut: a r.rawTable() PgTable must not coincide with any
|
|
997
|
-
// registered projection's table. Silent dedupe via Set would mask a
|
|
998
|
-
// real authoring bug (two owners writing to the same physical table).
|
|
999
|
-
// Run after both passes so implicit projections are visible too.
|
|
1000
|
-
const projectionTables = new Set<unknown>();
|
|
1001
|
-
for (const proj of projectionMap.values()) projectionTables.add(proj.table);
|
|
1002
|
-
for (const msp of multiStreamProjectionMap.values()) {
|
|
1003
|
-
if (msp.table) projectionTables.add(msp.table);
|
|
1004
|
-
}
|
|
1005
|
-
for (const raw of rawTableMap.values()) {
|
|
1006
|
-
if (projectionTables.has(raw.table)) {
|
|
1007
|
-
throw new Error(
|
|
1008
|
-
`r.rawTable "${raw.name}" (feature "${raw.featureName}") shares a Drizzle ` +
|
|
1009
|
-
`PgTable with a registered projection. Pick one owner: r.entity() / ` +
|
|
1010
|
-
`r.projection() for event-sourced reads, r.rawTable() for the bypass.`,
|
|
1011
|
-
);
|
|
1012
|
-
}
|
|
1013
|
-
}
|
|
1014
|
-
|
|
1015
|
-
for (const [entityName, rels] of relationMap) {
|
|
1016
|
-
const includes = new Map<string, readonly string[]>();
|
|
1017
|
-
for (const [relName, rel] of Object.entries(rels)) {
|
|
1018
|
-
if ((rel.type === "belongsTo" || rel.type === "manyToMany") && rel.searchInclude?.length) {
|
|
1019
|
-
includes.set(relName, rel.searchInclude);
|
|
1020
|
-
}
|
|
1021
|
-
}
|
|
1022
|
-
searchIncludesCache.set(entityName, includes);
|
|
1023
|
-
|
|
1024
|
-
// Build reverse index for incoming relations
|
|
1025
|
-
for (const [relName, rel] of Object.entries(rels)) {
|
|
1026
|
-
const existing = incomingRelationsCache.get(rel.target) ?? [];
|
|
1027
|
-
existing.push({ sourceEntity: entityName, relationName: relName, relation: rel });
|
|
1028
|
-
incomingRelationsCache.set(rel.target, existing);
|
|
1029
|
-
}
|
|
1030
|
-
}
|
|
1031
|
-
|
|
1032
|
-
// Validate: handlers in features with field-access rules must be entity-mapped.
|
|
1033
|
-
// Without entity mapping, field-level access checks are silently skipped (security gap).
|
|
1034
|
-
for (const feature of features) {
|
|
1035
|
-
if (!hasFieldAccessRules(feature)) continue;
|
|
1036
|
-
|
|
1037
|
-
for (const handlerName of Object.keys(feature.writeHandlers ?? {})) {
|
|
1038
|
-
const qualified = qualify(feature.name, "write", handlerName);
|
|
1039
|
-
if (!handlerEntityMap.has(qualified)) {
|
|
1040
|
-
throw new Error(
|
|
1041
|
-
`Write handler "${qualified}" is not mapped to any entity, but feature "${feature.name}" has field-level access rules. ` +
|
|
1042
|
-
`Name must follow "entity:verb" convention (e.g. "user:create") or use create/update/delete on a matching entity.`,
|
|
1043
|
-
);
|
|
1044
|
-
}
|
|
1045
|
-
}
|
|
1046
|
-
|
|
1047
|
-
// Query handlers: only those with a colon must resolve (typo protection).
|
|
1048
|
-
for (const handlerName of Object.keys(feature.queryHandlers ?? {})) {
|
|
1049
|
-
if (!handlerName.includes(":")) continue;
|
|
1050
|
-
const qualified = qualify(feature.name, "query", handlerName);
|
|
1051
|
-
if (!handlerEntityMap.has(qualified)) {
|
|
1052
|
-
throw new Error(
|
|
1053
|
-
`Query handler "${qualified}" looks entity-bound but no matching entity exists. ` +
|
|
1054
|
-
`Either fix the entity name, or use a name without colons for standalone queries.`,
|
|
1055
|
-
);
|
|
1056
|
-
}
|
|
1057
|
-
}
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
|
-
// Extension preSave: useExtension on an entity must have at least one mapped
|
|
1061
|
-
// write handler. Shared with validateBoot's standalone callers (437/2) —
|
|
1062
|
-
// schema-cli's `validate` runs validateBoot on raw FEATURES without ever
|
|
1063
|
-
// building a Registry, so this can't live only here.
|
|
51
|
+
populateFeatureCore(state, feature);
|
|
52
|
+
populateHandlers(state, feature);
|
|
53
|
+
populateConfigKeys(state, feature);
|
|
54
|
+
populateJobsAndNotifications(state, feature);
|
|
55
|
+
populateEvents(state, feature);
|
|
56
|
+
populateTranslations(state, feature);
|
|
57
|
+
populateHooks(state, feature);
|
|
58
|
+
populateExtensionsAndSeeds(state, feature);
|
|
59
|
+
populateMetricsAndSecrets(state, feature);
|
|
60
|
+
populateProjectionsAndTables(state, feature);
|
|
61
|
+
populateClaimsAndAuth(state, feature);
|
|
62
|
+
populateScreensNavWorkspaces(state, feature);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
finalizeWorkspaceNavMembership(state);
|
|
66
|
+
populateHandlerEntityMappings(state, features);
|
|
67
|
+
validateExtensionSelectors(state);
|
|
68
|
+
applyExtensionUsages(state);
|
|
69
|
+
buildSearchableSortableCaches(state);
|
|
70
|
+
buildImplicitProjections(state, features);
|
|
71
|
+
validateNoRawTableProjectionClash(state);
|
|
72
|
+
buildSearchIncludesAndIncomingRelations(state);
|
|
73
|
+
validateFieldAccessHandlersAreEntityMapped(state, features);
|
|
1064
74
|
validateExtensionPreSaveWiring(features);
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
// cross-feature event packs).
|
|
1081
|
-
for (const feature of features) {
|
|
1082
|
-
for (const [shortName, migrations] of Object.entries(feature.eventMigrations ?? {})) {
|
|
1083
|
-
const qualified = qualify(feature.name, "event", shortName);
|
|
1084
|
-
const eventDef = eventMap.get(qualified);
|
|
1085
|
-
if (!eventDef) {
|
|
1086
|
-
throw new Error(
|
|
1087
|
-
`Feature "${feature.name}" registered r.eventMigration for "${shortName}" ` +
|
|
1088
|
-
`but no r.defineEvent exists for that name. Register the event first.`,
|
|
1089
|
-
);
|
|
1090
|
-
}
|
|
1091
|
-
for (const m of migrations) {
|
|
1092
|
-
if (m.toVersion > eventDef.version) {
|
|
1093
|
-
throw new Error(
|
|
1094
|
-
`Feature "${feature.name}" has r.eventMigration("${shortName}", ${m.fromVersion}, ${m.toVersion}) ` +
|
|
1095
|
-
`but r.defineEvent declares only version ${eventDef.version}. ` +
|
|
1096
|
-
`Bump the version in defineEvent to at least ${m.toVersion}, or remove the migration.`,
|
|
1097
|
-
);
|
|
1098
|
-
}
|
|
1099
|
-
}
|
|
1100
|
-
}
|
|
1101
|
-
}
|
|
1102
|
-
|
|
1103
|
-
// Stitch the upcaster chain per qualified event. At this point, gaps in
|
|
1104
|
-
// the chain (e.g. defineEvent version=3 but only a 1→2 migration exists)
|
|
1105
|
-
// are hard errors — they would silently hand a v2-shape payload to a
|
|
1106
|
-
// consumer expecting v3 at runtime, which is the class of bug upcasters
|
|
1107
|
-
// are supposed to prevent.
|
|
1108
|
-
for (const [qualified, eventDef] of eventMap) {
|
|
1109
|
-
const chainMap = new Map<number, EventUpcastFn>();
|
|
1110
|
-
// Locate the feature that owns this event (to pick up its migrations).
|
|
1111
|
-
for (const feature of features) {
|
|
1112
|
-
for (const [shortName, migs] of Object.entries(feature.eventMigrations ?? {})) {
|
|
1113
|
-
const candidateQn = qualify(feature.name, "event", shortName);
|
|
1114
|
-
if (candidateQn !== qualified) continue;
|
|
1115
|
-
for (const m of migs) chainMap.set(m.fromVersion, m.transform);
|
|
1116
|
-
}
|
|
1117
|
-
}
|
|
1118
|
-
if (eventDef.version > 1) {
|
|
1119
|
-
for (let v = 1; v < eventDef.version; v++) {
|
|
1120
|
-
if (!chainMap.has(v)) {
|
|
1121
|
-
throw new Error(
|
|
1122
|
-
`Event "${qualified}" declares version ${eventDef.version} but no migration ` +
|
|
1123
|
-
`covers the step v${v} → v${v + 1}. Register r.eventMigration("${qualified.split(":").pop() ?? qualified}", ${v}, ${v + 1}, transform) ` +
|
|
1124
|
-
`so stored v${v} payloads can be upcast on read.`,
|
|
1125
|
-
);
|
|
1126
|
-
}
|
|
1127
|
-
}
|
|
1128
|
-
}
|
|
1129
|
-
eventUpcasterMap.set(qualified, {
|
|
1130
|
-
currentVersion: eventDef.version,
|
|
1131
|
-
chain: chainMap,
|
|
1132
|
-
});
|
|
1133
|
-
}
|
|
1134
|
-
|
|
1135
|
-
// Validate: every projection's source must reference a registered entity.
|
|
1136
|
-
// A typo ("unti" instead of "unit") would otherwise be a silent no-op —
|
|
1137
|
-
// the projection is stored but never fires because no aggregateType ever
|
|
1138
|
-
// matches. Fail at boot so the feature author sees it immediately.
|
|
1139
|
-
//
|
|
1140
|
-
// Same guard extends to apply-keys: a handler for "unit.creatd" (missing
|
|
1141
|
-
// 'e') would silently never fire. Valid apply-keys are the auto-generated
|
|
1142
|
-
// CRUD types per source entity PLUS every domain event registered via
|
|
1143
|
-
// r.defineEvent — an apply-handler for a domain event is how a projection
|
|
1144
|
-
// reacts to ctx.appendEvent writes on the same aggregate stream.
|
|
1145
|
-
const AUTO_EVENT_VERBS = ["created", "updated", "deleted", "restored", "forgotten"] as const;
|
|
1146
|
-
const allDomainEventNames = new Set(eventMap.keys());
|
|
1147
|
-
for (const [projName, projDef] of projectionMap) {
|
|
1148
|
-
const sources = Array.isArray(projDef.source) ? projDef.source : [projDef.source];
|
|
1149
|
-
// extraSources (r.extendEntityProjection) sit in the rebuild filter, so
|
|
1150
|
-
// their auto-verbs are legitimately observable apply-keys too.
|
|
1151
|
-
const rebuildSources = [...sources, ...(projDef.extraSources ?? [])];
|
|
1152
|
-
const validEventTypes = new Set<string>();
|
|
1153
|
-
// Two source-modes are legal:
|
|
1154
|
-
//
|
|
1155
|
-
// (a) Registered entity (r.entity(src, ...)) — the "normal" case:
|
|
1156
|
-
// auto-lifecycle events `<src>.created/.updated/.deleted/.restored`
|
|
1157
|
-
// fire when the event-store-executor writes, and any domain-event
|
|
1158
|
-
// (r.defineEvent) appended onto an aggregate of that type is
|
|
1159
|
-
// observable too.
|
|
1160
|
-
//
|
|
1161
|
-
// (b) Events-only source — no r.entity registered, but at least one
|
|
1162
|
-
// apply-key must be a domain-event (not a CRUD-verb on the source
|
|
1163
|
-
// name). Use-case: features that own an append-only event-stream
|
|
1164
|
-
// without a CRUD lifecycle, e.g. `deliveryAttempt` (each call to
|
|
1165
|
-
// the delivery-service produces one event on a fresh aggregate)
|
|
1166
|
-
// or `jobRun` (BullMQ-callback-driven lifecycle, no executor).
|
|
1167
|
-
// A "Shape-Anchor"-entity is no longer needed for this case.
|
|
1168
|
-
const isEventsOnlySource = !sources.every((src) => entityMap.has(src));
|
|
1169
|
-
for (const src of rebuildSources) {
|
|
1170
|
-
if (entityMap.has(src)) {
|
|
1171
|
-
for (const verb of AUTO_EVENT_VERBS) validEventTypes.add(`${src}.${verb}`);
|
|
1172
|
-
}
|
|
1173
|
-
}
|
|
1174
|
-
// Domain events are valid apply-keys for any projection. They arrive via
|
|
1175
|
-
// ctx.appendEvent on a specific aggregate — the runtime matches by event
|
|
1176
|
-
// type, so a projection can observe domain events whose aggregate matches
|
|
1177
|
-
// one of its declared sources.
|
|
1178
|
-
for (const domainEvt of allDomainEventNames) validEventTypes.add(domainEvt);
|
|
1179
|
-
|
|
1180
|
-
// In events-only mode, at least one apply-key MUST be a domain-event —
|
|
1181
|
-
// otherwise the source is simply a typo (no events will ever fire).
|
|
1182
|
-
if (isEventsOnlySource) {
|
|
1183
|
-
const hasAnyDomainEvent = Object.keys(projDef.apply).some((k) => allDomainEventNames.has(k));
|
|
1184
|
-
if (!hasAnyDomainEvent) {
|
|
1185
|
-
const unregistered = sources.filter((src) => !entityMap.has(src));
|
|
1186
|
-
throw new Error(
|
|
1187
|
-
`Projection "${projName}" declares source(s) [${unregistered.join(", ")}] that are not registered entities, ` +
|
|
1188
|
-
`and has no domain-event apply-keys. This is either a typo or a missing r.defineEvent registration. ` +
|
|
1189
|
-
`Events-only projections need at least one apply-key from r.defineEvent; ` +
|
|
1190
|
-
`CRUD-style projections need r.entity("${unregistered[0]}", ...).`,
|
|
1191
|
-
);
|
|
1192
|
-
}
|
|
1193
|
-
}
|
|
1194
|
-
|
|
1195
|
-
for (const applyKey of Object.keys(projDef.apply)) {
|
|
1196
|
-
if (!validEventTypes.has(applyKey)) {
|
|
1197
|
-
throw new Error(
|
|
1198
|
-
`Projection "${projName}" has an apply handler for "${applyKey}" but no such event ` +
|
|
1199
|
-
`type exists for its source(s) [${sources.join(", ")}]. ` +
|
|
1200
|
-
`Valid types: ${[...validEventTypes].join(", ")}. ` +
|
|
1201
|
-
`Check for a typo — auto-verbs follow "<entity>.<verb>"; ` +
|
|
1202
|
-
`domain events follow "<feature>:event:<short-name>" (see r.defineEvent).`,
|
|
1203
|
-
);
|
|
1204
|
-
}
|
|
1205
|
-
}
|
|
1206
|
-
}
|
|
1207
|
-
|
|
1208
|
-
// Validate: all required features must be registered
|
|
1209
|
-
for (const feature of features) {
|
|
1210
|
-
for (const required of feature.requires ?? []) {
|
|
1211
|
-
if (!featureMap.has(required)) {
|
|
1212
|
-
throw new Error(
|
|
1213
|
-
`Feature "${feature.name}" requires feature "${required}" which is not registered`,
|
|
1214
|
-
);
|
|
1215
|
-
}
|
|
1216
|
-
}
|
|
1217
|
-
}
|
|
1218
|
-
|
|
1219
|
-
// Resolve notification triggers and register postSave hooks
|
|
1220
|
-
// Done after all features are registered so cross-feature triggers work
|
|
1221
|
-
const allHandlerNames = new Set([...writeHandlerMap.keys(), ...queryHandlerMap.keys()]);
|
|
1222
|
-
for (const [qualifiedName, notifDef] of notificationMap) {
|
|
1223
|
-
// Both maps are populated in lockstep — same key-set by construction.
|
|
1224
|
-
const featureName = notificationFeatureMap.get(qualifiedName) as string; // @cast-boundary engine-bridge
|
|
1225
|
-
// I'll try the easy path first: if the trigger is already a fully qualified QN
|
|
1226
|
-
// (cross-feature), I take it as-is. Otherwise I qualify with the own feature —
|
|
1227
|
-
// as a write handler first (the common case), then as a query. If nothing
|
|
1228
|
-
// matches by then, it was a typo and I'll say so.
|
|
1229
|
-
let triggerOn: string;
|
|
1230
|
-
if (allHandlerNames.has(notifDef.trigger.on)) {
|
|
1231
|
-
triggerOn = notifDef.trigger.on;
|
|
1232
|
-
} else {
|
|
1233
|
-
// Try as write handler first (most common), then query
|
|
1234
|
-
const writeQn = qualify(featureName, "write", notifDef.trigger.on);
|
|
1235
|
-
const queryQn = qualify(featureName, "query", notifDef.trigger.on);
|
|
1236
|
-
if (allHandlerNames.has(writeQn)) {
|
|
1237
|
-
triggerOn = writeQn;
|
|
1238
|
-
} else if (allHandlerNames.has(queryQn)) {
|
|
1239
|
-
triggerOn = queryQn;
|
|
1240
|
-
} else {
|
|
1241
|
-
throw new Error(
|
|
1242
|
-
`Notification "${qualifiedName}" triggers on "${notifDef.trigger.on}" ` +
|
|
1243
|
-
`but no handler with that name exists. ` +
|
|
1244
|
-
`Tried: "${notifDef.trigger.on}", "${writeQn}", and "${queryQn}"`,
|
|
1245
|
-
);
|
|
1246
|
-
}
|
|
1247
|
-
}
|
|
1248
|
-
// Update the stored definition with resolved trigger
|
|
1249
|
-
notificationMap.set(qualifiedName, { ...notifDef, trigger: { on: triggerOn } });
|
|
1250
|
-
|
|
1251
|
-
if (!postSaveHooks.has(triggerOn)) postSaveHooks.set(triggerOn, []);
|
|
1252
|
-
postSaveHooks.get(triggerOn)?.push({
|
|
1253
|
-
phase: HookPhases.afterCommit,
|
|
1254
|
-
featureName,
|
|
1255
|
-
fn: async (result, context) => {
|
|
1256
|
-
if (!context.notify) {
|
|
1257
|
-
context.log?.debug(
|
|
1258
|
-
`notification ${qualifiedName}: skipping — no notify function configured on context`,
|
|
1259
|
-
);
|
|
1260
|
-
return;
|
|
1261
|
-
}
|
|
1262
|
-
const to = notifDef.recipient(result);
|
|
1263
|
-
if (to === null) {
|
|
1264
|
-
context.log?.debug(
|
|
1265
|
-
`notification ${qualifiedName}: skipping — recipient resolver returned null for result ${result.id}`,
|
|
1266
|
-
);
|
|
1267
|
-
return;
|
|
1268
|
-
}
|
|
1269
|
-
const data = notifDef.data(result);
|
|
1270
|
-
await context.notify(qualifiedName, { to, data });
|
|
1271
|
-
},
|
|
1272
|
-
});
|
|
1273
|
-
}
|
|
1274
|
-
|
|
1275
|
-
// Validate: lifecycle hook targets must reference existing handlers
|
|
1276
|
-
const allHandlers = allHandlerNames;
|
|
1277
|
-
const lifecycleHookMaps = [
|
|
1278
|
-
{ map: preSaveHooks, phase: "preSave" },
|
|
1279
|
-
{ map: postSaveHooks, phase: "postSave" },
|
|
1280
|
-
{ map: preDeleteHooks, phase: "preDelete" },
|
|
1281
|
-
{ map: postDeleteHooks, phase: "postDelete" },
|
|
1282
|
-
{ map: preQueryHooks, phase: "preQuery" },
|
|
1283
|
-
{ map: postQueryHooks, phase: "postQuery" },
|
|
1284
|
-
] as const;
|
|
1285
|
-
|
|
1286
|
-
// I'd rather warn you now at boot than have you open a ticket three weeks from now
|
|
1287
|
-
// saying "my hook isn't firing". One typo in the target and the thing goes silent.
|
|
1288
|
-
for (const { map, phase } of lifecycleHookMaps) {
|
|
1289
|
-
for (const hookTarget of map.keys()) {
|
|
1290
|
-
if (!allHandlers.has(hookTarget)) {
|
|
1291
|
-
throw new Error(
|
|
1292
|
-
`${phase} hook targets "${hookTarget}" but no handler with that name exists. ` +
|
|
1293
|
-
`Check for typos — the hook will never fire.`,
|
|
1294
|
-
);
|
|
1295
|
-
}
|
|
1296
|
-
}
|
|
1297
|
-
}
|
|
1298
|
-
|
|
1299
|
-
// Same logic for entity-keyed hooks — targets must reference existing entities.
|
|
1300
|
-
// Memory `feedback_dead_hook_needs_second_consumer`: a typo silently registers
|
|
1301
|
-
// and never fires. Validates all four entity-hook types (postSave/preDelete/
|
|
1302
|
-
// postDelete/postQuery) — net cleanup of an existing antipattern, not a
|
|
1303
|
-
// postQuery-special.
|
|
1304
|
-
const allEntities = new Set<string>();
|
|
1305
|
-
for (const feature of features) {
|
|
1306
|
-
for (const entityName of Object.keys(feature.entities ?? {})) {
|
|
1307
|
-
allEntities.add(entityName);
|
|
1308
|
-
}
|
|
1309
|
-
}
|
|
1310
|
-
const entityHookMaps = [
|
|
1311
|
-
{ map: entityPostSaveHooks, phase: "postSave (entityHook)", kind: "hook" },
|
|
1312
|
-
{ map: entityPreDeleteHooks, phase: "preDelete (entityHook)", kind: "hook" },
|
|
1313
|
-
{ map: entityPostDeleteHooks, phase: "postDelete (entityHook)", kind: "hook" },
|
|
1314
|
-
{ map: entityPostQueryHooks, phase: "postQuery (entityHook)", kind: "hook" },
|
|
1315
|
-
{ map: searchPayloadExtensions, phase: "searchPayloadExtension", kind: "extension" },
|
|
1316
|
-
] as const;
|
|
1317
|
-
for (const { map, phase, kind } of entityHookMaps) {
|
|
1318
|
-
for (const entityName of map.keys()) {
|
|
1319
|
-
if (!allEntities.has(entityName)) {
|
|
1320
|
-
throw new Error(
|
|
1321
|
-
`${phase} ${kind} targets entity "${entityName}" but no entity with that name exists. ` +
|
|
1322
|
-
`Check for typos — the ${kind} will never fire.`,
|
|
1323
|
-
);
|
|
1324
|
-
}
|
|
1325
|
-
}
|
|
1326
|
-
}
|
|
1327
|
-
|
|
1328
|
-
// Validate: job event triggers must reference existing handlers.
|
|
1329
|
-
// Multi-Trigger-Form: jeden Eintrag im Array gegen allHandlers prüfen,
|
|
1330
|
-
// auch wenn nur einer fehlt fail-fast.
|
|
1331
|
-
for (const [jobName, jobDef] of jobMap) {
|
|
1332
|
-
if (!("on" in jobDef.trigger)) continue;
|
|
1333
|
-
const triggerOn = jobDef.trigger.on;
|
|
1334
|
-
const triggers = Array.isArray(triggerOn) ? triggerOn : [triggerOn];
|
|
1335
|
-
for (const t of triggers) {
|
|
1336
|
-
const rawName = resolveName(t);
|
|
1337
|
-
if (allHandlers.has(rawName)) continue;
|
|
1338
|
-
throw new Error(
|
|
1339
|
-
`Job "${jobName}" triggers on "${rawName}" but no handler with that name exists`,
|
|
1340
|
-
);
|
|
1341
|
-
}
|
|
1342
|
-
}
|
|
1343
|
-
|
|
1344
|
-
// Validate: extension usages must reference existing extensions
|
|
1345
|
-
for (const usage of extensionUsages) {
|
|
1346
|
-
if (!extensionMap.has(usage.extensionName)) {
|
|
1347
|
-
throw new Error(
|
|
1348
|
-
`Extension usage "${usage.extensionName}" on entity "${usage.entityName}" references an extension that does not exist`,
|
|
1349
|
-
);
|
|
1350
|
-
}
|
|
1351
|
-
}
|
|
1352
|
-
|
|
1353
|
-
// Pre-compute: any handler with a rateLimit option? Keeps the boot
|
|
1354
|
-
// path able to short-circuit the RateLimitResolver wiring (and its
|
|
1355
|
-
// Lua-script registration on Redis) when nobody opted in.
|
|
1356
|
-
const hasRateLimitedHandlerCached = (() => {
|
|
1357
|
-
for (const h of writeHandlerMap.values()) if (h.rateLimit !== undefined) return true;
|
|
1358
|
-
for (const h of queryHandlerMap.values()) if (h.rateLimit !== undefined) return true;
|
|
1359
|
-
return false;
|
|
1360
|
-
})();
|
|
1361
|
-
|
|
1362
|
-
// Publish the event-PII catalog (#799): append() — the single write funnel
|
|
1363
|
-
// into kumiko_events — encrypts catalogued payload fields regardless of
|
|
1364
|
-
// which path produced the event (ctx.appendEvent, MSP-apply, low-level
|
|
1365
|
-
// append in delivery/jobs loggers).
|
|
1366
|
-
const eventPiiCatalog = new Map<string, EventPiiFields>();
|
|
1367
|
-
for (const [qualified, def] of eventMap) {
|
|
1368
|
-
if (def.piiFields) eventPiiCatalog.set(qualified, def.piiFields);
|
|
1369
|
-
}
|
|
1370
|
-
configureEventPiiCatalog(eventPiiCatalog);
|
|
1371
|
-
|
|
1372
|
-
// Auto-wire the soft-delete cleanup cron + its grace-days config key when ANY
|
|
1373
|
-
// entity opts into softDelete — the framework owns this machinery, no feature
|
|
1374
|
-
// declares it (mirrors the auto restore-handler). Job-runner reads getAllJobs
|
|
1375
|
-
// ungated; config-resolver reads getConfigKey → default. Reserved owner
|
|
1376
|
-
// segment, guarded against a real-feature collision.
|
|
1377
|
-
if ([...entityMap.values()].some((e) => e.softDelete)) {
|
|
1378
|
-
if (!jobMap.has(SOFT_DELETE_CLEANUP_JOB)) {
|
|
1379
|
-
jobMap.set(SOFT_DELETE_CLEANUP_JOB, buildSoftDeleteCleanupJob());
|
|
1380
|
-
}
|
|
1381
|
-
if (!jobMap.has(SOFT_DELETE_CLEANUP_SYSTEM_JOB)) {
|
|
1382
|
-
jobMap.set(SOFT_DELETE_CLEANUP_SYSTEM_JOB, buildSoftDeleteCleanupSystemJob());
|
|
1383
|
-
}
|
|
1384
|
-
if (!configKeyMap.has(SOFT_DELETE_GRACE_DAYS_KEY)) {
|
|
1385
|
-
configKeyMap.set(SOFT_DELETE_GRACE_DAYS_KEY, softDeleteGraceDaysConfig);
|
|
1386
|
-
}
|
|
1387
|
-
}
|
|
1388
|
-
|
|
1389
|
-
return {
|
|
1390
|
-
features: featureMap,
|
|
1391
|
-
|
|
1392
|
-
getFeature(name: string): FeatureDefinition | undefined {
|
|
1393
|
-
return featureMap.get(name);
|
|
1394
|
-
},
|
|
1395
|
-
|
|
1396
|
-
hasRateLimitedHandler(): boolean {
|
|
1397
|
-
return hasRateLimitedHandlerCached;
|
|
1398
|
-
},
|
|
1399
|
-
|
|
1400
|
-
getEntity(name: string): EntityDefinition | undefined {
|
|
1401
|
-
return entityMap.get(name);
|
|
1402
|
-
},
|
|
1403
|
-
|
|
1404
|
-
getAllEntities(): ReadonlyMap<string, EntityDefinition> {
|
|
1405
|
-
return entityMap;
|
|
1406
|
-
},
|
|
1407
|
-
|
|
1408
|
-
getWriteHandler(name: string): WriteHandlerDef | undefined {
|
|
1409
|
-
return writeHandlerMap.get(name);
|
|
1410
|
-
},
|
|
1411
|
-
|
|
1412
|
-
getQueryHandler(name: string): QueryHandlerDef | undefined {
|
|
1413
|
-
return queryHandlerMap.get(name);
|
|
1414
|
-
},
|
|
1415
|
-
|
|
1416
|
-
getSearchableFields(entityName: string): readonly string[] {
|
|
1417
|
-
return searchableFieldsCache.get(entityName) ?? [];
|
|
1418
|
-
},
|
|
1419
|
-
|
|
1420
|
-
getSortableFields(entityName: string): readonly string[] {
|
|
1421
|
-
return sortableFieldsCache.get(entityName) ?? [];
|
|
1422
|
-
},
|
|
1423
|
-
|
|
1424
|
-
getRelations(entityName: string): EntityRelations {
|
|
1425
|
-
return (relationMap.get(entityName) ?? {}) as EntityRelations; // @cast-boundary schema-walk
|
|
1426
|
-
},
|
|
1427
|
-
|
|
1428
|
-
getSearchIncludes(entityName: string): ReadonlyMap<string, readonly string[]> {
|
|
1429
|
-
return searchIncludesCache.get(entityName) ?? new Map();
|
|
1430
|
-
},
|
|
1431
|
-
|
|
1432
|
-
getIncomingRelations(entityName: string): readonly IncomingRelation[] {
|
|
1433
|
-
return incomingRelationsCache.get(entityName) ?? [];
|
|
1434
|
-
},
|
|
1435
|
-
|
|
1436
|
-
getPreSaveHooks(
|
|
1437
|
-
name: string,
|
|
1438
|
-
effectiveFeatures?: ReadonlySet<string>,
|
|
1439
|
-
): readonly PreSaveHookFn[] {
|
|
1440
|
-
return filterOwned(preSaveHooks.get(name), effectiveFeatures);
|
|
1441
|
-
},
|
|
1442
|
-
|
|
1443
|
-
getPostSaveHooks(
|
|
1444
|
-
name: string,
|
|
1445
|
-
phase?: HookPhase,
|
|
1446
|
-
effectiveFeatures?: ReadonlySet<string>,
|
|
1447
|
-
): readonly PostSaveHookFn[] {
|
|
1448
|
-
return filterByPhase(postSaveHooks.get(name), phase, effectiveFeatures);
|
|
1449
|
-
},
|
|
1450
|
-
|
|
1451
|
-
getPreDeleteHooks(
|
|
1452
|
-
name: string,
|
|
1453
|
-
phase?: HookPhase,
|
|
1454
|
-
effectiveFeatures?: ReadonlySet<string>,
|
|
1455
|
-
): readonly PreDeleteHookFn[] {
|
|
1456
|
-
return filterByPhase(preDeleteHooks.get(name), phase, effectiveFeatures);
|
|
1457
|
-
},
|
|
1458
|
-
|
|
1459
|
-
getPostDeleteHooks(
|
|
1460
|
-
name: string,
|
|
1461
|
-
phase?: HookPhase,
|
|
1462
|
-
effectiveFeatures?: ReadonlySet<string>,
|
|
1463
|
-
): readonly PostDeleteHookFn[] {
|
|
1464
|
-
return filterByPhase(postDeleteHooks.get(name), phase, effectiveFeatures);
|
|
1465
|
-
},
|
|
1466
|
-
|
|
1467
|
-
getPreQueryHooks(
|
|
1468
|
-
name: string,
|
|
1469
|
-
effectiveFeatures?: ReadonlySet<string>,
|
|
1470
|
-
): readonly PreQueryHookFn[] {
|
|
1471
|
-
return filterOwned(preQueryHooks.get(name), effectiveFeatures);
|
|
1472
|
-
},
|
|
1473
|
-
|
|
1474
|
-
getPostQueryHooks(
|
|
1475
|
-
name: string,
|
|
1476
|
-
effectiveFeatures?: ReadonlySet<string>,
|
|
1477
|
-
): readonly PostQueryHookFn[] {
|
|
1478
|
-
return filterOwned(postQueryHooks.get(name), effectiveFeatures);
|
|
1479
|
-
},
|
|
1480
|
-
|
|
1481
|
-
// Entity hooks — fire for all writes on an entity
|
|
1482
|
-
getEntityPostSaveHooks(
|
|
1483
|
-
entityName: string,
|
|
1484
|
-
phase?: HookPhase,
|
|
1485
|
-
effectiveFeatures?: ReadonlySet<string>,
|
|
1486
|
-
): readonly PostSaveHookFn[] {
|
|
1487
|
-
return filterByPhase(entityPostSaveHooks.get(entityName), phase, effectiveFeatures);
|
|
1488
|
-
},
|
|
1489
|
-
|
|
1490
|
-
getEntityPreDeleteHooks(
|
|
1491
|
-
entityName: string,
|
|
1492
|
-
phase?: HookPhase,
|
|
1493
|
-
effectiveFeatures?: ReadonlySet<string>,
|
|
1494
|
-
): readonly PreDeleteHookFn[] {
|
|
1495
|
-
return filterByPhase(entityPreDeleteHooks.get(entityName), phase, effectiveFeatures);
|
|
1496
|
-
},
|
|
1497
|
-
|
|
1498
|
-
getEntityPostDeleteHooks(
|
|
1499
|
-
entityName: string,
|
|
1500
|
-
phase?: HookPhase,
|
|
1501
|
-
effectiveFeatures?: ReadonlySet<string>,
|
|
1502
|
-
): readonly PostDeleteHookFn[] {
|
|
1503
|
-
return filterByPhase(entityPostDeleteHooks.get(entityName), phase, effectiveFeatures);
|
|
1504
|
-
},
|
|
1505
|
-
|
|
1506
|
-
getEntityPostQueryHooks(
|
|
1507
|
-
entityName: string,
|
|
1508
|
-
effectiveFeatures?: ReadonlySet<string>,
|
|
1509
|
-
): readonly PostQueryHookFn[] {
|
|
1510
|
-
return filterOwned(entityPostQueryHooks.get(entityName), effectiveFeatures);
|
|
1511
|
-
},
|
|
1512
|
-
|
|
1513
|
-
// F3 — Search-Payload-Extension contributors for an entity. Used by
|
|
1514
|
-
// `buildSearchDocument` in system-hooks.ts to enrich the indexed payload.
|
|
1515
|
-
// `effectiveFeatures` filters out contributors owned by feature-toggle-
|
|
1516
|
-
// disabled features (parallel to getEntityPostQueryHooks etc.).
|
|
1517
|
-
getSearchPayloadExtensions(
|
|
1518
|
-
entityName: string,
|
|
1519
|
-
effectiveFeatures?: ReadonlySet<string>,
|
|
1520
|
-
): readonly SearchPayloadContributorFn[] {
|
|
1521
|
-
return filterOwned(searchPayloadExtensions.get(entityName), effectiveFeatures);
|
|
1522
|
-
},
|
|
1523
|
-
|
|
1524
|
-
getAllTranslations(): TranslationKeys {
|
|
1525
|
-
return mergedTranslations;
|
|
1526
|
-
},
|
|
1527
|
-
|
|
1528
|
-
getHandlerEntity(qualifiedHandler: string): string | undefined {
|
|
1529
|
-
return handlerEntityMap.get(qualifiedHandler);
|
|
1530
|
-
},
|
|
1531
|
-
|
|
1532
|
-
isHandlerSystemScoped(qualifiedHandler: string): boolean {
|
|
1533
|
-
const featureName = handlerFeatureMap.get(qualifiedHandler);
|
|
1534
|
-
if (!featureName) return false;
|
|
1535
|
-
return featureMap.get(featureName)?.systemScope ?? false;
|
|
1536
|
-
},
|
|
1537
|
-
|
|
1538
|
-
getHandlerFeature(qualifiedHandler: string): string | undefined {
|
|
1539
|
-
return handlerFeatureMap.get(qualifiedHandler);
|
|
1540
|
-
},
|
|
1541
|
-
|
|
1542
|
-
getAllMetrics() {
|
|
1543
|
-
return metricMap;
|
|
1544
|
-
},
|
|
1545
|
-
|
|
1546
|
-
getAllSecretKeys(): ReadonlyMap<string, SecretKeyDefinition> {
|
|
1547
|
-
return secretKeyMap;
|
|
1548
|
-
},
|
|
1549
|
-
|
|
1550
|
-
getSecretKey(qualifiedName: string): SecretKeyDefinition | undefined {
|
|
1551
|
-
return secretKeyMap.get(qualifiedName);
|
|
1552
|
-
},
|
|
1553
|
-
|
|
1554
|
-
getConfigKey(qualifiedKey: string): ConfigKeyDefinition | undefined {
|
|
1555
|
-
return configKeyMap.get(qualifiedKey);
|
|
1556
|
-
},
|
|
1557
|
-
|
|
1558
|
-
getAllConfigKeys(): ReadonlyMap<string, ConfigKeyDefinition> {
|
|
1559
|
-
return configKeyMap;
|
|
1560
|
-
},
|
|
1561
|
-
|
|
1562
|
-
getJob(qualifiedName: string): JobDefinition | undefined {
|
|
1563
|
-
return jobMap.get(qualifiedName);
|
|
1564
|
-
},
|
|
1565
|
-
|
|
1566
|
-
getAllJobs(): ReadonlyMap<string, JobDefinition> {
|
|
1567
|
-
return jobMap;
|
|
1568
|
-
},
|
|
1569
|
-
|
|
1570
|
-
getEvent(qualifiedName: string): EventDef | undefined {
|
|
1571
|
-
return eventMap.get(qualifiedName);
|
|
1572
|
-
},
|
|
1573
|
-
|
|
1574
|
-
getEventUpcasters() {
|
|
1575
|
-
return eventUpcasterMap;
|
|
1576
|
-
},
|
|
1577
|
-
|
|
1578
|
-
getExtension(name: string): RegistrarExtensionDef | undefined {
|
|
1579
|
-
return extensionMap.get(name);
|
|
1580
|
-
},
|
|
1581
|
-
|
|
1582
|
-
getExtensionUsages(extensionName: string): readonly RegistrarExtensionRegistration[] {
|
|
1583
|
-
return extensionUsages.filter((u) => u.extensionName === extensionName);
|
|
1584
|
-
},
|
|
1585
|
-
|
|
1586
|
-
getAllExtensionSelectors(): ReadonlyMap<string, string> {
|
|
1587
|
-
return extensionSelectorMap;
|
|
1588
|
-
},
|
|
1589
|
-
|
|
1590
|
-
getAllNotifications(): ReadonlyMap<string, NotificationDefinition> {
|
|
1591
|
-
return notificationMap;
|
|
1592
|
-
},
|
|
1593
|
-
|
|
1594
|
-
getAllReferenceData(): readonly ReferenceDataDef[] {
|
|
1595
|
-
return allReferenceData;
|
|
1596
|
-
},
|
|
1597
|
-
|
|
1598
|
-
getAllConfigSeeds(): readonly ConfigSeedDef[] {
|
|
1599
|
-
return allConfigSeeds;
|
|
1600
|
-
},
|
|
1601
|
-
|
|
1602
|
-
getProjectionsForSource(entityName: string): readonly ProjectionDefinition[] {
|
|
1603
|
-
return projectionsBySource.get(entityName) ?? [];
|
|
1604
|
-
},
|
|
1605
|
-
|
|
1606
|
-
getAllProjections(): ReadonlyMap<string, ProjectionDefinition> {
|
|
1607
|
-
return projectionMap;
|
|
1608
|
-
},
|
|
1609
|
-
|
|
1610
|
-
getAllRawTables(): ReadonlyMap<string, RawTableDef> {
|
|
1611
|
-
return rawTableMap;
|
|
1612
|
-
},
|
|
1613
|
-
|
|
1614
|
-
getAllMultiStreamProjections(): ReadonlyMap<string, MultiStreamProjectionDefinition> {
|
|
1615
|
-
return multiStreamProjectionMap;
|
|
1616
|
-
},
|
|
1617
|
-
|
|
1618
|
-
getMultiStreamProjectionFeature(qualifiedName: string): string | undefined {
|
|
1619
|
-
return multiStreamProjectionFeatureMap.get(qualifiedName);
|
|
1620
|
-
},
|
|
1621
|
-
|
|
1622
|
-
getAuthClaimsHooks(): readonly AuthClaimsHookDef[] {
|
|
1623
|
-
return authClaimsHooks;
|
|
1624
|
-
},
|
|
1625
|
-
|
|
1626
|
-
getAllClaimKeys(): ReadonlyMap<string, ClaimKeyDefinition> {
|
|
1627
|
-
return claimKeyMap;
|
|
1628
|
-
},
|
|
1629
|
-
|
|
1630
|
-
getClaimKey(qualifiedName: string): ClaimKeyDefinition | undefined {
|
|
1631
|
-
return claimKeyMap.get(qualifiedName);
|
|
1632
|
-
},
|
|
1633
|
-
|
|
1634
|
-
getAllScreens(): ReadonlyMap<string, ScreenDefinition> {
|
|
1635
|
-
return screenMap;
|
|
1636
|
-
},
|
|
1637
|
-
|
|
1638
|
-
getScreen(qualifiedName: string): ScreenDefinition | undefined {
|
|
1639
|
-
return screenMap.get(qualifiedName);
|
|
1640
|
-
},
|
|
1641
|
-
|
|
1642
|
-
getScreenFeature(qualifiedName: string): string | undefined {
|
|
1643
|
-
return screenFeatureMap.get(qualifiedName);
|
|
1644
|
-
},
|
|
1645
|
-
|
|
1646
|
-
getScreensByEntity(entityName: string): readonly ScreenDefinition[] {
|
|
1647
|
-
return screensByEntity.get(entityName) ?? [];
|
|
1648
|
-
},
|
|
1649
|
-
|
|
1650
|
-
getAllNavs(): ReadonlyMap<string, NavDefinition> {
|
|
1651
|
-
return navMap;
|
|
1652
|
-
},
|
|
1653
|
-
|
|
1654
|
-
getNav(qualifiedName: string): NavDefinition | undefined {
|
|
1655
|
-
return navMap.get(qualifiedName);
|
|
1656
|
-
},
|
|
1657
|
-
|
|
1658
|
-
getNavFeature(qualifiedName: string): string | undefined {
|
|
1659
|
-
return navFeatureMap.get(qualifiedName);
|
|
1660
|
-
},
|
|
1661
|
-
|
|
1662
|
-
getNavsByParent(parentQualifiedName: string): readonly NavDefinition[] {
|
|
1663
|
-
return navsByParent.get(parentQualifiedName) ?? [];
|
|
1664
|
-
},
|
|
1665
|
-
|
|
1666
|
-
getTopLevelNavs(): readonly NavDefinition[] {
|
|
1667
|
-
return topLevelNavs;
|
|
1668
|
-
},
|
|
1669
|
-
|
|
1670
|
-
getAllWorkspaces(): ReadonlyMap<string, WorkspaceDefinition> {
|
|
1671
|
-
return workspaceMap;
|
|
1672
|
-
},
|
|
1673
|
-
|
|
1674
|
-
getWorkspace(qualifiedName: string): WorkspaceDefinition | undefined {
|
|
1675
|
-
return workspaceMap.get(qualifiedName);
|
|
1676
|
-
},
|
|
1677
|
-
|
|
1678
|
-
getWorkspaceFeature(qualifiedName: string): string | undefined {
|
|
1679
|
-
return workspaceFeatureMap.get(qualifiedName);
|
|
1680
|
-
},
|
|
1681
|
-
|
|
1682
|
-
getWorkspaceNavs(workspaceQualifiedName: string): readonly string[] {
|
|
1683
|
-
return navsByWorkspace.get(workspaceQualifiedName) ?? [];
|
|
1684
|
-
},
|
|
1685
|
-
|
|
1686
|
-
getDefaultWorkspace(): WorkspaceDefinition | undefined {
|
|
1687
|
-
return defaultWorkspace;
|
|
1688
|
-
},
|
|
1689
|
-
|
|
1690
|
-
getTreeActions(featureName: string): Readonly<Record<string, TreeActionDef>> | undefined {
|
|
1691
|
-
return treeActionsMap.get(featureName);
|
|
1692
|
-
},
|
|
1693
|
-
};
|
|
1694
|
-
}
|
|
1695
|
-
|
|
1696
|
-
/** Returns true if any entity in the feature has field-level access rules (read or write). */
|
|
1697
|
-
function hasFieldAccessRules(feature: FeatureDefinition): boolean {
|
|
1698
|
-
for (const entity of Object.values(feature.entities ?? {})) {
|
|
1699
|
-
for (const field of Object.values(entity.fields)) {
|
|
1700
|
-
if (field.access?.read?.length || field.access?.write?.length) {
|
|
1701
|
-
return true;
|
|
1702
|
-
}
|
|
1703
|
-
}
|
|
1704
|
-
}
|
|
1705
|
-
return false;
|
|
75
|
+
validateRelationTargetsExist(state);
|
|
76
|
+
validateEventMigrationVersions(state, features);
|
|
77
|
+
buildEventUpcasterChains(state, features);
|
|
78
|
+
validateProjectionApplyKeys(state);
|
|
79
|
+
validateRequiredFeatures(state, features);
|
|
80
|
+
resolveNotificationTriggersAndRegisterHooks(state);
|
|
81
|
+
validateLifecycleHookTargets(state);
|
|
82
|
+
validateEntityHookTargets(state, features);
|
|
83
|
+
validateJobTriggers(state);
|
|
84
|
+
validateExtensionUsageTargets(state);
|
|
85
|
+
computeHasRateLimitedHandler(state);
|
|
86
|
+
publishEventPiiCatalog(state);
|
|
87
|
+
autoWireSoftDeleteJobs(state);
|
|
88
|
+
|
|
89
|
+
return buildRegistryFacade(state);
|
|
1706
90
|
}
|