@cosmicdrift/kumiko-framework 0.153.0 → 0.154.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 (31) hide show
  1. package/package.json +2 -2
  2. package/src/__tests__/anonymous-access.integration.test.ts +25 -0
  3. package/src/engine/__tests__/boot-validator-action-wiring.test.ts +242 -0
  4. package/src/engine/__tests__/boot-validator.test.ts +7 -4
  5. package/src/engine/__tests__/event-migration-declarative.test.ts +9 -2
  6. package/src/engine/boot-validator/action-wiring.ts +99 -0
  7. package/src/engine/boot-validator/index.ts +7 -0
  8. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +7 -21
  9. package/src/engine/feature-ast/__tests__/parse.test.ts +33 -6
  10. package/src/engine/feature-ast/__tests__/patch.test.ts +8 -13
  11. package/src/engine/feature-ast/__tests__/patcher.test.ts +4 -14
  12. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +37 -1
  13. package/src/engine/feature-ast/extractors/index.ts +0 -1
  14. package/src/engine/feature-ast/extractors/round4.ts +92 -136
  15. package/src/engine/feature-ast/index.ts +0 -2
  16. package/src/engine/feature-ast/parse.ts +0 -3
  17. package/src/engine/feature-ast/patch.ts +0 -41
  18. package/src/engine/feature-ast/patcher.ts +14 -20
  19. package/src/engine/feature-ast/patterns.ts +10 -19
  20. package/src/engine/feature-ast/render.ts +10 -13
  21. package/src/engine/feature-config-events-jobs.ts +77 -51
  22. package/src/engine/pattern-library/__tests__/library.test.ts +0 -10
  23. package/src/engine/pattern-library/library.ts +0 -2
  24. package/src/engine/pattern-library/mixed-schemas.ts +8 -35
  25. package/src/engine/registry-validate.ts +6 -6
  26. package/src/engine/types/feature.ts +18 -17
  27. package/src/engine/types/handlers.ts +6 -3
  28. package/src/event-store/__tests__/upcaster.integration.test.ts +77 -45
  29. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +16 -8
  30. package/src/stack/redis.ts +8 -0
  31. package/src/stack/test-stack.ts +156 -136
@@ -93,6 +93,64 @@ export function buildConfigEventsJobsMethods<TName extends string>(
93
93
  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
94
  }
95
95
 
