@cosmicdrift/kumiko-framework 0.154.1 → 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 (40) 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__/hook-phases.test.ts +5 -5
  6. package/src/engine/__tests__/post-query-hook.test.ts +7 -7
  7. package/src/engine/__tests__/registrar-object-form.test.ts +141 -0
  8. package/src/engine/define-feature.ts +22 -4
  9. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +8 -7
  10. package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +1 -2
  11. package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +1 -1
  12. package/src/engine/feature-ast/__tests__/parse.test.ts +22 -8
  13. package/src/engine/feature-ast/__tests__/patcher.test.ts +8 -8
  14. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +210 -4
  15. package/src/engine/feature-ast/extractors/index.ts +0 -2
  16. package/src/engine/feature-ast/extractors/round4.ts +21 -151
  17. package/src/engine/feature-ast/index.ts +0 -2
  18. package/src/engine/feature-ast/parse.ts +0 -3
  19. package/src/engine/feature-ast/patch.ts +42 -11
  20. package/src/engine/feature-ast/patcher.ts +4 -20
  21. package/src/engine/feature-ast/patterns.ts +10 -22
  22. package/src/engine/feature-ast/render.ts +7 -14
  23. package/src/engine/feature-config-events-jobs.ts +74 -24
  24. package/src/engine/feature-entity-handlers.ts +24 -6
  25. package/src/engine/feature-ui-extensions.ts +116 -45
  26. package/src/engine/object-form.ts +12 -0
  27. package/src/engine/pattern-library/__tests__/library.test.ts +0 -9
  28. package/src/engine/pattern-library/library.ts +0 -2
  29. package/src/engine/pattern-library/mixed-schemas.ts +1 -45
  30. package/src/engine/pattern-library/shared-fields.ts +1 -6
  31. package/src/engine/registry-ingest.ts +7 -2
  32. package/src/engine/types/feature.ts +58 -28
  33. package/src/engine/types/nav.ts +4 -0
  34. package/src/jobs/__tests__/jobs.integration.test.ts +66 -1
  35. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +2 -2
  36. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +1 -1
  37. package/src/pipeline/__tests__/post-query-hook.integration.test.ts +3 -3
  38. package/src/search/__tests__/meilisearch-adapter.integration.test.ts +200 -185
  39. package/src/search/__tests__/meilisearch-ids.test.ts +30 -0
  40. package/src/search/meilisearch-adapter.ts +13 -12
@@ -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
 
@@ -559,16 +574,25 @@ export type FeatureRegistrar<TFeature extends string = string> = {
559
574
  ): EventDef<TPayload, QualifiedEventName<TFeature, TInner>>;
560
575
 
561
576
  readsConfig(...qualifiedKeys: string[]): void;
577
+ readsConfig(options: { readonly keys: readonly string[] }): void;
562
578
 
563
579
  referenceData(
564
580
  entity: NameOrRef,
565
581
  data: readonly Record<string, unknown>[],
566
582
  options?: { upsertKey?: string },
567
583
  ): void;
584
+ referenceData(definition: {
585
+ readonly entity: NameOrRef;
586
+ readonly data: readonly Record<string, unknown>[];
587
+ readonly upsertKey?: string;
588
+ }): void;
568
589
 
569
590
  extendsRegistrar(name: string, def: RegistrarExtensionDef): void;
570
591
 
571
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;
572
596
 
573
597
  /**
574
598
  * Declares which config key selects the active provider under an
@@ -621,12 +645,14 @@ export type FeatureRegistrar<TFeature extends string = string> = {
621
645
  // qualifies it on boot. Validation (snake_case + typ-suffix) runs at boot.
622
646
  // Usage at runtime: ctx.metrics.inc("created_total", { status: "new" }).
623
647
  metric(shortName: string, options: MetricOptions): void;
648
+ metric(definition: { readonly name: string } & MetricOptions): void;
624
649
 
625
650
  // Declare a secret key. Qualified name follows "<feature>:secret:<kebab>"
626
651
  // via the QN helper. Returns a typed handle so feature code can pass it
627
652
  // to ctx.secrets.get without retyping the qualified string — same
628
653
  // ergonomics as r.config's handle.
629
654
  secret(shortName: string, options: SecretOptions): SecretKeyHandle;
655
+ secret(definition: { readonly name: string } & SecretOptions): SecretKeyHandle;
630
656
 
631
657
  // Register a projection driven by events of one or more source entities.
632
658
  // The runtime fires projection.apply[event.type] inside the event-store's
@@ -680,6 +706,10 @@ export type FeatureRegistrar<TFeature extends string = string> = {
680
706
  shortName: string,
681
707
  options: { readonly type: T },
682
708
  ): ClaimKeyHandle<T>;
709
+ claimKey<T extends ClaimKeyType>(definition: {
710
+ readonly name: string;
711
+ readonly type: T;
712
+ }): ClaimKeyHandle<T>;
683
713
 
684
714
  // Register a screen. The id is the feature-local short name (kebab-case);
685
715
  // the registry qualifies to "<feature>:screen:<id>". Boot-validation checks
@@ -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
@@ -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
  });
@@ -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 ---