@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.
Files changed (46) hide show
  1. package/package.json +2 -2
  2. package/src/__tests__/full-stack.integration.test.ts +1 -1
  3. package/src/api/__tests__/batch.integration.test.ts +9 -9
  4. package/src/engine/__tests__/engine.test.ts +16 -0
  5. package/src/engine/__tests__/event-migration-declarative.test.ts +9 -2
  6. package/src/engine/__tests__/hook-phases.test.ts +5 -5
  7. package/src/engine/__tests__/post-query-hook.test.ts +7 -7
  8. package/src/engine/__tests__/registrar-object-form.test.ts +141 -0
  9. package/src/engine/define-feature.ts +22 -4
  10. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +15 -28
  11. package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +1 -2
  12. package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +1 -1
  13. package/src/engine/feature-ast/__tests__/parse.test.ts +55 -14
  14. package/src/engine/feature-ast/__tests__/patch.test.ts +8 -13
  15. package/src/engine/feature-ast/__tests__/patcher.test.ts +12 -22
  16. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +247 -5
  17. package/src/engine/feature-ast/extractors/index.ts +0 -3
  18. package/src/engine/feature-ast/extractors/round4.ts +113 -287
  19. package/src/engine/feature-ast/index.ts +0 -4
  20. package/src/engine/feature-ast/parse.ts +0 -6
  21. package/src/engine/feature-ast/patch.ts +35 -45
  22. package/src/engine/feature-ast/patcher.ts +18 -40
  23. package/src/engine/feature-ast/patterns.ts +20 -41
  24. package/src/engine/feature-ast/render.ts +17 -27
  25. package/src/engine/feature-config-events-jobs.ts +150 -74
  26. package/src/engine/feature-entity-handlers.ts +24 -6
  27. package/src/engine/feature-ui-extensions.ts +116 -45
  28. package/src/engine/object-form.ts +12 -0
  29. package/src/engine/pattern-library/__tests__/library.test.ts +0 -19
  30. package/src/engine/pattern-library/library.ts +0 -4
  31. package/src/engine/pattern-library/mixed-schemas.ts +9 -80
  32. package/src/engine/pattern-library/shared-fields.ts +1 -6
  33. package/src/engine/registry-ingest.ts +7 -2
  34. package/src/engine/registry-validate.ts +6 -6
  35. package/src/engine/types/feature.ts +76 -45
  36. package/src/engine/types/handlers.ts +6 -3
  37. package/src/engine/types/nav.ts +4 -0
  38. package/src/event-store/__tests__/upcaster.integration.test.ts +77 -45
  39. package/src/jobs/__tests__/jobs.integration.test.ts +66 -1
  40. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +2 -2
  41. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +1 -1
  42. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +16 -8
  43. package/src/pipeline/__tests__/post-query-hook.integration.test.ts +3 -3
  44. package/src/search/__tests__/meilisearch-adapter.integration.test.ts +200 -185
  45. package/src/search/__tests__/meilisearch-ids.test.ts +30 -0
  46. 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 r.eventMigration(). Keyed by event
301
+ // Event schema migrations declared via defineEvent's `migrations` option. Keyed by event
302
302
  // short-name; each entry carries the step transforms (fromVersion →
303
303
  // toVersion). The registry stitches these with the defineEvent-declared
304
304
  // current version and exposes a per-qualified-name upcaster chain.
