@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
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { ZodObject, type ZodType, type z } from "zod";
|
|
2
|
+
import type { FeatureBuilderState } from "./feature-builder-state";
|
|
3
|
+
import { QnTypes, qn, toKebab } from "./qualified-name";
|
|
4
|
+
import type {
|
|
5
|
+
AuthClaimsFn,
|
|
6
|
+
ClaimKeyHandle,
|
|
7
|
+
ClaimKeyType,
|
|
8
|
+
ConfigKeyDefinition,
|
|
9
|
+
ConfigKeyHandle,
|
|
10
|
+
ConfigKeyType,
|
|
11
|
+
ConfigSeedDef,
|
|
12
|
+
DeclarativeEventMigration,
|
|
13
|
+
EventDef,
|
|
14
|
+
EventPiiFields,
|
|
15
|
+
EventUpcastFn,
|
|
16
|
+
JobDefinition,
|
|
17
|
+
JobHandlerFn,
|
|
18
|
+
MetricOptions,
|
|
19
|
+
NameOrRef,
|
|
20
|
+
NotificationDataFn,
|
|
21
|
+
NotificationRecipientFn,
|
|
22
|
+
NotificationTemplateFn,
|
|
23
|
+
QualifiedEventName,
|
|
24
|
+
SecretKeyHandle,
|
|
25
|
+
SecretOptions,
|
|
26
|
+
TranslationsDef,
|
|
27
|
+
} from "./types";
|
|
28
|
+
import { resolveName } from "./types/handlers";
|
|
29
|
+
|
|
30
|
+
// Builds config/secrets/claims/events/jobs/notifications registrar methods.
|
|
31
|
+
export function buildConfigEventsJobsMethods<TName extends string>(
|
|
32
|
+
state: FeatureBuilderState,
|
|
33
|
+
name: TName,
|
|
34
|
+
) {
|
|
35
|
+
return {
|
|
36
|
+
config<TKeys extends Readonly<Record<string, ConfigKeyDefinition<ConfigKeyType>>>>(definition: {
|
|
37
|
+
readonly keys: TKeys;
|
|
38
|
+
readonly seeds?: Readonly<Record<string, ConfigSeedDef>>;
|
|
39
|
+
}): { readonly [K in keyof TKeys]: ConfigKeyHandle<TKeys[K]["type"]> } {
|
|
40
|
+
// Qualify eagerly (same as defineEvent) so the handle name matches what
|
|
41
|
+
// the registry stores — lazy qualification would break compile-time
|
|
42
|
+
// autocomplete and hand-built test registries.
|
|
43
|
+
const handles: Record<string, ConfigKeyHandle<ConfigKeyType>> = {};
|
|
44
|
+
for (const [key, keyDef] of Object.entries(definition.keys)) {
|
|
45
|
+
state.configKeys[key] = keyDef;
|
|
46
|
+
handles[key] = {
|
|
47
|
+
name: qn(toKebab(name), "config", toKebab(key)),
|
|
48
|
+
type: keyDef.type,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
// Parse seeds: resolve qualified key names and validate scope
|
|
52
|
+
if (definition.seeds) {
|
|
53
|
+
for (const [shortKey, seedDef] of Object.entries(definition.seeds)) {
|
|
54
|
+
const keyDef = definition.keys[shortKey];
|
|
55
|
+
if (!keyDef) continue; // skip — boot-validator reports unknown keys
|
|
56
|
+
const qualifiedKey = qn(toKebab(name), "config", toKebab(shortKey));
|
|
57
|
+
const scope = seedDef.scope ?? keyDef.scope;
|
|
58
|
+
state.configSeeds.push({
|
|
59
|
+
key: qualifiedKey,
|
|
60
|
+
value: seedDef.value,
|
|
61
|
+
scope,
|
|
62
|
+
tenantId: seedDef.tenantId,
|
|
63
|
+
userId: seedDef.userId,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return handles as {
|
|
68
|
+
readonly [K in keyof TKeys]: ConfigKeyHandle<TKeys[K]["type"]>;
|
|
69
|
+
}; // @cast-boundary engine-bridge — Mapped-Type-Inference at config()-callsite
|
|
70
|
+
},
|
|
71
|
+
job(
|
|
72
|
+
jobName: string,
|
|
73
|
+
options: Omit<JobDefinition, "name" | "handler">,
|
|
74
|
+
handler: JobHandlerFn,
|
|
75
|
+
): void {
|
|
76
|
+
// Resolve NameOrRef(s) in trigger.on. Multi-Trigger-Form: Array
|
|
77
|
+
// wird zu Array von resolved strings, Single bleibt single string —
|
|
78
|
+
// job-runner unterscheidet anhand Array.isArray.
|
|
79
|
+
const trigger =
|
|
80
|
+
"on" in options.trigger
|
|
81
|
+
? {
|
|
82
|
+
on: Array.isArray(options.trigger.on)
|
|
83
|
+
? options.trigger.on.map(resolveName)
|
|
84
|
+
: resolveName(options.trigger.on as NameOrRef), // @cast-boundary engine-bridge
|
|
85
|
+
}
|
|
86
|
+
: options.trigger;
|
|
87
|
+
state.jobs[jobName] = { ...options, trigger, name: jobName, handler };
|
|
88
|
+
},
|
|
89
|
+
notification(
|
|
90
|
+
notificationName: string,
|
|
91
|
+
definition: {
|
|
92
|
+
readonly trigger: { readonly on: NameOrRef };
|
|
93
|
+
readonly recipient: NotificationRecipientFn;
|
|
94
|
+
readonly data: NotificationDataFn;
|
|
95
|
+
readonly templates?: Readonly<Record<string, NotificationTemplateFn>>;
|
|
96
|
+
},
|
|
97
|
+
): void {
|
|
98
|
+
state.notifications[notificationName] = {
|
|
99
|
+
name: notificationName,
|
|
100
|
+
trigger: { on: resolveName(definition.trigger.on) },
|
|
101
|
+
recipient: definition.recipient,
|
|
102
|
+
data: definition.data,
|
|
103
|
+
templates: definition.templates,
|
|
104
|
+
};
|
|
105
|
+
},
|
|
106
|
+
translations(def: TranslationsDef): void {
|
|
107
|
+
state.translations = { ...state.translations, ...def.keys };
|
|
108
|
+
},
|
|
109
|
+
defineEvent: <const TInner extends string, TPayload>(
|
|
110
|
+
eventName: TInner,
|
|
111
|
+
schema: ZodType<TPayload>,
|
|
112
|
+
options?: { readonly version?: number; readonly piiFields?: EventPiiFields },
|
|
113
|
+
): EventDef<TPayload, QualifiedEventName<TName, TInner>> => {
|
|
114
|
+
// Return the fully-qualified event name so callers can pass it
|
|
115
|
+
// straight to ctx.appendEvent without hand-building the
|
|
116
|
+
// "<feature>:event:<name>" shape. Registry keeps events keyed by
|
|
117
|
+
// short name — qualification is the framework's job, not the feature
|
|
118
|
+
// author's.
|
|
119
|
+
//
|
|
120
|
+
// The runtime kebab-step (`qn(toKebab(feature), …)`) is mirrored at
|
|
121
|
+
// the type-level by `QualifiedEventName<TName, TInner>` so the
|
|
122
|
+
// returned `name` carries the literal qualified shape that the
|
|
123
|
+
// augmented `KumikoEventTypeMap` keys against.
|
|
124
|
+
const qualified = qn(toKebab(name), "event", toKebab(eventName));
|
|
125
|
+
const version = options?.version ?? 1;
|
|
126
|
+
if (!Number.isInteger(version) || version < 1) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
`[Feature ${name}] defineEvent("${eventName}"): version must be a positive integer, got ${String(version)}`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
// piiFields misconfiguration is a boot-time error, not a silent
|
|
132
|
+
// plaintext leak: both the pii field and its subjectField must exist
|
|
133
|
+
// on the payload schema (checkable when the schema is a ZodObject).
|
|
134
|
+
const piiFields = options?.piiFields;
|
|
135
|
+
if (piiFields) {
|
|
136
|
+
const shape = schema instanceof ZodObject ? schema.shape : undefined;
|
|
137
|
+
for (const [field, spec] of Object.entries(piiFields)) {
|
|
138
|
+
if (field === spec.subjectField) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
`[Feature ${name}] defineEvent("${eventName}"): piiFields."${field}" cannot use itself as subjectField — the subject id is a plaintext pseudonymous fk, the pii field is the value it owns.`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
for (const required of [field, spec.subjectField]) {
|
|
144
|
+
if (shape && !(required in shape)) {
|
|
145
|
+
throw new Error(
|
|
146
|
+
`[Feature ${name}] defineEvent("${eventName}"): piiFields references "${required}" which is not a field of the payload schema.`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// @cast-boundary engine-bridge — runtime-string mirrors the
|
|
153
|
+
// template-literal-type via QualifiedEventName + toKebab. Both
|
|
154
|
+
// sides are tested (CamelToKebab type-tests + scan-events kebab
|
|
155
|
+
// tests), so the cast is a contract, not a typing-loss.
|
|
156
|
+
const def: EventDef<TPayload, QualifiedEventName<TName, TInner>> = {
|
|
157
|
+
name: qualified as QualifiedEventName<TName, TInner>,
|
|
158
|
+
schema,
|
|
159
|
+
version,
|
|
160
|
+
...(piiFields !== undefined && { piiFields }),
|
|
161
|
+
};
|
|
162
|
+
state.events[eventName] = def;
|
|
163
|
+
return def;
|
|
164
|
+
},
|
|
165
|
+
eventMigration(
|
|
166
|
+
eventName: string,
|
|
167
|
+
fromVersion: number,
|
|
168
|
+
toVersion: number,
|
|
169
|
+
transform: EventUpcastFn | DeclarativeEventMigration,
|
|
170
|
+
): void {
|
|
171
|
+
if (toVersion !== fromVersion + 1) {
|
|
172
|
+
throw new Error(
|
|
173
|
+
`[Feature ${name}] eventMigration("${eventName}", ${fromVersion}, ${toVersion}): ` +
|
|
174
|
+
`only single-step migrations are allowed — toVersion must be fromVersion + 1. ` +
|
|
175
|
+
`Chain larger jumps by registering each step separately.`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
if (!Number.isInteger(fromVersion) || fromVersion < 1) {
|
|
179
|
+
throw new Error(
|
|
180
|
+
`[Feature ${name}] eventMigration("${eventName}", ...): fromVersion must be >= 1, got ${String(fromVersion)}`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
const qualified = qn(toKebab(name), "event", toKebab(eventName));
|
|
184
|
+
const list = state.eventMigrations[eventName] ?? [];
|
|
185
|
+
if (list.some((m) => m.fromVersion === fromVersion)) {
|
|
186
|
+
throw new Error(
|
|
187
|
+
`[Feature ${name}] eventMigration("${eventName}", ${fromVersion}, ${toVersion}): ` +
|
|
188
|
+
`a migration from v${fromVersion} is already registered. Each step may only be declared once.`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
const transformFn =
|
|
192
|
+
typeof transform === "function" ? transform : compileEventMigration(transform);
|
|
193
|
+
list.push({ eventName: qualified, fromVersion, toVersion, transform: transformFn });
|
|
194
|
+
state.eventMigrations[eventName] = list;
|
|
195
|
+
},
|
|
196
|
+
readsConfig(...qualifiedKeys: string[]): void {
|
|
197
|
+
state.configReads.push(...qualifiedKeys);
|
|
198
|
+
},
|
|
199
|
+
metric(shortName: string, options: MetricOptions): void {
|
|
200
|
+
if (state.metrics[shortName]) {
|
|
201
|
+
throw new Error(
|
|
202
|
+
`[Feature ${name}] Metric "${shortName}" already registered. ` +
|
|
203
|
+
`Metric names must be unique per feature.`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
state.metrics[shortName] = { shortName, ...options };
|
|
207
|
+
},
|
|
208
|
+
envSchema(schema: z.ZodObject<z.ZodRawShape>): void {
|
|
209
|
+
if (state.envSchema !== undefined) {
|
|
210
|
+
throw new Error(
|
|
211
|
+
`[Feature ${name}] r.envSchema() called twice — declare one composed Zod-object per feature.`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
state.envSchema = schema;
|
|
215
|
+
},
|
|
216
|
+
secret(shortName: string, options: SecretOptions): SecretKeyHandle {
|
|
217
|
+
if (state.secretKeys[shortName]) {
|
|
218
|
+
throw new Error(
|
|
219
|
+
`[Feature ${name}] Secret "${shortName}" already registered. ` +
|
|
220
|
+
`Secret key names must be unique per feature.`,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
// Qualified name follows the framework's "<feature>:<type>:<name>"
|
|
224
|
+
// QN convention — same pattern config / jobs / events use. toKebab
|
|
225
|
+
// handles the common input shapes ("stripe.apiKey" → "stripe-api-key")
|
|
226
|
+
// so features can declare keys in their natural style without
|
|
227
|
+
// thinking about kebab-case on every call.
|
|
228
|
+
const qualifiedName = qn(toKebab(name), QnTypes.secret, toKebab(shortName));
|
|
229
|
+
state.secretKeys[shortName] = {
|
|
230
|
+
shortName,
|
|
231
|
+
qualifiedName,
|
|
232
|
+
...options,
|
|
233
|
+
};
|
|
234
|
+
return { name: qualifiedName };
|
|
235
|
+
},
|
|
236
|
+
claimKey<T extends ClaimKeyType>(
|
|
237
|
+
shortName: string,
|
|
238
|
+
options: { readonly type: T },
|
|
239
|
+
): ClaimKeyHandle<T> {
|
|
240
|
+
if (state.claimKeys[shortName]) {
|
|
241
|
+
throw new Error(
|
|
242
|
+
`[Feature ${name}] Claim key "${shortName}" already declared. ` +
|
|
243
|
+
"Claim short-names must be unique per feature.",
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
// Claim keys are NOT full QNs — the JWT shape is 2-segment
|
|
247
|
+
// "<featureName>:<shortName>" (same as Translation keys), not
|
|
248
|
+
// kebab-cased. The authClaims resolver prefixes with the raw
|
|
249
|
+
// feature.name + the raw inner key the hook returns, so the handle's
|
|
250
|
+
// `name` must match that literal string exactly for `readClaim` to
|
|
251
|
+
// find the value. kebab-conversion here would break the round-trip.
|
|
252
|
+
const qualifiedName = `${name}:${shortName}`;
|
|
253
|
+
state.claimKeys[shortName] = {
|
|
254
|
+
shortName,
|
|
255
|
+
qualifiedName,
|
|
256
|
+
type: options.type,
|
|
257
|
+
};
|
|
258
|
+
return { name: qualifiedName, type: options.type };
|
|
259
|
+
},
|
|
260
|
+
authClaims(fn: AuthClaimsFn): void {
|
|
261
|
+
state.authClaimsHooks.push(fn);
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Compile the declarative {rename, default, map} migration spec into an
|
|
267
|
+
// EventUpcastFn. Fixed order: rename → default → map.
|
|
268
|
+
function compileEventMigration(spec: DeclarativeEventMigration): EventUpcastFn {
|
|
269
|
+
// Registration-time (not replay-time) check: two rename sources mapping to
|
|
270
|
+
// the same target would silently drop one value on every future replay —
|
|
271
|
+
// event migrations run against the full production event history, so a
|
|
272
|
+
// typo here must fail loud at r.eventMigration() call time, not at replay.
|
|
273
|
+
const renameTargets = new Map<string, string>();
|
|
274
|
+
for (const [from, to] of Object.entries(spec.rename ?? {})) {
|
|
275
|
+
const existing = renameTargets.get(to);
|
|
276
|
+
if (existing !== undefined) {
|
|
277
|
+
throw new Error(
|
|
278
|
+
`Declarative event migration: rename collision — both "${existing}" and "${from}" rename to "${to}". Only one source may map to a given target.`,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
renameTargets.set(to, from);
|
|
282
|
+
}
|
|
283
|
+
return (payload) => {
|
|
284
|
+
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
|
|
285
|
+
throw new Error("Declarative event migration expects an object payload");
|
|
286
|
+
}
|
|
287
|
+
// @cast-boundary parse — payload is guarded as a plain object above
|
|
288
|
+
const next = { ...(payload as Record<string, unknown>) };
|
|
289
|
+
for (const [from, to] of Object.entries(spec.rename ?? {})) {
|
|
290
|
+
if (from in next) {
|
|
291
|
+
next[to] = next[from];
|
|
292
|
+
delete next[from];
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
for (const [key, value] of Object.entries(spec.default ?? {})) {
|
|
296
|
+
if (!(key in next)) next[key] = value;
|
|
297
|
+
}
|
|
298
|
+
for (const [key, fn] of Object.entries(spec.map ?? {})) {
|
|
299
|
+
if (key in next) next[key] = fn(next[key]);
|
|
300
|
+
}
|
|
301
|
+
return next;
|
|
302
|
+
};
|
|
303
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import type { ZodType, z } from "zod";
|
|
2
|
+
import { toTableName } from "../db/table-builder";
|
|
3
|
+
import type { QueryHandlerDefinition, WriteHandlerDefinition } from "./define-handler";
|
|
4
|
+
import { type RegisterEntityCrudOptions, registerEntityCrud } from "./entity-handlers";
|
|
5
|
+
import type { FeatureBuilderState } from "./feature-builder-state";
|
|
6
|
+
import type {
|
|
7
|
+
AccessRule,
|
|
8
|
+
EntityDefinition,
|
|
9
|
+
EntityRef,
|
|
10
|
+
FeatureRegistrar,
|
|
11
|
+
HandlerRef,
|
|
12
|
+
NameOrRef,
|
|
13
|
+
QueryHandlerFn,
|
|
14
|
+
RateLimitOption,
|
|
15
|
+
RelationDefinition,
|
|
16
|
+
WriteHandlerFn,
|
|
17
|
+
} from "./types";
|
|
18
|
+
import { resolveName } from "./types/handlers";
|
|
19
|
+
import type { PipelineDef } from "./types/step";
|
|
20
|
+
|
|
21
|
+
const CRUD_VERBS = new Set(["create", "update", "delete"]);
|
|
22
|
+
|
|
23
|
+
// Map handler name to entity via colon convention.
|
|
24
|
+
// "task:create" → entity "task". Bare CRUD verbs (create/update/delete) map
|
|
25
|
+
// when feature name matches an entity or the feature owns exactly one entity.
|
|
26
|
+
function tryMapEntity(state: FeatureBuilderState, name: string, handlerName: string): void {
|
|
27
|
+
const colonIdx = handlerName.indexOf(":");
|
|
28
|
+
if (colonIdx >= 0) {
|
|
29
|
+
const candidate = handlerName.slice(0, colonIdx);
|
|
30
|
+
if (state.entities[candidate]) {
|
|
31
|
+
state.handlerEntityMappings[handlerName] = candidate;
|
|
32
|
+
}
|
|
33
|
+
// skip: colon-prefixed handler processed (mapped or not), bare CRUD path not applicable
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (CRUD_VERBS.has(handlerName)) {
|
|
37
|
+
if (state.entities[name]) {
|
|
38
|
+
state.handlerEntityMappings[handlerName] = name;
|
|
39
|
+
// skip: feature-name entity match is the preferred mapping
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const entityKeys = Object.keys(state.entities);
|
|
43
|
+
if (entityKeys.length === 1) {
|
|
44
|
+
state.handlerEntityMappings[handlerName] = entityKeys[0] as string;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Builds entity/crud/relation/writeHandler/queryHandler — the registrar
|
|
50
|
+
// methods that create or reference entities. `crud` needs the fully-built
|
|
51
|
+
// registrar (self-reference into registerEntityCrud); getRegistrar is a lazy
|
|
52
|
+
// thunk since the registrar object doesn't exist yet while this is composed.
|
|
53
|
+
export function buildEntityHandlerMethods<TName extends string>(
|
|
54
|
+
state: FeatureBuilderState,
|
|
55
|
+
name: TName,
|
|
56
|
+
getRegistrar: () => FeatureRegistrar<TName>,
|
|
57
|
+
) {
|
|
58
|
+
return {
|
|
59
|
+
entity(
|
|
60
|
+
entityName: string,
|
|
61
|
+
definition: EntityDefinition,
|
|
62
|
+
options?: { readonly table?: unknown },
|
|
63
|
+
): EntityRef {
|
|
64
|
+
state.entities[entityName] = definition;
|
|
65
|
+
if (options?.table !== undefined) state.entityTables[entityName] = options.table;
|
|
66
|
+
return { name: entityName, table: definition.table ?? toTableName(entityName) };
|
|
67
|
+
},
|
|
68
|
+
crud(
|
|
69
|
+
entityName: string,
|
|
70
|
+
definition: EntityDefinition,
|
|
71
|
+
options?: RegisterEntityCrudOptions,
|
|
72
|
+
): EntityRef {
|
|
73
|
+
registerEntityCrud(getRegistrar(), entityName, definition, options);
|
|
74
|
+
return { name: entityName, table: definition.table ?? toTableName(entityName) };
|
|
75
|
+
},
|
|
76
|
+
relation(entityRef: NameOrRef, relationName: string, definition: RelationDefinition): void {
|
|
77
|
+
const entityName = resolveName(entityRef);
|
|
78
|
+
if (!state.relations[entityName]) state.relations[entityName] = {};
|
|
79
|
+
state.relations[entityName][relationName] = definition;
|
|
80
|
+
},
|
|
81
|
+
writeHandler<TName extends string, TSchema extends ZodType>(
|
|
82
|
+
nameOrDef: string | WriteHandlerDefinition<TName, TSchema>,
|
|
83
|
+
schema?: TSchema,
|
|
84
|
+
handler?: WriteHandlerFn<z.infer<TSchema>>,
|
|
85
|
+
options?: { access?: AccessRule; rateLimit?: RateLimitOption },
|
|
86
|
+
): HandlerRef {
|
|
87
|
+
if (typeof nameOrDef === "object") {
|
|
88
|
+
const def = nameOrDef;
|
|
89
|
+
state.writeHandlers[def.name] = {
|
|
90
|
+
name: def.name,
|
|
91
|
+
schema: def.schema,
|
|
92
|
+
// @cast-boundary engine-bridge — typed Dev-API's handler is
|
|
93
|
+
// generic over the schema's parsed payload (`WriteEvent<output<TSchema>>`),
|
|
94
|
+
// the storage form WriteHandlerFn carries `WriteEvent<unknown>`.
|
|
95
|
+
// Function-arg variance: TS sees the typed handler as stricter
|
|
96
|
+
// than the loose storage shape and rejects direct assignment.
|
|
97
|
+
// The runtime value is identical — the cast crosses that boundary.
|
|
98
|
+
// `satisfies` does not work here (it asserts assignability, which
|
|
99
|
+
// is what fails). Explicit cast is the right tool.
|
|
100
|
+
handler: def.handler as WriteHandlerFn,
|
|
101
|
+
...(def.access && { access: def.access }),
|
|
102
|
+
...(def.unsafeSkipTransitionGuard && { unsafeSkipTransitionGuard: true }),
|
|
103
|
+
...(def.rateLimit && { rateLimit: def.rateLimit }),
|
|
104
|
+
// Forward the pipeline-build closure so boot-validators and
|
|
105
|
+
// Designer/AI tooling can inspect the step list. Absent on
|
|
106
|
+
// free-form handlers — defineWriteHandler only sets `perform`
|
|
107
|
+
// when the author used the pipeline form. Variance cast
|
|
108
|
+
// mirrors the handler-cast above: PipelineDef<output<TSchema>>
|
|
109
|
+
// is stricter than PipelineDef<unknown> for the same reason.
|
|
110
|
+
...(def.perform !== undefined && {
|
|
111
|
+
perform: def.perform as PipelineDef,
|
|
112
|
+
}),
|
|
113
|
+
};
|
|
114
|
+
tryMapEntity(state, name, def.name);
|
|
115
|
+
return { name: def.name };
|
|
116
|
+
}
|
|
117
|
+
if (!schema || !handler)
|
|
118
|
+
throw new Error("writeHandler inline form requires schema + handler");
|
|
119
|
+
state.writeHandlers[nameOrDef] = {
|
|
120
|
+
name: nameOrDef,
|
|
121
|
+
schema,
|
|
122
|
+
handler: handler as WriteHandlerFn, // @cast-boundary engine-bridge
|
|
123
|
+
...(options?.access && { access: options.access }),
|
|
124
|
+
...(options?.rateLimit && { rateLimit: options.rateLimit }),
|
|
125
|
+
};
|
|
126
|
+
tryMapEntity(state, name, nameOrDef);
|
|
127
|
+
return { name: nameOrDef };
|
|
128
|
+
},
|
|
129
|
+
queryHandler<TName extends string, TSchema extends ZodType>(
|
|
130
|
+
nameOrDef: string | QueryHandlerDefinition<TName, TSchema>,
|
|
131
|
+
schema?: TSchema,
|
|
132
|
+
handler?: QueryHandlerFn<z.infer<TSchema>>,
|
|
133
|
+
options?: { access?: AccessRule; rateLimit?: RateLimitOption },
|
|
134
|
+
): HandlerRef {
|
|
135
|
+
if (typeof nameOrDef === "object") {
|
|
136
|
+
const def = nameOrDef;
|
|
137
|
+
state.queryHandlers[def.name] = {
|
|
138
|
+
name: def.name,
|
|
139
|
+
schema: def.schema,
|
|
140
|
+
// @cast-boundary engine-bridge — typed Dev-API → erased internal storage
|
|
141
|
+
handler: def.handler as QueryHandlerFn, // @cast-boundary engine-bridge
|
|
142
|
+
...(def.access && { access: def.access }),
|
|
143
|
+
...(def.rateLimit && { rateLimit: def.rateLimit }),
|
|
144
|
+
};
|
|
145
|
+
tryMapEntity(state, name, def.name);
|
|
146
|
+
return { name: def.name };
|
|
147
|
+
}
|
|
148
|
+
if (!schema || !handler)
|
|
149
|
+
throw new Error("queryHandler inline form requires schema + handler");
|
|
150
|
+
state.queryHandlers[nameOrDef] = {
|
|
151
|
+
name: nameOrDef,
|
|
152
|
+
schema,
|
|
153
|
+
handler: handler as QueryHandlerFn, // @cast-boundary engine-bridge
|
|
154
|
+
...(options?.access && { access: options.access }),
|
|
155
|
+
...(options?.rateLimit && { rateLimit: options.rateLimit }),
|
|
156
|
+
};
|
|
157
|
+
tryMapEntity(state, name, nameOrDef);
|
|
158
|
+
return { name: nameOrDef };
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|