@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
@@ -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 { Project } from "ts-morph";
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", { kind: "belongsTo", to: "user" });
33
+ r.relation("item", "supplier", { type: "belongsTo", target: "user", foreignKey: "supplierId" });
33
34
 
34
35
  r.metric("created", { type: "counter" });
35
- r.secret("apiKey", { description: "Stripe key" });
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({ keys: { maxRows: { type: "number", default: 50 } } });
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 = readNameOrRefOrList(targetInit);
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 = readNameOrRefOrList(targetArg);
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
- | { readonly kind: "hook"; readonly hookType: string; readonly target: string }
57
- | { readonly kind: "entityHook"; readonly hookType: string; readonly entityName: string }
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
- readonly target: string | readonly string[];
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. Post-hooks
380
- // accept a `phase` option; `preDelete` always runs in-transaction — it
381
- // guards the delete. The hook body is an opaque code span.
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 or a list; we keep both shapes so
387
- // the Designer can render the original author intent.
388
- readonly target: string | readonly string[];
389
- readonly fnBody: SourceLocation;
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":