@@ -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
- readonly projection: (tableName: string) => void;
404
- // Tier-2 step opt-in (Q9). Tier-1 implicit, Tier-2 must be declared.
405
- readonly step: (stepKind: string) => void;
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: RefOrRefs,
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: RefOrRefs, fn: PreDeleteHookFn): void;
492
+ hook(type: "preDelete", target: HookTarget, fn: PreDeleteHookFn): void;
467
493
  hook(
468
494
  type: "postDelete",
469
- target: RefOrRefs,
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: RefOrRefs, fn: PostQueryHookFn): void;
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, raise version
536
- // AND register r.eventMigration(shortName, N, N+1, transform) — the
537
- // framework refuses to boot if the chain from 1 version has gaps.
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?: { readonly version?: number; readonly piiFields?: EventPiiFields },
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.eventMigration("invoiceCreated", 1, 2, async (payload, ctx) => {
740
- // const customer = await ctx.db.select().from(customersTable)...;
741
- // return { ...payload, customerSegment: customer.segment };
739
+ // r.defineEvent("invoiceCreated", schema, {
740
+ // version: 2,
741
+ // migrations: [{ fromVersion: 1, toVersion: 2, transform: async (payload, ctx) => {
742
+ // const customer = await ctx.db.select().from(customersTable)...;
743
+ // return { ...payload, customerSegment: customer.segment };
744
+ // } }],
742
745
  // });
743
746
  export type EventUpcastCtx = {
744
747
  readonly db: import("../../db").DbRunner;
@@ -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
- { version: 3 },
59
+ {
60
+ version: 3,
61
+ migrations: [
62
+ // v1 → v2: renamed totalEuros → total (kept as string for this step)
63
+ {
64
+ fromVersion: 1,
65
+ toVersion: 2,
66
+ transform: (payload) => {
67
+ const p = payload as { totalEuros: string };
68
+ return { total: p.totalEuros, currency: "EUR" };
69
+ },
70
+ },
71
+ // v2 → v3: parse "total" string into integer cents
72
+ {
73
+ fromVersion: 2,
74
+ toVersion: 3,
75
+ transform: (payload) => {
76
+ const p = payload as { total: string; currency: string };
77
+ const euros = Number.parseFloat(p.total);
78
+ return { totalCents: Math.round(euros * 100), currency: p.currency };
79
+ },
80
+ },
81
+ ],
82
+ },
60
83
  );
61
84
 
62
- // v1 → v2: renamed totalEuros → total (kept as string for this step)
63
- r.eventMigration("priced", 1, 2, (payload) => {
64
- const p = payload as { totalEuros: string };
65
- return { total: p.totalEuros, currency: "EUR" };
66
- });
67
- // v2 → v3: parse "total" string into integer cents
68
- r.eventMigration("priced", 2, 3, (payload) => {
69
- const p = payload as { total: string; currency: string };
70
- const euros = Number.parseFloat(p.total);
71
- return { totalCents: Math.round(euros * 100), currency: p.currency };
72
- });
73
-
74
85
  r.projection({
75
86
  name: "order-summary",
76
87
  source: "upcast-order",
@@ -309,18 +320,27 @@ describe("upcaster: async (Marten AsyncOnlyEventUpcaster — DB-Lookups)", () =>
309
320
  const placed = r.defineEvent(
310
321
  "placed",
311
322
  z.object({ customerId: z.string(), segment: z.string() }),
312
- { version: 2 },
323
+ {
324
+ version: 2,
325
+ migrations: [
326
+ {
327
+ fromVersion: 1,
328
+ toVersion: 2,
329
+ transform: async (payload, ctx) => {
330
+ const p = payload as { customerId: string };
331
+ const [row] = await selectMany(ctx.db, customerSegments, {
332
+ customerId: p.customerId,
333
+ });
334
+ return {
335
+ customerId: p.customerId,
336
+ segment: (row as { segment?: string } | undefined)?.segment ?? "UNKNOWN",
337
+ };
338
+ },
339
+ },
340
+ ],
341
+ },
313
342
  );
314
343
 
315
- r.eventMigration("placed", 1, 2, async (payload, ctx) => {
316
- const p = payload as { customerId: string };
317
- const [row] = await selectMany(ctx.db, customerSegments, { customerId: p.customerId });
318
- return {
319
- customerId: p.customerId,
320
- segment: (row as { segment?: string } | undefined)?.segment ?? "UNKNOWN",
321
- };
322
- });
323
-
324
344
  r.projection({
325
345
  name: "async-summary",
326
346
  source: "upcast-async-order",
@@ -385,26 +405,28 @@ describe("upcaster: boot-time validation", () => {
385
405
  test("defineEvent with version=N and only partial migrations fails at registry build", () => {
386
406
  const incomplete = defineFeature("holes", (r) => {
387
407
  r.entity("hole-order", orderEntity);
388
- r.defineEvent("bad", z.object({ v3: z.string() }), { version: 3 });
389
408
  // Only 1→2 registered — the 2→3 gap must be rejected.
390
- r.eventMigration("bad", 1, 2, (p) => p);
409
+ r.defineEvent("bad", z.object({ v3: z.string() }), {
410
+ version: 3,
411
+ migrations: [{ fromVersion: 1, toVersion: 2, transform: (p) => p }],
412
+ });
391
413
  });
392
414
  expect(() => createRegistry([incomplete])).toThrow(/v2.*v3|covers the step v2/);
393
415
  });
394
416
 
395
- test("migration declared but no defineEvent rejected", () => {
396
- const orphan = defineFeature("orphans", (r) => {
397
- r.entity("orph-order", orderEntity);
398
- r.eventMigration("ghost", 1, 2, (p) => p);
399
- });
400
- expect(() => createRegistry([orphan])).toThrow(/no r\.defineEvent/i);
401
- });
417
+ // "migration declared but no defineEvent" is now structurally impossible:
418
+ // migrations live in defineEvent's `migrations` option, always scoped to
419
+ // the event being defined in that same call — there is no longer a
420
+ // registrar shape that can express a dangling migration for an
421
+ // undefined event (formerly the "ghost" registry-validate error path).
402
422
 
403
423
  test("migration toVersion > defineEvent version → rejected", () => {
404
424
  const future = defineFeature("future", (r) => {
405
425
  r.entity("future-order", orderEntity);
406
- r.defineEvent("early", z.object({ x: z.number() }), { version: 1 });
407
- r.eventMigration("early", 1, 2, (p) => p);
426
+ r.defineEvent("early", z.object({ x: z.number() }), {
427
+ version: 1,
428
+ migrations: [{ fromVersion: 1, toVersion: 2, transform: (p) => p }],
429
+ });
408
430
  });
409
431
  expect(() => createRegistry([future])).toThrow(/declares only version 1/);
410
432
  });
@@ -412,34 +434,44 @@ describe("upcaster: boot-time validation", () => {
412
434
  test("non-contiguous (1→2 and 3→4 without 2→3) → rejected", () => {
413
435
  const gaps = defineFeature("gaps", (r) => {
414
436
  r.entity("gap-order", orderEntity);
415
- r.defineEvent("jumpy", z.object({ v: z.number() }), { version: 4 });
416
- r.eventMigration("jumpy", 1, 2, (p) => p);
417
- r.eventMigration("jumpy", 3, 4, (p) => p);
437
+ r.defineEvent("jumpy", z.object({ v: z.number() }), {
438
+ version: 4,
439
+ migrations: [
440
+ { fromVersion: 1, toVersion: 2, transform: (p) => p },
441
+ { fromVersion: 3, toVersion: 4, transform: (p) => p },
442
+ ],
443
+ });
418
444
  });
419
445
  expect(() => createRegistry([gaps])).toThrow(/v2.*v3|covers the step v2/);
420
446
  });
421
447
  });
422
448
 
423
449
  describe("upcaster: registrar input validation", () => {
424
- test("r.eventMigration rejects multi-step jumps", () => {
450
+ test("defineEvent migrations reject multi-step jumps", () => {
425
451
  expect(() =>
426
452
  defineFeature("bigstep", (r) => {
427
453
  r.entity("bigstep-order", orderEntity);
428
- r.defineEvent("biz", z.object({ x: z.number() }), { version: 3 });
429
- r.eventMigration("biz", 1, 3, (p) => p);
454
+ r.defineEvent("biz", z.object({ x: z.number() }), {
455
+ version: 3,
456
+ migrations: [{ fromVersion: 1, toVersion: 3, transform: (p) => p }],
457
+ });
430
458
  }),
431
459
  ).toThrow(/single-step/);
432
460
  });
433
461
 
434
- test("r.eventMigration rejects duplicate step", () => {
462
+ test("defineEvent migrations reject duplicate step", () => {
435
463
  expect(() =>
436
464
  defineFeature("dupestep", (r) => {
437
465
  r.entity("dup-order", orderEntity);
438
- r.defineEvent("dup", z.object({ x: z.number() }), { version: 2 });
439
- r.eventMigration("dup", 1, 2, (p) => p);
440
- r.eventMigration("dup", 1, 2, (p) => p);
466
+ r.defineEvent("dup", z.object({ x: z.number() }), {
467
+ version: 2,
468
+ migrations: [
469
+ { fromVersion: 1, toVersion: 2, transform: (p) => p },
470
+ { fromVersion: 1, toVersion: 2, transform: (p) => p },
471
+ ],
472
+ });
441
473
  }),
442
- ).toThrow(/already registered/);
474
+ ).toThrow(/already declared/);
443
475
  });
444
476
 
445
477
  test("r.defineEvent rejects non-positive version", () => {
@@ -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 { createJobRunner, type JobRunner } from "../job-runner";
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.entityHook("postSave", bag, async (result) => {
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.entityHook("postSave", secret, async (result) => {
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.entityHook("postSave", "widget", async (_result, ctx) => {
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
- { version: 2 },
48
+ {
49
+ version: 2,
50
+ migrations: [
51
+ {
52
+ fromVersion: 1,
53
+ toVersion: 2,
54
+ transform: (payload) => {
55
+ const p = payload as { amount: string; approvedBy: string };
56
+ return {
57
+ amount: Math.round(Number.parseFloat(p.amount) * 100),
58
+ approvedBy: p.approvedBy,
59
+ };
60
+ },
61
+ },
62
+ ],
63
+ },
49
64
  );
50
- r.eventMigration("approved", 1, 2, (payload) => {
51
- const p = payload as { amount: string; approvedBy: string };
52
- return {
53
- amount: Math.round(Number.parseFloat(p.amount) * 100),
54
- approvedBy: p.approvedBy,
55
- };
56
- });
57
65
 
58
66
  const executor = createEventStoreExecutor(invoiceTable, invoiceEntity, {
59
67
  entityName: "asof-invoice",
@@ -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.entityHook("postQuery", widget, entityKeyedHook);
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.entityHook("postQuery", gadget, dropRowHook);
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.entityHook("postQuery", gizmo, duplicateRowHook);
102
+ r.hook("postQuery", { allOf: gizmo }, duplicateRowHook);
103
103
  });
104
104
 
105
105
  // --- Test stack ---