@cosmicdrift/kumiko-framework 0.200.0 → 0.201.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 (31) hide show
  1. package/package.json +3 -3
  2. package/src/__tests__/entity-list-limits.integration.test.ts +84 -0
  3. package/src/api/__tests__/api.test.ts +116 -1
  4. package/src/api/__tests__/batch.integration.test.ts +53 -0
  5. package/src/api/__tests__/body-limit.test.ts +16 -0
  6. package/src/api/route-registrars.ts +4 -3
  7. package/src/api/routes.ts +47 -1
  8. package/src/db/__tests__/unchecked-system-db.test.ts +66 -0
  9. package/src/db/tenant-db.ts +46 -2
  10. package/src/engine/entity-handlers.ts +8 -1
  11. package/src/engine/feature-ast/__tests__/fixtures/cross-file-patch-const/constants.ts +2 -0
  12. package/src/engine/feature-ast/__tests__/fixtures/cross-file-patch-const/feature.ts +8 -0
  13. package/src/engine/feature-ast/__tests__/patch.test.ts +98 -0
  14. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +155 -0
  15. package/src/engine/feature-ast/extractors/events.ts +5 -3
  16. package/src/engine/feature-ast/extractors/round3.ts +5 -3
  17. package/src/engine/feature-ast/extractors/round5.ts +5 -4
  18. package/src/engine/feature-ast/extractors/shared.ts +29 -4
  19. package/src/engine/feature-ast/patch.ts +28 -21
  20. package/src/engine/feature-ast/patterns.ts +18 -0
  21. package/src/engine/feature-ast/render.ts +19 -6
  22. package/src/engine/index.ts +1 -0
  23. package/src/files/__tests__/files.integration.test.ts +97 -1
  24. package/src/files/file-routes.ts +10 -2
  25. package/src/files/types.ts +72 -0
  26. package/src/pipeline/__tests__/ctx-systemdb.integration.test.ts +44 -8
  27. package/src/pipeline/__tests__/dispatcher.test.ts +23 -4
  28. package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +116 -22
  29. package/src/pipeline/dispatch-batch.ts +11 -5
  30. package/src/pipeline/dispatch-shared.ts +42 -16
  31. package/src/pipeline/idempotency.ts +91 -30
@@ -6,6 +6,7 @@
6
6
  // here narrows the cause to patch.ts itself.
7
7
 
8
8
  import { describe, expect, test } from "bun:test";
9
+ import { resolve } from "node:path";
9
10
  import { Project, type SourceFile } from "ts-morph";
10
11
  import { parseSourceFile } from "../parse";
11
12
  import { addPattern, applyChanges, type PatternId, removePattern, replacePattern } from "../patch";
@@ -552,3 +553,100 @@ defineFeature("object", (r) => {
552
553
  expect(parseSourceFile(sf).patterns.find((p) => p.kind === "notification")).toBeUndefined();
553
554
  });
554
555
  });