96
+ // piiFields misconfiguration is a boot-time error, not a silent
97
+ // plaintext leak: both the pii field and its subjectField must exist
98
+ // on the payload schema (checkable when the schema is a ZodObject).
99
+ function validateEventPiiFields(
100
+ eventName: string,
101
+ schema: ZodType,
102
+ piiFields: EventPiiFields,
103
+ ): void {
104
+ const shape = schema instanceof ZodObject ? schema.shape : undefined;
105
+ for (const [field, spec] of Object.entries(piiFields)) {
106
+ if (field === spec.subjectField) {
107
+ throw new Error(
108
+ `[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.`,
109
+ );
110
+ }
111
+ for (const required of [field, spec.subjectField]) {
112
+ if (shape && !(required in shape)) {
113
+ throw new Error(
114
+ `[Feature ${name}] defineEvent("${eventName}"): piiFields references "${required}" which is not a field of the payload schema.`,
115
+ );
116
+ }
117
+ }
118
+ }
119
+ }
120
+
121
+ // Shared by defineEvent's `migrations` option — each entry registers a
122
+ // single upcast step, same validation/dedup as the old standalone
123
+ // r.eventMigration() call (folded into defineEvent, #1082 step 8).
124
+ function registerEventMigration(
125
+ eventName: string,
126
+ fromVersion: number,
127
+ toVersion: number,
128
+ transform: EventUpcastFn | DeclarativeEventMigration,
129
+ ): void {
130
+ if (toVersion !== fromVersion + 1) {
131
+ throw new Error(
132
+ `[Feature ${name}] defineEvent("${eventName}") migrations: only single-step migrations are allowed — toVersion must be fromVersion + 1 (got ${fromVersion} -> ${toVersion}). ` +
133
+ `Chain larger jumps by declaring each step separately.`,
134
+ );
135
+ }
136
+ if (!Number.isInteger(fromVersion) || fromVersion < 1) {
137
+ throw new Error(
138
+ `[Feature ${name}] defineEvent("${eventName}") migrations: fromVersion must be >= 1, got ${String(fromVersion)}`,
139
+ );
140
+ }
141
+ const qualified = qn(toKebab(name), "event", toKebab(eventName));
142
+ const list = state.eventMigrations[eventName] ?? [];
143
+ if (list.some((m) => m.fromVersion === fromVersion)) {
144
+ throw new Error(
145
+ `[Feature ${name}] defineEvent("${eventName}") migrations: a migration from v${fromVersion} is already declared. Each step may only be declared once.`,
146
+ );
147
+ }
148
+ const transformFn =
149
+ typeof transform === "function" ? transform : compileEventMigration(transform);
150
+ list.push({ eventName: qualified, fromVersion, toVersion, transform: transformFn });
151
+ state.eventMigrations[eventName] = list;
152
+ }
153
+
96
154
  return {
97
155
  config,
98
156
  job(
@@ -136,7 +194,20 @@ export function buildConfigEventsJobsMethods<TName extends string>(
136
194
  defineEvent: <const TInner extends string, TPayload>(
137
195
  eventName: TInner,
138
196
  schema: ZodType<TPayload>,
139
- options?: { readonly version?: number; readonly piiFields?: EventPiiFields },
197
+ options?: {
198
+ readonly version?: number;
199
+ readonly piiFields?: EventPiiFields;
200
+ // Step-wise upcast chain for this event, folded in from the former
201
+ // standalone r.eventMigration() call (#1082 step 8) — an event and
202
+ // its schema evolution are one lifecycle, not two registrar
203
+ // concepts. Each entry's fromVersion must be unique and the chain
204
+ // from 1 to `version` must be gap-free (same validation as before).
205
+ readonly migrations?: readonly {
206
+ readonly fromVersion: number;
207
+ readonly toVersion: number;
208
+ readonly transform: EventUpcastFn | DeclarativeEventMigration;
209
+ }[];
210
+ },
140
211
  ): EventDef<TPayload, QualifiedEventName<TName, TInner>> => {
141
212
  // Return the fully-qualified event name so callers can pass it
142
213
  // straight to ctx.appendEvent without hand-building the
@@ -155,26 +226,9 @@ export function buildConfigEventsJobsMethods<TName extends string>(
155
226
  `[Feature ${name}] defineEvent("${eventName}"): version must be a positive integer, got ${String(version)}`,
156
227
  );
157
228
  }
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
229
  const piiFields = options?.piiFields;
162
230
  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
- }
231
+ validateEventPiiFields(eventName, schema, piiFields);
178
232
  }
179
233
  // @cast-boundary engine-bridge — runtime-string mirrors the
180
234
  // template-literal-type via QualifiedEventName + toKebab. Both
@@ -187,38 +241,10 @@ export function buildConfigEventsJobsMethods<TName extends string>(
187
241
  ...(piiFields !== undefined && { piiFields }),
188
242
  };
189
243
  state.events[eventName] = def;
190
- return def;
191
- },
192
- eventMigration(
193
- eventName: string,
194
- fromVersion: number,
195
- toVersion: number,
196
- transform: EventUpcastFn | DeclarativeEventMigration,
197
- ): 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
- );
244
+ for (const m of options?.migrations ?? []) {
245
+ registerEventMigration(eventName, m.fromVersion, m.toVersion, m.transform);
204
246
  }
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;
247
+ return def;
222
248
  },
