@cosmicdrift/kumiko-framework 0.154.1 → 0.155.0

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 (41) 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 +23 -5
  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 +26 -21
  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 -34
  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
  41. package/src/engine/__tests__/define-feature-crud.test.ts +0 -55
@@ -7,7 +7,6 @@ import type { EntityTableMeta } from "../../db/entity-table-meta";
7
7
  type PgTable = unknown;
8
8
 
9
9
  import type { QueryHandlerDefinition, WriteHandlerDefinition } from "../define-handler";
10
- import type { RegisterEntityCrudOptions } from "../entity-handlers";
11
10
  import type {
12
11
  ConfigKeyDefinition,
13
12
  ConfigKeyHandle,
@@ -381,6 +380,12 @@ export type FeatureDefinition = {
381
380
  // --- Feature Registrar (the "r" object in defineFeature) ---
382
381
 
383
382
  type RefOrRefs = NameOrRef | readonly NameOrRef[];
383
+ // Entity-wide hook target — "all query/write handlers of this entity",
384
+ // same reach r.entityHook() used to have. Only valid for postSave/
385
+ // preDelete/postDelete/postQuery (the same 4 types entityHook covered);
386
+ // hook() throws at registration time if used with validation/preSave/
387
+ // preQuery.
388
+ type HookTarget = RefOrRefs | { readonly allOf: NameOrRef };
384
389
 
385
390
  /**
386
391
  * `TFeature` is the literal feature-name from `defineFeature("foo", ...)` —
@@ -399,11 +404,16 @@ type RefOrRefs = NameOrRef | readonly NameOrRef[];
399
404
  * steps are allowed to write via `r.step.unsafeProjectionUpsert`.
400
405
  * Hard-required for any unsafeProjection-* step usage (see Q10).
401
406
  */
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
- };
407
+ export type RequiresApi = ((...featureNames: string[]) => void) &
408
+ // Object-Form — the shape the feature-ast renderer (`render.ts`) emits
409
+ // for Designer/AI-generated code. A single object argument with named
410
+ // fields is easier to generate correctly than positional args whose
411
+ // count/order vary per registrar method.
412
+ ((options: { readonly features: readonly string[] }) => void) & {
413
+ readonly projection: (tableName: string) => void;
414
+ // Tier-2 step opt-in (Q9). Tier-1 implicit, Tier-2 must be declared.
415
+ readonly step: (stepKind: string) => void;
416
+ };
407
417
 
408
418
  export type FeatureRegistrar<TFeature extends string = string> = {
409
419
  systemScope(): void;
@@ -412,6 +422,7 @@ export type FeatureRegistrar<TFeature extends string = string> = {
412
422
  describe(text: string): void;
413
423
  requires: RequiresApi;
414
424
  optionalRequires(...featureNames: string[]): void;
425
+ optionalRequires(options: { readonly features: readonly string[] }): void;
415
426
  // Declare the feature as operator-togglable. `default` is the effective
416
427
  // state when no global-toggle row exists. Must be called at most once per
417
428
  // feature; calling on an always-on feature (e.g. auth/tenant/user) is a
@@ -425,11 +436,7 @@ export type FeatureRegistrar<TFeature extends string = string> = {
425
436
  definition: EntityDefinition,
426
437
  options?: { readonly table?: unknown },
427
438
  ): EntityRef;
428
-
429
- // One-call CRUD for an event-sourced entity — delegates to registerEntityCrud():
430
- // r.entity + create/update/delete/restore/list/detail handlers per verb flag.
431
- // Access stays explicit — no openToAll default.
432
- crud(name: string, definition: EntityDefinition, options?: RegisterEntityCrudOptions): EntityRef;
439
+ entity(definition: { readonly name: string } & EntityDefinition): EntityRef;
433
440
 
434
441
  writeHandler<TName extends string, TSchema extends ZodType>(
435
442
  def: WriteHandlerDefinition<TName, TSchema>,
@@ -452,45 +459,39 @@ export type FeatureRegistrar<TFeature extends string = string> = {
452
459
  ): HandlerRef;
453
460
 
454
461
  relation(entity: NameOrRef, relationName: string, definition: RelationDefinition): void;
462
+ // TDef inferred from the literal — keeps the excess-property check
463
+ // resolved against the matching RelationDefinition union member instead
464
+ // of distributing across all three (which spuriously rejects valid
465
+ // combinations when checked directly against the raw union).
466
+ relation<TDef extends RelationDefinition>(
467
+ definition: { readonly entity: NameOrRef; readonly name: string } & TDef,
468
+ ): void;
455
469
 
456
470
  hook(type: "validation", target: RefOrRefs, fn: ValidationHookFn): void;
457
471
  hook(type: "preSave", target: RefOrRefs, fn: PreSaveHookFn): void;
472
+ // postSave/preDelete/postDelete/postQuery accept `{ allOf: entityRef }` —
473
+ // fires for every write/query handler of that entity, replacing the old
474
+ // r.entityHook(type, entity, fn). postQuery's entity-wide form fires for
475
+ // ALL query-handlers of the entity (e.g. customFields-bundle merging
476
+ // custom-fields-jsonb into every read); no phase semantics there
477
+ // (synchronous after handler-execute, before field-access-filter).
458
478
  hook(
459
479
  type: "postSave",
460
- target: RefOrRefs,
480
+ target: HookTarget,
461
481
  fn: PostSaveHookFn,
462
482
  options?: { phase?: HookPhase },
463
483
  ): void;
464
484
  // preDelete always runs in-transaction (it guards the delete — there is no
465
485
  // meaningful "after" for a pre-hook). No phase option.
466
- hook(type: "preDelete", target: RefOrRefs, fn: PreDeleteHookFn): void;
486
+ hook(type: "preDelete", target: HookTarget, fn: PreDeleteHookFn): void;
467
487
  hook(
468
488
  type: "postDelete",
469
- target: RefOrRefs,
489
+ target: HookTarget,
470
490
  fn: PostDeleteHookFn,
471
491
  options?: { phase?: HookPhase },
472
492
  ): void;
473
493
  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;
494
+ hook(type: "postQuery", target: HookTarget, fn: PostQueryHookFn): void;
494
495
 
495
496
  // F3 — Search-Payload-Extension: contributor function adds flat fields to
496
497
  // an entity's search-index document. Fires synchronously during
@@ -514,6 +515,7 @@ export type FeatureRegistrar<TFeature extends string = string> = {
514
515
  }): { readonly [K in keyof TKeys]: ConfigKeyHandle<TKeys[K]["type"]> };
515
516
 
516
517
  job(name: string, options: Omit<JobDefinition, "name" | "handler">, handler: JobHandlerFn): void;
518
+ job(definition: JobDefinition): void;
517
519
 
518
520
  notification(
519
521
  name: string,
@@ -524,6 +526,13 @@ export type FeatureRegistrar<TFeature extends string = string> = {
524
526
  readonly templates?: Readonly<Record<string, NotificationTemplateFn>>;
525
527
  },
526
528
  ): void;
529
+ notification(definition: {
530
+ readonly name: string;
531
+ readonly trigger: { readonly on: NameOrRef };
532
+ readonly recipient: NotificationRecipientFn;
533
+ readonly data: NotificationDataFn;
534
+ readonly templates?: Readonly<Record<string, NotificationTemplateFn>>;
535
+ }): void;
527
536
 
528
537
  translations(def: TranslationsDef): void;
529
538
 
@@ -559,16 +568,25 @@ export type FeatureRegistrar<TFeature extends string = string> = {
559
568
  ): EventDef<TPayload, QualifiedEventName<TFeature, TInner>>;
560
569
 
561
570
  readsConfig(...qualifiedKeys: string[]): void;
571
+ readsConfig(options: { readonly keys: readonly string[] }): void;
562
572
 
563
573
  referenceData(
564
574
  entity: NameOrRef,
565
575
  data: readonly Record<string, unknown>[],
566
576
  options?: { upsertKey?: string },
567
577
  ): void;
578
+ referenceData(definition: {
579
+ readonly entity: NameOrRef;
580
+ readonly data: readonly Record<string, unknown>[];
581
+ readonly upsertKey?: string;
582
+ }): void;
568
583
 
569
584
  extendsRegistrar(name: string, def: RegistrarExtensionDef): void;
570
585
 
571
586
  useExtension(extensionName: string, entity: NameOrRef, options?: Record<string, unknown>): void;
587
+ useExtension(
588
+ definition: { readonly name: string; readonly entity: NameOrRef } & Record<string, unknown>,
589
+ ): void;
572
590
 
573
591
  /**
574
592
  * Declares which config key selects the active provider under an
@@ -621,12 +639,14 @@ export type FeatureRegistrar<TFeature extends string = string> = {
621
639
  // qualifies it on boot. Validation (snake_case + typ-suffix) runs at boot.
622
640
  // Usage at runtime: ctx.metrics.inc("created_total", { status: "new" }).
623
641
  metric(shortName: string, options: MetricOptions): void;
642
+ metric(definition: { readonly name: string } & MetricOptions): void;
624
643
 
625
644
  // Declare a secret key. Qualified name follows "<feature>:secret:<kebab>"
626
645
  // via the QN helper. Returns a typed handle so feature code can pass it
627
646
  // to ctx.secrets.get without retyping the qualified string — same
628
647
  // ergonomics as r.config's handle.
629
648
  secret(shortName: string, options: SecretOptions): SecretKeyHandle;
649
+ secret(definition: { readonly name: string } & SecretOptions): SecretKeyHandle;
630
650
 
631
651
  // Register a projection driven by events of one or more source entities.
632
652
  // The runtime fires projection.apply[event.type] inside the event-store's
@@ -680,6 +700,10 @@ export type FeatureRegistrar<TFeature extends string = string> = {
680
700
  shortName: string,
681
701
  options: { readonly type: T },
682
702
  ): ClaimKeyHandle<T>;
703
+ claimKey<T extends ClaimKeyType>(definition: {
704
+ readonly name: string;
705
+ readonly type: T;
706
+ }): ClaimKeyHandle<T>;
683
707
 
684
708
  // Register a screen. The id is the feature-local short name (kebab-case);
685
709
  // 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 ---