@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.
- package/package.json +2 -2
- package/src/__tests__/full-stack.integration.test.ts +1 -1
- package/src/api/__tests__/batch.integration.test.ts +9 -9
- package/src/engine/__tests__/engine.test.ts +16 -0
- package/src/engine/__tests__/event-migration-declarative.test.ts +9 -2
- package/src/engine/__tests__/hook-phases.test.ts +5 -5
- package/src/engine/__tests__/post-query-hook.test.ts +7 -7
- package/src/engine/__tests__/registrar-object-form.test.ts +141 -0
- package/src/engine/define-feature.ts +22 -4
- package/src/engine/feature-ast/__tests__/canonical-form.test.ts +15 -28
- package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +1 -2
- package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +1 -1
- package/src/engine/feature-ast/__tests__/parse.test.ts +55 -14
- package/src/engine/feature-ast/__tests__/patch.test.ts +8 -13
- package/src/engine/feature-ast/__tests__/patcher.test.ts +12 -22
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +247 -5
- package/src/engine/feature-ast/extractors/index.ts +0 -3
- package/src/engine/feature-ast/extractors/round4.ts +113 -287
- package/src/engine/feature-ast/index.ts +0 -4
- package/src/engine/feature-ast/parse.ts +0 -6
- package/src/engine/feature-ast/patch.ts +35 -45
- package/src/engine/feature-ast/patcher.ts +18 -40
- package/src/engine/feature-ast/patterns.ts +20 -41
- package/src/engine/feature-ast/render.ts +17 -27
- package/src/engine/feature-config-events-jobs.ts +150 -74
- package/src/engine/feature-entity-handlers.ts +24 -6
- package/src/engine/feature-ui-extensions.ts +116 -45
- package/src/engine/object-form.ts +12 -0
- package/src/engine/pattern-library/__tests__/library.test.ts +0 -19
- package/src/engine/pattern-library/library.ts +0 -4
- package/src/engine/pattern-library/mixed-schemas.ts +9 -80
- package/src/engine/pattern-library/shared-fields.ts +1 -6
- package/src/engine/registry-ingest.ts +7 -2
- package/src/engine/registry-validate.ts +6 -6
- package/src/engine/types/feature.ts +76 -45
- package/src/engine/types/handlers.ts +6 -3
- package/src/engine/types/nav.ts +4 -0
- package/src/event-store/__tests__/upcaster.integration.test.ts +77 -45
- package/src/jobs/__tests__/jobs.integration.test.ts +66 -1
- package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +2 -2
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +1 -1
- package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +16 -8
- package/src/pipeline/__tests__/post-query-hook.integration.test.ts +3 -3
- package/src/search/__tests__/meilisearch-adapter.integration.test.ts +200 -185
- package/src/search/__tests__/meilisearch-ids.test.ts +30 -0
- 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
|
-
|
|
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
|
-
|
|
150
|
-
readonly
|
|
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
|
|
380
|
-
//
|
|
381
|
-
//
|
|
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
|
|
387
|
-
// the Designer can render the
|
|
388
|
-
|
|
389
|
-
readonly
|
|
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
|
-
//
|
|
506
|
-
//
|
|
494
|
+
// `options.version` declares the CURRENT schema generation (default 1).
|
|
495
|
+
// `options.migrations` is a step-wise upcast chain — keyed 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
|
-
|
|
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
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
|
415
|
-
const lines: string[] = ["r.
|
|
404
|
+
function renderHook(p: HookPattern): string {
|
|
405
|
+
const lines: string[] = ["r.hook({"];
|
|
416
406
|
lines.push(` type: ${JSON.stringify(p.hookType)},`);
|
|
417
|
-
lines.push(`
|
|
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
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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
|
-
|
|
100
|
-
options
|
|
101
|
-
handler
|
|
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
|
|
177
|
+
"on" in jobOptions.trigger
|
|
108
178
|
? {
|
|
109
|
-
on: Array.isArray(
|
|
110
|
-
?
|
|
111
|
-
: resolveName(
|
|
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
|
-
:
|
|
114
|
-
state.jobs[jobName] = { ...
|
|
183
|
+
: jobOptions.trigger;
|
|
184
|
+
state.jobs[jobName] = { ...jobOptions, trigger, name: jobName, handler: jobHandler };
|
|
115
185
|
},
|
|
116
186
|
notification(
|
|
117
|
-
|
|
118
|
-
|
|
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(
|
|
128
|
-
recipient:
|
|
129
|
-
data:
|
|
130
|
-
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?: {
|
|
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
|
-
|
|
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
|
-
|
|
193
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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(
|
|
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, ...
|
|
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(
|
|
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
|
-
...
|
|
327
|
+
...secretOptions,
|
|
260
328
|
};
|
|
261
329
|
return { name: qualifiedName };
|
|
262
330
|
},
|
|
263
331
|
claimKey<T extends ClaimKeyType>(
|
|
264
|
-
|
|
265
|
-
options
|
|
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:
|
|
359
|
+
type: claimType,
|
|
284
360
|
};
|
|
285
|
-
return { name: qualifiedName, 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
|
|
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);
|