@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.
- package/package.json +2 -2
- package/src/__tests__/anonymous-access.integration.test.ts +25 -0
- package/src/engine/__tests__/boot-validator-action-wiring.test.ts +242 -0
- package/src/engine/__tests__/boot-validator.test.ts +7 -4
- package/src/engine/__tests__/event-migration-declarative.test.ts +9 -2
- package/src/engine/boot-validator/action-wiring.ts +99 -0
- package/src/engine/boot-validator/index.ts +7 -0
- package/src/engine/feature-ast/__tests__/canonical-form.test.ts +7 -21
- package/src/engine/feature-ast/__tests__/parse.test.ts +33 -6
- package/src/engine/feature-ast/__tests__/patch.test.ts +8 -13
- package/src/engine/feature-ast/__tests__/patcher.test.ts +4 -14
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +37 -1
- package/src/engine/feature-ast/extractors/index.ts +0 -1
- package/src/engine/feature-ast/extractors/round4.ts +92 -136
- package/src/engine/feature-ast/index.ts +0 -2
- package/src/engine/feature-ast/parse.ts +0 -3
- package/src/engine/feature-ast/patch.ts +0 -41
- package/src/engine/feature-ast/patcher.ts +14 -20
- package/src/engine/feature-ast/patterns.ts +10 -19
- package/src/engine/feature-ast/render.ts +10 -13
- package/src/engine/feature-config-events-jobs.ts +77 -51
- package/src/engine/pattern-library/__tests__/library.test.ts +0 -10
- package/src/engine/pattern-library/library.ts +0 -2
- package/src/engine/pattern-library/mixed-schemas.ts +8 -35
- package/src/engine/registry-validate.ts +6 -6
- package/src/engine/types/feature.ts +18 -17
- package/src/engine/types/handlers.ts +6 -3
- package/src/event-store/__tests__/upcaster.integration.test.ts +77 -45
- package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +16 -8
- package/src/stack/redis.ts +8 -0
- 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?: {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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: "
|
|
458
|
-
label: {
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
|
318
|
-
// feature (same feature in practice, but the check stays lax for
|
|
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}"
|
|
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}"
|
|
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}.
|
|
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
|
|
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,
|
|
536
|
-
//
|
|
537
|
-
//
|
|
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?: {
|
|
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.
|
|
740
|
-
//
|
|
741
|
-
//
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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.
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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() }), {
|
|
407
|
-
|
|
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() }), {
|
|
416
|
-
|
|
417
|
-
|
|
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("
|
|
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() }), {
|
|
429
|
-
|
|
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("
|
|
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() }), {
|
|
439
|
-
|
|
440
|
-
|
|
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
|
|
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
|
-
{
|
|
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",
|
package/src/stack/redis.ts
CHANGED
|
@@ -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();
|