@cosmicdrift/kumiko-framework 0.200.1 → 0.202.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 +7 -3
- package/src/__tests__/entity-list-limits.integration.test.ts +84 -0
- package/src/api/__tests__/api-constants-completeness.test.ts +63 -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 +90 -0
- package/src/api/__tests__/server-jwt-ttl.test.ts +2 -2
- package/src/api/api-constants.ts +44 -7
- package/src/api/auth-middleware.ts +19 -3
- package/src/api/index.ts +1 -0
- package/src/api/route-registrars.ts +19 -21
- package/src/api/routes.ts +47 -1
- package/src/api/server.ts +1 -1
- package/src/db/__tests__/unchecked-system-db.test.ts +66 -0
- package/src/db/tenant-db.ts +46 -2
- package/src/engine/__tests__/boot-validator-detail-for.test.ts +82 -0
- package/src/engine/__tests__/build-app-schema.test.ts +25 -0
- package/src/engine/boot-validator/detail-screens.ts +35 -0
- package/src/engine/boot-validator/index.ts +2 -0
- 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 +156 -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 +48 -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/http/__tests__/egress-real-endpoint.integration.test.ts +37 -0
- package/src/http/__tests__/egress.test.ts +440 -0
- package/src/http/__tests__/policy.test.ts +125 -0
- package/src/http/egress.ts +158 -0
- package/src/http/index.ts +2 -0
- package/src/http/policy.ts +193 -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,158 @@ 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
|
+
});
|
|
653
|
+
|
|
654
|
+
// #2133 — matchArgString (shared by relation's second positional arg,
|
|
655
|
+
// hook's target, and useExtension's entity) only accepted a string literal
|
|
656
|
+
// or a name-resolving identifier. The parser itself is more permissive at
|
|
657
|
+
// two of those positions: useExtension's entity (round3.ts:445) and hook's
|
|
658
|
+
// target (hooks.ts:75) both additionally accept an inline `{ name: "..." }`
|
|
659
|
+
// object ref, same as the object-form fix in #2121 — so findCallForId
|
|
660
|
+
// couldn't locate a call authored that way. relation's second positional
|
|
661
|
+
// arg stays narrow on purpose: round2.ts:170 parses it via readNameLiteral,
|
|
662
|
+
// not readNameOrRef, so widening it would exceed what the parser accepts.
|
|
663
|
+
describe("callMatchesId — positional inline-ref args (#2133)", () => {
|
|
664
|
+
test("removePattern finds a positional useExtension whose entity is an inline { name } ref", () => {
|
|
665
|
+
const sf = makeSourceFile(`
|
|
666
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
667
|
+
|
|
668
|
+
defineFeature("inventory", (r) => {
|
|
669
|
+
r.useExtension("audit", { name: "item" });
|
|
670
|
+
});
|
|
671
|
+
`);
|
|
672
|
+
removePattern(sf, { kind: "useExtension", extensionName: "audit", entityName: "item" });
|
|
673
|
+
const reparsed = parseSourceFile(sf);
|
|
674
|
+
expect(reparsed.errors).toEqual([]);
|
|
675
|
+
expect(reparsed.patterns).toEqual([]);
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
test("removePattern finds a positional hook whose target is an inline { name } ref", () => {
|
|
679
|
+
const sf = makeSourceFile(`
|
|
680
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
681
|
+
|
|
682
|
+
defineFeature("hooks", (r) => {
|
|
683
|
+
r.hook("postSave", { name: "task" }, () => {});
|
|
684
|
+
});
|
|
685
|
+
`);
|
|
686
|
+
removePattern(sf, { kind: "hook", hookType: "postSave", target: "task" });
|
|
687
|
+
const reparsed = parseSourceFile(sf);
|
|
688
|
+
expect(reparsed.errors).toEqual([]);
|
|
689
|
+
expect(reparsed.patterns).toEqual([]);
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
// Boundary, not a gap left open by this fix: readDataLiteralNode (used by
|
|
693
|
+
// readNameOrRef's object-literal branch) keeps a nested Identifier as a
|
|
694
|
+
// RawRefSentinel instead of resolving it — same as the parser side, which
|
|
695
|
+
// is why this call fails to extract too (not just to patch-match).
|
|
696
|
+
test("does not resolve an identifier nested inside an inline-ref positional arg", () => {
|
|
697
|
+
const sf = makeSourceFile(`
|
|
698
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
699
|
+
|
|
700
|
+
const ENTITY_NAME = "item";
|
|
701
|
+
|
|
702
|
+
defineFeature("inventory", (r) => {
|
|
703
|
+
r.useExtension("audit", { name: ENTITY_NAME });
|
|
704
|
+
});
|
|
705
|
+
`);
|
|
706
|
+
expect(() =>
|
|
707
|
+
removePattern(sf, { kind: "useExtension", extensionName: "audit", entityName: "item" }),
|
|
708
|
+
).toThrow(/no call found/);
|
|
709
|
+
});
|
|
710
|
+
});
|
|
@@ -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 matchArgNameOrRef(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 matchArgNameOrRef(call, 1, id.entityName);
|
|
400
398
|
}
|
|
401
399
|
return (
|
|
402
400
|
matchObjectProperty(call, "name", id.extensionName) &&
|
|
@@ -430,33 +428,62 @@ 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
|
+
//
|
|
437
|
+
// Narrow on purpose: every kind's arg-0 (and relation's arg-1) is parsed
|
|
438
|
+
// via readNameLiteral, never readNameOrRef (see round2.ts/round3.ts) —
|
|
439
|
+
// widening this shared helper to readNameOrRef would let an object-form
|
|
440
|
+
// call's first argument (an ObjectLiteralExpression) match here too,
|
|
441
|
+
// short-circuiting the object-form branch in callMatchesId. Positions
|
|
442
|
+
// where the parser itself accepts an inline `{ name: "..." }` ref use
|
|
443
|
+
// `matchArgNameOrRef` below instead.
|
|
444
|
+
function matchArgString(call: CallExpression, index: number, expected: string): boolean {
|
|
445
|
+
const arg = call.getArguments()[index];
|
|
446
|
+
if (!arg) return false;
|
|
447
|
+
return readNameLiteral(arg) === expected;
|
|
448
|
+
}
|
|
449
|
+
|
|
433
450
|
function matchFirstArgString(call: CallExpression, expected: string): boolean {
|
|
434
|
-
|
|
435
|
-
const lit = first?.asKind(SyntaxKind.StringLiteral);
|
|
436
|
-
return lit?.getLiteralValue() === expected;
|
|
451
|
+
return matchArgString(call, 0, expected);
|
|
437
452
|
}
|
|
438
453
|
|
|
454
|
+
// Like matchArgString, but via readNameOrRef — for the specific positional
|
|
455
|
+
// slots where the parser accepts an inline `{ name: "..." }` object ref in
|
|
456
|
+
// addition to a literal/identifier (useExtension's entity arg, hook's
|
|
457
|
+
// target arg; see round3.ts:445 / hooks.ts:75). Not a drop-in replacement
|
|
458
|
+
// for matchArgString: applying it to an arg-0 position would match an
|
|
459
|
+
// object-form call's first (and only) argument, see the comment above.
|
|
460
|
+
function matchArgNameOrRef(call: CallExpression, index: number, expected: string): boolean {
|
|
461
|
+
const arg = call.getArguments()[index];
|
|
462
|
+
if (!arg) return false;
|
|
463
|
+
return readNameOrRef(arg) === expected;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Object-form property values are resolved via readNameOrRef, not the
|
|
467
|
+
// narrower readNameLiteral — some properties (useExtension's `entity`,
|
|
468
|
+
// hook's `target`/`allOf`) accept an inline `{ name: "..." }` ref in the
|
|
469
|
+
// parser (see extractors/round3.ts, extractors/hooks.ts), not just a
|
|
470
|
+
// literal or identifier. readNameOrRef is a superset of readNameLiteral,
|
|
471
|
+
// so this is safe for every other property too.
|
|
439
472
|
function matchObjectProperty(call: CallExpression, propName: string, expected: string): boolean {
|
|
440
473
|
const obj = call.getArguments()[0]?.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
441
474
|
if (!obj) return false;
|
|
442
|
-
const init = obj
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
?.getInitializer()
|
|
446
|
-
?.asKind(SyntaxKind.StringLiteral);
|
|
447
|
-
return init?.getLiteralValue() === expected;
|
|
475
|
+
const init = obj.getProperty(propName)?.asKind(SyntaxKind.PropertyAssignment)?.getInitializer();
|
|
476
|
+
if (!init) return false;
|
|
477
|
+
return readNameOrRef(init) === expected;
|
|
448
478
|
}
|
|
449
479
|
|
|
450
480
|
// Matches an r.hook `{ allOf: entity }` target — as the arg node directly
|
|
451
481
|
// (positional call form) or as a nested object property (object call form).
|
|
452
482
|
function matchAllOfArg(node: Node | undefined, expectedEntity: string): boolean {
|
|
453
483
|
const obj = node?.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
454
|
-
const init = obj
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
?.getInitializer()
|
|
458
|
-
?.asKind(SyntaxKind.StringLiteral);
|
|
459
|
-
return init?.getLiteralValue() === expectedEntity;
|
|
484
|
+
const init = obj?.getProperty("allOf")?.asKind(SyntaxKind.PropertyAssignment)?.getInitializer();
|
|
485
|
+
if (!init) return false;
|
|
486
|
+
return readNameOrRef(init) === expectedEntity;
|
|
460
487
|
}
|
|
461
488
|
|
|
462
489
|
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;
|