@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.
- package/package.json +3 -3
- package/src/__tests__/entity-list-limits.integration.test.ts +84 -0
- package/src/api/__tests__/api.test.ts +116 -1
- package/src/api/__tests__/batch.integration.test.ts +53 -0
- package/src/api/__tests__/body-limit.test.ts +16 -0
- package/src/api/route-registrars.ts +4 -3
- package/src/api/routes.ts +47 -1
- package/src/db/__tests__/unchecked-system-db.test.ts +66 -0
- package/src/db/tenant-db.ts +46 -2
- package/src/engine/entity-handlers.ts +8 -1
- package/src/engine/feature-ast/__tests__/fixtures/cross-file-patch-const/constants.ts +2 -0
- package/src/engine/feature-ast/__tests__/fixtures/cross-file-patch-const/feature.ts +8 -0
- package/src/engine/feature-ast/__tests__/patch.test.ts +98 -0
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +155 -0
- package/src/engine/feature-ast/extractors/events.ts +5 -3
- package/src/engine/feature-ast/extractors/round3.ts +5 -3
- package/src/engine/feature-ast/extractors/round5.ts +5 -4
- package/src/engine/feature-ast/extractors/shared.ts +29 -4
- package/src/engine/feature-ast/patch.ts +28 -21
- package/src/engine/feature-ast/patterns.ts +18 -0
- package/src/engine/feature-ast/render.ts +19 -6
- package/src/engine/index.ts +1 -0
- package/src/files/__tests__/files.integration.test.ts +97 -1
- package/src/files/file-routes.ts +10 -2
- package/src/files/types.ts +72 -0
- package/src/pipeline/__tests__/ctx-systemdb.integration.test.ts +44 -8
- package/src/pipeline/__tests__/dispatcher.test.ts +23 -4
- package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +116 -22
- package/src/pipeline/dispatch-batch.ts +11 -5
- package/src/pipeline/dispatch-shared.ts +42 -16
- 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
|
|
151
|
-
if (
|
|
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
|
|
429
|
-
if (
|
|
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
|
|
37
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
444
|
-
|
|
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
|
-
|
|
456
|
-
|
|
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(${
|
|
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(${
|
|
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
|
-
|
|
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 {
|