@openparachute/vault 0.4.4-rc.12 → 0.4.5
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/core/src/core.test.ts +348 -60
- package/core/src/mcp.ts +61 -32
- package/core/src/notes.ts +187 -81
- package/core/src/portable-md.test.ts +554 -1
- package/core/src/portable-md.ts +508 -19
- package/core/src/schema.ts +61 -3
- package/core/src/store.ts +5 -4
- package/core/src/types.ts +27 -3
- package/core/src/wikilinks.ts +74 -14
- package/package.json +1 -1
- package/src/cli.ts +17 -0
- package/src/import-daemon-busy.test.ts +109 -0
- package/src/published.test.ts +17 -0
- package/src/routes.ts +136 -48
- package/src/vault.test.ts +294 -32
package/src/vault.test.ts
CHANGED
|
@@ -1702,6 +1702,162 @@ describe("HTTP /notes", async () => {
|
|
|
1702
1702
|
expect(body.createdAt).toBe("2025-01-01T00:00:00.000Z");
|
|
1703
1703
|
});
|
|
1704
1704
|
|
|
1705
|
+
// ---- Extension field (vault#328) ----
|
|
1706
|
+
|
|
1707
|
+
test("POST /notes accepts extension and persists it", async () => {
|
|
1708
|
+
const res = await handleNotes(
|
|
1709
|
+
mkReq("POST", "/notes", {
|
|
1710
|
+
content: "month,total\n2026-01,9000",
|
|
1711
|
+
path: "Tabular/budget",
|
|
1712
|
+
extension: "csv",
|
|
1713
|
+
}),
|
|
1714
|
+
store,
|
|
1715
|
+
"",
|
|
1716
|
+
);
|
|
1717
|
+
expect(res.status).toBe(201);
|
|
1718
|
+
const body = await res.json() as any;
|
|
1719
|
+
expect(body.extension).toBe("csv");
|
|
1720
|
+
const fetched = await store.getNote(body.id);
|
|
1721
|
+
expect(fetched!.extension).toBe("csv");
|
|
1722
|
+
});
|
|
1723
|
+
|
|
1724
|
+
test("POST /notes defaults extension to 'md' when omitted", async () => {
|
|
1725
|
+
const res = await handleNotes(
|
|
1726
|
+
mkReq("POST", "/notes", { content: "plain", path: "p" }),
|
|
1727
|
+
store,
|
|
1728
|
+
"",
|
|
1729
|
+
);
|
|
1730
|
+
const body = await res.json() as any;
|
|
1731
|
+
expect(body.extension).toBe("md");
|
|
1732
|
+
});
|
|
1733
|
+
|
|
1734
|
+
test("POST /notes rejects invalid extension with 400 invalid_extension", async () => {
|
|
1735
|
+
const res = await handleNotes(
|
|
1736
|
+
mkReq("POST", "/notes", { content: "x", extension: "CSV" }),
|
|
1737
|
+
store,
|
|
1738
|
+
"",
|
|
1739
|
+
);
|
|
1740
|
+
expect(res.status).toBe(400);
|
|
1741
|
+
const body = await res.json() as any;
|
|
1742
|
+
expect(body.error_type).toBe("invalid_extension");
|
|
1743
|
+
expect(body.extension).toBe("CSV");
|
|
1744
|
+
});
|
|
1745
|
+
|
|
1746
|
+
test("POST /notes rejects reserved 'parachute' prefix with 400", async () => {
|
|
1747
|
+
const res = await handleNotes(
|
|
1748
|
+
mkReq("POST", "/notes", { content: "x", extension: "parachute" }),
|
|
1749
|
+
store,
|
|
1750
|
+
"",
|
|
1751
|
+
);
|
|
1752
|
+
expect(res.status).toBe(400);
|
|
1753
|
+
const body = await res.json() as any;
|
|
1754
|
+
expect(body.error_type).toBe("invalid_extension");
|
|
1755
|
+
expect(body.reason).toMatch(/reserved/);
|
|
1756
|
+
});
|
|
1757
|
+
|
|
1758
|
+
test("PATCH /notes/:id changes extension", async () => {
|
|
1759
|
+
const note = await store.createNote("hi", { id: "ext-patch", path: "p" });
|
|
1760
|
+
const res = await handleNotes(
|
|
1761
|
+
mkReq("PATCH", "/notes/ext-patch", {
|
|
1762
|
+
extension: "mdx",
|
|
1763
|
+
if_updated_at: note.updatedAt,
|
|
1764
|
+
}),
|
|
1765
|
+
store,
|
|
1766
|
+
"/ext-patch",
|
|
1767
|
+
);
|
|
1768
|
+
expect(res.status).toBe(200);
|
|
1769
|
+
const fetched = await store.getNote("ext-patch");
|
|
1770
|
+
expect(fetched!.extension).toBe("mdx");
|
|
1771
|
+
});
|
|
1772
|
+
|
|
1773
|
+
test("PATCH /notes/:id rejects invalid extension with 400", async () => {
|
|
1774
|
+
const note = await store.createNote("hi", { id: "ext-bad", path: "p" });
|
|
1775
|
+
const res = await handleNotes(
|
|
1776
|
+
mkReq("PATCH", "/notes/ext-bad", {
|
|
1777
|
+
extension: "foo.bar",
|
|
1778
|
+
if_updated_at: note.updatedAt,
|
|
1779
|
+
}),
|
|
1780
|
+
store,
|
|
1781
|
+
"/ext-bad",
|
|
1782
|
+
);
|
|
1783
|
+
expect(res.status).toBe(400);
|
|
1784
|
+
const body = await res.json() as any;
|
|
1785
|
+
expect(body.error_type).toBe("invalid_extension");
|
|
1786
|
+
});
|
|
1787
|
+
|
|
1788
|
+
test("GET /notes?extension=csv filters by extension", async () => {
|
|
1789
|
+
await store.createNote("md note", { path: "a" });
|
|
1790
|
+
await store.createNote("csv note", { path: "b", extension: "csv" });
|
|
1791
|
+
await store.createNote("yaml note", { path: "c", extension: "yaml" });
|
|
1792
|
+
const res = await handleNotes(
|
|
1793
|
+
mkReq("GET", "/notes?extension=csv&include_content=true"),
|
|
1794
|
+
store,
|
|
1795
|
+
"",
|
|
1796
|
+
);
|
|
1797
|
+
const body = await res.json() as any[];
|
|
1798
|
+
expect(body).toHaveLength(1);
|
|
1799
|
+
expect(body[0].path).toBe("b");
|
|
1800
|
+
});
|
|
1801
|
+
|
|
1802
|
+
test("GET /notes?extension=csv&extension=yaml filters by array of extensions", async () => {
|
|
1803
|
+
await store.createNote("md note", { path: "a" });
|
|
1804
|
+
await store.createNote("csv note", { path: "b", extension: "csv" });
|
|
1805
|
+
await store.createNote("yaml note", { path: "c", extension: "yaml" });
|
|
1806
|
+
const res = await handleNotes(
|
|
1807
|
+
mkReq("GET", "/notes?extension=csv&extension=yaml&include_content=true"),
|
|
1808
|
+
store,
|
|
1809
|
+
"",
|
|
1810
|
+
);
|
|
1811
|
+
const body = await res.json() as any[];
|
|
1812
|
+
expect(body).toHaveLength(2);
|
|
1813
|
+
expect(body.map((n) => n.path).sort()).toEqual(["b", "c"]);
|
|
1814
|
+
});
|
|
1815
|
+
|
|
1816
|
+
test("PATCH /notes/:id if_missing=create honors extension", async () => {
|
|
1817
|
+
const idOrPath = encodeURIComponent("Tabular/new-budget");
|
|
1818
|
+
const res = await handleNotes(
|
|
1819
|
+
mkReq("PATCH", `/notes/${idOrPath}`, {
|
|
1820
|
+
content: "month,total\n2026-02,1000",
|
|
1821
|
+
extension: "csv",
|
|
1822
|
+
if_missing: "create",
|
|
1823
|
+
}),
|
|
1824
|
+
store,
|
|
1825
|
+
`/${idOrPath}`,
|
|
1826
|
+
);
|
|
1827
|
+
expect(res.status).toBe(200);
|
|
1828
|
+
const body = await res.json() as any;
|
|
1829
|
+
expect(body.created).toBe(true);
|
|
1830
|
+
expect(body.extension).toBe("csv");
|
|
1831
|
+
});
|
|
1832
|
+
|
|
1833
|
+
test("GET /notes?id=<path> returns 409 ambiguous_path when path matches multiple extensions (vault#330 S1)", async () => {
|
|
1834
|
+
await store.createNote("# md", { path: "Foo", id: "foo-md" });
|
|
1835
|
+
await store.createNote("a,b\n1,2", { path: "Foo", extension: "csv", id: "foo-csv" });
|
|
1836
|
+
const res = await handleNotes(
|
|
1837
|
+
mkReq("GET", "/notes?id=Foo"),
|
|
1838
|
+
store,
|
|
1839
|
+
"",
|
|
1840
|
+
);
|
|
1841
|
+
expect(res.status).toBe(409);
|
|
1842
|
+
const body = await res.json() as any;
|
|
1843
|
+
expect(body.error_type).toBe("ambiguous_path");
|
|
1844
|
+
expect(body.path).toBe("Foo");
|
|
1845
|
+
expect(body.candidates).toHaveLength(2);
|
|
1846
|
+
});
|
|
1847
|
+
|
|
1848
|
+
test("GET /notes?id=Foo.csv resolves the explicit-extension form (vault#330 S1)", async () => {
|
|
1849
|
+
await store.createNote("# md", { path: "Foo", id: "foo-md" });
|
|
1850
|
+
await store.createNote("a,b\n1,2", { path: "Foo", extension: "csv", id: "foo-csv" });
|
|
1851
|
+
const res = await handleNotes(
|
|
1852
|
+
mkReq("GET", "/notes?id=Foo.csv"),
|
|
1853
|
+
store,
|
|
1854
|
+
"",
|
|
1855
|
+
);
|
|
1856
|
+
expect(res.status).toBe(200);
|
|
1857
|
+
const body = await res.json() as any;
|
|
1858
|
+
expect(body.id).toBe("foo-csv");
|
|
1859
|
+
});
|
|
1860
|
+
|
|
1705
1861
|
test("POST /notes/:id/attachments accepts mimeType (camelCase) in body", async () => {
|
|
1706
1862
|
const n = await store.createNote("x", { id: "x" });
|
|
1707
1863
|
const res = await handleNotes(
|
|
@@ -1883,36 +2039,30 @@ describe("HTTP /notes", async () => {
|
|
|
1883
2039
|
});
|
|
1884
2040
|
|
|
1885
2041
|
// -------------------------------------------------------------------------
|
|
1886
|
-
// Empty
|
|
2042
|
+
// Empty content is a valid state (vault#323)
|
|
1887
2043
|
// -------------------------------------------------------------------------
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
2044
|
+
// Skeleton notes, drafts, organizing-only notes, and capture-then-fill
|
|
2045
|
+
// flows all legitimately produce empty-content rows. The earlier #213
|
|
2046
|
+
// guard rejected `content + path both absent` — we no longer enforce
|
|
2047
|
+
// it because real vaults carry such rows and the round-trip import
|
|
2048
|
+
// has to accept them.
|
|
2049
|
+
|
|
2050
|
+
describe("empty content is valid (vault#323)", async () => {
|
|
2051
|
+
test("POST bare {} body → 201", async () => {
|
|
1891
2052
|
const res = await handleNotes(mkReq("POST", "/notes", {}), store, "");
|
|
1892
|
-
expect(res.status).toBe(
|
|
1893
|
-
const body = await res.json() as any;
|
|
1894
|
-
expect(body.error_type).toBe("empty_note");
|
|
1895
|
-
expect(body.error).toBe("EmptyNoteError");
|
|
2053
|
+
expect(res.status).toBe(201);
|
|
1896
2054
|
});
|
|
1897
2055
|
|
|
1898
|
-
test("POST batch with
|
|
1899
|
-
// Pre-validate the batch before any DB writes so a mixed batch with one
|
|
1900
|
-
// bad entry rolls back the whole call. The runaway-client signature
|
|
1901
|
-
// (#213) is "thousands of empties" — partial-create semantics would
|
|
1902
|
-
// still leak the prefix on every burst. Atomic is the only safe shape.
|
|
2056
|
+
test("POST batch with mixed empty + content entries → 201, all created", async () => {
|
|
1903
2057
|
const beforeCount = (await store.queryNotes({ path: "ok-1" })).length;
|
|
1904
2058
|
const res = await handleNotes(
|
|
1905
|
-
mkReq("POST", "/notes", { notes: [{ path: "ok-1" }, {}] }),
|
|
2059
|
+
mkReq("POST", "/notes", { notes: [{ path: "ok-1" }, {}, { content: "third" }] }),
|
|
1906
2060
|
store,
|
|
1907
2061
|
"",
|
|
1908
2062
|
);
|
|
1909
|
-
expect(res.status).toBe(
|
|
1910
|
-
const body = await res.json() as any;
|
|
1911
|
-
expect(body.error_type).toBe("empty_note");
|
|
1912
|
-
expect(body.item_index).toBe(1);
|
|
1913
|
-
// ok-1 must NOT have been created — atomic rollback.
|
|
2063
|
+
expect(res.status).toBe(201);
|
|
1914
2064
|
const afterCount = (await store.queryNotes({ path: "ok-1" })).length;
|
|
1915
|
-
expect(afterCount).toBe(beforeCount);
|
|
2065
|
+
expect(afterCount).toBe(beforeCount + 1);
|
|
1916
2066
|
});
|
|
1917
2067
|
|
|
1918
2068
|
test("POST single content-only (path absent) → 201", async () => {
|
|
@@ -1924,9 +2074,7 @@ describe("HTTP /notes", async () => {
|
|
|
1924
2074
|
expect(res.status).toBe(201);
|
|
1925
2075
|
});
|
|
1926
2076
|
|
|
1927
|
-
test("POST single path-only (content absent) → 201
|
|
1928
|
-
// Path-only is a wikilink placeholder / `_schemas/*` shape — must
|
|
1929
|
-
// remain accepted (per #223 design Q3).
|
|
2077
|
+
test("POST single path-only (content absent) → 201", async () => {
|
|
1930
2078
|
const res = await handleNotes(
|
|
1931
2079
|
mkReq("POST", "/notes", { path: "wiki/placeholder" }),
|
|
1932
2080
|
store,
|
|
@@ -1935,8 +2083,8 @@ describe("HTTP /notes", async () => {
|
|
|
1935
2083
|
expect(res.status).toBe(201);
|
|
1936
2084
|
});
|
|
1937
2085
|
|
|
1938
|
-
test("PATCH that
|
|
1939
|
-
|
|
2086
|
+
test("PATCH that clears both content and path → 200", async () => {
|
|
2087
|
+
await store.createNote("starts with content", { id: "ep1" });
|
|
1940
2088
|
const updated = await store.getNote("ep1");
|
|
1941
2089
|
const res = await handleNotes(
|
|
1942
2090
|
mkReq("PATCH", "/notes/ep1", {
|
|
@@ -1947,14 +2095,11 @@ describe("HTTP /notes", async () => {
|
|
|
1947
2095
|
store,
|
|
1948
2096
|
"/ep1",
|
|
1949
2097
|
);
|
|
1950
|
-
expect(res.status).toBe(
|
|
1951
|
-
const body = await res.json() as any;
|
|
1952
|
-
expect(body.error_type).toBe("empty_note");
|
|
1953
|
-
expect(body.note_id).toBe("ep1");
|
|
2098
|
+
expect(res.status).toBe(200);
|
|
1954
2099
|
});
|
|
1955
2100
|
|
|
1956
2101
|
test("PATCH that clears content but preserves path → 200", async () => {
|
|
1957
|
-
|
|
2102
|
+
await store.createNote("body", { id: "ep2", path: "p2" });
|
|
1958
2103
|
const updated = await store.getNote("ep2");
|
|
1959
2104
|
const res = await handleNotes(
|
|
1960
2105
|
mkReq("PATCH", "/notes/ep2", {
|
|
@@ -1970,8 +2115,7 @@ describe("HTTP /notes", async () => {
|
|
|
1970
2115
|
|
|
1971
2116
|
describe("batch atomicity (#236)", async () => {
|
|
1972
2117
|
test("POST batch where mid-item triggers PATH_CONFLICT → 409, NOTHING created", async () => {
|
|
1973
|
-
//
|
|
1974
|
-
// path-conflict only surfaces on the actual INSERT, mid-loop. Without
|
|
2118
|
+
// A path-conflict only surfaces on the actual INSERT, mid-loop. Without
|
|
1975
2119
|
// the BEGIN/COMMIT wrap the prefix would have already landed by then.
|
|
1976
2120
|
await store.createNote("existing", { path: "taken" });
|
|
1977
2121
|
const beforeIds = (await store.queryNotes({})).map((n) => n.id).sort();
|
|
@@ -2998,6 +3142,105 @@ describe("HTTP PATCH /notes/:idOrPath if_missing=create (vault#309)", async () =
|
|
|
2998
3142
|
const body = await res.json() as any;
|
|
2999
3143
|
expect(body.created).toBe(false);
|
|
3000
3144
|
});
|
|
3145
|
+
|
|
3146
|
+
// vault#321 F2 — REST create-on-missing branch applies links.add.
|
|
3147
|
+
// MCP's create branch already did; REST was missing the
|
|
3148
|
+
// link-creation pass entirely. Cross-surface inconsistency Gitcoin
|
|
3149
|
+
// would trip on if they migrated from MCP to REST. The new pass
|
|
3150
|
+
// mirrors MCP exactly (links.add applied, links.remove ignored,
|
|
3151
|
+
// missing targets skip silently).
|
|
3152
|
+
test("if_missing=create + links.add creates typed-link rows (vault#321 F2)", async () => {
|
|
3153
|
+
// Two pre-existing target notes (different ids + paths) so the
|
|
3154
|
+
// source can fan out to both.
|
|
3155
|
+
await store.createNote("target A", { id: "t-a-321", path: "Targets/A" });
|
|
3156
|
+
await store.createNote("target B", { id: "t-b-321", path: "Targets/B" });
|
|
3157
|
+
|
|
3158
|
+
const res = await handleNotes(
|
|
3159
|
+
mkReq("PATCH", `/notes/${encodeURIComponent("Inbox/source-321")}`, {
|
|
3160
|
+
content: "source body",
|
|
3161
|
+
if_missing: "create",
|
|
3162
|
+
links: {
|
|
3163
|
+
add: [
|
|
3164
|
+
{ target: "t-a-321", relationship: "derived-from" },
|
|
3165
|
+
{ target: "Targets/B", relationship: "responds-to", metadata: { weight: 5 } },
|
|
3166
|
+
],
|
|
3167
|
+
},
|
|
3168
|
+
}),
|
|
3169
|
+
store,
|
|
3170
|
+
`/${encodeURIComponent("Inbox/source-321")}`,
|
|
3171
|
+
);
|
|
3172
|
+
expect(res.status).toBe(200);
|
|
3173
|
+
const body = await res.json() as any;
|
|
3174
|
+
expect(body.created).toBe(true);
|
|
3175
|
+
|
|
3176
|
+
// Link rows exist + resolved targets correctly. We look up by the
|
|
3177
|
+
// source's note id (the body returned by the create branch).
|
|
3178
|
+
const sourceId = body.id as string;
|
|
3179
|
+
const outboundLinks = await store.getLinks(sourceId, { direction: "outbound" });
|
|
3180
|
+
const derivedFrom = outboundLinks.find((l) => l.relationship === "derived-from");
|
|
3181
|
+
expect(derivedFrom).toBeDefined();
|
|
3182
|
+
expect(derivedFrom!.targetId).toBe("t-a-321");
|
|
3183
|
+
|
|
3184
|
+
const respondsTo = outboundLinks.find((l) => l.relationship === "responds-to");
|
|
3185
|
+
expect(respondsTo).toBeDefined();
|
|
3186
|
+
expect(respondsTo!.targetId).toBe("t-b-321");
|
|
3187
|
+
expect(respondsTo!.metadata).toEqual({ weight: 5 });
|
|
3188
|
+
});
|
|
3189
|
+
|
|
3190
|
+
test("if_missing=create + links.add silently skips when target does not exist (vault#321 F2)", async () => {
|
|
3191
|
+
// Mirrors MCP: missing target → silent skip, no error. Sync loops
|
|
3192
|
+
// that declare links to not-yet-imported notes shouldn't abort
|
|
3193
|
+
// the whole upsert.
|
|
3194
|
+
const res = await handleNotes(
|
|
3195
|
+
mkReq("PATCH", `/notes/${encodeURIComponent("Inbox/source-missing-321")}`, {
|
|
3196
|
+
content: "x",
|
|
3197
|
+
if_missing: "create",
|
|
3198
|
+
links: { add: [{ target: "does-not-exist", relationship: "derived-from" }] },
|
|
3199
|
+
}),
|
|
3200
|
+
store,
|
|
3201
|
+
`/${encodeURIComponent("Inbox/source-missing-321")}`,
|
|
3202
|
+
);
|
|
3203
|
+
expect(res.status).toBe(200);
|
|
3204
|
+
const body = await res.json() as any;
|
|
3205
|
+
expect(body.created).toBe(true);
|
|
3206
|
+
// The note is created. No links resolved.
|
|
3207
|
+
const links = await store.getLinks(body.id, { direction: "outbound" });
|
|
3208
|
+
expect(links.filter((l) => l.relationship === "derived-from")).toHaveLength(0);
|
|
3209
|
+
});
|
|
3210
|
+
|
|
3211
|
+
// vault#321 F3 — schema-conflict warning on REST create branch
|
|
3212
|
+
// (mirror of the MCP test in core.test.ts). Same conflict-detection
|
|
3213
|
+
// path runs on both surfaces via attachValidationStatus, but we pin
|
|
3214
|
+
// both ends explicitly so a regression on either side surfaces
|
|
3215
|
+
// immediately.
|
|
3216
|
+
test("schema-conflict warning surfaces on REST create branch (vault#321 F3)", async () => {
|
|
3217
|
+
await store.upsertTagSchema("kpi-rest-321", {
|
|
3218
|
+
fields: { count: { type: "integer" } },
|
|
3219
|
+
});
|
|
3220
|
+
await store.upsertTagSchema("metric-rest-321", {
|
|
3221
|
+
fields: { count: { type: "string" } }, // conflicting
|
|
3222
|
+
});
|
|
3223
|
+
const res = await handleNotes(
|
|
3224
|
+
mkReq("PATCH", `/notes/${encodeURIComponent("Inbox/conflict-321")}`, {
|
|
3225
|
+
content: "x",
|
|
3226
|
+
if_missing: "create",
|
|
3227
|
+
tags: ["kpi-rest-321", "metric-rest-321"],
|
|
3228
|
+
metadata: { count: 5 },
|
|
3229
|
+
}),
|
|
3230
|
+
store,
|
|
3231
|
+
`/${encodeURIComponent("Inbox/conflict-321")}`,
|
|
3232
|
+
);
|
|
3233
|
+
expect(res.status).toBe(200);
|
|
3234
|
+
const body = await res.json() as any;
|
|
3235
|
+
expect(body.created).toBe(true);
|
|
3236
|
+
const conflict = body.validation_status.warnings.find(
|
|
3237
|
+
(w: any) => w.reason === "schema_conflict",
|
|
3238
|
+
);
|
|
3239
|
+
expect(conflict).toBeDefined();
|
|
3240
|
+
expect(conflict.field).toBe("count");
|
|
3241
|
+
expect(conflict.schema).toBe("kpi-rest-321");
|
|
3242
|
+
expect(conflict.loser_schema).toBe("metric-rest-321");
|
|
3243
|
+
});
|
|
3001
3244
|
});
|
|
3002
3245
|
|
|
3003
3246
|
describe("HTTP /tags", async () => {
|
|
@@ -3192,6 +3435,25 @@ describe("HTTP /find-path", async () => {
|
|
|
3192
3435
|
const res = await handleFindPath(mkReq("GET", "/find-path?source=a"), store);
|
|
3193
3436
|
expect(res.status).toBe(400);
|
|
3194
3437
|
});
|
|
3438
|
+
|
|
3439
|
+
test("returns 409 ambiguous_path when source path is ambiguous (vault#331 N1)", async () => {
|
|
3440
|
+
// Two notes share path "Foo" differing by extension. handleFindPath's
|
|
3441
|
+
// resolveNote(source) would otherwise non-deterministically pick
|
|
3442
|
+
// one; post-#331 it throws AmbiguousPathError and the handler's
|
|
3443
|
+
// catch surfaces a structured 409 (same shape as handleNotes).
|
|
3444
|
+
await store.createNote("# md", { id: "foo-md", path: "Foo" });
|
|
3445
|
+
await store.createNote("a,b\n1,2", { id: "foo-csv", path: "Foo", extension: "csv" });
|
|
3446
|
+
await store.createNote("target", { id: "target" });
|
|
3447
|
+
const res = await handleFindPath(
|
|
3448
|
+
mkReq("GET", "/find-path?source=Foo&target=target"),
|
|
3449
|
+
store,
|
|
3450
|
+
);
|
|
3451
|
+
expect(res.status).toBe(409);
|
|
3452
|
+
const body = await res.json() as any;
|
|
3453
|
+
expect(body.error_type).toBe("ambiguous_path");
|
|
3454
|
+
expect(body.path).toBe("Foo");
|
|
3455
|
+
expect(body.candidates).toHaveLength(2);
|
|
3456
|
+
});
|
|
3195
3457
|
});
|
|
3196
3458
|
|
|
3197
3459
|
describe("stateless MCP transport", async () => {
|