@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.
Files changed (68) hide show
  1. package/package.json +6 -2
  2. package/src/api/auth-routes.ts +32 -67
  3. package/src/api/routes.ts +1 -3
  4. package/src/bun-db/__tests__/PATTERN.md +0 -1
  5. package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +29 -3
  6. package/src/db/__tests__/schema-inspection.test.ts +7 -0
  7. package/src/db/event-store-executor-context.ts +398 -0
  8. package/src/db/event-store-executor-read.ts +276 -0
  9. package/src/db/event-store-executor-write.ts +615 -0
  10. package/src/db/event-store-executor.ts +29 -1166
  11. package/src/db/queries/shadow-swap.ts +3 -1
  12. package/src/db/schema-inspection.ts +1 -1
  13. package/src/engine/__tests__/boot-validator-dashboard.test.ts +10 -0
  14. package/src/engine/__tests__/engine.test.ts +45 -1
  15. package/src/engine/__tests__/event-migration-declarative.test.ts +6 -0
  16. package/src/engine/__tests__/membership-roles.test.ts +4 -10
  17. package/src/engine/__tests__/screen.test.ts +26 -0
  18. package/src/engine/boot-validator/entity-list-screens.ts +1 -1
  19. package/src/engine/boot-validator/gdpr-storage.ts +1 -1
  20. package/src/engine/boot-validator/index.ts +12 -8
  21. package/src/engine/boot-validator/nav.ts +120 -0
  22. package/src/engine/boot-validator/{screens-nav.ts → screens.ts} +12 -190
  23. package/src/engine/boot-validator/workspaces.ts +68 -0
  24. package/src/engine/define-feature.ts +77 -1011
  25. package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/feature.ts +9 -0
  26. package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/screens.ts +4 -0
  27. package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +18 -8
  28. package/src/engine/feature-ast/__tests__/parse.test.ts +291 -2
  29. package/src/engine/feature-ast/__tests__/patcher.test.ts +1 -1
  30. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +73 -0
  31. package/src/engine/feature-ast/extractors/round1.ts +3 -3
  32. package/src/engine/feature-ast/extractors/round4.ts +45 -10
  33. package/src/engine/feature-ast/extractors/shared.ts +64 -6
  34. package/src/engine/feature-ast/parse.ts +123 -10
  35. package/src/engine/feature-ast/patterns.ts +14 -7
  36. package/src/engine/feature-ast/render.ts +28 -7
  37. package/src/engine/feature-builder-state.ts +165 -0
  38. package/src/engine/feature-config-events-jobs.ts +303 -0
  39. package/src/engine/feature-entity-handlers.ts +161 -0
  40. package/src/engine/feature-ui-extensions.ts +413 -0
  41. package/src/engine/index.ts +0 -2
  42. package/src/engine/pattern-library/library.ts +44 -1131
  43. package/src/engine/pattern-library/mixed-schemas.ts +484 -0
  44. package/src/engine/pattern-library/opaque-schemas.ts +124 -0
  45. package/src/engine/pattern-library/shared-fields.ts +80 -0
  46. package/src/engine/pattern-library/static-schemas.ts +456 -0
  47. package/src/engine/registry-facade.ts +349 -0
  48. package/src/engine/registry-ingest.ts +473 -0
  49. package/src/engine/registry-state.ts +388 -0
  50. package/src/engine/registry-validate.ts +660 -0
  51. package/src/engine/registry.ts +79 -1695
  52. package/src/engine/types/screen.ts +4 -2
  53. package/src/engine/types/tree-node.ts +2 -5
  54. package/src/event-store/snapshot.ts +7 -7
  55. package/src/i18n/required-surface-keys.ts +2 -0
  56. package/src/jobs/job-runner.ts +1 -1
  57. package/src/observability/standard-metrics.ts +4 -3
  58. package/src/pipeline/dispatch-batch.ts +3 -3
  59. package/src/pipeline/dispatch-shared.ts +17 -10
  60. package/src/pipeline/event-dispatcher-admin.ts +289 -0
  61. package/src/pipeline/event-dispatcher-delivery.ts +264 -0
  62. package/src/pipeline/event-dispatcher.ts +28 -540
  63. package/src/pipeline/projection-rebuild.ts +1 -1
  64. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +95 -0
  65. package/src/stack/test-stack.ts +46 -1
  66. package/src/testing/boot-validator-fixture.ts +1 -2
  67. package/src/engine/__tests__/deep-link.test.ts +0 -35
  68. 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
+ }