556
+
557
+ // #2121 — findCallForId only accepted a StringLiteral first argument, so
558
+ // it couldn't locate calls authored with an imported/local constant name
559
+ // (the dominant style in the framework's own bundled-features, per #1746
560
+ // on the parser side). Covers both the single-arg case (matchFirstArgString)
561
+ // and the two-arg case (relation/hook/useExtension's second positional arg),
562
+ // same-file and genuinely cross-file via a real filesystem Project.
563
+ describe("findCallForId resolves identifier-authored names (#2121)", () => {
564
+ function loadFixture(relPath: string): SourceFile {
565
+ const project = new Project({
566
+ skipAddingFilesFromTsConfig: true,
567
+ skipFileDependencyResolution: true,
568
+ });
569
+ return project.addSourceFileAtPath(resolve(__dirname, relPath));
570
+ }
571
+
572
+ const SAME_FILE_CONST = `
573
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
574
+
575
+ const ENTITY = "task";
576
+
577
+ defineFeature("inventory", (r) => {
578
+ r.entity(ENTITY, { fields: { name: { type: "text", required: true } } });
579
+ });
580
+ `;
581
+
582
+ test("removePattern finds a call whose name arg is a same-file const identifier", () => {
583
+ const sf = makeSourceFile(SAME_FILE_CONST);
584
+ removePattern(sf, { kind: "entity", entityName: "task" });
585
+ const reparsed = parseSourceFile(sf);
586
+ expect(reparsed.errors).toEqual([]);
587
+ expect(reparsed.patterns).toEqual([]);
588
+ });
589
+
590
+ test("replacePattern finds a call whose name arg is a same-file const identifier", () => {
591
+ const sf = makeSourceFile(SAME_FILE_CONST);
592
+ const replacement: FeaturePattern = {
593
+ kind: "entity",
594
+ source: SAMPLE_LOC,
595
+ entityName: "task",
596
+ definition: {
597
+ fields: { name: { type: "text", required: true }, sku: { type: "text" } },
598
+ } as never,
599
+ };
600
+ replacePattern(sf, { kind: "entity", entityName: "task" }, replacement);
601
+ const reparsed = parseSourceFile(sf);
602
+ expect(reparsed.errors).toEqual([]);
603
+ const entity = reparsed.patterns.find((p) => p.kind === "entity");
604
+ if (entity?.kind === "entity") {
605
+ const fields = (entity.definition as { fields: Record<string, unknown> }).fields;
606
+ expect(Object.keys(fields)).toEqual(["name", "sku"]);
607
+ } else {
608
+ throw new Error("expected an entity pattern");
609
+ }
610
+ });
611
+
612
+ test("does not match when the identifier resolves to a different string", () => {
613
+ const sf = makeSourceFile(SAME_FILE_CONST);
614
+ expect(() => removePattern(sf, { kind: "entity", entityName: "other" })).toThrow(
615
+ /no call found/,
616
+ );
617
+ });
618
+
619
+ test("removePattern finds an extendsRegistrar call authored with a cross-file imported constant (#1746 fixture)", () => {
620
+ const sf = loadFixture("fixtures/cross-file-name-const/feature.ts");
621
+ removePattern(sf, { kind: "extendsRegistrar", extensionName: "audit" });
622
+ const reparsed = parseSourceFile(sf);
623
+ expect(reparsed.errors).toEqual([]);
624
+ expect(reparsed.patterns).toEqual([]);
625
+ });
626
+
627
+ test("removePattern resolves both positional args when authored as cross-file imported constants", () => {
628
+ const sf = loadFixture("fixtures/cross-file-patch-const/feature.ts");
629
+ removePattern(sf, {
630
+ kind: "useExtension",
631
+ extensionName: "tenant-data",
632
+ entityName: "item",
633
+ });
634
+ const reparsed = parseSourceFile(sf);
635
+ expect(reparsed.errors).toEqual([]);
636
+ expect(reparsed.patterns).toEqual([]);
637
+ });
638
+
639
+ test("removePattern finds an object-form useExtension whose entity is an inline { name } ref", () => {
640
+ const sf = makeSourceFile(`
641
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
642
+
643
+ defineFeature("inventory", (r) => {
644
+ r.useExtension({ name: "audit", entity: { name: "item" } });
645
+ });
646
+ `);
647
+ removePattern(sf, { kind: "useExtension", extensionName: "audit", entityName: "item" });
648
+ const reparsed = parseSourceFile(sf);
649
+ expect(reparsed.errors).toEqual([]);
650
+ expect(reparsed.patterns).toEqual([]);
651
+ });
652
+ });
@@ -380,6 +380,161 @@ describe("renderPattern — single-pattern shape", () => {
380
380
  });
381
381
  expect(out).toBe('r.metric({ name: "requests", type: "counter" });');
382
382
  });
