@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
|
@@ -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.
|
|
@@ -381,6 +381,12 @@ export type FeatureDefinition = {
|
|
|
381
381
|
// --- Feature Registrar (the "r" object in defineFeature) ---
|
|
382
382
|
|
|
383
383
|
type RefOrRefs = NameOrRef | readonly NameOrRef[];
|
|
384
|
+
// Entity-wide hook target — "all query/write handlers of this entity",
|
|
385
|
+
// same reach r.entityHook() used to have. Only valid for postSave/
|
|
386
|
+
// preDelete/postDelete/postQuery (the same 4 types entityHook covered);
|
|
387
|
+
// hook() throws at registration time if used with validation/preSave/
|
|
388
|
+
// preQuery.
|
|
389
|
+
type HookTarget = RefOrRefs | { readonly allOf: NameOrRef };
|
|
384
390
|
|
|
385
391
|
/**
|
|
386
392
|
* `TFeature` is the literal feature-name from `defineFeature("foo", ...)` —
|
|
@@ -399,11 +405,16 @@ type RefOrRefs = NameOrRef | readonly NameOrRef[];
|
|
|
399
405
|
* steps are allowed to write via `r.step.unsafeProjectionUpsert`.
|
|
400
406
|
* Hard-required for any unsafeProjection-* step usage (see Q10).
|
|
401
407
|
*/
|
|
402
|
-
export type RequiresApi = ((...featureNames: string[]) => void) &
|
|
403
|
-
|
|
404
|
-
//
|
|
405
|
-
|
|
406
|
-
|
|
408
|
+
export type RequiresApi = ((...featureNames: string[]) => void) &
|
|
409
|
+
// Object-Form — the shape the feature-ast renderer (`render.ts`) emits
|
|
410
|
+
// for Designer/AI-generated code. A single object argument with named
|
|
411
|
+
// fields is easier to generate correctly than positional args whose
|
|
412
|
+
// count/order vary per registrar method.
|
|
413
|
+
((options: { readonly features: readonly string[] }) => void) & {
|
|
414
|
+
readonly projection: (tableName: string) => void;
|
|
415
|
+
// Tier-2 step opt-in (Q9). Tier-1 implicit, Tier-2 must be declared.
|
|
416
|
+
readonly step: (stepKind: string) => void;
|
|
417
|
+
};
|
|
407
418
|
|
|
408
419
|
export type FeatureRegistrar<TFeature extends string = string> = {
|
|
409
420
|
systemScope(): void;
|
|
@@ -412,6 +423,7 @@ export type FeatureRegistrar<TFeature extends string = string> = {
|
|
|
412
423
|
describe(text: string): void;
|
|
413
424
|
requires: RequiresApi;
|
|
414
425
|
optionalRequires(...featureNames: string[]): void;
|
|
426
|
+
optionalRequires(options: { readonly features: readonly string[] }): void;
|
|
415
427
|
// Declare the feature as operator-togglable. `default` is the effective
|
|
416
428
|
// state when no global-toggle row exists. Must be called at most once per
|
|
417
429
|
// feature; calling on an always-on feature (e.g. auth/tenant/user) is a
|
|
@@ -425,6 +437,7 @@ export type FeatureRegistrar<TFeature extends string = string> = {
|
|
|
425
437
|
definition: EntityDefinition,
|
|
426
438
|
options?: { readonly table?: unknown },
|
|
427
439
|
): EntityRef;
|
|
440
|
+
entity(definition: { readonly name: string } & EntityDefinition): EntityRef;
|
|
428
441
|
|
|
429
442
|
// One-call CRUD for an event-sourced entity — delegates to registerEntityCrud():
|
|
430
443
|
// r.entity + create/update/delete/restore/list/detail handlers per verb flag.
|
|
@@ -452,45 +465,39 @@ export type FeatureRegistrar<TFeature extends string = string> = {
|
|
|
452
465
|
): HandlerRef;
|
|
453
466
|
|
|
454
467
|
relation(entity: NameOrRef, relationName: string, definition: RelationDefinition): void;
|
|
468
|
+
// TDef inferred from the literal — keeps the excess-property check
|
|
469
|
+
// resolved against the matching RelationDefinition union member instead
|
|
470
|
+
// of distributing across all three (which spuriously rejects valid
|
|
471
|
+
// combinations when checked directly against the raw union).
|
|
472
|
+
relation<TDef extends RelationDefinition>(
|
|
473
|
+
definition: { readonly entity: NameOrRef; readonly name: string } & TDef,
|
|
474
|
+
): void;
|
|
455
475
|
|
|
456
476
|
hook(type: "validation", target: RefOrRefs, fn: ValidationHookFn): void;
|
|
457
477
|
hook(type: "preSave", target: RefOrRefs, fn: PreSaveHookFn): void;
|
|
478
|
+
// postSave/preDelete/postDelete/postQuery accept `{ allOf: entityRef }` —
|
|
479
|
+
// fires for every write/query handler of that entity, replacing the old
|
|
480
|
+
// r.entityHook(type, entity, fn). postQuery's entity-wide form fires for
|
|
481
|
+
// ALL query-handlers of the entity (e.g. customFields-bundle merging
|
|
482
|
+
// custom-fields-jsonb into every read); no phase semantics there
|
|
483
|
+
// (synchronous after handler-execute, before field-access-filter).
|
|
458
484
|
hook(
|
|
459
485
|
type: "postSave",
|
|
460
|
-
target:
|
|
486
|
+
target: HookTarget,
|
|
461
487
|
fn: PostSaveHookFn,
|
|
462
488
|
options?: { phase?: HookPhase },
|
|
463
489
|
): void;
|
|
464
490
|
// preDelete always runs in-transaction (it guards the delete — there is no
|
|
465
491
|
// meaningful "after" for a pre-hook). No phase option.
|
|
466
|
-
hook(type: "preDelete", target:
|
|
492
|
+
hook(type: "preDelete", target: HookTarget, fn: PreDeleteHookFn): void;
|
|
467
493
|
hook(
|
|
468
494
|
type: "postDelete",
|
|
469
|
-
target:
|
|
495
|
+
target: HookTarget,
|
|
470
496
|
fn: PostDeleteHookFn,
|
|
471
497
|
options?: { phase?: HookPhase },
|
|
472
498
|
): void;
|
|
473
499
|
hook(type: "preQuery", target: RefOrRefs, fn: PreQueryHookFn): void;
|
|
474
|
-
hook(type: "postQuery", target:
|
|
475
|
-
|
|
476
|
-
entityHook(
|
|
477
|
-
type: "postSave",
|
|
478
|
-
entity: NameOrRef,
|
|
479
|
-
fn: PostSaveHookFn,
|
|
480
|
-
options?: { phase?: HookPhase },
|
|
481
|
-
): void;
|
|
482
|
-
entityHook(type: "preDelete", entity: NameOrRef, fn: PreDeleteHookFn): void;
|
|
483
|
-
entityHook(
|
|
484
|
-
type: "postDelete",
|
|
485
|
-
entity: NameOrRef,
|
|
486
|
-
fn: PostDeleteHookFn,
|
|
487
|
-
options?: { phase?: HookPhase },
|
|
488
|
-
): void;
|
|
489
|
-
// postQuery-entityHook: fires for ALL query-handlers of this entity (e.g.,
|
|
490
|
-
// for customFields-bundle to merge custom-fields-jsonb into every read).
|
|
491
|
-
// No phase semantics (synchronous after handler-execute, before field-
|
|
492
|
-
// access-filter).
|
|
493
|
-
entityHook(type: "postQuery", entity: NameOrRef, fn: PostQueryHookFn): void;
|
|
500
|
+
hook(type: "postQuery", target: HookTarget, fn: PostQueryHookFn): void;
|
|
494
501
|
|
|
495
502
|
// F3 — Search-Payload-Extension: contributor function adds flat fields to
|
|
496
503
|
// an entity's search-index document. Fires synchronously during
|
|
@@ -514,6 +521,7 @@ export type FeatureRegistrar<TFeature extends string = string> = {
|
|
|
514
521
|
}): { readonly [K in keyof TKeys]: ConfigKeyHandle<TKeys[K]["type"]> };
|
|
515
522
|
|
|
516
523
|
job(name: string, options: Omit<JobDefinition, "name" | "handler">, handler: JobHandlerFn): void;
|
|
524
|
+
job(definition: JobDefinition): void;
|
|
517
525
|
|
|
518
526
|
notification(
|
|
519
527
|
name: string,
|
|
@@ -524,6 +532,13 @@ export type FeatureRegistrar<TFeature extends string = string> = {
|
|
|
524
532
|
readonly templates?: Readonly<Record<string, NotificationTemplateFn>>;
|
|
525
533
|
},
|
|
526
534
|
): void;
|
|
535
|
+
notification(definition: {
|
|
536
|
+
readonly name: string;
|
|
537
|
+
readonly trigger: { readonly on: NameOrRef };
|
|
538
|
+
readonly recipient: NotificationRecipientFn;
|
|
539
|
+
readonly data: NotificationDataFn;
|
|
540
|
+
readonly templates?: Readonly<Record<string, NotificationTemplateFn>>;
|
|
541
|
+
}): void;
|
|
527
542
|
|
|
528
543
|
translations(def: TranslationsDef): void;
|
|
529
544
|
|
|
@@ -532,9 +547,14 @@ export type FeatureRegistrar<TFeature extends string = string> = {
|
|
|
532
547
|
// "<feature>:event:<short>" string.
|
|
533
548
|
//
|
|
534
549
|
// `options.version` declares the CURRENT schema generation. Defaults to 1
|
|
535
|
-
// on first registration. When you bump the payload shape,
|
|
536
|
-
//
|
|
537
|
-
//
|
|
550
|
+
// on first registration. When you bump the payload shape, add a step to
|
|
551
|
+
// `options.migrations` covering N -> N+1 — the framework refuses to boot
|
|
552
|
+
// if the chain from 1 to `version` has gaps. Migrations were formerly a
|
|
553
|
+
// separate r.eventMigration() call; folded in here because an event and
|
|
554
|
+
// its schema evolution are one lifecycle, not two registrar concepts
|
|
555
|
+
// (#1082 step 8) — transforms are pure functions (old payload in, new
|
|
556
|
+
// payload out) and run once per read, not once per event persisted, so
|
|
557
|
+
// keep them cheap.
|
|
538
558
|
//
|
|
539
559
|
// `options.piiFields` declares PII payload fields encrypted under the DEK
|
|
540
560
|
// of the user named by `subjectField` (crypto-shredding, #799). append()
|
|
@@ -542,32 +562,37 @@ export type FeatureRegistrar<TFeature extends string = string> = {
|
|
|
542
562
|
defineEvent<const TInner extends string, TPayload>(
|
|
543
563
|
name: TInner,
|
|
544
564
|
schema: ZodType<TPayload>,
|
|
545
|
-
options?: {
|
|
565
|
+
options?: {
|
|
566
|
+
readonly version?: number;
|
|
567
|
+
readonly piiFields?: EventPiiFields;
|
|
568
|
+
readonly migrations?: readonly {
|
|
569
|
+
readonly fromVersion: number;
|
|
570
|
+
readonly toVersion: number;
|
|
571
|
+
readonly transform: EventUpcastFn | DeclarativeEventMigration;
|
|
572
|
+
}[];
|
|
573
|
+
},
|
|
546
574
|
): EventDef<TPayload, QualifiedEventName<TFeature, TInner>>;
|
|
547
575
|
|
|
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
576
|
readsConfig(...qualifiedKeys: string[]): void;
|
|
577
|
+
readsConfig(options: { readonly keys: readonly string[] }): void;
|
|
561
578
|
|
|
562
579
|
referenceData(
|
|
563
580
|
entity: NameOrRef,
|
|
564
581
|
data: readonly Record<string, unknown>[],
|
|
565
582
|
options?: { upsertKey?: string },
|
|
566
583
|
): void;
|
|
584
|
+
referenceData(definition: {
|
|
585
|
+
readonly entity: NameOrRef;
|
|
586
|
+
readonly data: readonly Record<string, unknown>[];
|
|
587
|
+
readonly upsertKey?: string;
|
|
588
|
+
}): void;
|
|
567
589
|
|
|
568
590
|
extendsRegistrar(name: string, def: RegistrarExtensionDef): void;
|
|
569
591
|
|
|
570
592
|
useExtension(extensionName: string, entity: NameOrRef, options?: Record<string, unknown>): void;
|
|
593
|
+
useExtension(
|
|
594
|
+
definition: { readonly name: string; readonly entity: NameOrRef } & Record<string, unknown>,
|
|
595
|
+
): void;
|
|
571
596
|
|
|
572
597
|
/**
|
|
573
598
|
* Declares which config key selects the active provider under an
|
|
@@ -620,12 +645,14 @@ export type FeatureRegistrar<TFeature extends string = string> = {
|
|
|
620
645
|
// qualifies it on boot. Validation (snake_case + typ-suffix) runs at boot.
|
|
621
646
|
// Usage at runtime: ctx.metrics.inc("created_total", { status: "new" }).
|
|
622
647
|
metric(shortName: string, options: MetricOptions): void;
|
|
648
|
+
metric(definition: { readonly name: string } & MetricOptions): void;
|
|
623
649
|
|
|
624
650
|
// Declare a secret key. Qualified name follows "<feature>:secret:<kebab>"
|
|
625
651
|
// via the QN helper. Returns a typed handle so feature code can pass it
|
|
626
652
|
// to ctx.secrets.get without retyping the qualified string — same
|
|
627
653
|
// ergonomics as r.config's handle.
|
|
628
654
|
secret(shortName: string, options: SecretOptions): SecretKeyHandle;
|
|
655
|
+
secret(definition: { readonly name: string } & SecretOptions): SecretKeyHandle;
|
|
629
656
|
|
|
630
657
|
// Register a projection driven by events of one or more source entities.
|
|
631
658
|
// The runtime fires projection.apply[event.type] inside the event-store's
|
|
@@ -679,6 +706,10 @@ export type FeatureRegistrar<TFeature extends string = string> = {
|
|
|
679
706
|
shortName: string,
|
|
680
707
|
options: { readonly type: T },
|
|
681
708
|
): ClaimKeyHandle<T>;
|
|
709
|
+
claimKey<T extends ClaimKeyType>(definition: {
|
|
710
|
+
readonly name: string;
|
|
711
|
+
readonly type: T;
|
|
712
|
+
}): ClaimKeyHandle<T>;
|
|
682
713
|
|
|
683
714
|
// Register a screen. The id is the feature-local short name (kebab-case);
|
|
684
715
|
// the registry qualifies to "<feature>:screen:<id>". Boot-validation checks
|
|
@@ -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;
|
package/src/engine/types/nav.ts
CHANGED
|
@@ -54,6 +54,10 @@ export type NavDefinition = {
|
|
|
54
54
|
// Role / openToAll gate. The nav resolver hides entries the user can't
|
|
55
55
|
// reach; leave unset to always show (engine stays un-opinionated about
|
|
56
56
|
// who sees what — apps that need default-deny can set { roles: [] }).
|
|
57
|
+
// If `screen` is set and `access` is left unset, the client-side nav
|
|
58
|
+
// builder (buildNavRegistrySliceForApp) fills this in from the target
|
|
59
|
+
// screen's own `access` — a nav entry never invites a role into a 403.
|
|
60
|
+
// An explicit `access` here always wins over the screen's.
|
|
57
61
|
readonly access?: AccessRule;
|
|
58
62
|
// Workspace QNs this entry self-assigns to. Merged at boot with any
|
|
59
63
|
// r.workspace({ nav: [...] }) explicit lists. Omit to leave workspace
|
|
@@ -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", () => {
|
|
@@ -4,7 +4,12 @@ import { createRegistry, defineFeature } from "../../engine";
|
|
|
4
4
|
import type { AppContext, Registry } from "../../engine/types";
|
|
5
5
|
import { createTestRedis, type TestRedis } from "../../stack";
|
|
6
6
|
import { sleep, waitFor } from "../../testing";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
createJobRunner,
|
|
9
|
+
type JobMeta,
|
|
10
|
+
type JobRunner,
|
|
11
|
+
type JobRunnerOptions,
|
|
12
|
+
} from "../job-runner";
|
|
8
13
|
|
|
9
14
|
// --- Shared state ---
|
|
10
15
|
|
|
@@ -104,6 +109,12 @@ const testFeature = defineFeature("test", (r) => {
|
|
|
104
109
|
throw new Error("intentional failure");
|
|
105
110
|
});
|
|
106
111
|
|
|
112
|
+
// perTenant fan-out — used to assert missing getActiveTenantIds fails
|
|
113
|
+
// the wrapper job without killing the worker.
|
|
114
|
+
r.job("perTenantFanout", { trigger: { manual: true }, perTenant: true }, async (payload) => {
|
|
115
|
+
jobLog.push({ name: "test:job:per-tenant-fanout", payload, timestamp: Date.now() });
|
|
116
|
+
});
|
|
117
|
+
|
|
107
118
|
// Correlation propagation probe — records the requestContext it sees at
|
|
108
119
|
// handler-time so tests can assert the scheduling request's correlationId
|
|
109
120
|
// made it through BullMQ into the worker process.
|
|
@@ -133,6 +144,7 @@ afterAll(async () => {
|
|
|
133
144
|
// Helper to create a runner, run tests, then stop
|
|
134
145
|
async function withRunner(
|
|
135
146
|
fn: (runner: JobRunner, registry: Registry) => Promise<void>,
|
|
147
|
+
opts?: Partial<JobRunnerOptions>,
|
|
136
148
|
): Promise<void> {
|
|
137
149
|
const registry = createRegistry([testFeature]);
|
|
138
150
|
const context: AppContext = {};
|
|
@@ -145,6 +157,7 @@ async function withRunner(
|
|
|
145
157
|
redisUrl,
|
|
146
158
|
consumerLane: "worker",
|
|
147
159
|
queueNamePrefix,
|
|
160
|
+
...opts,
|
|
148
161
|
});
|
|
149
162
|
|
|
150
163
|
try {
|
|
@@ -534,6 +547,58 @@ describe("error handling", () => {
|
|
|
534
547
|
});
|
|
535
548
|
});
|
|
536
549
|
});
|
|
550
|
+
|
|
551
|
+
test("onJobFailed receives the handler error on each attempt", async () => {
|
|
552
|
+
clearLog();
|
|
553
|
+
const failures: Array<{ jobName: string; error: string }> = [];
|
|
554
|
+
const starts: JobMeta[] = [];
|
|
555
|
+
await withRunner(
|
|
556
|
+
async (runner) => {
|
|
557
|
+
await runner.dispatch("test:job:failing-job");
|
|
558
|
+
// retries: 1 → attempts = 2 (initial + one retry)
|
|
559
|
+
await waitFor(() => {
|
|
560
|
+
expect(failures.length).toBeGreaterThanOrEqual(2);
|
|
561
|
+
});
|
|
562
|
+
expect(failures.every((f) => f.jobName === "test:job:failing-job")).toBe(true);
|
|
563
|
+
expect(failures.every((f) => f.error === "intentional failure")).toBe(true);
|
|
564
|
+
expect(starts.map((s) => s.attempt)).toEqual([1, 2]);
|
|
565
|
+
|
|
566
|
+
// Worker still alive after exhausted retries
|
|
567
|
+
await runner.dispatch("test:job:manual-report", { after: "retries" });
|
|
568
|
+
await waitFor(() => {
|
|
569
|
+
expect(
|
|
570
|
+
jobLog.some(
|
|
571
|
+
(e) => e.name === "test:job:manual-report" && e.payload["after"] === "retries",
|
|
572
|
+
),
|
|
573
|
+
).toBe(true);
|
|
574
|
+
});
|
|
575
|
+
},
|
|
576
|
+
{
|
|
577
|
+
onJobStart: (name, _id, meta) => {
|
|
578
|
+
if (name === "test:job:failing-job") starts.push(meta);
|
|
579
|
+
},
|
|
580
|
+
onJobFailed: (jobName, _id, error) => {
|
|
581
|
+
if (jobName === "test:job:failing-job") failures.push({ jobName, error });
|
|
582
|
+
},
|
|
583
|
+
},
|
|
584
|
+
);
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
test("perTenant without getActiveTenantIds fails wrapper; worker stays alive", async () => {
|
|
588
|
+
clearLog();
|
|
589
|
+
await withRunner(async (runner) => {
|
|
590
|
+
await runner.dispatch("test:job:per-tenant-fanout", { n: 1 });
|
|
591
|
+
// Wrapper throws before fan-out (and before onJobFailed) — settle, then
|
|
592
|
+
// prove the worker still consumes.
|
|
593
|
+
await sleep(300);
|
|
594
|
+
expect(jobLog.filter((e) => e.name === "test:job:per-tenant-fanout")).toHaveLength(0);
|
|
595
|
+
|
|
596
|
+
await runner.dispatch("test:job:manual-report", { after: "perTenant-miss" });
|
|
597
|
+
await waitFor(() => {
|
|
598
|
+
expect(jobLog.some((e) => e.name === "test:job:manual-report")).toBe(true);
|
|
599
|
+
});
|
|
600
|
+
});
|
|
601
|
+
});
|
|
537
602
|
});
|
|
538
603
|
|
|
539
604
|
// --- Registry ---
|
|
@@ -131,14 +131,14 @@ const bridgeFeature = defineFeature("ctxbridge", (r) => {
|
|
|
131
131
|
);
|
|
132
132
|
|
|
133
133
|
// afterCommit hook on bag — fires once per outer commit.
|
|
134
|
-
r.
|
|
134
|
+
r.hook("postSave", { allOf: bag }, async (result) => {
|
|
135
135
|
afterCommitLog.push(`bag:${result.data["label"]}`);
|
|
136
136
|
});
|
|
137
137
|
|
|
138
138
|
// afterCommit hook on secret — the entity targeted by the nested writeAs.
|
|
139
139
|
// Proves: (a) hook fires exactly once per successful writeAs, (b) hook
|
|
140
140
|
// does NOT fire when the outer transaction rolls back.
|
|
141
|
-
r.
|
|
141
|
+
r.hook("postSave", { allOf: secret }, async (result) => {
|
|
142
142
|
afterCommitLog.push(`secret:${result.data["owner"]}`);
|
|
143
143
|
});
|
|
144
144
|
});
|
|
@@ -424,7 +424,7 @@ describe("Sprint 8a: per-tenant entity-hook filter", () => {
|
|
|
424
424
|
});
|
|
425
425
|
|
|
426
426
|
const featureB = defineFeature("feat-b", (r) => {
|
|
427
|
-
r.
|
|
427
|
+
r.hook("postSave", { allOf: "widget" }, async (_result, ctx) => {
|
|
428
428
|
calls.push({ tenant: ctx._tenantId ?? "no-tenant" });
|
|
429
429
|
});
|
|
430
430
|
});
|
|
@@ -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",
|
|
@@ -65,7 +65,7 @@ const postQueryFeature = defineFeature("postquerytest", (r) => {
|
|
|
65
65
|
r.hook("postQuery", "widget:list", handlerKeyedHook);
|
|
66
66
|
|
|
67
67
|
// Entity-keyed: fires for ALL query-handlers of widget-entity
|
|
68
|
-
r.
|
|
68
|
+
r.hook("postQuery", { allOf: widget }, entityKeyedHook);
|
|
69
69
|
});
|
|
70
70
|
|
|
71
71
|
// --- Single-object-result invariant fixtures ---
|
|
@@ -93,13 +93,13 @@ const singleObjectFeature = defineFeature("singleobjtest", (r) => {
|
|
|
93
93
|
r.queryHandler("gadget:get", z.object({}), async () => ({ id: "g1", name: "Gadget" }), {
|
|
94
94
|
access: { openToAll: true },
|
|
95
95
|
});
|
|
96
|
-
r.
|
|
96
|
+
r.hook("postQuery", { allOf: gadget }, dropRowHook);
|
|
97
97
|
|
|
98
98
|
const gizmo = r.entity("gizmo", gizmoEntity);
|
|
99
99
|
r.queryHandler("gizmo:get", z.object({}), async () => ({ id: "z1", name: "Gizmo" }), {
|
|
100
100
|
access: { openToAll: true },
|
|
101
101
|
});
|
|
102
|
-
r.
|
|
102
|
+
r.hook("postQuery", { allOf: gizmo }, duplicateRowHook);
|
|
103
103
|
});
|
|
104
104
|
|
|
105
105
|
// --- Test stack ---
|