@cosmicdrift/kumiko-framework 0.154.0 → 0.154.2

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 (46) hide show
  1. package/package.json +2 -2
  2. package/src/__tests__/full-stack.integration.test.ts +1 -1
  3. package/src/api/__tests__/batch.integration.test.ts +9 -9
  4. package/src/engine/__tests__/engine.test.ts +16 -0
  5. package/src/engine/__tests__/event-migration-declarative.test.ts +9 -2
  6. package/src/engine/__tests__/hook-phases.test.ts +5 -5
  7. package/src/engine/__tests__/post-query-hook.test.ts +7 -7
  8. package/src/engine/__tests__/registrar-object-form.test.ts +141 -0
  9. package/src/engine/define-feature.ts +22 -4
  10. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +15 -28
  11. package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +1 -2
  12. package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +1 -1
  13. package/src/engine/feature-ast/__tests__/parse.test.ts +55 -14
  14. package/src/engine/feature-ast/__tests__/patch.test.ts +8 -13
  15. package/src/engine/feature-ast/__tests__/patcher.test.ts +12 -22
  16. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +247 -5
  17. package/src/engine/feature-ast/extractors/index.ts +0 -3
  18. package/src/engine/feature-ast/extractors/round4.ts +113 -287
  19. package/src/engine/feature-ast/index.ts +0 -4
  20. package/src/engine/feature-ast/parse.ts +0 -6
  21. package/src/engine/feature-ast/patch.ts +35 -45
  22. package/src/engine/feature-ast/patcher.ts +18 -40
  23. package/src/engine/feature-ast/patterns.ts +20 -41
  24. package/src/engine/feature-ast/render.ts +17 -27
  25. package/src/engine/feature-config-events-jobs.ts +150 -74
  26. package/src/engine/feature-entity-handlers.ts +24 -6
  27. package/src/engine/feature-ui-extensions.ts +116 -45
  28. package/src/engine/object-form.ts +12 -0
  29. package/src/engine/pattern-library/__tests__/library.test.ts +0 -19
  30. package/src/engine/pattern-library/library.ts +0 -4
  31. package/src/engine/pattern-library/mixed-schemas.ts +9 -80
  32. package/src/engine/pattern-library/shared-fields.ts +1 -6
  33. package/src/engine/registry-ingest.ts +7 -2
  34. package/src/engine/registry-validate.ts +6 -6
  35. package/src/engine/types/feature.ts +76 -45
  36. package/src/engine/types/handlers.ts +6 -3
  37. package/src/engine/types/nav.ts +4 -0
  38. package/src/event-store/__tests__/upcaster.integration.test.ts +77 -45
  39. package/src/jobs/__tests__/jobs.integration.test.ts +66 -1
  40. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +2 -2
  41. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +1 -1
  42. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +16 -8
  43. package/src/pipeline/__tests__/post-query-hook.integration.test.ts +3 -3
  44. package/src/search/__tests__/meilisearch-adapter.integration.test.ts +200 -185
  45. package/src/search/__tests__/meilisearch-ids.test.ts +30 -0
  46. package/src/search/meilisearch-adapter.ts +13 -12