383
+
384
+ // #2111 — a name-carrying pattern with a `*Raw` field re-emits the
385
+ // identifier verbatim; without it, the name flattens to its resolved
386
+ // literal same as before the fix.
387
+ test("useExtension pattern re-emits extensionNameRaw verbatim over the resolved literal", () => {
388
+ const out = renderPattern({
389
+ kind: "useExtension",
390
+ source: { file: "x", start: { line: 1, column: 1 }, end: { line: 1, column: 1 }, raw: "" },
391
+ extensionName: "tenant-data",
392
+ extensionNameRaw: "EXT_TENANT_DATA",
393
+ entityName: "document",
394
+ });
395
+ expect(out).toBe('r.useExtension(EXT_TENANT_DATA, "document");');
396
+ });
397
+
398
+ test("useExtension pattern falls back to the resolved literal when extensionNameRaw is absent", () => {
399
+ const out = renderPattern({
400
+ kind: "useExtension",
401
+ source: { file: "x", start: { line: 1, column: 1 }, end: { line: 1, column: 1 }, raw: "" },
402
+ extensionName: "tenant-data",
403
+ entityName: "document",
404
+ });
405
+ expect(out).toBe('r.useExtension({ name: "tenant-data", entity: "document" });');
406
+ });
407
+
408
+ test("useExtension pattern with extensionNameRaw indents a multi-line options object correctly", () => {
409
+ const out = renderPattern({
410
+ kind: "useExtension",
411
+ source: { file: "x", start: { line: 1, column: 1 }, end: { line: 1, column: 1 }, raw: "" },
412
+ extensionName: "tenant-data",
413
+ extensionNameRaw: "EXT_TENANT_DATA",
414
+ entityName: "document",
415
+ options: {
416
+ description:
417
+ "a fairly long description that pushes this object past eighty characters wide",
418
+ scope: "tenant",
419
+ exportable: true,
420
+ },
421
+ });
422
+ expect(out).toBe(
423
+ [
424
+ 'r.useExtension(EXT_TENANT_DATA, "document", {',
425
+ ' description: "a fairly long description that pushes this object past eighty characters wide",',
426
+ ' scope: "tenant",',
427
+ " exportable: true,",
428
+ "});",
429
+ ].join("\n"),
430
+ );
431
+ });
432
+
433
+ test("extendsRegistrar pattern re-emits extensionNameRaw verbatim over the resolved literal", () => {
434
+ const out = renderPattern({
435
+ kind: "extendsRegistrar",
436
+ source: { file: "x", start: { line: 1, column: 1 }, end: { line: 1, column: 1 }, raw: "" },
437
+ extensionName: "tenant-data",
438
+ extensionNameRaw: "EXT_TENANT_DATA",
439
+ defBody: { file: "x", start: { line: 1, column: 1 }, end: { line: 1, column: 1 }, raw: "{}" },
440
+ });
441
+ expect(out).toBe("r.extendsRegistrar(EXT_TENANT_DATA, {});");
442
+ });
443
+
444
+ test("defineEvent pattern re-emits eventNameRaw verbatim over the resolved literal", () => {
445
+ const out = renderPattern({
446
+ kind: "defineEvent",
447
+ source: { file: "x", start: { line: 1, column: 1 }, end: { line: 1, column: 1 }, raw: "" },
448
+ eventName: "docIngested",
449
+ eventNameRaw: "DOC_INGESTED_EVENT",
450
+ schemaSource: {
451
+ file: "x",
452
+ start: { line: 1, column: 1 },
453
+ end: { line: 1, column: 1 },
454
+ raw: "z.object({})",
455
+ },
456
+ });
457
+ expect(out).toBe("r.defineEvent(DOC_INGESTED_EVENT, z.object({}));");
458
+ });
459
+ });
460
+
461
+ // #2111 — registrar-call names authored as an imported/local constant
462
+ // (`r.useExtension(EXT_TENANT_DATA, ...)`, the style #1746 taught the
463
+ // parser to resolve) must not get inlined into their resolved string
464
+ // literal on a render → parse round-trip — the framework's own
465
+ // bundled-features rely on the identifier reference surviving edits made
466
+ // through the Designer to unrelated fields on the same pattern.
467
+ const NAME_CONST_FEATURE = `
468
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
469
+ import { z } from "zod";
470
+
471
+ const EXT_TENANT_DATA = "tenant-data" as const;
472
+ const DOC_INGESTED_EVENT = "docIngested" as const;
473
+
474
+ defineFeature("user-data-rights", (r) => {
475
+ r.extendsRegistrar(EXT_TENANT_DATA, { onRegister: () => {} });
476
+ r.useExtension(EXT_TENANT_DATA, "document", { description: "org-scoped" });
477
+ r.defineEvent(DOC_INGESTED_EVENT, z.object({ id: z.string() }));
478
+ });
479
+ `;
480
+
481
+ describe("render → parse roundtrip — imported-constant registrar-call names (#2111)", () => {
482
+ const initial = parse(NAME_CONST_FEATURE);
483
+
484
+ test("parses the identifier-authored names, keeping the source text alongside the resolved value", () => {
485
+ expect(initial.patterns).toMatchObject([
486
+ {
487
+ kind: "extendsRegistrar",
488
+ extensionName: "tenant-data",
489
+ extensionNameRaw: "EXT_TENANT_DATA",
490
+ },
491
+ {
492
+ kind: "useExtension",
493
+ extensionName: "tenant-data",
494
+ extensionNameRaw: "EXT_TENANT_DATA",
495
+ },
496
+ { kind: "defineEvent", eventName: "docIngested", eventNameRaw: "DOC_INGESTED_EVENT" },
497
+ ]);
498
+ });
499
+
500
+ test("rendering re-emits the identifier verbatim instead of the resolved literal", () => {
501
+ const rendered = renderFeatureFile({
502
+ featureName: initial.featureName ?? "",
503
+ patterns: initial.patterns,
504
+ });
505
+ expect(rendered).toContain("r.extendsRegistrar(EXT_TENANT_DATA,");
506
+ expect(rendered).toContain("r.useExtension(EXT_TENANT_DATA,");
507
+ expect(rendered).toContain("r.defineEvent(DOC_INGESTED_EVENT,");
508
+ expect(rendered).not.toContain('"tenant-data"');
509
+ expect(rendered).not.toContain('"docIngested"');
510
+ });
511
+
512
+ test("editing an unrelated field on the useExtension pattern still preserves the identifier name", () => {
513
+ // Simulates a Designer save: only `options` changes; `extensionName` /
514
+ // `extensionNameRaw` pass through untouched, as replacePattern callers
515
+ // are expected to do (see UseExtensionPattern.extensionNameRaw doc).
516
+ const edited = initial.patterns.map((p) =>
517
+ p.kind === "useExtension"
518
+ ? { ...p, options: { ...p.options, description: "tenant-scoped" } }
519
+ : p,
520
+ );
521
+ const rendered = renderFeatureFile({
522
+ featureName: initial.featureName ?? "",
523
+ patterns: edited,
524
+ });
525
+ expect(rendered).toContain("r.useExtension(EXT_TENANT_DATA,");
526
+ expect(rendered).toContain("tenant-scoped");
527
+ });
528
+
529
+ // No "reparse the full renderFeatureFile output" case here, unlike the
530
+ // other roundtrip blocks in this file: renderFeatureFile only emits the
531
+ // version header + imports + defineFeature body (see its doc comment),
532
+ // so it drops the top-level `const EXT_TENANT_DATA = ...` this fixture
533
+ // relies on — reparsing would ParseError for an unrelated, pre-existing
534
+ // reason (the const is gone), not because the raw-preservation broke.
535
+ // The real edit path (`replacePattern` patching a single call in place
536
+ // on the *original* SourceFile, which keeps the const) is covered by
537
+ // patch.test.ts.
383
538
  });
