@cosmicdrift/kumiko-framework 0.154.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/engine/__tests__/event-migration-declarative.test.ts +9 -2
- 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
|
@@ -144,13 +144,10 @@ export type AddDefineEventArgs = {
|
|
|
144
144
|
readonly name: string;
|
|
145
145
|
readonly schemaSource: string;
|
|
146
146
|
readonly version?: number;
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
readonly
|
|
151
|
-
readonly fromVersion: number;
|
|
152
|
-
readonly toVersion: number;
|
|
153
|
-
readonly transformSource: string;
|
|
147
|
+
// Keyed by fromVersion (as a string) → the transform source text for the
|
|
148
|
+
// fromVersion -> fromVersion+1 step. Folded in from the former
|
|
149
|
+
// addEventMigration (#1082 step 8).
|
|
150
|
+
readonly migrations?: Readonly<Record<string, string>>;
|
|
154
151
|
};
|
|
155
152
|
|
|
156
153
|
export type AddProjectionArgs = {
|
|
@@ -249,7 +246,6 @@ export type FeaturePatcher = {
|
|
|
249
246
|
readonly addProjection: (args: AddProjectionArgs) => void;
|
|
250
247
|
readonly addMultiStreamProjection: (args: AddMultiStreamProjectionArgs) => void;
|
|
251
248
|
readonly addDefineEvent: (args: AddDefineEventArgs) => void;
|
|
252
|
-
readonly addEventMigration: (args: AddEventMigrationArgs) => void;
|
|
253
249
|
// --- Symmetric ops (id-driven) ---
|
|
254
250
|
readonly replace: (id: PatternId, pattern: FeaturePattern) => void;
|
|
255
251
|
readonly remove: (id: PatternId) => void;
|
|
@@ -495,24 +491,22 @@ export function createFeaturePatcher(sourceFile: SourceFile): FeaturePatcher {
|
|
|
495
491
|
});
|
|
496
492
|
},
|
|
497
493
|
|
|
498
|
-
addDefineEvent({ name, schemaSource, version }) {
|
|
494
|
+
addDefineEvent({ name, schemaSource, version, migrations }) {
|
|
495
|
+
const migrationLocs = migrations
|
|
496
|
+
? Object.fromEntries(
|
|
497
|
+
Object.entries(migrations).map(([fromVersion, source]) => [
|
|
498
|
+
fromVersion,
|
|
499
|
+
rawLoc(source),
|
|
500
|
+
]),
|
|
501
|
+
)
|
|
502
|
+
: undefined;
|
|
499
503
|
add({
|
|
500
504
|
kind: "defineEvent",
|
|
501
505
|
source: SYNTHETIC_LOC,
|
|
502
506
|
eventName: name,
|
|
503
507
|
schemaSource: rawLoc(schemaSource),
|
|
504
508
|
...(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),
|
|
509
|
+
...(migrationLocs !== undefined && { migrations: migrationLocs }),
|
|
516
510
|
});
|
|
517
511
|
},
|
|
518
512
|
|
|
@@ -501,29 +501,22 @@ export type MultiStreamProjectionPattern = {
|
|
|
501
501
|
// `r.defineEvent(name, schema, options?)` — registers an event payload
|
|
502
502
|
// shape and returns the qualified `EventDef`, so callers pass `.name` to
|
|
503
503
|
// `ctx.appendEvent` instead of hand-building `<feature>:event:<short>`.
|
|
504
|
-
// `options.version` declares the CURRENT schema generation (default 1)
|
|
505
|
-
//
|
|
506
|
-
//
|
|
504
|
+
// `options.version` declares the CURRENT schema generation (default 1).
|
|
505
|
+
// `options.migrations` is a step-wise upcast chain — keyed by fromVersion
|
|
506
|
+
// (toVersion is always fromVersion + 1, enforced at registration, so it is
|
|
507
|
+
// not stored separately); the framework refuses to boot if the chain from
|
|
508
|
+
// 1 to `version` has gaps. Formerly a separate `r.eventMigration()` call
|
|
509
|
+
// per step, folded in here (#1082 step 8) — an event and its schema
|
|
510
|
+
// evolution are one lifecycle, not two registrar concepts.
|
|
507
511
|
export type DefineEventPattern = {
|
|
508
512
|
readonly kind: "defineEvent";
|
|
509
513
|
readonly source: SourceLocation;
|
|
510
514
|
readonly eventName: string;
|
|
511
515
|
readonly schemaSource: SourceLocation;
|
|
512
516
|
readonly version?: number;
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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;
|
|
517
|
+
// Map fromVersion (as string, e.g. "1") → SourceLocation of the transform
|
|
518
|
+
// closure for the fromVersion → fromVersion+1 step.
|
|
519
|
+
readonly migrations?: Readonly<Record<string, SourceLocation>>;
|
|
527
520
|
};
|
|
528
521
|
|
|
529
522
|
// `r.extendsRegistrar(extensionName, def)` — declares a named, globally
|
|
@@ -618,7 +611,6 @@ export type FeaturePattern =
|
|
|
618
611
|
| ProjectionPattern
|
|
619
612
|
| MultiStreamProjectionPattern
|
|
620
613
|
| DefineEventPattern
|
|
621
|
-
| EventMigrationPattern
|
|
622
614
|
| ExtendsRegistrarPattern
|
|
623
615
|
| EnvSchemaPattern
|
|
624
616
|
// Catch-all
|
|
@@ -674,7 +666,6 @@ export function getEditability(pattern: FeaturePattern): Editability {
|
|
|
674
666
|
case "projection":
|
|
675
667
|
case "multiStreamProjection":
|
|
676
668
|
case "defineEvent":
|
|
677
|
-
case "eventMigration":
|
|
678
669
|
return "mixed";
|
|
679
670
|
case "authClaims":
|
|
680
671
|
case "extendsRegistrar":
|
|
@@ -26,7 +26,6 @@ import type {
|
|
|
26
26
|
EntityHookPattern,
|
|
27
27
|
EntityPattern,
|
|
28
28
|
EnvSchemaPattern,
|
|
29
|
-
EventMigrationPattern,
|
|
30
29
|
ExposesApiPattern,
|
|
31
30
|
ExtendsRegistrarPattern,
|
|
32
31
|
FeaturePattern,
|
|
@@ -129,8 +128,6 @@ export function renderPattern(pattern: FeaturePattern): string {
|
|
|
129
128
|
return renderMultiStreamProjection(pattern);
|
|
130
129
|
case "defineEvent":
|
|
131
130
|
return renderDefineEvent(pattern);
|
|
132
|
-
case "eventMigration":
|
|
133
|
-
return renderEventMigration(pattern);
|
|
134
131
|
case "extendsRegistrar":
|
|
135
132
|
return renderExtendsRegistrar(pattern);
|
|
136
133
|
case "usesApi":
|
|
@@ -505,16 +502,16 @@ function renderDefineEvent(p: DefineEventPattern): string {
|
|
|
505
502
|
lines.push(` name: ${JSON.stringify(p.eventName)},`);
|
|
506
503
|
lines.push(` schema: ${p.schemaSource.raw},`);
|
|
507
504
|
if (p.version !== undefined) lines.push(` version: ${p.version},`);
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
505
|
+
if (p.migrations !== undefined) {
|
|
506
|
+
const entries = Object.entries(p.migrations);
|
|
507
|
+
if (entries.length > 0) {
|
|
508
|
+
lines.push(" migrations: {");
|
|
509
|
+
for (const [fromVersion, transformBody] of entries) {
|
|
510
|
+
lines.push(` "${fromVersion}": ${transformBody.raw},`);
|
|
511
|
+
}
|
|
512
|
+
lines.push(" },");
|
|
513
|
+
}
|
|
514
|
+
}
|
|
518
515
|
lines.push("});");
|
|
519
516
|
return lines.join("\n");
|
|
520
517
|
}
|
|
@@ -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;
|