@cosmicdrift/kumiko-framework 0.146.4 → 0.147.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -2
- package/src/api/auth-routes.ts +32 -67
- package/src/api/routes.ts +1 -3
- package/src/bun-db/__tests__/PATTERN.md +0 -1
- package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +29 -3
- package/src/db/__tests__/schema-inspection.test.ts +7 -0
- package/src/db/event-store-executor-context.ts +398 -0
- package/src/db/event-store-executor-read.ts +276 -0
- package/src/db/event-store-executor-write.ts +615 -0
- package/src/db/event-store-executor.ts +29 -1166
- package/src/db/queries/shadow-swap.ts +3 -1
- package/src/db/schema-inspection.ts +1 -1
- package/src/engine/__tests__/boot-validator-dashboard.test.ts +10 -0
- package/src/engine/__tests__/engine.test.ts +45 -1
- package/src/engine/__tests__/event-migration-declarative.test.ts +6 -0
- package/src/engine/__tests__/membership-roles.test.ts +4 -10
- package/src/engine/__tests__/screen.test.ts +26 -0
- package/src/engine/boot-validator/entity-list-screens.ts +1 -1
- package/src/engine/boot-validator/gdpr-storage.ts +1 -1
- package/src/engine/boot-validator/index.ts +12 -8
- package/src/engine/boot-validator/nav.ts +120 -0
- package/src/engine/boot-validator/{screens-nav.ts → screens.ts} +12 -190
- package/src/engine/boot-validator/workspaces.ts +68 -0
- package/src/engine/define-feature.ts +77 -1011
- package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/feature.ts +9 -0
- package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/screens.ts +4 -0
- package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +18 -8
- package/src/engine/feature-ast/__tests__/parse.test.ts +291 -2
- package/src/engine/feature-ast/__tests__/patcher.test.ts +1 -1
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +73 -0
- package/src/engine/feature-ast/extractors/round1.ts +3 -3
- package/src/engine/feature-ast/extractors/round4.ts +45 -10
- package/src/engine/feature-ast/extractors/shared.ts +64 -6
- package/src/engine/feature-ast/parse.ts +123 -10
- package/src/engine/feature-ast/patterns.ts +14 -7
- package/src/engine/feature-ast/render.ts +28 -7
- package/src/engine/feature-builder-state.ts +165 -0
- package/src/engine/feature-config-events-jobs.ts +303 -0
- package/src/engine/feature-entity-handlers.ts +161 -0
- package/src/engine/feature-ui-extensions.ts +413 -0
- package/src/engine/index.ts +0 -2
- package/src/engine/pattern-library/library.ts +44 -1131
- package/src/engine/pattern-library/mixed-schemas.ts +484 -0
- package/src/engine/pattern-library/opaque-schemas.ts +124 -0
- package/src/engine/pattern-library/shared-fields.ts +80 -0
- package/src/engine/pattern-library/static-schemas.ts +456 -0
- package/src/engine/registry-facade.ts +349 -0
- package/src/engine/registry-ingest.ts +473 -0
- package/src/engine/registry-state.ts +388 -0
- package/src/engine/registry-validate.ts +660 -0
- package/src/engine/registry.ts +79 -1695
- package/src/engine/types/screen.ts +4 -2
- package/src/engine/types/tree-node.ts +2 -5
- package/src/event-store/snapshot.ts +7 -7
- package/src/i18n/required-surface-keys.ts +2 -0
- package/src/jobs/job-runner.ts +1 -1
- package/src/observability/standard-metrics.ts +4 -3
- package/src/pipeline/dispatch-batch.ts +3 -3
- package/src/pipeline/dispatch-shared.ts +17 -10
- package/src/pipeline/event-dispatcher-admin.ts +289 -0
- package/src/pipeline/event-dispatcher-delivery.ts +264 -0
- package/src/pipeline/event-dispatcher.ts +28 -540
- package/src/pipeline/projection-rebuild.ts +1 -1
- package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +95 -0
- package/src/stack/test-stack.ts +46 -1
- package/src/testing/boot-validator-fixture.ts +1 -2
- package/src/engine/__tests__/deep-link.test.ts +0 -35
- package/src/engine/deep-link.ts +0 -23
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import { applyEntityEvent } from "../db/apply-entity-event";
|
|
2
|
+
import { assertBackingTableSuperset, buildEntityTableMeta } from "../db/entity-table-meta";
|
|
3
|
+
import { asEntityTableMeta } from "../db/query";
|
|
4
|
+
import { buildEntityTable } from "../db/table-builder";
|
|
5
|
+
import { type QnType, qualifyEntityName } from "./qualified-name";
|
|
6
|
+
import type {
|
|
7
|
+
AuthClaimsHookDef,
|
|
8
|
+
ClaimKeyDefinition,
|
|
9
|
+
ConfigKeyDefinition,
|
|
10
|
+
ConfigSeedDef,
|
|
11
|
+
EntityDefinition,
|
|
12
|
+
EntityProjectionExtension,
|
|
13
|
+
EventDef,
|
|
14
|
+
EventUpcastFn,
|
|
15
|
+
FeatureDefinition,
|
|
16
|
+
FeatureMetricDef,
|
|
17
|
+
HookPhase,
|
|
18
|
+
JobDefinition,
|
|
19
|
+
MultiStreamProjectionDefinition,
|
|
20
|
+
NavDefinition,
|
|
21
|
+
NotificationDefinition,
|
|
22
|
+
OwnedFn,
|
|
23
|
+
PhasedHook,
|
|
24
|
+
PostDeleteHookFn,
|
|
25
|
+
PostQueryHookFn,
|
|
26
|
+
PostSaveHookFn,
|
|
27
|
+
PreDeleteHookFn,
|
|
28
|
+
PreQueryHookFn,
|
|
29
|
+
PreSaveHookFn,
|
|
30
|
+
ProjectionDefinition,
|
|
31
|
+
QueryHandlerDef,
|
|
32
|
+
RawTableDef,
|
|
33
|
+
ReferenceDataDef,
|
|
34
|
+
RegistrarExtensionDef,
|
|
35
|
+
RegistrarExtensionRegistration,
|
|
36
|
+
RelationDefinition,
|
|
37
|
+
ScreenDefinition,
|
|
38
|
+
SearchPayloadContributorFn,
|
|
39
|
+
SecretKeyDefinition,
|
|
40
|
+
TreeActionDef,
|
|
41
|
+
UnmanagedTableDef,
|
|
42
|
+
WorkspaceDefinition,
|
|
43
|
+
WriteHandlerDef,
|
|
44
|
+
} from "./types";
|
|
45
|
+
|
|
46
|
+
export type IncomingRelation = {
|
|
47
|
+
sourceEntity: string;
|
|
48
|
+
relationName: string;
|
|
49
|
+
relation: RelationDefinition;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const IMPLICIT_PROJECTION_SUFFIX = "-entity" as const;
|
|
53
|
+
|
|
54
|
+
// Pro r.entity-Registration eine ImplicitProjection mit auto-generierten
|
|
55
|
+
// apply-Handlern für die 4 Auto-Verben. Live-Pfad geht durch
|
|
56
|
+
// EventStoreExecutor und schreibt direkt in die Tabelle; rebuildProjection
|
|
57
|
+
// nutzt diese Definition um aus Events zu replayen. Beide rufen dieselbe
|
|
58
|
+
// applyEntityEvent-Funktion → Live==Rebuild by-construction (verstärkt
|
|
59
|
+
// durch implicit-projection-equivalence.integration.ts).
|
|
60
|
+
export function buildImplicitProjection(
|
|
61
|
+
featureName: string,
|
|
62
|
+
entityName: string,
|
|
63
|
+
entity: EntityDefinition,
|
|
64
|
+
qualify: typeof qualifyEntityName,
|
|
65
|
+
backingTable?: unknown,
|
|
66
|
+
extensions: readonly EntityProjectionExtension[] = [],
|
|
67
|
+
): ProjectionDefinition {
|
|
68
|
+
const name = qualify(featureName, "projection", `${entityName}${IMPLICIT_PROJECTION_SUFFIX}`);
|
|
69
|
+
// Backing table (r.entity(name, def, { table })) is the one physical table
|
|
70
|
+
// object shared by executor-writes, rebuild-replay, test-push and
|
|
71
|
+
// collectTableMetas — restoring the #255 invariant (test-push == generate).
|
|
72
|
+
// Validated as a superset of the field-derived columns so a field/table
|
|
73
|
+
// disagreement fails at boot, not as a silent thin-vs-rich row.
|
|
74
|
+
const drizzleTable =
|
|
75
|
+
backingTable !== undefined
|
|
76
|
+
? resolveBackingTable(entityName, entity, backingTable)
|
|
77
|
+
: buildEntityTable(entityName, entity);
|
|
78
|
+
// applyEntityEvent gibt ApplyResult zurück; SingleStreamApplyFn erwartet
|
|
79
|
+
// Promise<void>. Im rebuild-Pfad ist die Row irrelevant — wir discarden.
|
|
80
|
+
const handler = async (
|
|
81
|
+
event: Parameters<ProjectionDefinition["apply"][string]>[0],
|
|
82
|
+
tx: Parameters<ProjectionDefinition["apply"][string]>[1],
|
|
83
|
+
): Promise<void> => {
|
|
84
|
+
await applyEntityEvent(event, drizzleTable, entity, tx);
|
|
85
|
+
};
|
|
86
|
+
const apply: Record<string, ProjectionDefinition["apply"][string]> = {
|
|
87
|
+
[`${entityName}.created`]: handler,
|
|
88
|
+
[`${entityName}.updated`]: handler,
|
|
89
|
+
[`${entityName}.deleted`]: handler,
|
|
90
|
+
// forget/purge (Art. 17): hard-deletes the row even for softDelete entities.
|
|
91
|
+
// Registered for every entity so the erasure replays on rebuild.
|
|
92
|
+
[`${entityName}.forgotten`]: handler,
|
|
93
|
+
};
|
|
94
|
+
// Restore-Verb existiert nur für softDelete-Entities. Hard-Delete-
|
|
95
|
+
// Entities sollten keine restored-Events produzieren — würden sie es
|
|
96
|
+
// doch, würde applyEntityEvent intern als no-op laufen, aber wir
|
|
97
|
+
// registrieren den Handler gar nicht erst.
|
|
98
|
+
if (entity.softDelete) {
|
|
99
|
+
apply[`${entityName}.restored`] = handler;
|
|
100
|
+
}
|
|
101
|
+
// r.extendEntityProjection: merge extension applies into the rebuild
|
|
102
|
+
// replay. Collisions with lifecycle applies (or another extension) are
|
|
103
|
+
// authoring bugs — fail at boot, not by silently overwriting a handler.
|
|
104
|
+
const extraSources: string[] = [];
|
|
105
|
+
for (const ext of extensions) {
|
|
106
|
+
for (const [eventType, fn] of Object.entries(ext.apply)) {
|
|
107
|
+
if (apply[eventType]) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`Implicit projection "${name}": extendEntityProjection apply-key "${eventType}" ` +
|
|
110
|
+
`collides with an existing handler (entity lifecycle apply or another extension).`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
apply[eventType] = fn;
|
|
114
|
+
}
|
|
115
|
+
for (const s of ext.sources ?? []) {
|
|
116
|
+
if (s !== entityName && !extraSources.includes(s)) extraSources.push(s);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
name,
|
|
121
|
+
source: entityName,
|
|
122
|
+
...(extraSources.length > 0 && { extraSources }),
|
|
123
|
+
table: drizzleTable,
|
|
124
|
+
apply,
|
|
125
|
+
isImplicit: true,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Validates a r.entity backing table is a superset of the entity's field-
|
|
130
|
+
// derived columns, then hands it back as the projection table. The cast is a
|
|
131
|
+
// system-boundary reconstitution: the table is stored as `unknown` on
|
|
132
|
+
// FeatureDefinition only to keep drizzle out of the plain-data shape, and
|
|
133
|
+
// asEntityTableMeta confirms the kumiko-table shape at runtime.
|
|
134
|
+
function resolveBackingTable(
|
|
135
|
+
entityName: string,
|
|
136
|
+
entity: EntityDefinition,
|
|
137
|
+
backingTable: unknown,
|
|
138
|
+
): ProjectionDefinition["table"] {
|
|
139
|
+
const tableMeta = asEntityTableMeta(backingTable);
|
|
140
|
+
if (!tableMeta) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
`r.entity("${entityName}", …, { table }): the backing table carries no ` +
|
|
143
|
+
"EntityTableMeta — build it via table() / buildEntityTable.",
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
assertBackingTableSuperset(entityName, buildEntityTableMeta(entityName, entity), tableMeta);
|
|
147
|
+
return backingTable as ProjectionDefinition["table"];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Local alias for readability — `qualifyEntityName` is the shared helper
|
|
151
|
+
// from qualified-name.ts, also used by validateBoot to keep ingest and
|
|
152
|
+
// validation in lockstep on the qualification rule. Module-scope: stateless,
|
|
153
|
+
// no RegistryState threading needed.
|
|
154
|
+
export const qualify = qualifyEntityName;
|
|
155
|
+
|
|
156
|
+
// Bundles every Map/Set/array/scalar createRegistry populates during ingest —
|
|
157
|
+
// hoisted to module scope out of createRegistry's former closure, so the
|
|
158
|
+
// populateX/validateX phase-functions below can read/write them explicitly
|
|
159
|
+
// instead of via implicit capture. Every field is held BY REFERENCE (the
|
|
160
|
+
// actual Map/Set/array instance, never a destructured copy) — populateX
|
|
161
|
+
// functions mutate the same instance across calls for the same registry
|
|
162
|
+
// build. See docs/plans/god-files-refactor.md for the by-reference invariant.
|
|
163
|
+
export type RegistryState = {
|
|
164
|
+
featureMap: Map<string, FeatureDefinition>;
|
|
165
|
+
entityMap: Map<string, EntityDefinition>;
|
|
166
|
+
relationMap: Map<string, Record<string, RelationDefinition>>;
|
|
167
|
+
writeHandlerMap: Map<string, WriteHandlerDef>;
|
|
168
|
+
queryHandlerMap: Map<string, QueryHandlerDef>;
|
|
169
|
+
preSaveHooks: Map<string, OwnedFn<PreSaveHookFn>[]>;
|
|
170
|
+
postSaveHooks: Map<string, PhasedHook<PostSaveHookFn>[]>;
|
|
171
|
+
preDeleteHooks: Map<string, PhasedHook<PreDeleteHookFn>[]>;
|
|
172
|
+
postDeleteHooks: Map<string, PhasedHook<PostDeleteHookFn>[]>;
|
|
173
|
+
preQueryHooks: Map<string, OwnedFn<PreQueryHookFn>[]>;
|
|
174
|
+
postQueryHooks: Map<string, OwnedFn<PostQueryHookFn>[]>;
|
|
175
|
+
entityPostSaveHooks: Map<string, PhasedHook<PostSaveHookFn>[]>;
|
|
176
|
+
entityPreDeleteHooks: Map<string, PhasedHook<PreDeleteHookFn>[]>;
|
|
177
|
+
entityPostDeleteHooks: Map<string, PhasedHook<PostDeleteHookFn>[]>;
|
|
178
|
+
entityPostQueryHooks: Map<string, OwnedFn<PostQueryHookFn>[]>;
|
|
179
|
+
searchPayloadExtensions: Map<string, OwnedFn<SearchPayloadContributorFn>[]>;
|
|
180
|
+
configKeyMap: Map<string, ConfigKeyDefinition>;
|
|
181
|
+
jobMap: Map<string, JobDefinition>;
|
|
182
|
+
notificationMap: Map<string, NotificationDefinition>;
|
|
183
|
+
notificationFeatureMap: Map<string, string>;
|
|
184
|
+
eventMap: Map<string, EventDef>;
|
|
185
|
+
eventUpcasterMap: Map<
|
|
186
|
+
string,
|
|
187
|
+
{ readonly currentVersion: number; readonly chain: ReadonlyMap<number, EventUpcastFn> }
|
|
188
|
+
>;
|
|
189
|
+
handlerEntityMap: Map<string, string>;
|
|
190
|
+
handlerFeatureMap: Map<string, string>;
|
|
191
|
+
extensionMap: Map<string, RegistrarExtensionDef>;
|
|
192
|
+
extensionUsages: RegistrarExtensionRegistration[];
|
|
193
|
+
extensionSelectorMap: Map<string, string>;
|
|
194
|
+
allReferenceData: ReferenceDataDef[];
|
|
195
|
+
allConfigSeeds: ConfigSeedDef[];
|
|
196
|
+
mergedTranslations: Record<string, Record<string, string>>;
|
|
197
|
+
metricMap: Map<string, FeatureMetricDef & { readonly featureName: string }>;
|
|
198
|
+
secretKeyMap: Map<string, SecretKeyDefinition>;
|
|
199
|
+
projectionMap: Map<string, ProjectionDefinition>;
|
|
200
|
+
projectionsBySource: Map<string, ProjectionDefinition[]>;
|
|
201
|
+
multiStreamProjectionMap: Map<string, MultiStreamProjectionDefinition>;
|
|
202
|
+
multiStreamProjectionFeatureMap: Map<string, string>;
|
|
203
|
+
rawTableMap: Map<string, RawTableDef>;
|
|
204
|
+
unmanagedTableMap: Map<string, UnmanagedTableDef>;
|
|
205
|
+
physicalTableOwners: Map<
|
|
206
|
+
string,
|
|
207
|
+
{ kind: "entity" | "unmanaged"; owner: string; featureName: string }
|
|
208
|
+
>;
|
|
209
|
+
authClaimsHooks: AuthClaimsHookDef[];
|
|
210
|
+
claimKeyMap: Map<string, ClaimKeyDefinition>;
|
|
211
|
+
screenMap: Map<string, ScreenDefinition>;
|
|
212
|
+
screenFeatureMap: Map<string, string>;
|
|
213
|
+
screensByEntity: Map<string, ScreenDefinition[]>;
|
|
214
|
+
navMap: Map<string, NavDefinition>;
|
|
215
|
+
navFeatureMap: Map<string, string>;
|
|
216
|
+
navsByParent: Map<string, NavDefinition[]>;
|
|
217
|
+
topLevelNavs: NavDefinition[];
|
|
218
|
+
workspaceMap: Map<string, WorkspaceDefinition>;
|
|
219
|
+
workspaceFeatureMap: Map<string, string>;
|
|
220
|
+
navsByWorkspace: Map<string, string[]>;
|
|
221
|
+
defaultWorkspace: WorkspaceDefinition | undefined;
|
|
222
|
+
treeActionsMap: Map<string, Readonly<Record<string, TreeActionDef>>>;
|
|
223
|
+
searchableFieldsCache: Map<string, readonly string[]>;
|
|
224
|
+
sortableFieldsCache: Map<string, readonly string[]>;
|
|
225
|
+
searchIncludesCache: Map<string, ReadonlyMap<string, readonly string[]>>;
|
|
226
|
+
incomingRelationsCache: Map<string, IncomingRelation[]>;
|
|
227
|
+
hasRateLimitedHandlerCached: boolean;
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
export function createInitialState(): RegistryState {
|
|
231
|
+
return {
|
|
232
|
+
featureMap: new Map(),
|
|
233
|
+
entityMap: new Map(),
|
|
234
|
+
relationMap: new Map(),
|
|
235
|
+
writeHandlerMap: new Map(),
|
|
236
|
+
queryHandlerMap: new Map(),
|
|
237
|
+
preSaveHooks: new Map(),
|
|
238
|
+
postSaveHooks: new Map(),
|
|
239
|
+
preDeleteHooks: new Map(),
|
|
240
|
+
postDeleteHooks: new Map(),
|
|
241
|
+
preQueryHooks: new Map(),
|
|
242
|
+
postQueryHooks: new Map(),
|
|
243
|
+
entityPostSaveHooks: new Map(),
|
|
244
|
+
entityPreDeleteHooks: new Map(),
|
|
245
|
+
entityPostDeleteHooks: new Map(),
|
|
246
|
+
entityPostQueryHooks: new Map(),
|
|
247
|
+
searchPayloadExtensions: new Map(),
|
|
248
|
+
configKeyMap: new Map(),
|
|
249
|
+
jobMap: new Map(),
|
|
250
|
+
notificationMap: new Map(),
|
|
251
|
+
notificationFeatureMap: new Map(),
|
|
252
|
+
eventMap: new Map(),
|
|
253
|
+
eventUpcasterMap: new Map(),
|
|
254
|
+
handlerEntityMap: new Map(),
|
|
255
|
+
handlerFeatureMap: new Map(),
|
|
256
|
+
extensionMap: new Map(),
|
|
257
|
+
extensionUsages: [],
|
|
258
|
+
extensionSelectorMap: new Map(),
|
|
259
|
+
allReferenceData: [],
|
|
260
|
+
allConfigSeeds: [],
|
|
261
|
+
mergedTranslations: {},
|
|
262
|
+
metricMap: new Map(),
|
|
263
|
+
secretKeyMap: new Map(),
|
|
264
|
+
projectionMap: new Map(),
|
|
265
|
+
projectionsBySource: new Map(),
|
|
266
|
+
multiStreamProjectionMap: new Map(),
|
|
267
|
+
multiStreamProjectionFeatureMap: new Map(),
|
|
268
|
+
rawTableMap: new Map(),
|
|
269
|
+
unmanagedTableMap: new Map(),
|
|
270
|
+
physicalTableOwners: new Map(),
|
|
271
|
+
authClaimsHooks: [],
|
|
272
|
+
claimKeyMap: new Map(),
|
|
273
|
+
screenMap: new Map(),
|
|
274
|
+
screenFeatureMap: new Map(),
|
|
275
|
+
screensByEntity: new Map(),
|
|
276
|
+
navMap: new Map(),
|
|
277
|
+
navFeatureMap: new Map(),
|
|
278
|
+
navsByParent: new Map(),
|
|
279
|
+
topLevelNavs: [],
|
|
280
|
+
workspaceMap: new Map(),
|
|
281
|
+
workspaceFeatureMap: new Map(),
|
|
282
|
+
navsByWorkspace: new Map(),
|
|
283
|
+
defaultWorkspace: undefined,
|
|
284
|
+
treeActionsMap: new Map(),
|
|
285
|
+
searchableFieldsCache: new Map(),
|
|
286
|
+
sortableFieldsCache: new Map(),
|
|
287
|
+
searchIncludesCache: new Map(),
|
|
288
|
+
incomingRelationsCache: new Map(),
|
|
289
|
+
hasRateLimitedHandlerCached: false,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Filter hooks by phase and/or owning feature.
|
|
294
|
+
//
|
|
295
|
+
// - `phase === undefined` → any phase passes.
|
|
296
|
+
// - `effectiveFeatures === undefined` → ownership filter disabled.
|
|
297
|
+
// - hook.featureName === "*" or undefined → always passes ownership filter.
|
|
298
|
+
// "*" is reserved for extension-provided hooks that are invariant
|
|
299
|
+
// plumbing, not opt-in feature logic.
|
|
300
|
+
export function filterByPhase<TFn>(
|
|
301
|
+
list: readonly PhasedHook<TFn>[] | undefined,
|
|
302
|
+
phase: HookPhase | undefined,
|
|
303
|
+
effectiveFeatures?: ReadonlySet<string>,
|
|
304
|
+
): readonly TFn[] {
|
|
305
|
+
if (!list || list.length === 0) return [];
|
|
306
|
+
const result: TFn[] = [];
|
|
307
|
+
for (const entry of list) {
|
|
308
|
+
if (phase !== undefined && entry.phase !== phase) continue;
|
|
309
|
+
if (!ownerEnabled(entry.featureName, effectiveFeatures)) continue;
|
|
310
|
+
result.push(entry.fn);
|
|
311
|
+
}
|
|
312
|
+
return result;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Same ownership rule as filterByPhase, but for unphased hook lists
|
|
316
|
+
// (preSave, preQuery). Returns the raw fns ready for the lifecycle runner.
|
|
317
|
+
export function filterOwned<TFn>(
|
|
318
|
+
list: readonly OwnedFn<TFn>[] | undefined,
|
|
319
|
+
effectiveFeatures?: ReadonlySet<string>,
|
|
320
|
+
): readonly TFn[] {
|
|
321
|
+
if (!list || list.length === 0) return [];
|
|
322
|
+
const result: TFn[] = [];
|
|
323
|
+
for (const entry of list) {
|
|
324
|
+
if (!ownerEnabled(entry.featureName, effectiveFeatures)) continue;
|
|
325
|
+
result.push(entry.fn);
|
|
326
|
+
}
|
|
327
|
+
return result;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function ownerEnabled(
|
|
331
|
+
owner: string | undefined,
|
|
332
|
+
effectiveFeatures: ReadonlySet<string> | undefined,
|
|
333
|
+
): boolean {
|
|
334
|
+
if (!effectiveFeatures) return true;
|
|
335
|
+
if (owner === undefined || owner === "*") return true;
|
|
336
|
+
return effectiveFeatures.has(owner);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Merge hooks without prefix (entity hooks). featureName is already on
|
|
340
|
+
// every hook entry (set by defineFeature), so there's no parallel
|
|
341
|
+
// bookkeeping — just append.
|
|
342
|
+
export function mergeHookList<T>(
|
|
343
|
+
map: Map<string, T[]>,
|
|
344
|
+
source: Readonly<Record<string, readonly T[]>> | undefined,
|
|
345
|
+
): void {
|
|
346
|
+
// skip: optionaler entityHook-slot — features ohne postSave/preDelete/
|
|
347
|
+
// postDelete/postQuery lassen das slot undefined.
|
|
348
|
+
if (!source) return;
|
|
349
|
+
for (const [name, fns] of Object.entries(source)) {
|
|
350
|
+
const existing = map.get(name) ?? [];
|
|
351
|
+
existing.push(...fns);
|
|
352
|
+
map.set(name, existing);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Merge hooks with feature prefix (handler hooks).
|
|
357
|
+
// Hook keys are handler QNs — hooks don't get their own QN, they're keyed by the handler they target.
|
|
358
|
+
// The hookQnType indicates whether the targeted handler is a write or query handler.
|
|
359
|
+
export function mergeHookListQualified<T>(
|
|
360
|
+
map: Map<string, T[]>,
|
|
361
|
+
source: Readonly<Record<string, readonly T[]>> | undefined,
|
|
362
|
+
featureName: string,
|
|
363
|
+
hookQnType: QnType,
|
|
364
|
+
): void {
|
|
365
|
+
// skip: optionaler hook-slot — defineFeature materialisiert zwar alle
|
|
366
|
+
// Slots, aber hand-gebaute Definitionen an System-Grenzen (Fixtures,
|
|
367
|
+
// Partial-Boots, s. registry.test.ts) lassen sie weg. Leeres Record
|
|
368
|
+
// statt Object.entries(undefined)-Crash.
|
|
369
|
+
if (!source) return;
|
|
370
|
+
for (const [name, fns] of Object.entries(source)) {
|
|
371
|
+
const qualified = qualify(featureName, hookQnType, name);
|
|
372
|
+
const existing = map.get(qualified) ?? [];
|
|
373
|
+
existing.push(...fns);
|
|
374
|
+
map.set(qualified, existing);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Returns true if any entity in the feature has field-level access rules (read or write). */
|
|
379
|
+
export function hasFieldAccessRules(feature: FeatureDefinition): boolean {
|
|
380
|
+
for (const entity of Object.values(feature.entities ?? {})) {
|
|
381
|
+
for (const field of Object.values(entity.fields)) {
|
|
382
|
+
if (field.access?.read?.length || field.access?.write?.length) {
|
|
383
|
+
return true;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return false;
|
|
388
|
+
}
|