@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.
- 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 +22 -4
- 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 +24 -6
- package/src/engine/feature-ui-extensions.ts +116 -45
- package/src/engine/object-form.ts +12 -0
- package/src/engine/pattern-library/__tests__/library.test.ts +0 -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 -28
- 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
|
@@ -381,6 +381,12 @@ export type FeatureDefinition = {
|
|
|
381
381
|
// --- Feature Registrar (the "r" object in defineFeature) ---
|
|
382
382
|
|
|
383
383
|
type RefOrRefs = NameOrRef | readonly NameOrRef[];
|
|
384
|
+
// Entity-wide hook target — "all query/write handlers of this entity",
|
|
385
|
+
// same reach r.entityHook() used to have. Only valid for postSave/
|
|
386
|
+
// preDelete/postDelete/postQuery (the same 4 types entityHook covered);
|
|
387
|
+
// hook() throws at registration time if used with validation/preSave/
|
|
388
|
+
// preQuery.
|
|
389
|
+
type HookTarget = RefOrRefs | { readonly allOf: NameOrRef };
|
|
384
390
|
|
|
385
391
|
/**
|
|
386
392
|
* `TFeature` is the literal feature-name from `defineFeature("foo", ...)` —
|
|
@@ -399,11 +405,16 @@ type RefOrRefs = NameOrRef | readonly NameOrRef[];
|
|
|
399
405
|
* steps are allowed to write via `r.step.unsafeProjectionUpsert`.
|
|
400
406
|
* Hard-required for any unsafeProjection-* step usage (see Q10).
|
|
401
407
|
*/
|
|
402
|
-
export type RequiresApi = ((...featureNames: string[]) => void) &
|
|
403
|
-
|
|
404
|
-
//
|
|
405
|
-
|
|
406
|
-
|
|
408
|
+
export type RequiresApi = ((...featureNames: string[]) => void) &
|
|
409
|
+
// Object-Form — the shape the feature-ast renderer (`render.ts`) emits
|
|
410
|
+
// for Designer/AI-generated code. A single object argument with named
|
|
411
|
+
// fields is easier to generate correctly than positional args whose
|
|
412
|
+
// count/order vary per registrar method.
|
|
413
|
+
((options: { readonly features: readonly string[] }) => void) & {
|
|
414
|
+
readonly projection: (tableName: string) => void;
|
|
415
|
+
// Tier-2 step opt-in (Q9). Tier-1 implicit, Tier-2 must be declared.
|
|
416
|
+
readonly step: (stepKind: string) => void;
|
|
417
|
+
};
|
|
407
418
|
|
|
408
419
|
export type FeatureRegistrar<TFeature extends string = string> = {
|
|
409
420
|
systemScope(): void;
|
|
@@ -412,6 +423,7 @@ export type FeatureRegistrar<TFeature extends string = string> = {
|
|
|
412
423
|
describe(text: string): void;
|
|
413
424
|
requires: RequiresApi;
|
|
414
425
|
optionalRequires(...featureNames: string[]): void;
|
|
426
|
+
optionalRequires(options: { readonly features: readonly string[] }): void;
|
|
415
427
|
// Declare the feature as operator-togglable. `default` is the effective
|
|
416
428
|
// state when no global-toggle row exists. Must be called at most once per
|
|
417
429
|
// feature; calling on an always-on feature (e.g. auth/tenant/user) is a
|
|
@@ -425,6 +437,7 @@ export type FeatureRegistrar<TFeature extends string = string> = {
|
|
|
425
437
|
definition: EntityDefinition,
|
|
426
438
|
options?: { readonly table?: unknown },
|
|
427
439
|
): EntityRef;
|
|
440
|
+
entity(definition: { readonly name: string } & EntityDefinition): EntityRef;
|
|
428
441
|
|
|
429
442
|
// One-call CRUD for an event-sourced entity — delegates to registerEntityCrud():
|
|
430
443
|
// r.entity + create/update/delete/restore/list/detail handlers per verb flag.
|
|
@@ -452,45 +465,39 @@ export type FeatureRegistrar<TFeature extends string = string> = {
|
|
|
452
465
|
): HandlerRef;
|
|
453
466
|
|
|
454
467
|
relation(entity: NameOrRef, relationName: string, definition: RelationDefinition): void;
|
|
468
|
+
// TDef inferred from the literal — keeps the excess-property check
|
|
469
|
+
// resolved against the matching RelationDefinition union member instead
|
|
470
|
+
// of distributing across all three (which spuriously rejects valid
|
|
471
|
+
// combinations when checked directly against the raw union).
|
|
472
|
+
relation<TDef extends RelationDefinition>(
|
|
473
|
+
definition: { readonly entity: NameOrRef; readonly name: string } & TDef,
|
|
474
|
+
): void;
|
|
455
475
|
|
|
456
476
|
hook(type: "validation", target: RefOrRefs, fn: ValidationHookFn): void;
|
|
457
477
|
hook(type: "preSave", target: RefOrRefs, fn: PreSaveHookFn): void;
|
|
478
|
+
// postSave/preDelete/postDelete/postQuery accept `{ allOf: entityRef }` —
|
|
479
|
+
// fires for every write/query handler of that entity, replacing the old
|
|
480
|
+
// r.entityHook(type, entity, fn). postQuery's entity-wide form fires for
|
|
481
|
+
// ALL query-handlers of the entity (e.g. customFields-bundle merging
|
|
482
|
+
// custom-fields-jsonb into every read); no phase semantics there
|
|
483
|
+
// (synchronous after handler-execute, before field-access-filter).
|
|
458
484
|
hook(
|
|
459
485
|
type: "postSave",
|
|
460
|
-
target:
|
|
486
|
+
target: HookTarget,
|
|
461
487
|
fn: PostSaveHookFn,
|
|
462
488
|
options?: { phase?: HookPhase },
|
|
463
489
|
): void;
|
|
464
490
|
// preDelete always runs in-transaction (it guards the delete — there is no
|
|
465
491
|
// meaningful "after" for a pre-hook). No phase option.
|
|
466
|
-
hook(type: "preDelete", target:
|
|
492
|
+
hook(type: "preDelete", target: HookTarget, fn: PreDeleteHookFn): void;
|
|
467
493
|
hook(
|
|
468
494
|
type: "postDelete",
|
|
469
|
-
target:
|
|
495
|
+
target: HookTarget,
|
|
470
496
|
fn: PostDeleteHookFn,
|
|
471
497
|
options?: { phase?: HookPhase },
|
|
472
498
|
): void;
|
|
473
499
|
hook(type: "preQuery", target: RefOrRefs, fn: PreQueryHookFn): void;
|
|
474
|
-
hook(type: "postQuery", target:
|
|
475
|
-
|
|
476
|
-
entityHook(
|
|
477
|
-
type: "postSave",
|
|
478
|
-
entity: NameOrRef,
|
|
479
|
-
fn: PostSaveHookFn,
|
|
480
|
-
options?: { phase?: HookPhase },
|
|
481
|
-
): void;
|
|
482
|
-
entityHook(type: "preDelete", entity: NameOrRef, fn: PreDeleteHookFn): void;
|
|
483
|
-
entityHook(
|
|
484
|
-
type: "postDelete",
|
|
485
|
-
entity: NameOrRef,
|
|
486
|
-
fn: PostDeleteHookFn,
|
|
487
|
-
options?: { phase?: HookPhase },
|
|
488
|
-
): void;
|
|
489
|
-
// postQuery-entityHook: fires for ALL query-handlers of this entity (e.g.,
|
|
490
|
-
// for customFields-bundle to merge custom-fields-jsonb into every read).
|
|
491
|
-
// No phase semantics (synchronous after handler-execute, before field-
|
|
492
|
-
// access-filter).
|
|
493
|
-
entityHook(type: "postQuery", entity: NameOrRef, fn: PostQueryHookFn): void;
|
|
500
|
+
hook(type: "postQuery", target: HookTarget, fn: PostQueryHookFn): void;
|
|
494
501
|
|
|
495
502
|
// F3 — Search-Payload-Extension: contributor function adds flat fields to
|
|
496
503
|
// an entity's search-index document. Fires synchronously during
|
|
@@ -514,6 +521,7 @@ export type FeatureRegistrar<TFeature extends string = string> = {
|
|
|
514
521
|
}): { readonly [K in keyof TKeys]: ConfigKeyHandle<TKeys[K]["type"]> };
|
|
515
522
|
|
|
516
523
|
job(name: string, options: Omit<JobDefinition, "name" | "handler">, handler: JobHandlerFn): void;
|
|
524
|
+
job(definition: JobDefinition): void;
|
|
517
525
|
|
|
518
526
|
notification(
|
|
519
527
|
name: string,
|
|
@@ -524,6 +532,13 @@ export type FeatureRegistrar<TFeature extends string = string> = {
|
|
|
524
532
|
readonly templates?: Readonly<Record<string, NotificationTemplateFn>>;
|
|
525
533
|
},
|
|
526
534
|
): void;
|
|
535
|
+
notification(definition: {
|
|
536
|
+
readonly name: string;
|
|
537
|
+
readonly trigger: { readonly on: NameOrRef };
|
|
538
|
+
readonly recipient: NotificationRecipientFn;
|
|
539
|
+
readonly data: NotificationDataFn;
|
|
540
|
+
readonly templates?: Readonly<Record<string, NotificationTemplateFn>>;
|
|
541
|
+
}): void;
|
|
527
542
|
|
|
528
543
|
translations(def: TranslationsDef): void;
|
|
529
544
|
|
|
@@ -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
|
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 ---
|