384
539
 
385
540
  // Regression guard for the class of bug the r.exposesApi/r.usesApi fold
@@ -10,6 +10,7 @@ import {
10
10
  ok,
11
11
  readDataLiteralNode,
12
12
  readNameLiteral,
13
+ readNameLiteralRef,
13
14
  readNameOrRef,
14
15
  readPropertyKey,
15
16
  } from "./shared";
@@ -147,8 +148,8 @@ export function extractDefineEvent(
147
148
  });
148
149
  }
149
150
 
150
- const eventName = readNameLiteral(first);
151
- if (eventName === undefined) {
151
+ const eventNameRef = readNameLiteralRef(first);
152
+ if (eventNameRef === undefined) {
152
153
  return fail(
153
154
  "defineEvent",
154
155
  sourceLocationFromNode(call, sourceFile),
@@ -187,7 +188,8 @@ export function extractDefineEvent(
187
188
  return ok({
188
189
  kind: "defineEvent",
189
190
  source: sourceLocationFromNode(call, sourceFile),
190
- eventName,
191
+ eventName: eventNameRef.value,
192
+ ...(eventNameRef.raw !== undefined && { eventNameRaw: eventNameRef.raw }),
191
193
  schemaSource: sourceLocationFromNode(schemaArg, sourceFile),
192
194
  ...(version !== undefined && { version }),
193
195
  ...(migrations !== undefined && { migrations }),
@@ -21,6 +21,7 @@ import {
21
21
  ok,
22
22
  readDataLiteralNode,
23
23
  readNameLiteral,
24
+ readNameLiteralRef,
24
25
  readNameOrRef,
25
26
  } from "./shared";
26
27
 
@@ -425,8 +426,8 @@ export function extractUseExtension(
425
426
  });
426
427
  }
427
428
 
428
- const extensionName = readNameLiteral(first);
429
- if (extensionName === undefined) {
429
+ const extensionNameRef = readNameLiteralRef(first);
430
+ if (extensionNameRef === undefined) {
430
431
  return fail(
431
432
  "useExtension",
432
433
  sourceLocationFromNode(call, sourceFile),
@@ -465,7 +466,8 @@ export function extractUseExtension(
465
466
  return ok({
466
467
  kind: "useExtension",
467
468
  source: sourceLocationFromNode(call, sourceFile),
468
- extensionName,
469
+ extensionName: extensionNameRef.value,
470
+ ...(extensionNameRef.raw !== undefined && { extensionNameRaw: extensionNameRef.raw }),
469
471
  entityName,
470
472
  ...(options !== undefined && { options }),
471
473
  });
@@ -6,7 +6,7 @@ import type {
6
6
  UsesApiPattern,
7
7
  } from "../patterns";
8
8
  import { sourceLocationFromNode } from "../source-location";
9
- import { type ExtractOutput, fail, ok, readNameLiteral } from "./shared";
9
+ import { type ExtractOutput, fail, ok, readNameLiteral, readNameLiteralRef } from "./shared";
10
10
 
11
11
  export function extractEnvSchema(
12
12
  call: CallExpression,
@@ -33,8 +33,8 @@ export function extractExtendsRegistrar(
33
33
  ): ExtractOutput<ExtendsRegistrarPattern> {
34
34
  const args = call.getArguments();
35
35
  const first = args[0];
36
- const extensionName = first && readNameLiteral(first);
37
- if (!extensionName) {
36
+ const extensionNameRef = first && readNameLiteralRef(first);
37
+ if (!extensionNameRef) {
38
38
  return fail(
39
39
  "extendsRegistrar",
40
40
  sourceLocationFromNode(call, sourceFile),
@@ -52,7 +52,8 @@ export function extractExtendsRegistrar(
52
52
  return ok({
53
53
  kind: "extendsRegistrar",
54
54
  source: sourceLocationFromNode(call, sourceFile),
55
- extensionName,
55
+ extensionName: extensionNameRef.value,
56
+ ...(extensionNameRef.raw !== undefined && { extensionNameRaw: extensionNameRef.raw }),
56
57
  defBody: sourceLocationFromNode(defArg, sourceFile),
57
58
  });
58
59
  }
@@ -215,6 +215,32 @@ function resolveIdentifierToStringLiteral(identifier: Node): string | undefined
215
215
  return undefined;
216
216
  }
217
217
 
218
+ /**
219
+ * Resolved name plus, when the source node was an identifier rather than
220
+ * a string literal, the identifier's exact source text. Mirrors
221
+ * `RawRefSentinel`'s round-trip contract: extractors that need to re-emit
222
+ * the original reference (not its resolved value) keep `raw` alongside
223
+ * `value`; extractors that only need the string keep using
224
+ * `readNameLiteral`, which discards it.
225
+ */
226
+ export type NameLiteralRef = { readonly value: string; readonly raw?: string };
227
+
228
+ /**
229
+ * Like `readNameLiteral`, but for callers that must preserve an
230
+ * identifier-authored name across a render → parse round-trip (see #2111).
231
+ * `raw` is populated only when the node resolved via an identifier — a
232
+ * string-literal node has nothing worth preserving beyond its value, and
233
+ * setting `raw` for it too would make `JSON.stringify(value)` and
234
+ * `node.getText()` diverge on quote style.
235
+ */
236
+ export function readNameLiteralRef(node: Node): NameLiteralRef | undefined {
237
+ const literal = node.asKind(SyntaxKind.StringLiteral);
238
+ if (literal) return { value: literal.getLiteralValue() };
239
+ const resolved = resolveIdentifierToStringLiteral(node);
240
+ if (resolved === undefined) return undefined;
241
+ return { value: resolved, raw: node.getText() };
242
+ }
243
+
218
244
  /**
219
245
  * A node's string value when it's a string literal, or when it's a bare
220
246
  * Identifier that resolves to one via a `const X = "..."` declaration
@@ -223,12 +249,11 @@ function resolveIdentifierToStringLiteral(identifier: Node): string | undefined
223
249
  * `TENANT_SECRET_READ_EVENT`, ...) instead of repeating string literals.
224
250
  * undefined for anything unresolvable (factory call, member access,
225
251
  * external/ambient identifier) — callers keep their existing ParseError
226
- * fallback, no crash.
252
+ * fallback, no crash. Discards the original identifier text; use
253
+ * `readNameLiteralRef` when the caller must preserve it for rendering.
227
254
  */
228
255
  export function readNameLiteral(node: Node): string | undefined {
229
- const literal = node.asKind(SyntaxKind.StringLiteral);
230
- if (literal) return literal.getLiteralValue();
231
- return resolveIdentifierToStringLiteral(node);
256
+ return readNameLiteralRef(node)?.value;
232
257
  }
233
258
 
234
259
  export function readNameOrRef(node: Node): string | undefined {
@@ -31,6 +31,7 @@
31
31
  // preserve prefixed `// kumiko-comment:` markers across roundtrips.
32
32
 
33
33
  import { type CallExpression, type Node, type SourceFile, SyntaxKind } from "ts-morph";
34
+ import { readNameLiteral, readNameOrRef } from "./extractors/shared";
34
35
  import type { FeaturePattern, FeaturePatternKind } from "./patterns";
35
36
  import { indent, PATTERN_INDENT, renderPattern } from "./render";
36
37
 
@@ -341,8 +342,7 @@ function callMatchesId(call: CallExpression, id: PatternId): boolean {
341
342
  case "relation":
342
343
  // Positional: r.relation(entity, name, def) | Object: { entity, name, ... }
343
344
  if (matchFirstArgString(call, id.entityName)) {
344
- const second = call.getArguments()[1]?.asKind(SyntaxKind.StringLiteral)?.getLiteralValue();
345
- return second === id.relationName;
345
+ return matchArgString(call, 1, id.relationName);
346
346
  }
347
347
  return (
348
348
  matchObjectProperty(call, "entity", id.entityName) &&
@@ -374,8 +374,7 @@ function callMatchesId(call: CallExpression, id: PatternId): boolean {
374
374
  );
375
375
  }
376
376
  if (matchFirstArgString(call, id.hookType)) {
377
- const target = call.getArguments()[1]?.asKind(SyntaxKind.StringLiteral)?.getLiteralValue();
378
- return target === id.target;
377
+ return matchArgString(call, 1, id.target);
379
378
  }
380
379
  return (
381
380
  matchObjectProperty(call, "type", id.hookType) &&
@@ -395,8 +394,7 @@ function callMatchesId(call: CallExpression, id: PatternId): boolean {
395
394
  case "useExtension":
396
395
  // Positional: r.useExtension(name, entity) | Object: { name, entity }
397
396
  if (matchFirstArgString(call, id.extensionName)) {
398
- const ent = call.getArguments()[1]?.asKind(SyntaxKind.StringLiteral)?.getLiteralValue();
399
- return ent === id.entityName;
397
+ return matchArgString(call, 1, id.entityName);
400
398
  }
401
399
  return (
402
400
  matchObjectProperty(call, "name", id.extensionName) &&
@@ -430,33 +428,42 @@ function callMatchesId(call: CallExpression, id: PatternId): boolean {
430
428
  }
431
429
  }
432
430
 
431
+ // Resolves an argument the same way the extractors do (readNameLiteral):
432
+ // a string literal directly, or a bare identifier following its
433
+ // declaration (same-file or imported) to a string-literal initializer —
434
+ // the dominant naming style in the framework's own bundled-features
435
+ // (`r.entity(ENTITY, ...)`, `r.useExtension(EXT_X, ...)`, see #1746).
436
+ function matchArgString(call: CallExpression, index: number, expected: string): boolean {
437
+ const arg = call.getArguments()[index];
438
+ if (!arg) return false;
439
+ return readNameLiteral(arg) === expected;
440
+ }
441
+
433
442
  function matchFirstArgString(call: CallExpression, expected: string): boolean {
434
- const first = call.getArguments()[0];
435
- const lit = first?.asKind(SyntaxKind.StringLiteral);
436
- return lit?.getLiteralValue() === expected;
443
+ return matchArgString(call, 0, expected);
437
444
  }
438
445
 
446
+ // Object-form property values are resolved via readNameOrRef, not the
447
+ // narrower readNameLiteral — some properties (useExtension's `entity`,
448
+ // hook's `target`/`allOf`) accept an inline `{ name: "..." }` ref in the
449
+ // parser (see extractors/round3.ts, extractors/hooks.ts), not just a
450
+ // literal or identifier. readNameOrRef is a superset of readNameLiteral,
451
+ // so this is safe for every other property too.
439
452
  function matchObjectProperty(call: CallExpression, propName: string, expected: string): boolean {
440
453
  const obj = call.getArguments()[0]?.asKind(SyntaxKind.ObjectLiteralExpression);
441
454
  if (!obj) return false;
442
- const init = obj
443
- .getProperty(propName)
444
- ?.asKind(SyntaxKind.PropertyAssignment)
445
- ?.getInitializer()
446
- ?.asKind(SyntaxKind.StringLiteral);
447
- return init?.getLiteralValue() === expected;
455
+ const init = obj.getProperty(propName)?.asKind(SyntaxKind.PropertyAssignment)?.getInitializer();
456
+ if (!init) return false;
457
+ return readNameOrRef(init) === expected;
448
458
  }
449
459
 
450
460
  // Matches an r.hook `{ allOf: entity }` target — as the arg node directly
451
461
  // (positional call form) or as a nested object property (object call form).
452
462
  function matchAllOfArg(node: Node | undefined, expectedEntity: string): boolean {
453
463
  const obj = node?.asKind(SyntaxKind.ObjectLiteralExpression);
454
- const init = obj
455
- ?.getProperty("allOf")
456
- ?.asKind(SyntaxKind.PropertyAssignment)
457
- ?.getInitializer()
458
- ?.asKind(SyntaxKind.StringLiteral);
459
- return init?.getLiteralValue() === expectedEntity;
464
+ const init = obj?.getProperty("allOf")?.asKind(SyntaxKind.PropertyAssignment)?.getInitializer();
465
+ if (!init) return false;
466
+ return readNameOrRef(init) === expectedEntity;
460
467
  }
461
468
 
462
469
  function matchObjectAllOfProperty(
@@ -283,6 +283,14 @@ export type UseExtensionPattern = {
283
283
  readonly kind: "useExtension";
284
284
  readonly source: SourceLocation;
285
285
  readonly extensionName: string;
286
+ // Set only when `extensionName` was authored as an identifier resolving
287
+ // to a string constant (`r.useExtension(EXT_TENANT_DATA, ...)`) rather
288
+ // than a literal — the renderer emits this verbatim instead of
289
+ // `JSON.stringify(extensionName)` so the round-trip doesn't inline the
290
+ // reference (#2111). A caller constructing an edited pattern must omit
291
+ // this field (or recompute it) whenever it changes `extensionName`,
292
+ // otherwise the stale identifier text wins over the new value.
293
+ readonly extensionNameRaw?: string;
286
294
  readonly entityName: string;
287
295
  readonly options?: Readonly<Record<string, unknown>>;
288
296
  };
@@ -516,6 +524,11 @@ export type DefineEventPattern = {
516
524
  readonly kind: "defineEvent";
517
525
  readonly source: SourceLocation;
518
526
  readonly eventName: string;
527
+ // Set only when `eventName` was authored as an identifier resolving to
528
+ // a string constant rather than a literal — see UseExtensionPattern's
529
+ // `extensionNameRaw` doc for the round-trip contract (#2111). A caller
530
+ // editing `eventName` must omit or recompute this field.
531
+ readonly eventNameRaw?: string;
519
532
  readonly schemaSource: SourceLocation;
520
533
  readonly version?: number;
521
534
  // Map fromVersion (as string, e.g. "1") → SourceLocation of the transform
@@ -532,6 +545,11 @@ export type ExtendsRegistrarPattern = {
532
545
  readonly kind: "extendsRegistrar";
533
546
  readonly source: SourceLocation;
534
547
  readonly extensionName: string;
548
+ // Set only when `extensionName` was authored as an identifier resolving
549
+ // to a string constant rather than a literal — see UseExtensionPattern's
550
+ // `extensionNameRaw` doc for the round-trip contract (#2111). A caller
551
+ // editing `extensionName` must omit or recompute this field.
552
+ readonly extensionNameRaw?: string;
535
553
  // Meta-programming surface — kept fully opaque in the MVP. The
536
554
  // Designer shows "Custom Registrar Extension"; AI leaves it alone.
537
555
  readonly defBody: SourceLocation;
@@ -9,6 +9,11 @@
9
9
  // - Mixed patterns (writeHandler, hook, screen) embed the original
10
10
  // source-text of opaque bodies (handler/fn/closure) verbatim via
11
11
  // SourceLocation.raw — the renderer doesn't re-print closure code.
12
+ // - Patterns carrying a RawRefSentinel value (e.g. entity/metric/secret
13
+ // `definition`/`options`) or a `*NameRaw` identifier reference
14
+ // (useExtension/defineEvent/extendsRegistrar) fall back to positional-arg
15
+ // form instead of Object-Form, since the raw source text can't be spread
16
+ // into a merged object literal without losing its verbatim-ness.
12
17
  // - Comments inside an existing pattern are NOT preserved (Designer
13
18
  // edits via forms; for AI generation the output is fresh anyway).
14
19
  //
@@ -306,8 +311,16 @@ function renderReferenceData(p: ReferenceDataPattern): string {
306
311
  }
307
312
 
308
313
  function renderUseExtension(p: UseExtensionPattern): string {
314
+ const nameLiteral = p.extensionNameRaw ?? JSON.stringify(p.extensionName);
309
315
  if (isRawRefSentinel(p.options)) {
310
- return `r.useExtension(${JSON.stringify(p.extensionName)}, ${JSON.stringify(p.entityName)}, ${p.options.__raw});`;
316
+ return `r.useExtension(${nameLiteral}, ${JSON.stringify(p.entityName)}, ${p.options.__raw});`;
317
+ }
318
+ if (p.extensionNameRaw !== undefined) {
319
+ // A raw-ref name can't be spread into the merged Object-Form without
320
+ // losing it to renderValue's plain-string serialization — fall back
321
+ // to positional form, which keeps the reference verbatim (#2111).
322
+ const optionsArg = p.options !== undefined ? `, ${renderValue(p.options)}` : "";
323
+ return `r.useExtension(${nameLiteral}, ${JSON.stringify(p.entityName)}${optionsArg});`;
311
324
  }
312
325
  const merged: Record<string, unknown> = {
313
326
  name: p.extensionName,
@@ -506,14 +519,13 @@ function renderMultiStreamProjection(p: MultiStreamProjectionPattern): string {
506
519
  }
507
520
 
508
521
  function renderDefineEvent(p: DefineEventPattern): string {
522
+ const nameLiteral = p.eventNameRaw ?? JSON.stringify(p.eventName);
509
523
  const migrationEntries = p.migrations !== undefined ? Object.entries(p.migrations) : [];
510
524
  const hasOptions = p.version !== undefined || migrationEntries.length > 0;
511
525
  if (!hasOptions) {
512
- return `r.defineEvent(${JSON.stringify(p.eventName)}, ${p.schemaSource.raw});`;
526
+ return `r.defineEvent(${nameLiteral}, ${p.schemaSource.raw});`;
513
527
  }
514
- const lines: string[] = [
515
- `r.defineEvent(${JSON.stringify(p.eventName)}, ${p.schemaSource.raw}, {`,
516
- ];
528
+ const lines: string[] = [`r.defineEvent(${nameLiteral}, ${p.schemaSource.raw}, {`];
517
529
  if (p.version !== undefined) lines.push(` version: ${p.version},`);
518
530
  if (migrationEntries.length > 0) {
519
531
  lines.push(" migrations: [");
@@ -530,7 +542,8 @@ function renderDefineEvent(p: DefineEventPattern): string {
530
542
  }
531
543
 
532
544
  function renderExtendsRegistrar(p: ExtendsRegistrarPattern): string {
533
- return `r.extendsRegistrar(${JSON.stringify(p.extensionName)}, ${p.defBody.raw});`;
545
+ const nameLiteral = p.extensionNameRaw ?? JSON.stringify(p.extensionName);
546
+ return `r.extendsRegistrar(${nameLiteral}, ${p.defBody.raw});`;
534
547
  }
535
548
 
536
549
  function renderEnvSchema(p: EnvSchemaPattern): string {
@@ -70,6 +70,7 @@ export {
70
70
  defineProjectionQueryHandler,
71
71
  type EntityCrudRegistrar,
72
72
  entityListSchema,
73
+ MAX_LIST_LIMIT,
73
74
  type RegisterEntityCrudOptions,
74
75
  registerEntityCrud,
75
76
  } from "./entity-handlers";