@@ -106,19 +106,15 @@ export type AddQueryHandlerArgs = {
106
106
 
107
107
  export type AddHookArgs = {
108
108
  readonly type: LifecycleHookType | "validation";
109
- readonly target: string | readonly string[];
109
+ // `{ allOf: entity }` entity-wide target, fires for every write/query
110
+ // handler of that entity; replaces the old addEntityHook(). Only valid
111
+ // for postSave/preDelete/postDelete/postQuery, same as r.hook() itself.
112
+ readonly target: string | readonly string[] | { readonly allOf: string };
110
113
  /** Source text of the closure, e.g. `"async (event, ctx) => { ... }"`. */
111
114
  readonly handlerSource: string;
112
115
  readonly phase?: HookPhase;
113
116
  };
114
117
 
115
- export type AddEntityHookArgs = {
116
- readonly type: "postSave" | "preDelete" | "postDelete";
117
- readonly entity: string;
118
- readonly handlerSource: string;
119
- readonly phase?: HookPhase;
120
- };
121
-
122
118
  export type AddJobArgs = {
123
119
  readonly name: string;
124
120
  readonly options: Omit<JobDefinition, "name" | "handler">;
@@ -144,13 +140,10 @@ export type AddDefineEventArgs = {
144
140
  readonly name: string;
145
141
  readonly schemaSource: string;
146
142
  readonly version?: number;
147
- };
148
-
149
- export type AddEventMigrationArgs = {
150
- readonly event: string;
151
- readonly fromVersion: number;
152
- readonly toVersion: number;
153
- readonly transformSource: string;
143
+ // Keyed by fromVersion (as a string) → the transform source text for the
144
+ // fromVersion -> fromVersion+1 step. Folded in from the former
145
+ // addEventMigration (#1082 step 8).
146
+ readonly migrations?: Readonly<Record<string, string>>;
154
147
  };
155
148
 
156
149
  export type AddProjectionArgs = {
@@ -241,7 +234,6 @@ export type FeaturePatcher = {
241
234
  readonly addWriteHandler: (args: AddWriteHandlerArgs) => void;
242
235
  readonly addQueryHandler: (args: AddQueryHandlerArgs) => void;
243
236
  readonly addHook: (args: AddHookArgs) => void;
244
- readonly addEntityHook: (args: AddEntityHookArgs) => void;
245
237
  readonly addJob: (args: AddJobArgs) => void;
246
238
  readonly addNotification: (args: AddNotificationArgs) => void;
247
239
  readonly addAuthClaims: (args: AddAuthClaimsArgs) => void;
@@ -249,7 +241,6 @@ export type FeaturePatcher = {
249
241
  readonly addProjection: (args: AddProjectionArgs) => void;
250
242
  readonly addMultiStreamProjection: (args: AddMultiStreamProjectionArgs) => void;
251
243
  readonly addDefineEvent: (args: AddDefineEventArgs) => void;
252
- readonly addEventMigration: (args: AddEventMigrationArgs) => void;
253
244
  // --- Symmetric ops (id-driven) ---
254
245
  readonly replace: (id: PatternId, pattern: FeaturePattern) => void;
255
246
  readonly remove: (id: PatternId) => void;
@@ -412,17 +403,6 @@ export function createFeaturePatcher(sourceFile: SourceFile): FeaturePatcher {
412
403
  });
413
404
  },
414
405
 
415
- addEntityHook({ type, entity, handlerSource, phase }) {
416
- add({
417
- kind: "entityHook",
418
- source: SYNTHETIC_LOC,
419
- hookType: type,
420
- entityName: entity,
421
- fnBody: rawLoc(handlerSource),
422
- ...(phase !== undefined && { phase }),
423
- });
424
- },
425
-
426
406
  addJob({ name, options, handlerSource }) {
427
407
  add({
428
408
  kind: "job",
@@ -495,24 +475,22 @@ export function createFeaturePatcher(sourceFile: SourceFile): FeaturePatcher {
495
475
  });
496
476
  },
497
477
 
498
- addDefineEvent({ name, schemaSource, version }) {
478
+ addDefineEvent({ name, schemaSource, version, migrations }) {
479
+ const migrationLocs = migrations
480
+ ? Object.fromEntries(
481
+ Object.entries(migrations).map(([fromVersion, source]) => [
482
+ fromVersion,
483
+ rawLoc(source),
484
+ ]),
485
+ )
486
+ : undefined;
499
487
  add({
500
488
  kind: "defineEvent",
501
489
  source: SYNTHETIC_LOC,
502
490
  eventName: name,
503
491
  schemaSource: rawLoc(schemaSource),
504
492
  ...(version !== undefined && { version }),
505
- });
506
- },
507
-
508
- addEventMigration({ event, fromVersion, toVersion, transformSource }) {
509
- add({
510
- kind: "eventMigration",
511
- source: SYNTHETIC_LOC,
512
- eventName: event,
513
- fromVersion,
514
- toVersion,
515
- transformBody: rawLoc(transformSource),
493
+ ...(migrationLocs !== undefined && { migrations: migrationLocs }),
516
494
  });
517
495
  },
518
496
 
@@ -376,30 +376,20 @@ export type QueryHandlerPattern = {
376
376
 
377
377
  // `r.hook(type, target, fn, options?)` — attaches a lifecycle hook
378
378
  // (`validation`, `preSave`, `postSave`, `preDelete`, `postDelete`,
379
- // `preQuery`, `postQuery`) to one or more target handlers. Post-hooks
380
- // accept a `phase` option; `preDelete` always runs in-transaction — it
381
- // guards the delete. The hook body is an opaque code span.
379
+ // `preQuery`, `postQuery`) to one or more target handlers, or — for
380
+ // postSave/preDelete/postDelete/postQuery to `{ allOf: entityRef }`
381
+ // ("every write/query handler of this entity", replacing the old
382
+ // `r.entityHook(type, entity, fn)`). Post-hooks accept a `phase` option;
383
+ // `preDelete` always runs in-transaction — it guards the delete. The hook
384
+ // body is an opaque code span.
382
385
  export type HookPattern = {
383
386
  readonly kind: "hook";
384
387
  readonly source: SourceLocation;
385
388
  readonly hookType: LifecycleHookType | "validation";
386
- // r.hook accepts a single target or a list; we keep both shapes so
387
- // the Designer can render the original author intent.
388
- readonly target: string | readonly string[];
389
- readonly fnBody: SourceLocation;
390
- readonly phase?: HookPhase;
391
- };
392
-
393
- // `r.entityHook(type, entity, fn, options?)` — like `r.hook`, but bound to
394
- // an entity instead of individual handlers: `postSave`, `preDelete`, and
395
- // `postDelete` fire on every matching write. The runtime API additionally
396
- // accepts `postQuery` (fires for all query-handlers of the entity), but
397
- // this pattern type only represents the three write-side hooks.
398
- export type EntityHookPattern = {
399
- readonly kind: "entityHook";
400
- readonly source: SourceLocation;
401
- readonly hookType: "postSave" | "preDelete" | "postDelete";
402
- readonly entityName: string;
389
+ // r.hook accepts a single target, a list, or an entity-wide { allOf }
390
+ // target; we keep all three shapes so the Designer can render the
391
+ // original author intent.
392
+ readonly target: string | readonly string[] | { readonly allOf: string };
403
393
  readonly fnBody: SourceLocation;
404
394
  readonly phase?: HookPhase;
405
395
  };
@@ -501,29 +491,22 @@ export type MultiStreamProjectionPattern = {
501
491
  // `r.defineEvent(name, schema, options?)` — registers an event payload
502
492
  // shape and returns the qualified `EventDef`, so callers pass `.name` to
503
493
  // `ctx.appendEvent` instead of hand-building `<feature>:event:<short>`.
504
- // `options.version` declares the CURRENT schema generation (default 1);
505
- // bump it together with an `r.eventMigration` step the framework refuses
506
- // to boot if the chain from 1 to the current version has gaps.
494
+ // `options.version` declares the CURRENT schema generation (default 1).
495
+ // `options.migrations` is a step-wise upcast chainkeyed by fromVersion
496
+ // (toVersion is always fromVersion + 1, enforced at registration, so it is
497
+ // not stored separately); the framework refuses to boot if the chain from
498
+ // 1 to `version` has gaps. Formerly a separate `r.eventMigration()` call
499
+ // per step, folded in here (#1082 step 8) — an event and its schema
500
+ // evolution are one lifecycle, not two registrar concepts.
507
501
  export type DefineEventPattern = {
508
502
  readonly kind: "defineEvent";
509
503
  readonly source: SourceLocation;
510
504
  readonly eventName: string;
511
505
  readonly schemaSource: SourceLocation;
512
506
  readonly version?: number;
513
- };
514
-
515
- // `r.eventMigration(eventName, fromVersion, toVersion, transform)` —
516
- // registers a step-wise payload upcast for event-schema evolution.
517
- // `toVersion` must be `fromVersion + 1`; chain larger jumps step by step.
518
- // Transforms are pure old-payload-in/new-payload-out functions and run once
519
- // per READ, not once per event persisted — keep them cheap.
520
- export type EventMigrationPattern = {
521
- readonly kind: "eventMigration";
522
- readonly source: SourceLocation;
523
- readonly eventName: string;
524
- readonly fromVersion: number;
525
- readonly toVersion: number;
526
- readonly transformBody: SourceLocation;
507
+ // Map fromVersion (as string, e.g. "1") → SourceLocation of the transform
508
+ // closure for the fromVersion → fromVersion+1 step.
509
+ readonly migrations?: Readonly<Record<string, SourceLocation>>;
527
510
  };
528
511
 
529
512
  // `r.extendsRegistrar(extensionName, def)` — declares a named, globally
@@ -610,7 +593,6 @@ export type FeaturePattern =
610
593
  | WriteHandlerPattern
611
594
  | QueryHandlerPattern
612
595
  | HookPattern
613
- | EntityHookPattern
614
596
  | JobPattern
615
597
  | NotificationPattern
616
598
  | AuthClaimsPattern
@@ -618,7 +600,6 @@ export type FeaturePattern =
618
600
  | ProjectionPattern
619
601
  | MultiStreamProjectionPattern
620
602
  | DefineEventPattern
621
- | EventMigrationPattern
622
603
  | ExtendsRegistrarPattern
623
604
  | EnvSchemaPattern
624
605
  // Catch-all
@@ -667,14 +648,12 @@ export function getEditability(pattern: FeaturePattern): Editability {
667
648
  case "writeHandler":
668
649
  case "queryHandler":
669
650
  case "hook":
670
- case "entityHook":
671
651
  case "job":
672
652
  case "notification":
673
653
  case "httpRoute":
674
654
  case "projection":
675
655
  case "multiStreamProjection":
676
656
  case "defineEvent":
677
- case "eventMigration":
678
657
  return "mixed";
679
658
  case "authClaims":
680
659
  case "extendsRegistrar":
@@ -23,10 +23,8 @@ import type {
23
23
  ConfigPattern,
24
24
  DefineEventPattern,
25
25
  DescribePattern,
26
- EntityHookPattern,
27
26
  EntityPattern,
28
27
  EnvSchemaPattern,
29
- EventMigrationPattern,
30
28
  ExposesApiPattern,
31
29
  ExtendsRegistrarPattern,
32
30
  FeaturePattern,
@@ -113,8 +111,6 @@ export function renderPattern(pattern: FeaturePattern): string {
113
111
  return renderQueryHandler(pattern);
114
112
  case "hook":
115
113
  return renderHook(pattern);
116
- case "entityHook":
117
- return renderEntityHook(pattern);
118
114
  case "job":
119
115
  return renderJob(pattern);
120
116
  case "notification":
@@ -129,8 +125,6 @@ export function renderPattern(pattern: FeaturePattern): string {
129
125
  return renderMultiStreamProjection(pattern);
130
126
  case "defineEvent":
131
127
  return renderDefineEvent(pattern);
132
- case "eventMigration":
133
- return renderEventMigration(pattern);
134
128
  case "extendsRegistrar":
135
129
  return renderExtendsRegistrar(pattern);
136
130
  case "usesApi":
@@ -401,20 +395,16 @@ function renderQueryHandler(p: QueryHandlerPattern): string {
401
395
  return lines.join("\n");
402
396
  }
403
397
 
404
- function renderHook(p: HookPattern): string {
405
- const lines: string[] = ["r.hook({"];
406
- lines.push(` type: ${JSON.stringify(p.hookType)},`);
407
- lines.push(` target: ${renderValue(typeof p.target === "string" ? p.target : [...p.target])},`);
408
- lines.push(` handler: ${reindentBody(p.fnBody.raw, PATTERN_INDENT)},`);
409
- if (p.phase !== undefined) lines.push(` phase: ${JSON.stringify(p.phase)},`);
410
- lines.push("});");
411
- return lines.join("\n");
398
+ function renderHookTarget(target: HookPattern["target"]): string {
399
+ if (typeof target === "string") return renderValue(target);
400
+ if ("allOf" in target) return `{ allOf: ${JSON.stringify(target.allOf)} }`;
401
+ return renderValue([...target]);
412
402
  }
413
403
 
414
- function renderEntityHook(p: EntityHookPattern): string {
415
- const lines: string[] = ["r.entityHook({"];
404
+ function renderHook(p: HookPattern): string {
405
+ const lines: string[] = ["r.hook({"];
416
406
  lines.push(` type: ${JSON.stringify(p.hookType)},`);
417
- lines.push(` entity: ${JSON.stringify(p.entityName)},`);
407
+ lines.push(` target: ${renderHookTarget(p.target)},`);
418
408
  lines.push(` handler: ${reindentBody(p.fnBody.raw, PATTERN_INDENT)},`);
419
409
  if (p.phase !== undefined) lines.push(` phase: ${JSON.stringify(p.phase)},`);
420
410
  lines.push("});");
@@ -505,16 +495,16 @@ function renderDefineEvent(p: DefineEventPattern): string {
505
495
  lines.push(` name: ${JSON.stringify(p.eventName)},`);
506
496
  lines.push(` schema: ${p.schemaSource.raw},`);
507
497
  if (p.version !== undefined) lines.push(` version: ${p.version},`);
508
- lines.push("});");
509
- return lines.join("\n");
510
- }
511
-
512
- function renderEventMigration(p: EventMigrationPattern): string {
513
- const lines: string[] = ["r.eventMigration({"];
514
- lines.push(` event: ${JSON.stringify(p.eventName)},`);
515
- lines.push(` fromVersion: ${p.fromVersion},`);
516
- lines.push(` toVersion: ${p.toVersion},`);
517
- lines.push(` transform: ${p.transformBody.raw},`);
498
+ if (p.migrations !== undefined) {
499
+ const entries = Object.entries(p.migrations);
500
+ if (entries.length > 0) {
501
+ lines.push(" migrations: {");
502
+ for (const [fromVersion, transformBody] of entries) {
503
+ lines.push(` "${fromVersion}": ${transformBody.raw},`);
504
+ }
505
+ lines.push(" },");
506
+ }
507
+ }
518
508
  lines.push("});");
519
509
  return lines.join("\n");
520
510
  }
@@ -1,5 +1,6 @@
1
1
  import { ZodObject, type ZodType, type z } from "zod";
2
2
  import type { FeatureBuilderState } from "./feature-builder-state";
3
+ import { splitNamedDefinition } from "./object-form";
3
4
  import { QnTypes, qn, toKebab } from "./qualified-name";
4
5
  import type {
5
6
  AuthClaimsFn,
@@ -93,41 +94,121 @@ export function buildConfigEventsJobsMethods<TName extends string>(
93
94
  return typeof arg1 === "string" ? handles[arg1] : handles; // @cast-boundary engine-bridge — overload impl signature widens to unknown, narrowed by the two public overloads above
94
95
  }
95
96
 
97
+ // piiFields misconfiguration is a boot-time error, not a silent
98
+ // plaintext leak: both the pii field and its subjectField must exist
99
+ // on the payload schema (checkable when the schema is a ZodObject).
100
+ function validateEventPiiFields(
101
+ eventName: string,
102
+ schema: ZodType,
103
+ piiFields: EventPiiFields,
104
+ ): void {
105
+ const shape = schema instanceof ZodObject ? schema.shape : undefined;
106
+ for (const [field, spec] of Object.entries(piiFields)) {
107
+ if (field === spec.subjectField) {
108
+ throw new Error(
109
+ `[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.`,
110
+ );
111
+ }
112
+ for (const required of [field, spec.subjectField]) {
113
+ if (shape && !(required in shape)) {
114
+ throw new Error(
115
+ `[Feature ${name}] defineEvent("${eventName}"): piiFields references "${required}" which is not a field of the payload schema.`,
116
+ );
117
+ }
118
+ }
119
+ }
120
+ }
121
+
122
+ // Shared by defineEvent's `migrations` option — each entry registers a
123
+ // single upcast step, same validation/dedup as the old standalone
124
+ // r.eventMigration() call (folded into defineEvent, #1082 step 8).
125
+ function registerEventMigration(
126
+ eventName: string,
127
+ fromVersion: number,
128
+ toVersion: number,
129
+ transform: EventUpcastFn | DeclarativeEventMigration,
130
+ ): void {
131
+ if (toVersion !== fromVersion + 1) {
132
+ throw new Error(
133
+ `[Feature ${name}] defineEvent("${eventName}") migrations: only single-step migrations are allowed — toVersion must be fromVersion + 1 (got ${fromVersion} -> ${toVersion}). ` +
134
+ `Chain larger jumps by declaring each step separately.`,
135
+ );
136
+ }
137
+ if (!Number.isInteger(fromVersion) || fromVersion < 1) {
138
+ throw new Error(
139
+ `[Feature ${name}] defineEvent("${eventName}") migrations: fromVersion must be >= 1, got ${String(fromVersion)}`,
140
+ );
141
+ }
142
+ const qualified = qn(toKebab(name), "event", toKebab(eventName));
143
+ const list = state.eventMigrations[eventName] ?? [];
144
+ if (list.some((m) => m.fromVersion === fromVersion)) {
145
+ throw new Error(
146
+ `[Feature ${name}] defineEvent("${eventName}") migrations: a migration from v${fromVersion} is already declared. Each step may only be declared once.`,
147
+ );
148
+ }
149
+ const transformFn =
150
+ typeof transform === "function" ? transform : compileEventMigration(transform);
151
+ list.push({ eventName: qualified, fromVersion, toVersion, transform: transformFn });
152
+ state.eventMigrations[eventName] = list;
153
+ }
154
+
96
155
  return {
97
156
  config,
98
157
  job(
99
- jobName: string,
100
- options: Omit<JobDefinition, "name" | "handler">,
101
- handler: JobHandlerFn,
158
+ jobNameOrDefinition: string | JobDefinition,
159
+ options?: Omit<JobDefinition, "name" | "handler">,
160
+ handler?: JobHandlerFn,
102
161
  ): void {
162
+ const [jobName, jobOptions, jobHandler] =
163
+ typeof jobNameOrDefinition === "string"
164
+ ? [
165
+ jobNameOrDefinition,
166
+ options as Omit<JobDefinition, "name" | "handler">,
167
+ handler as JobHandlerFn,
168
+ ]
169
+ : (() => {
170
+ const { name, handler: h, ...rest } = jobNameOrDefinition;
171
+ return [name, rest, h] as const;
172
+ })();
103
173
  // Resolve NameOrRef(s) in trigger.on. Multi-Trigger-Form: Array
104
174
  // wird zu Array von resolved strings, Single bleibt single string —
105
175
  // job-runner unterscheidet anhand Array.isArray.
106
176
  const trigger =
107
- "on" in options.trigger
177
+ "on" in jobOptions.trigger
108
178
  ? {
109
- on: Array.isArray(options.trigger.on)
110
- ? options.trigger.on.map(resolveName)
111
- : resolveName(options.trigger.on as NameOrRef), // @cast-boundary engine-bridge
179
+ on: Array.isArray(jobOptions.trigger.on)
180
+ ? jobOptions.trigger.on.map(resolveName)
181
+ : resolveName(jobOptions.trigger.on as NameOrRef), // @cast-boundary engine-bridge
112
182
  }
113
- : options.trigger;
114
- state.jobs[jobName] = { ...options, trigger, name: jobName, handler };
183
+ : jobOptions.trigger;
184
+ state.jobs[jobName] = { ...jobOptions, trigger, name: jobName, handler: jobHandler };
115
185
  },
116
186
  notification(
117
- notificationName: string,
118
- definition: {
187
+ notificationNameOrDefinition:
188
+ | string
189
+ | ({ readonly name: string } & {
190
+ readonly trigger: { readonly on: NameOrRef };
191
+ readonly recipient: NotificationRecipientFn;
192
+ readonly data: NotificationDataFn;
193
+ readonly templates?: Readonly<Record<string, NotificationTemplateFn>>;
194
+ }),
195
+ definition?: {
119
196
  readonly trigger: { readonly on: NameOrRef };
120
197
  readonly recipient: NotificationRecipientFn;
121
198
  readonly data: NotificationDataFn;
122
199
  readonly templates?: Readonly<Record<string, NotificationTemplateFn>>;
123
200
  },
124
201
  ): void {
202
+ const [notificationName, resolvedDefinition] =
203
+ typeof notificationNameOrDefinition === "string"
204
+ ? [notificationNameOrDefinition, definition as NonNullable<typeof definition>]
205
+ : splitNamedDefinition(notificationNameOrDefinition);
125
206
  state.notifications[notificationName] = {
126
207
  name: notificationName,
127
- trigger: { on: resolveName(definition.trigger.on) },
128
- recipient: definition.recipient,
129
- data: definition.data,
130
- templates: definition.templates,
208
+ trigger: { on: resolveName(resolvedDefinition.trigger.on) },
209
+ recipient: resolvedDefinition.recipient,
210
+ data: resolvedDefinition.data,
211
+ templates: resolvedDefinition.templates,
131
212
  };
132
213
  },
133
214
  translations(def: TranslationsDef): void {
@@ -136,7 +217,20 @@ export function buildConfigEventsJobsMethods<TName extends string>(
136
217
  defineEvent: <const TInner extends string, TPayload>(
137
218
  eventName: TInner,
138
219
  schema: ZodType<TPayload>,
139
- options?: { readonly version?: number; readonly piiFields?: EventPiiFields },
220
+ options?: {
221
+ readonly version?: number;
222
+ readonly piiFields?: EventPiiFields;
223
+ // Step-wise upcast chain for this event, folded in from the former
224
+ // standalone r.eventMigration() call (#1082 step 8) — an event and
225
+ // its schema evolution are one lifecycle, not two registrar
226
+ // concepts. Each entry's fromVersion must be unique and the chain
227
+ // from 1 to `version` must be gap-free (same validation as before).
228
+ readonly migrations?: readonly {
229
+ readonly fromVersion: number;
230
+ readonly toVersion: number;
231
+ readonly transform: EventUpcastFn | DeclarativeEventMigration;
232
+ }[];
233
+ },
140
234
  ): EventDef<TPayload, QualifiedEventName<TName, TInner>> => {
141
235
  // Return the fully-qualified event name so callers can pass it
142
236
  // straight to ctx.appendEvent without hand-building the
@@ -155,26 +249,9 @@ export function buildConfigEventsJobsMethods<TName extends string>(
155
249
  `[Feature ${name}] defineEvent("${eventName}"): version must be a positive integer, got ${String(version)}`,
156
250
  );
157
251
  }
158
- // piiFields misconfiguration is a boot-time error, not a silent
159
- // plaintext leak: both the pii field and its subjectField must exist
160
- // on the payload schema (checkable when the schema is a ZodObject).
161
252
  const piiFields = options?.piiFields;
162
253
  if (piiFields) {
163
- const shape = schema instanceof ZodObject ? schema.shape : undefined;
164
- for (const [field, spec] of Object.entries(piiFields)) {
165
- if (field === spec.subjectField) {
166
- throw new Error(
167
- `[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.`,
168
- );
169
- }
170
- for (const required of [field, spec.subjectField]) {
171
- if (shape && !(required in shape)) {
172
- throw new Error(
173
- `[Feature ${name}] defineEvent("${eventName}"): piiFields references "${required}" which is not a field of the payload schema.`,
174
- );
175
- }
176
- }
177
- }
254
+ validateEventPiiFields(eventName, schema, piiFields);
178
255
  }
179
256
  // @cast-boundary engine-bridge — runtime-string mirrors the
180
257
  // template-literal-type via QualifiedEventName + toKebab. Both
@@ -187,50 +264,34 @@ export function buildConfigEventsJobsMethods<TName extends string>(
187
264
  ...(piiFields !== undefined && { piiFields }),
188
265
  };
189
266
  state.events[eventName] = def;
267
+ for (const m of options?.migrations ?? []) {
268
+ registerEventMigration(eventName, m.fromVersion, m.toVersion, m.transform);
269
+ }
190
270
  return def;
191
271
  },
192
- eventMigration(
193
- eventName: string,
194
- fromVersion: number,
195
- toVersion: number,
196
- transform: EventUpcastFn | DeclarativeEventMigration,
272
+ readsConfig(
273
+ ...args: readonly [{ readonly keys: readonly string[] }] | readonly string[]
197
274
  ): void {
198
- if (toVersion !== fromVersion + 1) {
199
- throw new Error(
200
- `[Feature ${name}] eventMigration("${eventName}", ${fromVersion}, ${toVersion}): ` +
201
- `only single-step migrations are allowed — toVersion must be fromVersion + 1. ` +
202
- `Chain larger jumps by registering each step separately.`,
203
- );
204
- }
205
- if (!Number.isInteger(fromVersion) || fromVersion < 1) {
206
- throw new Error(
207
- `[Feature ${name}] eventMigration("${eventName}", ...): fromVersion must be >= 1, got ${String(fromVersion)}`,
208
- );
209
- }
210
- const qualified = qn(toKebab(name), "event", toKebab(eventName));
211
- const list = state.eventMigrations[eventName] ?? [];
212
- if (list.some((m) => m.fromVersion === fromVersion)) {
213
- throw new Error(
214
- `[Feature ${name}] eventMigration("${eventName}", ${fromVersion}, ${toVersion}): ` +
215
- `a migration from v${fromVersion} is already registered. Each step may only be declared once.`,
216
- );
217
- }
218
- const transformFn =
219
- typeof transform === "function" ? transform : compileEventMigration(transform);
220
- list.push({ eventName: qualified, fromVersion, toVersion, transform: transformFn });
221
- state.eventMigrations[eventName] = list;
222
- },
223
- readsConfig(...qualifiedKeys: string[]): void {
275
+ const [first] = args;
276
+ const qualifiedKeys =
277
+ typeof first === "object" && first !== null ? first.keys : (args as readonly string[]);
224
278
  state.configReads.push(...qualifiedKeys);
225
279
  },
226
- metric(shortName: string, options: MetricOptions): void {
280
+ metric(
281
+ shortNameOrDefinition: string | ({ readonly name: string } & MetricOptions),
282
+ options?: MetricOptions,
283
+ ): void {
284
+ const [shortName, metricOptions] =
285
+ typeof shortNameOrDefinition === "string"
286
+ ? [shortNameOrDefinition, options as MetricOptions]
287
+ : splitNamedDefinition(shortNameOrDefinition);
227
288
  if (state.metrics[shortName]) {
228
289
  throw new Error(
229
290
  `[Feature ${name}] Metric "${shortName}" already registered. ` +
230
291
  `Metric names must be unique per feature.`,
231
292
  );
232
293
  }
233
- state.metrics[shortName] = { shortName, ...options };
294
+ state.metrics[shortName] = { shortName, ...metricOptions };
234
295
  },
235
296
  envSchema(schema: z.ZodObject<z.ZodRawShape>): void {
236
297
  if (state.envSchema !== undefined) {
@@ -240,7 +301,14 @@ export function buildConfigEventsJobsMethods<TName extends string>(
240
301
  }
241
302
  state.envSchema = schema;
242
303
  },
243
- secret(shortName: string, options: SecretOptions): SecretKeyHandle {
304
+ secret(
305
+ shortNameOrDefinition: string | ({ readonly name: string } & SecretOptions),
306
+ options?: SecretOptions,
307
+ ): SecretKeyHandle {
308
+ const [shortName, secretOptions] =
309
+ typeof shortNameOrDefinition === "string"
310
+ ? [shortNameOrDefinition, options as SecretOptions]
311
+ : splitNamedDefinition(shortNameOrDefinition);
244
312
  if (state.secretKeys[shortName]) {
245
313
  throw new Error(
246
314
  `[Feature ${name}] Secret "${shortName}" already registered. ` +
@@ -256,14 +324,22 @@ export function buildConfigEventsJobsMethods<TName extends string>(
256
324
  state.secretKeys[shortName] = {
257
325
  shortName,
258
326
  qualifiedName,
259
- ...options,
327
+ ...secretOptions,
260
328
  };
261
329
  return { name: qualifiedName };
262
330
  },
263
331
  claimKey<T extends ClaimKeyType>(
264
- shortName: string,
265
- options: { readonly type: T },
332
+ shortNameOrDefinition: string | { readonly name: string; readonly type: T },
333
+ options?: { readonly type: T },
266
334
  ): ClaimKeyHandle<T> {
335
+ const shortName =
336
+ typeof shortNameOrDefinition === "string"
337
+ ? shortNameOrDefinition
338
+ : shortNameOrDefinition.name;
339
+ const claimType: T =
340
+ typeof shortNameOrDefinition === "string"
341
+ ? (options as { readonly type: T }).type
342
+ : shortNameOrDefinition.type;
267
343
  if (state.claimKeys[shortName]) {
268
344
  throw new Error(
269
345
  `[Feature ${name}] Claim key "${shortName}" already declared. ` +
@@ -280,9 +356,9 @@ export function buildConfigEventsJobsMethods<TName extends string>(
280
356
  state.claimKeys[shortName] = {
281
357
  shortName,
282
358
  qualifiedName,
283
- type: options.type,
359
+ type: claimType,
284
360
  };
285
- return { name: qualifiedName, type: options.type };
361
+ return { name: qualifiedName, type: claimType };
286
362
  },
287
363
  authClaims(fn: AuthClaimsFn): void {
288
364
  state.authClaimsHooks.push(fn);
@@ -296,7 +372,7 @@ function compileEventMigration(spec: DeclarativeEventMigration): EventUpcastFn {
296
372
  // Registration-time (not replay-time) check: two rename sources mapping to
297
373
  // the same target would silently drop one value on every future replay —
298
374
  // event migrations run against the full production event history, so a
299
- // typo here must fail loud at r.eventMigration() call time, not at replay.
375
+ // typo here must fail loud at defineEvent() registration time, not at replay.
300
376
  const renameTargets = new Map<string, string>();
301
377
  for (const [from, to] of Object.entries(spec.rename ?? {})) {
302
378
  const existing = renameTargets.get(to);