@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/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-note guard + batch cap (#213) — runaway-client protection
2042
+ // Empty content is a valid state (vault#323)
1887
2043
  // -------------------------------------------------------------------------
1888
-
1889
- describe("empty-note guard (#213)", async () => {
1890
- test("POST bare {} body 400 EmptyNoteError", async () => {
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(400);
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 one empty entry400 EmptyNoteError, NOTHING created (atomic)", async () => {
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(400);
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, no warning log", async () => {
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 would clear both content and path → 400 EmptyNoteError", async () => {
1939
- const note = await store.createNote("starts with content", { id: "ep1" });
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(400);
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
- const note = await store.createNote("body", { id: "ep2", path: "p2" });
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
- // The empty-note pre-walk catches `{}` before any DB write (#213); a
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 () => {