@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.
- 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__/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 +23 -5
- package/src/engine/feature-ast/__tests__/canonical-form.test.ts +8 -7
- 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 +22 -8
- package/src/engine/feature-ast/__tests__/patcher.test.ts +8 -8
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +210 -4
- package/src/engine/feature-ast/extractors/index.ts +0 -2
- package/src/engine/feature-ast/extractors/round4.ts +21 -151
- package/src/engine/feature-ast/index.ts +0 -2
- package/src/engine/feature-ast/parse.ts +0 -3
- package/src/engine/feature-ast/patch.ts +42 -11
- package/src/engine/feature-ast/patcher.ts +4 -20
- package/src/engine/feature-ast/patterns.ts +10 -22
- package/src/engine/feature-ast/render.ts +7 -14
- package/src/engine/feature-config-events-jobs.ts +74 -24
- package/src/engine/feature-entity-handlers.ts +26 -21
- 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 -9
- package/src/engine/pattern-library/library.ts +0 -2
- package/src/engine/pattern-library/mixed-schemas.ts +1 -45
- package/src/engine/pattern-library/shared-fields.ts +1 -6
- package/src/engine/registry-ingest.ts +7 -2
- package/src/engine/types/feature.ts +58 -34
- package/src/engine/types/nav.ts +4 -0
- 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__/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
- 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
|
-
|
|
404
|
-
//
|
|
405
|
-
|
|
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:
|
|
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:
|
|
486
|
+
hook(type: "preDelete", target: HookTarget, fn: PreDeleteHookFn): void;
|
|
467
487
|
hook(
|
|
468
488
|
type: "postDelete",
|
|
469
|
-
target:
|
|
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:
|
|
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
|
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
|
|
@@ -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
|
});
|
|
@@ -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 ---
|