@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
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
// up at canonical positions). We compare every other field.
|
|
10
10
|
|
|
11
11
|
import { describe, expect, test } from "bun:test";
|
|
12
|
-
import
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
import { Project, ts } from "ts-morph";
|
|
13
14
|
import { parseSourceFile } from "../parse";
|
|
14
15
|
import type { FeaturePattern } from "../patterns";
|
|
15
16
|
import { renderFeatureFile, renderPattern } from "../render";
|
|
@@ -29,10 +30,10 @@ defineFeature("inventory", (r) => {
|
|
|
29
30
|
},
|
|
30
31
|
});
|
|
31
32
|
|
|
32
|
-
r.relation("item", "supplier", {
|
|
33
|
+
r.relation("item", "supplier", { type: "belongsTo", target: "user", foreignKey: "supplierId" });
|
|
33
34
|
|
|
34
35
|
r.metric("created", { type: "counter" });
|
|
35
|
-
r.secret("apiKey", {
|
|
36
|
+
r.secret("apiKey", { label: { en: "Stripe API Key" }, scope: "tenant" });
|
|
36
37
|
r.claimKey("teamId", { type: "string" });
|
|
37
38
|
|
|
38
39
|
r.referenceData(
|
|
@@ -44,7 +45,16 @@ defineFeature("inventory", (r) => {
|
|
|
44
45
|
{ upsertKey: "id" },
|
|
45
46
|
);
|
|
46
47
|
|
|
47
|
-
r.config({
|
|
48
|
+
r.config({
|
|
49
|
+
keys: {
|
|
50
|
+
maxRows: {
|
|
51
|
+
type: "number",
|
|
52
|
+
default: 50,
|
|
53
|
+
scope: "tenant",
|
|
54
|
+
access: { read: ["user"], write: ["admin"] },
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
});
|
|
48
58
|
});
|
|
49
59
|
`;
|
|
50
60
|
|
|
@@ -359,6 +369,41 @@ describe("renderPattern — single-pattern shape", () => {
|
|
|
359
369
|
});
|
|
360
370
|
});
|
|
361
371
|
|
|
372
|
+
// Regression guard for the class of bug the r.exposesApi/r.usesApi fold
|
|
373
|
+
// hit: a registrar-shape change must survive the Designer's parse→render→
|
|
374
|
+
// parse cycle, not just a TS compile of the framework itself. r.hook()'s
|
|
375
|
+
// `{ allOf: entity }` target (replacing r.entityHook) reuses HookPattern's
|
|
376
|
+
// already-generic target field, so this is the cheap case — but proving it
|
|
377
|
+
// beats assuming it.
|
|
378
|
+
const HOOK_ALL_OF_FEATURE = `
|
|
379
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
380
|
+
|
|
381
|
+
defineFeature("shop", (r) => {
|
|
382
|
+
r.entity("product", { fields: { name: { type: "text" } } });
|
|
383
|
+
r.hook("postSave", { allOf: "product" }, async (result) => {
|
|
384
|
+
console.log(result);
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
`;
|
|
388
|
+
|
|
389
|
+
describe("render → parse roundtrip — r.hook({ allOf }) entity-wide target", () => {
|
|
390
|
+
test("allOf target survives parse → render → parse unchanged", () => {
|
|
391
|
+
const initial = parse(HOOK_ALL_OF_FEATURE);
|
|
392
|
+
const rendered = renderFeatureFile({
|
|
393
|
+
featureName: initial.featureName ?? "",
|
|
394
|
+
patterns: initial.patterns,
|
|
395
|
+
});
|
|
396
|
+
const reparsed = parse(rendered);
|
|
397
|
+
expect(reparsed.patterns.map(stripLocations)).toEqual(initial.patterns.map(stripLocations));
|
|
398
|
+
|
|
399
|
+
const hookPattern = reparsed.patterns.find((p) => p.kind === "hook");
|
|
400
|
+
expect(hookPattern?.kind).toBe("hook");
|
|
401
|
+
if (hookPattern?.kind === "hook") {
|
|
402
|
+
expect(hookPattern.target).toEqual({ allOf: "product" });
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
|
|
362
407
|
// Regression guard for the class of bug the r.exposesApi/r.usesApi fold
|
|
363
408
|
// hit: a registrar-shape change (there, removing a method; here, adding a
|
|
364
409
|
// nested optional field) must survive the Designer's parse→render→parse
|
|
@@ -436,3 +481,164 @@ describe("render → parse roundtrip — r.defineEvent({ migrations }) fold", ()
|
|
|
436
481
|
}
|
|
437
482
|
});
|
|
438
483
|
});
|
|
484
|
+
|
|
485
|
+
// --- Real-compile check ---------------------------------------------------
|
|
486
|
+
//
|
|
487
|
+
// Everything above proves parse(render(patterns)) is structurally faithful.
|
|
488
|
+
// It does NOT prove the rendered code actually compiles against the real
|
|
489
|
+
// FeatureRegistrar type — parse() runs against a bare in-memory Project with
|
|
490
|
+
// no lib/module resolution, so a rendered call can be well-formed AST and
|
|
491
|
+
// still be a type error against the real registrar (e.g. a removed method).
|
|
492
|
+
// That gap let the r.usesApi removal (folded into r.requires({apis})) pass
|
|
493
|
+
// the full 4960-test suite silently; only the visual Designer would have hit
|
|
494
|
+
// the broken shape.
|
|
495
|
+
//
|
|
496
|
+
// This Project resolves `@cosmicdrift/kumiko-framework/*` against the real
|
|
497
|
+
// framework source (unlike parse()'s Project, dependency resolution is NOT
|
|
498
|
+
// skipped), so `r` in a compiled fixture is the real FeatureRegistrar, not a
|
|
499
|
+
// structural stand-in.
|
|
500
|
+
const FRAMEWORK_TSCONFIG_PATH = path.join(import.meta.dir, "../../../../tsconfig.json");
|
|
501
|
+
|
|
502
|
+
const compileProject = new Project({
|
|
503
|
+
tsConfigFilePath: FRAMEWORK_TSCONFIG_PATH,
|
|
504
|
+
skipAddingFilesFromTsConfig: true,
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
let compileCheckCounter = 0;
|
|
508
|
+
|
|
509
|
+
function compileAgainstRegistrar(source: string): readonly string[] {
|
|
510
|
+
compileCheckCounter += 1;
|
|
511
|
+
const filePath = path.join(
|
|
512
|
+
import.meta.dir,
|
|
513
|
+
`__generated__/compile-check-${compileCheckCounter}.gen.ts`,
|
|
514
|
+
);
|
|
515
|
+
const sourceFile = compileProject.createSourceFile(filePath, source, { overwrite: true });
|
|
516
|
+
const diagnostics = sourceFile.getPreEmitDiagnostics().map((d) => {
|
|
517
|
+
const text = ts.flattenDiagnosticMessageText(d.compilerObject.messageText, "\n");
|
|
518
|
+
return `${d.getLineNumber()}: ${text}`;
|
|
519
|
+
});
|
|
520
|
+
sourceFile.forget();
|
|
521
|
+
return diagnostics;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function renderAndCompile(source: string): readonly string[] {
|
|
525
|
+
const { featureName, patterns } = parse(source);
|
|
526
|
+
const rendered = renderFeatureFile({ featureName: featureName ?? "", patterns });
|
|
527
|
+
return compileAgainstRegistrar(rendered);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
describe("render → compiles against the real FeatureRegistrar type", () => {
|
|
531
|
+
test("compile check has teeth: an unknown registrar method is reported", () => {
|
|
532
|
+
const diagnostics = compileAgainstRegistrar(`
|
|
533
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
534
|
+
|
|
535
|
+
defineFeature("teeth-check", (r) => {
|
|
536
|
+
r.methodThatDoesNotExist({ nope: true });
|
|
537
|
+
});
|
|
538
|
+
`);
|
|
539
|
+
expect(diagnostics.length).toBeGreaterThan(0);
|
|
540
|
+
expect(diagnostics.join("\n")).toContain("methodThatDoesNotExist");
|
|
541
|
+
}, 20_000);
|
|
542
|
+
|
|
543
|
+
test("positive control: a minimal always-matched-the-real-shape fixture compiles clean", () => {
|
|
544
|
+
const diagnostics = compileAgainstRegistrar(`
|
|
545
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
546
|
+
|
|
547
|
+
defineFeature("teeth-check-positive", (r) => {
|
|
548
|
+
r.systemScope();
|
|
549
|
+
r.describe("Minimal fixture proving the harness returns [] on real success.");
|
|
550
|
+
r.toggleable({ default: true });
|
|
551
|
+
});
|
|
552
|
+
`);
|
|
553
|
+
expect(diagnostics).toEqual([]);
|
|
554
|
+
}, 20_000);
|
|
555
|
+
|
|
556
|
+
test("STATIC_FEATURE's rendered header-data patterns compile clean", () => {
|
|
557
|
+
expect(renderAndCompile(STATIC_FEATURE)).toEqual([]);
|
|
558
|
+
}, 20_000);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
// One self-contained, compile-clean fixture per remaining pattern kind not
|
|
562
|
+
// already exercised above (STATIC_FEATURE / MIXED_FEATURE / HOOK_ALL_OF /
|
|
563
|
+
// SCREEN_WITH_NAV / DEFINE_EVENT_WITH_MIGRATIONS). Deliberately separate
|
|
564
|
+
// from the parse-roundtrip fixtures above: those were written to test
|
|
565
|
+
// parse/render symmetry, not to be type-correct (e.g. DEFINE_EVENT_WITH_
|
|
566
|
+
// MIGRATIONS_FEATURE uses `z` without importing it) — reusing them here
|
|
567
|
+
// would produce diagnostics unrelated to the registrar shape and defeat the
|
|
568
|
+
// point of an exact-match assertion.
|
|
569
|
+
//
|
|
570
|
+
// RAW_REF_FEATURE and the "unknown" pattern kind are excluded on purpose:
|
|
571
|
+
// raw-ref sentinels reference symbols that don't exist by design, and
|
|
572
|
+
// "unknown" is the parser's catch-all bucket for unrecognized r.calls, not
|
|
573
|
+
// a real user-authored pattern.
|
|
574
|
+
const MANIFEST_AND_CONFIG_FEATURE = `
|
|
575
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
576
|
+
import { z } from "zod";
|
|
577
|
+
|
|
578
|
+
defineFeature("catalog", (r) => {
|
|
579
|
+
r.systemScope();
|
|
580
|
+
r.describe("Product catalog and pricing.");
|
|
581
|
+
r.optionalRequires("promotions");
|
|
582
|
+
r.uiHints({ displayLabel: "Catalog", category: "Commerce", recommended: true });
|
|
583
|
+
r.readsConfig("auth.smtpHost");
|
|
584
|
+
r.translations({ keys: { en: { title: "Catalog" } } });
|
|
585
|
+
r.useExtension("audit-log", "item");
|
|
586
|
+
r.extendsRegistrar("audit-log", {});
|
|
587
|
+
r.envSchema(z.object({ CATALOG_API_KEY: z.string() }));
|
|
588
|
+
r.usesApi("compliance.forTenant");
|
|
589
|
+
r.exposesApi("catalog.pricingFor");
|
|
590
|
+
});
|
|
591
|
+
`;
|
|
592
|
+
|
|
593
|
+
// r.projection is deliberately excluded here: its `table` field (a Drizzle
|
|
594
|
+
// table reference) is dropped by the feature-ast renderer independent of
|
|
595
|
+
// the object-form/positional-form issue this fixture targets — a
|
|
596
|
+
// pre-existing, separately tracked gap (public-api-registrar-consolidation
|
|
597
|
+
// plan doc, kumiko-platform). multiStreamProjection below covers the
|
|
598
|
+
// `apply`-map shape without hitting it (its `table` is optional).
|
|
599
|
+
const INTEGRATION_FEATURE = `
|
|
600
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
601
|
+
|
|
602
|
+
defineFeature("fulfillment", (r) => {
|
|
603
|
+
r.job("reconcile-counts", { trigger: { manual: true } }, async (payload, context) => {
|
|
604
|
+
console.log(payload, context);
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
r.notification("itemLowStock", {
|
|
608
|
+
trigger: { on: "item:lowStock" },
|
|
609
|
+
recipient: () => null,
|
|
610
|
+
data: () => ({}),
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
r.authClaims(async () => ({}));
|
|
614
|
+
|
|
615
|
+
r.httpRoute({
|
|
616
|
+
method: "GET",
|
|
617
|
+
path: "/fulfillment/feed.xml",
|
|
618
|
+
handler: (c) => c.text("ok"),
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
r.multiStreamProjection({
|
|
622
|
+
name: "item-audit",
|
|
623
|
+
apply: {
|
|
624
|
+
"item.created": async (event, tx, ctx) => {
|
|
625
|
+
console.log(event, tx, ctx);
|
|
626
|
+
},
|
|
627
|
+
},
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
r.workspace({ id: "ops", label: "fulfillment:workspace.ops" });
|
|
631
|
+
|
|
632
|
+
r.treeActions({ list: {} });
|
|
633
|
+
});
|
|
634
|
+
`;
|
|
635
|
+
|
|
636
|
+
describe("render → compiles against the real FeatureRegistrar type — full pattern-kind coverage", () => {
|
|
637
|
+
test("manifest/config-only patterns compile clean", () => {
|
|
638
|
+
expect(renderAndCompile(MANIFEST_AND_CONFIG_FEATURE)).toEqual([]);
|
|
639
|
+
}, 20_000);
|
|
640
|
+
|
|
641
|
+
test("job/notification/httpRoute/projection/workspace patterns compile clean", () => {
|
|
642
|
+
expect(renderAndCompile(INTEGRATION_FEATURE)).toEqual([]);
|
|
643
|
+
}, 20_000);
|
|
644
|
+
});
|
|
@@ -29,7 +29,6 @@ export {
|
|
|
29
29
|
collectScreenOpaqueProps,
|
|
30
30
|
extractAuthClaims,
|
|
31
31
|
extractDefineEvent,
|
|
32
|
-
extractEntityHook,
|
|
33
32
|
extractHook,
|
|
34
33
|
extractHttpRoute,
|
|
35
34
|
extractJob,
|
|
@@ -39,7 +38,6 @@ export {
|
|
|
39
38
|
extractQueryHandler,
|
|
40
39
|
extractScreen,
|
|
41
40
|
extractWriteHandler,
|
|
42
|
-
isEntityHookType,
|
|
43
41
|
isHookType,
|
|
44
42
|
isHttpRouteMethod,
|
|
45
43
|
type ParsedHandlerCall,
|
|
@@ -10,7 +10,6 @@ import type { ScreenDefinition } from "../../types/screen";
|
|
|
10
10
|
import type {
|
|
11
11
|
AuthClaimsPattern,
|
|
12
12
|
DefineEventPattern,
|
|
13
|
-
EntityHookPattern,
|
|
14
13
|
HookPattern,
|
|
15
14
|
HttpRoutePattern,
|
|
16
15
|
JobPattern,
|
|
@@ -90,6 +89,25 @@ export function readOptionalRateLimit(value: unknown): RateLimitOption | undefin
|
|
|
90
89
|
return value as unknown as RateLimitOption;
|
|
91
90
|
}
|
|
92
91
|
|
|
92
|
+
// r.hook's target: a NameOrRef, a list of them, or an entity-wide
|
|
93
|
+
// `{ allOf: entityRef }` (replaces the old r.entityHook(type, entity, fn)).
|
|
94
|
+
// Checked first so a malformed `{ allOf }` doesn't silently fall through
|
|
95
|
+
// to being read as some other object shape.
|
|
96
|
+
function readHookTarget(
|
|
97
|
+
node: Node,
|
|
98
|
+
): string | readonly string[] | { readonly allOf: string } | undefined {
|
|
99
|
+
const obj = node.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
100
|
+
if (obj) {
|
|
101
|
+
const allOfProp = obj.getProperty("allOf")?.asKind(SyntaxKind.PropertyAssignment);
|
|
102
|
+
if (allOfProp) {
|
|
103
|
+
const initializer = allOfProp.getInitializer();
|
|
104
|
+
const entityName = initializer && readNameOrRef(initializer);
|
|
105
|
+
return entityName ? { allOf: entityName } : undefined;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return readNameOrRefOrList(node);
|
|
109
|
+
}
|
|
110
|
+
|
|
93
111
|
export function extractHook(
|
|
94
112
|
call: CallExpression,
|
|
95
113
|
sourceFile: SourceFile,
|
|
@@ -133,7 +151,7 @@ export function extractHook(
|
|
|
133
151
|
"object form requires a `target` property",
|
|
134
152
|
);
|
|
135
153
|
}
|
|
136
|
-
const target =
|
|
154
|
+
const target = readHookTarget(targetInit);
|
|
137
155
|
if (!target) {
|
|
138
156
|
return fail(
|
|
139
157
|
"hook",
|
|
@@ -195,7 +213,7 @@ export function extractHook(
|
|
|
195
213
|
"expected a target (NameOrRef or array) as second argument",
|
|
196
214
|
);
|
|
197
215
|
}
|
|
198
|
-
const target =
|
|
216
|
+
const target = readHookTarget(targetArg);
|
|
199
217
|
if (!target) {
|
|
200
218
|
return fail(
|
|
201
219
|
"hook",
|
|
@@ -230,154 +248,6 @@ export function extractHook(
|
|
|
230
248
|
});
|
|
231
249
|
}
|
|
232
250
|
|
|
233
|
-
export function isEntityHookType(value: string): value is "postSave" | "preDelete" | "postDelete" {
|
|
234
|
-
return value === "postSave" || value === "preDelete" || value === "postDelete";
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
export function extractEntityHook(
|
|
238
|
-
call: CallExpression,
|
|
239
|
-
sourceFile: SourceFile,
|
|
240
|
-
): ExtractOutput<EntityHookPattern> {
|
|
241
|
-
const args = call.getArguments();
|
|
242
|
-
const first = args[0];
|
|
243
|
-
if (!first) {
|
|
244
|
-
return fail(
|
|
245
|
-
"entityHook",
|
|
246
|
-
sourceLocationFromNode(call, sourceFile),
|
|
247
|
-
"expected at least one argument",
|
|
248
|
-
);
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
const obj = first.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
252
|
-
if (obj && args.length === 1) {
|
|
253
|
-
const typeInit = obj
|
|
254
|
-
.getProperty("type")
|
|
255
|
-
?.asKind(SyntaxKind.PropertyAssignment)
|
|
256
|
-
?.getInitializer()
|
|
257
|
-
?.asKind(SyntaxKind.StringLiteral);
|
|
258
|
-
if (!typeInit) {
|
|
259
|
-
return fail(
|
|
260
|
-
"entityHook",
|
|
261
|
-
sourceLocationFromNode(call, sourceFile),
|
|
262
|
-
"object form requires a string-literal `type` property",
|
|
263
|
-
);
|
|
264
|
-
}
|
|
265
|
-
const hookType = typeInit.getLiteralValue();
|
|
266
|
-
if (!isEntityHookType(hookType)) {
|
|
267
|
-
return fail(
|
|
268
|
-
"entityHook",
|
|
269
|
-
sourceLocationFromNode(call, sourceFile),
|
|
270
|
-
`entity hook type must be postSave, preDelete, or postDelete (got "${hookType}")`,
|
|
271
|
-
);
|
|
272
|
-
}
|
|
273
|
-
const entityInit = obj
|
|
274
|
-
.getProperty("entity")
|
|
275
|
-
?.asKind(SyntaxKind.PropertyAssignment)
|
|
276
|
-
?.getInitializer();
|
|
277
|
-
if (!entityInit) {
|
|
278
|
-
return fail(
|
|
279
|
-
"entityHook",
|
|
280
|
-
sourceLocationFromNode(call, sourceFile),
|
|
281
|
-
"object form requires an `entity` property",
|
|
282
|
-
);
|
|
283
|
-
}
|
|
284
|
-
const entityName = readNameOrRef(entityInit);
|
|
285
|
-
if (!entityName) {
|
|
286
|
-
return fail(
|
|
287
|
-
"entityHook",
|
|
288
|
-
sourceLocationFromNode(call, sourceFile),
|
|
289
|
-
"`entity` must be a string literal or inline { name } object",
|
|
290
|
-
);
|
|
291
|
-
}
|
|
292
|
-
const handlerInit = obj
|
|
293
|
-
.getProperty("handler")
|
|
294
|
-
?.asKind(SyntaxKind.PropertyAssignment)
|
|
295
|
-
?.getInitializer();
|
|
296
|
-
if (!handlerInit) {
|
|
297
|
-
return fail(
|
|
298
|
-
"entityHook",
|
|
299
|
-
sourceLocationFromNode(call, sourceFile),
|
|
300
|
-
"object form requires a `handler` property",
|
|
301
|
-
);
|
|
302
|
-
}
|
|
303
|
-
const fn = findFunctionLiteral(handlerInit);
|
|
304
|
-
if (!fn) {
|
|
305
|
-
return fail(
|
|
306
|
-
"entityHook",
|
|
307
|
-
sourceLocationFromNode(call, sourceFile),
|
|
308
|
-
"handler must be an inline arrow function or function expression",
|
|
309
|
-
);
|
|
310
|
-
}
|
|
311
|
-
const phase = readOptionalPhase(obj);
|
|
312
|
-
return ok({
|
|
313
|
-
kind: "entityHook",
|
|
314
|
-
source: sourceLocationFromNode(call, sourceFile),
|
|
315
|
-
hookType,
|
|
316
|
-
entityName,
|
|
317
|
-
fnBody: sourceLocationFromNode(fn, sourceFile),
|
|
318
|
-
...(phase !== undefined && { phase }),
|
|
319
|
-
});
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
const typeArg = first.asKind(SyntaxKind.StringLiteral);
|
|
323
|
-
if (!typeArg) {
|
|
324
|
-
return fail(
|
|
325
|
-
"entityHook",
|
|
326
|
-
sourceLocationFromNode(call, sourceFile),
|
|
327
|
-
"first argument must be a string literal hook type (or use the object form)",
|
|
328
|
-
);
|
|
329
|
-
}
|
|
330
|
-
const hookType = typeArg.getLiteralValue();
|
|
331
|
-
if (!isEntityHookType(hookType)) {
|
|
332
|
-
return fail(
|
|
333
|
-
"entityHook",
|
|
334
|
-
sourceLocationFromNode(call, sourceFile),
|
|
335
|
-
`entity hook type must be postSave, preDelete, or postDelete (got "${hookType}")`,
|
|
336
|
-
);
|
|
337
|
-
}
|
|
338
|
-
const entityArg = args[1];
|
|
339
|
-
if (!entityArg) {
|
|
340
|
-
return fail(
|
|
341
|
-
"entityHook",
|
|
342
|
-
sourceLocationFromNode(call, sourceFile),
|
|
343
|
-
"expected an entity reference as second argument",
|
|
344
|
-
);
|
|
345
|
-
}
|
|
346
|
-
const entityName = readNameOrRef(entityArg);
|
|
347
|
-
if (!entityName) {
|
|
348
|
-
return fail(
|
|
349
|
-
"entityHook",
|
|
350
|
-
sourceLocationFromNode(call, sourceFile),
|
|
351
|
-
"second argument must be a string literal or inline { name } object",
|
|
352
|
-
);
|
|
353
|
-
}
|
|
354
|
-
const fnArg = args[2];
|
|
355
|
-
if (!fnArg) {
|
|
356
|
-
return fail(
|
|
357
|
-
"entityHook",
|
|
358
|
-
sourceLocationFromNode(call, sourceFile),
|
|
359
|
-
"expected a hook function as third argument",
|
|
360
|
-
);
|
|
361
|
-
}
|
|
362
|
-
const fn = findFunctionLiteral(fnArg);
|
|
363
|
-
if (!fn) {
|
|
364
|
-
return fail(
|
|
365
|
-
"entityHook",
|
|
366
|
-
sourceLocationFromNode(call, sourceFile),
|
|
367
|
-
"third argument must be an inline arrow function or function expression",
|
|
368
|
-
);
|
|
369
|
-
}
|
|
370
|
-
const phase = readOptionalPhase(args[3]);
|
|
371
|
-
return ok({
|
|
372
|
-
kind: "entityHook",
|
|
373
|
-
source: sourceLocationFromNode(call, sourceFile),
|
|
374
|
-
hookType,
|
|
375
|
-
entityName,
|
|
376
|
-
fnBody: sourceLocationFromNode(fn, sourceFile),
|
|
377
|
-
...(phase !== undefined && { phase }),
|
|
378
|
-
});
|
|
379
|
-
}
|
|
380
|
-
|
|
381
251
|
// guard:dup-ok — intentionale Parallele zu extractTree (round6); verschiedene Feature-AST-Extraktoren by design
|
|
382
252
|
export function extractAuthClaims(
|
|
383
253
|
call: CallExpression,
|
|
@@ -27,7 +27,6 @@ export type {
|
|
|
27
27
|
AddConfigArgs,
|
|
28
28
|
AddDefineEventArgs,
|
|
29
29
|
AddEntityArgs,
|
|
30
|
-
AddEntityHookArgs,
|
|
31
30
|
AddHookArgs,
|
|
32
31
|
AddHttpRouteArgs,
|
|
33
32
|
AddJobArgs,
|
|
@@ -58,7 +57,6 @@ export type {
|
|
|
58
57
|
ConfigPattern,
|
|
59
58
|
DefineEventPattern,
|
|
60
59
|
Editability,
|
|
61
|
-
EntityHookPattern,
|
|
62
60
|
// Static patterns
|
|
63
61
|
EntityPattern,
|
|
64
62
|
ExtendsRegistrarPattern,
|
|
@@ -38,7 +38,6 @@ import {
|
|
|
38
38
|
extractDefineEvent,
|
|
39
39
|
extractDescribe,
|
|
40
40
|
extractEntity,
|
|
41
|
-
extractEntityHook,
|
|
42
41
|
extractEnvSchema,
|
|
43
42
|
extractExposesApi,
|
|
44
43
|
extractExtendsRegistrar,
|
|
@@ -442,8 +441,6 @@ function dispatchExtractor(
|
|
|
442
441
|
// Round 4 — mixed (header + body) patterns
|
|
443
442
|
case "hook":
|
|
444
443
|
return extractHook(call, sourceFile);
|
|
445
|
-
case "entityHook":
|
|
446
|
-
return extractEntityHook(call, sourceFile);
|
|
447
444
|
case "authClaims":
|
|
448
445
|
return extractAuthClaims(call, sourceFile);
|
|
449
446
|
case "writeHandler":
|
|
@@ -53,8 +53,11 @@ export type PatternId =
|
|
|
53
53
|
| { readonly kind: "screen"; readonly id: string }
|
|
54
54
|
| { readonly kind: "writeHandler"; readonly handlerName: string }
|
|
55
55
|
| { readonly kind: "queryHandler"; readonly handlerName: string }
|
|
56
|
-
| {
|
|
57
|
-
|
|
56
|
+
| {
|
|
57
|
+
readonly kind: "hook";
|
|
58
|
+
readonly hookType: string;
|
|
59
|
+
readonly target: string | { readonly allOf: string };
|
|
60
|
+
}
|
|
58
61
|
| { readonly kind: "metric"; readonly shortName: string }
|
|
59
62
|
| { readonly kind: "secret"; readonly shortName: string }
|
|
60
63
|
| { readonly kind: "claimKey"; readonly shortName: string }
|
|
@@ -356,6 +359,18 @@ function callMatchesId(call: CallExpression, id: PatternId): boolean {
|
|
|
356
359
|
);
|
|
357
360
|
case "hook":
|
|
358
361
|
// Positional: r.hook(type, target, fn) | Object: { type, target }
|
|
362
|
+
if (typeof id.target === "object") {
|
|
363
|
+
// Entity-wide { allOf } target — only ever appears as the object
|
|
364
|
+
// literal { allOf: entity } (positional or nested in the object
|
|
365
|
+
// form), never as a bare string, so check both call shapes.
|
|
366
|
+
if (matchFirstArgString(call, id.hookType)) {
|
|
367
|
+
return matchAllOfArg(call.getArguments()[1], id.target.allOf);
|
|
368
|
+
}
|
|
369
|
+
return (
|
|
370
|
+
matchObjectProperty(call, "type", id.hookType) &&
|
|
371
|
+
matchObjectAllOfProperty(call, "target", id.target.allOf)
|
|
372
|
+
);
|
|
373
|
+
}
|
|
359
374
|
if (matchFirstArgString(call, id.hookType)) {
|
|
360
375
|
const target = call.getArguments()[1]?.asKind(SyntaxKind.StringLiteral)?.getLiteralValue();
|
|
361
376
|
return target === id.target;
|
|
@@ -364,15 +379,6 @@ function callMatchesId(call: CallExpression, id: PatternId): boolean {
|
|
|
364
379
|
matchObjectProperty(call, "type", id.hookType) &&
|
|
365
380
|
matchObjectProperty(call, "target", id.target)
|
|
366
381
|
);
|
|
367
|
-
case "entityHook":
|
|
368
|
-
if (matchFirstArgString(call, id.hookType)) {
|
|
369
|
-
const ent = call.getArguments()[1]?.asKind(SyntaxKind.StringLiteral)?.getLiteralValue();
|
|
370
|
-
return ent === id.entityName;
|
|
371
|
-
}
|
|
372
|
-
return (
|
|
373
|
-
matchObjectProperty(call, "type", id.hookType) &&
|
|
374
|
-
matchObjectProperty(call, "entity", id.entityName)
|
|
375
|
-
);
|
|
376
382
|
case "metric":
|
|
377
383
|
case "secret":
|
|
378
384
|
case "claimKey":
|
|
@@ -439,6 +445,31 @@ function matchObjectProperty(call: CallExpression, propName: string, expected: s
|
|
|
439
445
|
return init?.getLiteralValue() === expected;
|
|
440
446
|
}
|
|
441
447
|
|
|
448
|
+
// Matches an r.hook `{ allOf: entity }` target — as the arg node directly
|
|
449
|
+
// (positional call form) or as a nested object property (object call form).
|
|
450
|
+
function matchAllOfArg(node: Node | undefined, expectedEntity: string): boolean {
|
|
451
|
+
const obj = node?.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
452
|
+
const init = obj
|
|
453
|
+
?.getProperty("allOf")
|
|
454
|
+
?.asKind(SyntaxKind.PropertyAssignment)
|
|
455
|
+
?.getInitializer()
|
|
456
|
+
?.asKind(SyntaxKind.StringLiteral);
|
|
457
|
+
return init?.getLiteralValue() === expectedEntity;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function matchObjectAllOfProperty(
|
|
461
|
+
call: CallExpression,
|
|
462
|
+
propName: string,
|
|
463
|
+
expectedEntity: string,
|
|
464
|
+
): boolean {
|
|
465
|
+
const obj = call.getArguments()[0]?.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
466
|
+
const propInit = obj
|
|
467
|
+
?.getProperty(propName)
|
|
468
|
+
?.asKind(SyntaxKind.PropertyAssignment)
|
|
469
|
+
?.getInitializer();
|
|
470
|
+
return matchAllOfArg(propInit, expectedEntity);
|
|
471
|
+
}
|
|
472
|
+
|
|
442
473
|
// =============================================================================
|
|
443
474
|
// Format helpers — line boundaries / blank-line collapse
|
|
444
475
|
// (indent / PATTERN_INDENT live in render.ts and are imported above.)
|
|
@@ -106,19 +106,15 @@ export type AddQueryHandlerArgs = {
|
|
|
106
106
|
|
|
107
107
|
export type AddHookArgs = {
|
|
108
108
|
readonly type: LifecycleHookType | "validation";
|
|
109
|
-
|
|
109
|
+
// `{ allOf: entity }` — entity-wide target, fires for every write/query
|
|
110
|
+
// handler of that entity; replaces the old addEntityHook(). Only valid
|
|
111
|
+
// for postSave/preDelete/postDelete/postQuery, same as r.hook() itself.
|
|
112
|
+
readonly target: string | readonly string[] | { readonly allOf: string };
|
|
110
113
|
/** Source text of the closure, e.g. `"async (event, ctx) => { ... }"`. */
|
|
111
114
|
readonly handlerSource: string;
|
|
112
115
|
readonly phase?: HookPhase;
|
|
113
116
|
};
|
|
114
117
|
|
|
115
|
-
export type AddEntityHookArgs = {
|
|
116
|
-
readonly type: "postSave" | "preDelete" | "postDelete";
|
|
117
|
-
readonly entity: string;
|
|
118
|
-
readonly handlerSource: string;
|
|
119
|
-
readonly phase?: HookPhase;
|
|
120
|
-
};
|
|
121
|
-
|
|
122
118
|
export type AddJobArgs = {
|
|
123
119
|
readonly name: string;
|
|
124
120
|
readonly options: Omit<JobDefinition, "name" | "handler">;
|
|
@@ -238,7 +234,6 @@ export type FeaturePatcher = {
|
|
|
238
234
|
readonly addWriteHandler: (args: AddWriteHandlerArgs) => void;
|
|
239
235
|
readonly addQueryHandler: (args: AddQueryHandlerArgs) => void;
|
|
240
236
|
readonly addHook: (args: AddHookArgs) => void;
|
|
241
|
-
readonly addEntityHook: (args: AddEntityHookArgs) => void;
|
|
242
237
|
readonly addJob: (args: AddJobArgs) => void;
|
|
243
238
|
readonly addNotification: (args: AddNotificationArgs) => void;
|
|
244
239
|
readonly addAuthClaims: (args: AddAuthClaimsArgs) => void;
|
|
@@ -408,17 +403,6 @@ export function createFeaturePatcher(sourceFile: SourceFile): FeaturePatcher {
|
|
|
408
403
|
});
|
|
409
404
|
},
|
|
410
405
|
|
|
411
|
-
addEntityHook({ type, entity, handlerSource, phase }) {
|
|
412
|
-
add({
|
|
413
|
-
kind: "entityHook",
|
|
414
|
-
source: SYNTHETIC_LOC,
|
|
415
|
-
hookType: type,
|
|
416
|
-
entityName: entity,
|
|
417
|
-
fnBody: rawLoc(handlerSource),
|
|
418
|
-
...(phase !== undefined && { phase }),
|
|
419
|
-
});
|
|
420
|
-
},
|
|
421
|
-
|
|
422
406
|
addJob({ name, options, handlerSource }) {
|
|
423
407
|
add({
|
|
424
408
|
kind: "job",
|
|
@@ -376,30 +376,20 @@ export type QueryHandlerPattern = {
|
|
|
376
376
|
|
|
377
377
|
// `r.hook(type, target, fn, options?)` — attaches a lifecycle hook
|
|
378
378
|
// (`validation`, `preSave`, `postSave`, `preDelete`, `postDelete`,
|
|
379
|
-
// `preQuery`, `postQuery`) to one or more target handlers
|
|
380
|
-
//
|
|
381
|
-
//
|
|
379
|
+
// `preQuery`, `postQuery`) to one or more target handlers, or — for
|
|
380
|
+
// postSave/preDelete/postDelete/postQuery — to `{ allOf: entityRef }`
|
|
381
|
+
// ("every write/query handler of this entity", replacing the old
|
|
382
|
+
// `r.entityHook(type, entity, fn)`). Post-hooks accept a `phase` option;
|
|
383
|
+
// `preDelete` always runs in-transaction — it guards the delete. The hook
|
|
384
|
+
// body is an opaque code span.
|
|
382
385
|
export type HookPattern = {
|
|
383
386
|
readonly kind: "hook";
|
|
384
387
|
readonly source: SourceLocation;
|
|
385
388
|
readonly hookType: LifecycleHookType | "validation";
|
|
386
|
-
// r.hook accepts a single target
|
|
387
|
-
// the Designer can render the
|
|
388
|
-
|
|
389
|
-
readonly
|
|
390
|
-
readonly phase?: HookPhase;
|
|
391
|
-
};
|
|
392
|
-
|
|
393
|
-
// `r.entityHook(type, entity, fn, options?)` — like `r.hook`, but bound to
|
|
394
|
-
// an entity instead of individual handlers: `postSave`, `preDelete`, and
|
|
395
|
-
// `postDelete` fire on every matching write. The runtime API additionally
|
|
396
|
-
// accepts `postQuery` (fires for all query-handlers of the entity), but
|
|
397
|
-
// this pattern type only represents the three write-side hooks.
|
|
398
|
-
export type EntityHookPattern = {
|
|
399
|
-
readonly kind: "entityHook";
|
|
400
|
-
readonly source: SourceLocation;
|
|
401
|
-
readonly hookType: "postSave" | "preDelete" | "postDelete";
|
|
402
|
-
readonly entityName: string;
|
|
389
|
+
// r.hook accepts a single target, a list, or an entity-wide { allOf }
|
|
390
|
+
// target; we keep all three shapes so the Designer can render the
|
|
391
|
+
// original author intent.
|
|
392
|
+
readonly target: string | readonly string[] | { readonly allOf: string };
|
|
403
393
|
readonly fnBody: SourceLocation;
|
|
404
394
|
readonly phase?: HookPhase;
|
|
405
395
|
};
|
|
@@ -603,7 +593,6 @@ export type FeaturePattern =
|
|
|
603
593
|
| WriteHandlerPattern
|
|
604
594
|
| QueryHandlerPattern
|
|
605
595
|
| HookPattern
|
|
606
|
-
| EntityHookPattern
|
|
607
596
|
| JobPattern
|
|
608
597
|
| NotificationPattern
|
|
609
598
|
| AuthClaimsPattern
|
|
@@ -659,7 +648,6 @@ export function getEditability(pattern: FeaturePattern): Editability {
|
|
|
659
648
|
case "writeHandler":
|
|
660
649
|
case "queryHandler":
|
|
661
650
|
case "hook":
|
|
662
|
-
case "entityHook":
|
|
663
651
|
case "job":
|
|
664
652
|
case "notification":
|
|
665
653
|
case "httpRoute":
|