223
249
  readsConfig(...qualifiedKeys: string[]): void {
224
250
  state.configReads.push(...qualifiedKeys);
@@ -296,7 +322,7 @@ function compileEventMigration(spec: DeclarativeEventMigration): EventUpcastFn {
296
322
  // Registration-time (not replay-time) check: two rename sources mapping to
297
323
  // the same target would silently drop one value on every future replay —
298
324
  // 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.
325
+ // typo here must fail loud at defineEvent() registration time, not at replay.
300
326
  const renameTargets = new Map<string, string>();
301
327
  for (const [from, to] of Object.entries(spec.rename ?? {})) {
302
328
  const existing = renameTargets.get(to);
@@ -55,7 +55,6 @@ const ALL_KINDS: FeaturePatternKind[] = [
55
55
  "projection",
56
56
  "multiStreamProjection",
57
57
  "defineEvent",
58
- "eventMigration",
59
58
  "extendsRegistrar",
60
59
  "usesApi",
61
60
  "exposesApi",
@@ -332,15 +331,6 @@ function makePlaceholderPattern(kind: FeaturePatternKind): FeaturePattern {
332
331
  eventName: "x",
333
332
  schemaSource: PLACEHOLDER_BODY_LOC,
334
333
  };
335
- case "eventMigration":
336
- return {
337
- kind,
338
- source: PLACEHOLDER_LOC,
339
- eventName: "x",
340
- fromVersion: 1,
341
- toVersion: 2,
342
- transformBody: PLACEHOLDER_BODY_LOC,
343
- };
344
334
  case "extendsRegistrar":
345
335
  return {
346
336
  kind,
@@ -18,7 +18,6 @@ import {
18
18
  authClaimsSchema,
19
19
  defineEventSchema,
20
20
  entityHookSchema,
21
- eventMigrationSchema,
22
21
  hookSchema,
23
22
  httpRouteSchema,
24
23
  jobSchema,
@@ -90,7 +89,6 @@ export const PATTERN_LIBRARY: Readonly<Record<FeaturePatternKind, PatternFormSch
90
89
  projection: projectionSchema,
91
90
  multiStreamProjection: multiStreamProjectionSchema,
92
91
  defineEvent: defineEventSchema,
93
- eventMigration: eventMigrationSchema,
94
92
  extendsRegistrar: extendsRegistrarSchema,
95
93
  usesApi: usesApiSchema,
96
94
  exposesApi: exposesApiSchema,
@@ -443,42 +443,15 @@ export const defineEventSchema: PatternFormSchema = {
443
443
  input: "number",
444
444
  min: 1,
445
445
  },
446
- ],
447
- };
448
-
449
- export const eventMigrationSchema: PatternFormSchema = {
450
- kind: "eventMigration",
451
- label: { en: "Event migration", de: "Event-Migration" },
452
- summary: { en: "Step-wise transform between event versions." },
453
- category: "data",
454
- editability: "mixed",
455
- fields: [
456
446
  {
457
- path: "eventName",
458
- label: { en: "Event", de: "Event" },
459
- input: "text",
460
- required: true,
461
- },
462
- {
463
- path: "fromVersion",
464
- label: { en: "From version", de: "Von Version" },
465
- input: "number",
466
- min: 1,
467
- required: true,
468
- },
469
- {
470
- path: "toVersion",
471
- label: { en: "To version", de: "Auf Version" },
472
- input: "number",
473
- min: 2,
474
- required: true,
475
- },
476
- {
477
- path: "transformBody",
478
- label: { en: "Transform (source)", de: "Transform (Source)" },
479
- input: "code-block",
480
- language: "typescript",
481
- readOnly: true,
447
+ path: "migrations",
448
+ label: {
449
+ en: "Migrations (fromVersion → transform)",
450
+ de: "Migrationen (fromVersion → Transform)",
451
+ },
452
+ input: "key-value-map",
453
+ keyPlaceholder: "1",
454
+ valueInput: "code-block",
482
455
  },
483
456
  ],
484
457
  };
@@ -314,23 +314,23 @@ export function validateEventMigrationVersions(
314
314
  features: readonly FeatureDefinition[],
315
315
  ): void {
316
316
  // Build + validate event upcaster chains. Run AFTER all features are
317
- // ingested so r.eventMigration calls can reference events from any
318
- // feature (same feature in practice, but the check stays lax for future
319
- // cross-feature event packs).
317
+ // ingested so defineEvent's `migrations` option can reference events from
318
+ // any feature (same feature in practice, but the check stays lax for
319
+ // future cross-feature event packs).
320
320
  for (const feature of features) {
321
321
  for (const [shortName, migrations] of Object.entries(feature.eventMigrations ?? {})) {
322
322
  const qualified = qualify(feature.name, "event", shortName);
323
323
  const eventDef = state.eventMap.get(qualified);
324
324
  if (!eventDef) {
325
325
  throw new Error(
326
- `Feature "${feature.name}" registered r.eventMigration for "${shortName}" ` +
326
+ `Feature "${feature.name}" has migrations declared for event "${shortName}" ` +
327
327
  `but no r.defineEvent exists for that name. Register the event first.`,
328
328
  );
329
329
  }
330
330
  for (const m of migrations) {
331
331
  if (m.toVersion > eventDef.version) {
332
332
  throw new Error(
333
- `Feature "${feature.name}" has r.eventMigration("${shortName}", ${m.fromVersion}, ${m.toVersion}) ` +
333
+ `Feature "${feature.name}" declares a migration for event "${shortName}" from v${m.fromVersion} to v${m.toVersion} ` +
334
334
  `but r.defineEvent declares only version ${eventDef.version}. ` +
335
335
  `Bump the version in defineEvent to at least ${m.toVersion}, or remove the migration.`,
336
336
  );
@@ -364,7 +364,7 @@ export function buildEventUpcasterChains(
364
364
  if (!chainMap.has(v)) {
365
365
  throw new Error(
366
366
  `Event "${qualified}" declares version ${eventDef.version} but no migration ` +
367
- `covers the step v${v} → v${v + 1}. Register r.eventMigration("${qualified.split(":").pop() ?? qualified}", ${v}, ${v + 1}, transform) ` +
367
+ `covers the step v${v} → v${v + 1}. Add { fromVersion: ${v}, toVersion: ${v + 1}, transform } to r.defineEvent("${qualified.split(":").pop() ?? qualified}", ...)'s migrations array ` +
368
368
  `so stored v${v} payloads can be upcast on read.`,
369
369
  );
370
370
  }
@@ -298,7 +298,7 @@ export type FeatureDefinition = {
298
298
  readonly referenceData: readonly ReferenceDataDef[];
299
299
  readonly notifications: Readonly<Record<string, NotificationDefinition>>;
300
300
  readonly events: Readonly<Record<string, EventDef>>;
301
- // Event schema migrations declared via r.eventMigration(). Keyed by event
301
+ // Event schema migrations declared via defineEvent's `migrations` option. Keyed by event
302
302
  // short-name; each entry carries the step transforms (fromVersion →
303
303
  // toVersion). The registry stitches these with the defineEvent-declared
304
304
  // current version and exposes a per-qualified-name upcaster chain.
@@ -532,9 +532,14 @@ export type FeatureRegistrar<TFeature extends string = string> = {
532
532
  // "<feature>:event:<short>" string.
533
533
  //
534
534
  // `options.version` declares the CURRENT schema generation. Defaults to 1
535
- // on first registration. When you bump the payload shape, raise version
536
- // AND register r.eventMigration(shortName, N, N+1, transform) — the
537
- // framework refuses to boot if the chain from 1 version has gaps.
535
+ // on first registration. When you bump the payload shape, add a step to
536
+ // `options.migrations` covering N -> N+1 — the framework refuses to boot
537
+ // if the chain from 1 to `version` has gaps. Migrations were formerly a
538
+ // separate r.eventMigration() call; folded in here because an event and
539
+ // its schema evolution are one lifecycle, not two registrar concepts
540
+ // (#1082 step 8) — transforms are pure functions (old payload in, new
541
+ // payload out) and run once per read, not once per event persisted, so
542
+ // keep them cheap.
538
543
  //
539
544
  // `options.piiFields` declares PII payload fields encrypted under the DEK
540
545
  // of the user named by `subjectField` (crypto-shredding, #799). append()
@@ -542,21 +547,17 @@ export type FeatureRegistrar<TFeature extends string = string> = {
542
547
  defineEvent<const TInner extends string, TPayload>(
543
548
  name: TInner,
544
549
  schema: ZodType<TPayload>,
545
- options?: { readonly version?: number; readonly piiFields?: EventPiiFields },
550
+ options?: {
551
+ readonly version?: number;
552
+ readonly piiFields?: EventPiiFields;
553
+ readonly migrations?: readonly {
554
+ readonly fromVersion: number;
555
+ readonly toVersion: number;
556
+ readonly transform: EventUpcastFn | DeclarativeEventMigration;
557
+ }[];
558
+ },
546
559
  ): EventDef<TPayload, QualifiedEventName<TFeature, TInner>>;
547
560
 
548
- // Register a step-wise payload transform for event-schema evolution.
549
- // `eventName` is the SHORT name (same as defineEvent). `toVersion` must
550
- // be `fromVersion + 1` — chain larger jumps by registering each step.
551
- // Transforms are pure functions: old payload in, new payload out. They
552
- // run once per read (not once per event persisted), so keep them cheap.
553
- eventMigration(
554
- eventName: string,
555
- fromVersion: number,
556
- toVersion: number,
557
- transform: EventUpcastFn | DeclarativeEventMigration,
558
- ): void;
559
-
560
561
  readsConfig(...qualifiedKeys: string[]): void;
561
562
 
562
563
  referenceData(
@@ -736,9 +736,12 @@ export type AggregateStreamHandle = {
736
736
  // ctx-arg, run the lookup via ctx.db, return a Promise. The framework
737
737
  // awaits unconditionally — sync transforms return a plain value and pay
738
738
  // only the await-microtask overhead. Pattern-match Marten:
739
- // r.eventMigration("invoiceCreated", 1, 2, async (payload, ctx) => {
740
- // const customer = await ctx.db.select().from(customersTable)...;
741
- // return { ...payload, customerSegment: customer.segment };
739
+ // r.defineEvent("invoiceCreated", schema, {
740
+ // version: 2,
741
+ // migrations: [{ fromVersion: 1, toVersion: 2, transform: async (payload, ctx) => {
742
+ // const customer = await ctx.db.select().from(customersTable)...;
743
+ // return { ...payload, customerSegment: customer.segment };
744
+ // } }],
742
745
  // });
743
746
  export type EventUpcastCtx = {
744
747
  readonly db: import("../../db").DbRunner;
@@ -56,21 +56,32 @@ const orderFeature = defineFeature("upcastshop", (r) => {
56
56
  const orderPriced = r.defineEvent(
57
57
  "priced",
58
58
  z.object({ totalCents: z.number().int(), currency: z.string() }),
59
- { version: 3 },
59
+ {
60
+ version: 3,
61
+ migrations: [
62
+ // v1 → v2: renamed totalEuros → total (kept as string for this step)
63
+ {
64
+ fromVersion: 1,
65
+ toVersion: 2,
66
+ transform: (payload) => {
67
+ const p = payload as { totalEuros: string };
68
+ return { total: p.totalEuros, currency: "EUR" };
69
+ },
70
+ },
71
+ // v2 → v3: parse "total" string into integer cents
72
+ {
73
+ fromVersion: 2,
74
+ toVersion: 3,
75
+ transform: (payload) => {
76
+ const p = payload as { total: string; currency: string };
77
+ const euros = Number.parseFloat(p.total);
78
+ return { totalCents: Math.round(euros * 100), currency: p.currency };
79
+ },
80
+ },
81
+ ],
82
+ },
60
83
  );
61
84
 
62
- // v1 → v2: renamed totalEuros → total (kept as string for this step)
63
- r.eventMigration("priced", 1, 2, (payload) => {
64
- const p = payload as { totalEuros: string };
65
- return { total: p.totalEuros, currency: "EUR" };
66
- });
67
- // v2 → v3: parse "total" string into integer cents
68
- r.eventMigration("priced", 2, 3, (payload) => {
69
- const p = payload as { total: string; currency: string };
70
- const euros = Number.parseFloat(p.total);
71
- return { totalCents: Math.round(euros * 100), currency: p.currency };
72
- });
73
-
74
85
  r.projection({
75
86
  name: "order-summary",
76
87
  source: "upcast-order",
@@ -309,18 +320,27 @@ describe("upcaster: async (Marten AsyncOnlyEventUpcaster — DB-Lookups)", () =>
309
320
  const placed = r.defineEvent(
310
321
  "placed",
311
322
  z.object({ customerId: z.string(), segment: z.string() }),
312
- { version: 2 },
323
+ {
324
+ version: 2,
325
+ migrations: [
326
+ {
327
+ fromVersion: 1,
328
+ toVersion: 2,
329
+ transform: async (payload, ctx) => {
330
+ const p = payload as { customerId: string };
331
+ const [row] = await selectMany(ctx.db, customerSegments, {
332
+ customerId: p.customerId,
333
+ });
334
+ return {
335
+ customerId: p.customerId,
336
+ segment: (row as { segment?: string } | undefined)?.segment ?? "UNKNOWN",
337
+ };
338
+ },
339
+ },
340
+ ],
341
+ },
313
342
  );
314
343
 
315
- r.eventMigration("placed", 1, 2, async (payload, ctx) => {
316
- const p = payload as { customerId: string };
317
- const [row] = await selectMany(ctx.db, customerSegments, { customerId: p.customerId });
318
- return {
319
- customerId: p.customerId,
320
- segment: (row as { segment?: string } | undefined)?.segment ?? "UNKNOWN",
321
- };
322
- });
323
-
324
344
  r.projection({
325
345
  name: "async-summary",
326
346
  source: "upcast-async-order",
@@ -385,26 +405,28 @@ describe("upcaster: boot-time validation", () => {
385
405
  test("defineEvent with version=N and only partial migrations fails at registry build", () => {
386
406
  const incomplete = defineFeature("holes", (r) => {
387
407
  r.entity("hole-order", orderEntity);
388
- r.defineEvent("bad", z.object({ v3: z.string() }), { version: 3 });
389
408
  // Only 1→2 registered — the 2→3 gap must be rejected.
390
- r.eventMigration("bad", 1, 2, (p) => p);
409
+ r.defineEvent("bad", z.object({ v3: z.string() }), {
410
+ version: 3,
411
+ migrations: [{ fromVersion: 1, toVersion: 2, transform: (p) => p }],
412
+ });
391
413
  });
392
414
  expect(() => createRegistry([incomplete])).toThrow(/v2.*v3|covers the step v2/);
393
415
  });
394
416
 
395
- test("migration declared but no defineEvent rejected", () => {
396
- const orphan = defineFeature("orphans", (r) => {
397
- r.entity("orph-order", orderEntity);
398
- r.eventMigration("ghost", 1, 2, (p) => p);
399
- });
400
- expect(() => createRegistry([orphan])).toThrow(/no r\.defineEvent/i);
401
- });
417
+ // "migration declared but no defineEvent" is now structurally impossible:
418
+ // migrations live in defineEvent's `migrations` option, always scoped to
419
+ // the event being defined in that same call — there is no longer a
420
+ // registrar shape that can express a dangling migration for an
421
+ // undefined event (formerly the "ghost" registry-validate error path).
402
422
 
403
423
  test("migration toVersion > defineEvent version → rejected", () => {
404
424
  const future = defineFeature("future", (r) => {
405
425
  r.entity("future-order", orderEntity);
406
- r.defineEvent("early", z.object({ x: z.number() }), { version: 1 });
407
- r.eventMigration("early", 1, 2, (p) => p);
426
+ r.defineEvent("early", z.object({ x: z.number() }), {
427
+ version: 1,
428
+ migrations: [{ fromVersion: 1, toVersion: 2, transform: (p) => p }],
429
+ });
408
430
  });
409
431
  expect(() => createRegistry([future])).toThrow(/declares only version 1/);
410
432
  });
@@ -412,34 +434,44 @@ describe("upcaster: boot-time validation", () => {
412
434
  test("non-contiguous (1→2 and 3→4 without 2→3) → rejected", () => {
413
435
  const gaps = defineFeature("gaps", (r) => {
414
436
  r.entity("gap-order", orderEntity);
415
- r.defineEvent("jumpy", z.object({ v: z.number() }), { version: 4 });
416
- r.eventMigration("jumpy", 1, 2, (p) => p);
417
- r.eventMigration("jumpy", 3, 4, (p) => p);
437
+ r.defineEvent("jumpy", z.object({ v: z.number() }), {
438
+ version: 4,
439
+ migrations: [
440
+ { fromVersion: 1, toVersion: 2, transform: (p) => p },
441
+ { fromVersion: 3, toVersion: 4, transform: (p) => p },
442
+ ],
443
+ });
418
444
  });
419
445
  expect(() => createRegistry([gaps])).toThrow(/v2.*v3|covers the step v2/);
420
446
  });
421
447
  });
422
448
 
423
449
  describe("upcaster: registrar input validation", () => {
424
- test("r.eventMigration rejects multi-step jumps", () => {
450
+ test("defineEvent migrations reject multi-step jumps", () => {
425
451
  expect(() =>
426
452
  defineFeature("bigstep", (r) => {
427
453
  r.entity("bigstep-order", orderEntity);
428
- r.defineEvent("biz", z.object({ x: z.number() }), { version: 3 });
429
- r.eventMigration("biz", 1, 3, (p) => p);
454
+ r.defineEvent("biz", z.object({ x: z.number() }), {
455
+ version: 3,
456
+ migrations: [{ fromVersion: 1, toVersion: 3, transform: (p) => p }],
457
+ });
430
458
  }),
431
459
  ).toThrow(/single-step/);
432
460
  });
433
461
 
434
- test("r.eventMigration rejects duplicate step", () => {
462
+ test("defineEvent migrations reject duplicate step", () => {
435
463
  expect(() =>
436
464
  defineFeature("dupestep", (r) => {
437
465
  r.entity("dup-order", orderEntity);
438
- r.defineEvent("dup", z.object({ x: z.number() }), { version: 2 });
439
- r.eventMigration("dup", 1, 2, (p) => p);
440
- r.eventMigration("dup", 1, 2, (p) => p);
466
+ r.defineEvent("dup", z.object({ x: z.number() }), {
467
+ version: 2,
468
+ migrations: [
469
+ { fromVersion: 1, toVersion: 2, transform: (p) => p },
470
+ { fromVersion: 1, toVersion: 2, transform: (p) => p },
471
+ ],
472
+ });
441
473
  }),
442
- ).toThrow(/already registered/);
474
+ ).toThrow(/already declared/);
443
475
  });
444
476
 
445
477
  test("r.defineEvent rejects non-positive version", () => {
@@ -45,15 +45,23 @@ const asOfFeature = defineFeature("asoftest", (r) => {
45
45
  const approved = r.defineEvent(
46
46
  "approved",
47
47
  z.object({ amount: z.number().int(), approvedBy: z.string() }),
48
- { version: 2 },
48
+ {
49
+ version: 2,
50
+ migrations: [
51
+ {
52
+ fromVersion: 1,
53
+ toVersion: 2,
54
+ transform: (payload) => {
55
+ const p = payload as { amount: string; approvedBy: string };
56
+ return {
57
+ amount: Math.round(Number.parseFloat(p.amount) * 100),
58
+ approvedBy: p.approvedBy,
59
+ };
60
+ },
61
+ },
62
+ ],
63
+ },
49
64
  );
50
- r.eventMigration("approved", 1, 2, (payload) => {
51
- const p = payload as { amount: string; approvedBy: string };
52
- return {
53
- amount: Math.round(Number.parseFloat(p.amount) * 100),
54
- approvedBy: p.approvedBy,
55
- };
56
- });
57
65
 
58
66
  const executor = createEventStoreExecutor(invoiceTable, invoiceEntity, {
59
67
  entityName: "asof-invoice",
@@ -3,6 +3,13 @@ import { requireEnv } from "./db";
3
3
 
4
4
  export type TestRedis = {
5
5
  redis: import("ioredis").default;
6
+ // The exact REDIS_URL used to build `redis` above — for a second, unrelated
7
+ // connection (e.g. the test-stack's JobRunner) that needs its own client
8
+ // rather than sharing this one's keyPrefix. Reconstructing a URL from
9
+ // `redis.options` loses password/username/tls/path (pr-review
10
+ // kumiko-framework #1036/2) — callers needing a fresh connection should use
11
+ // this raw string, not rebuild one from parsed options.
12
+ redisUrl: string;
6
13
  /** Delete every key this test created (prefix-scoped). Replaces the old
7
14
  * `redis.flushdb()` — that wiped other parallel tests' BullMQ state. */
8
15
  flushNamespace: () => Promise<void>;
@@ -35,6 +42,7 @@ export async function createTestRedis(): Promise<TestRedis> {
35
42
 
36
43
  return {
37
44
  redis,
45
+ redisUrl,
38
46
  flushNamespace,
39
47
  cleanup: async () => {
40
48
  await flushNamespace();