@openparachute/vault 0.4.4-rc.14 → 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.
@@ -31,8 +31,12 @@ import {
31
31
  noteToPortable,
32
32
  parseFrontmatter,
33
33
  portableExportFilePath,
34
+ probeCaseSensitive,
34
35
  SIDECAR_DIR,
36
+ NOTES_META_DIR,
37
+ supportsInlineFrontmatter,
35
38
  toPortableMarkdown,
39
+ toSidecarYaml,
36
40
  type PortableNote,
37
41
  } from "./portable-md.js";
38
42
 
@@ -294,6 +298,25 @@ describe("portableExportFilePath", () => {
294
298
  id: "01HABC", content: "", created_at: "2026-05-12T00:00:00.000Z",
295
299
  })).toBe("_unpathed/01HABC.md");
296
300
  });
301
+
302
+ it("honors the note's extension for pathless notes (vault#329 F4)", () => {
303
+ expect(portableExportFilePath({
304
+ id: "01HXCSV",
305
+ content: "a,b\n1,2",
306
+ created_at: "2026-05-12T00:00:00.000Z",
307
+ extension: "csv",
308
+ })).toBe("_unpathed/01HXCSV.csv");
309
+ });
310
+
311
+ it("honors the note's extension for pathed notes (vault#329 F4)", () => {
312
+ expect(portableExportFilePath({
313
+ id: "x",
314
+ content: "",
315
+ created_at: "2026-05-12T00:00:00.000Z",
316
+ path: "Inbox/y",
317
+ extension: "mdx",
318
+ })).toBe("Inbox/y.mdx");
319
+ });
297
320
  });
298
321
 
299
322
  // ---------------------------------------------------------------------------
@@ -1021,3 +1044,511 @@ note body
1021
1044
  expect(existsSync(wouldBeEscape)).toBe(false);
1022
1045
  });
1023
1046
  });
1047
+
1048
+ // ---------------------------------------------------------------------------
1049
+ // File-extension support — non-markdown notes (vault#328)
1050
+ // ---------------------------------------------------------------------------
1051
+ //
1052
+ // Two pinned behaviors per the design issue:
1053
+ // 1. supportsInlineFrontmatter: md + mdx return true; everything else
1054
+ // returns false.
1055
+ // 2. Round-trip integration: vault containing csv/yaml/json/mdx/empty
1056
+ // notes survives export → blow-away import → re-export byte-equiv.
1057
+
1058
+ describe("supportsInlineFrontmatter (vault#328)", () => {
1059
+ it("returns true for md and mdx", () => {
1060
+ expect(supportsInlineFrontmatter("md")).toBe(true);
1061
+ expect(supportsInlineFrontmatter("mdx")).toBe(true);
1062
+ // Case-insensitive — guard against caller-supplied 'MD'.
1063
+ expect(supportsInlineFrontmatter("MD")).toBe(true);
1064
+ });
1065
+
1066
+ it("returns false for sidecar-required formats", () => {
1067
+ expect(supportsInlineFrontmatter("csv")).toBe(false);
1068
+ expect(supportsInlineFrontmatter("yaml")).toBe(false);
1069
+ expect(supportsInlineFrontmatter("json")).toBe(false);
1070
+ expect(supportsInlineFrontmatter("txt")).toBe(false);
1071
+ expect(supportsInlineFrontmatter("org")).toBe(false); // not yet in the set
1072
+ });
1073
+ });
1074
+
1075
+ describe("toPortableMarkdown — extension awareness (vault#328)", () => {
1076
+ it(".md note still gets inline frontmatter", () => {
1077
+ const out = toPortableMarkdown({
1078
+ id: "1",
1079
+ path: "Inbox/a",
1080
+ extension: "md",
1081
+ content: "hello\n",
1082
+ created_at: "2026-05-15T00:00:00.000Z",
1083
+ });
1084
+ expect(out.startsWith("---\n")).toBe(true);
1085
+ expect(out).toContain("id: '1'");
1086
+ expect(out).toContain("hello");
1087
+ });
1088
+
1089
+ it(".mdx note also gets inline frontmatter", () => {
1090
+ const out = toPortableMarkdown({
1091
+ id: "1",
1092
+ path: "Components/Card",
1093
+ extension: "mdx",
1094
+ content: "import X from './x';\n\n<X/>\n",
1095
+ created_at: "2026-05-15T00:00:00.000Z",
1096
+ });
1097
+ expect(out.startsWith("---\n")).toBe(true);
1098
+ expect(out).toContain("extension: mdx");
1099
+ });
1100
+
1101
+ it(".csv note returns raw content — no frontmatter prepend", () => {
1102
+ const out = toPortableMarkdown({
1103
+ id: "1",
1104
+ path: "Tabular/budget",
1105
+ extension: "csv",
1106
+ content: "month,total\n2026-01,9000\n",
1107
+ created_at: "2026-05-15T00:00:00.000Z",
1108
+ });
1109
+ expect(out.startsWith("---\n")).toBe(false);
1110
+ expect(out).toBe("month,total\n2026-01,9000\n");
1111
+ });
1112
+
1113
+ it("toSidecarYaml emits the same key set as inline frontmatter, always includes extension", () => {
1114
+ const sidecar = toSidecarYaml({
1115
+ id: "abc",
1116
+ path: "Tabular/budget",
1117
+ extension: "csv",
1118
+ tags: ["budget"],
1119
+ content: "irrelevant — sidecar carries metadata only",
1120
+ metadata: { fiscal_year: 2026 },
1121
+ created_at: "2026-05-15T00:00:00.000Z",
1122
+ updated_at: "2026-05-15T00:00:00.000Z",
1123
+ });
1124
+ expect(sidecar).toContain("id: abc");
1125
+ expect(sidecar).toContain("path: Tabular/budget");
1126
+ expect(sidecar).toContain("extension: csv");
1127
+ expect(sidecar).toContain("tags:");
1128
+ expect(sidecar).toContain("budget");
1129
+ expect(sidecar).toContain("fiscal_year");
1130
+ });
1131
+ });
1132
+
1133
+ describe("portable-md non-markdown round-trip (vault#328)", async () => {
1134
+ const tmpBase = join(tmpdir(), "parachute-portable-ext");
1135
+ let store: SqliteStore;
1136
+
1137
+ beforeEach(() => {
1138
+ try { rmSync(tmpBase, { recursive: true }); } catch {}
1139
+ mkdirSync(tmpBase, { recursive: true });
1140
+ store = new SqliteStore(new Database(":memory:"));
1141
+ });
1142
+
1143
+ it("csv/yaml/json/mdx/empty notes survive export → blow-away import → byte-equivalent re-export", async () => {
1144
+ // Seed a vault that hits every extension axis the brief calls out:
1145
+ // - .csv (sidecar-required, non-empty)
1146
+ // - .yaml (sidecar-required, non-empty)
1147
+ // - .json (sidecar-required, non-empty)
1148
+ // - .mdx (frontmatter-compatible, non-empty)
1149
+ // - .csv empty-content (the vault#323 edge case, vault#328 sidecar path)
1150
+ // - .md (back-compat baseline — same shape as pre-vault#328)
1151
+ await store.createNote("month,total\n2026-01,9000\n", {
1152
+ id: "csv-1",
1153
+ path: "Tabular/budget",
1154
+ extension: "csv",
1155
+ tags: ["budget"],
1156
+ metadata: { fiscal_year: 2026, currency: "USD" },
1157
+ });
1158
+ await store.createNote("- one\n- two\n", {
1159
+ id: "yaml-1",
1160
+ path: "Config/options",
1161
+ extension: "yaml",
1162
+ });
1163
+ await store.createNote(`{"k":1}\n`, {
1164
+ id: "json-1",
1165
+ path: "Data/sample",
1166
+ extension: "json",
1167
+ });
1168
+ await store.createNote("import X from './x';\n\n<X/>\n", {
1169
+ id: "mdx-1",
1170
+ path: "Components/Card",
1171
+ extension: "mdx",
1172
+ tags: ["component"],
1173
+ });
1174
+ await store.createNote("", {
1175
+ id: "csv-empty",
1176
+ path: "Tabular/skeleton",
1177
+ extension: "csv",
1178
+ });
1179
+ await store.createNote("plain markdown body\n", {
1180
+ id: "md-1",
1181
+ path: "Inbox/note",
1182
+ tags: ["inbox"],
1183
+ });
1184
+
1185
+ // Export A.
1186
+ const outA = join(tmpBase, "outA");
1187
+ const statsA = await exportVaultToDir(store, {
1188
+ outDir: outA,
1189
+ vaultName: "test",
1190
+ exportedAt: "2026-05-15T00:00:00.000Z",
1191
+ });
1192
+ expect(statsA.notes).toBe(6);
1193
+ // Four sidecar-required notes (csv ×2, yaml, json) → four sidecars.
1194
+ // md + mdx carry frontmatter inline so they don't get sidecars.
1195
+ expect(statsA.sidecars).toBe(4);
1196
+
1197
+ // Sidecar files exist with the right basenames (note ids).
1198
+ const sidecarDir = join(outA, SIDECAR_DIR, NOTES_META_DIR);
1199
+ expect(readdirSync(sidecarDir).sort()).toEqual([
1200
+ "csv-1.yaml",
1201
+ "csv-empty.yaml",
1202
+ "json-1.yaml",
1203
+ "yaml-1.yaml",
1204
+ ]);
1205
+
1206
+ // Content files at the user-visible paths with the right suffixes.
1207
+ expect(existsSync(join(outA, "Tabular/budget.csv"))).toBe(true);
1208
+ expect(existsSync(join(outA, "Config/options.yaml"))).toBe(true);
1209
+ expect(existsSync(join(outA, "Data/sample.json"))).toBe(true);
1210
+ expect(existsSync(join(outA, "Components/Card.mdx"))).toBe(true);
1211
+ expect(existsSync(join(outA, "Tabular/skeleton.csv"))).toBe(true);
1212
+ expect(existsSync(join(outA, "Inbox/note.md"))).toBe(true);
1213
+
1214
+ // .csv content is RAW — no `---` frontmatter prepend.
1215
+ const csvFile = readFileSync(join(outA, "Tabular/budget.csv"), "utf-8");
1216
+ expect(csvFile.startsWith("---")).toBe(false);
1217
+ expect(csvFile).toBe("month,total\n2026-01,9000\n");
1218
+
1219
+ // .mdx content has inline frontmatter (same as .md).
1220
+ const mdxFile = readFileSync(join(outA, "Components/Card.mdx"), "utf-8");
1221
+ expect(mdxFile.startsWith("---\n")).toBe(true);
1222
+ expect(mdxFile).toContain("extension: mdx");
1223
+
1224
+ // Blow-away import into a fresh store.
1225
+ const restored = new SqliteStore(new Database(":memory:"));
1226
+ const importStats = await importPortableVault(restored, { inDir: outA, blowAway: true });
1227
+ expect(importStats.notes_created).toBe(6);
1228
+
1229
+ // Verify each note round-tripped its content + extension.
1230
+ const csvRestored = await restored.getNote("csv-1");
1231
+ expect(csvRestored).not.toBeNull();
1232
+ expect(csvRestored!.content).toBe("month,total\n2026-01,9000\n");
1233
+ expect(csvRestored!.extension).toBe("csv");
1234
+ expect(csvRestored!.metadata).toEqual({ fiscal_year: 2026, currency: "USD" });
1235
+ expect(csvRestored!.tags).toContain("budget");
1236
+
1237
+ const emptyCsv = await restored.getNote("csv-empty");
1238
+ expect(emptyCsv!.content).toBe("");
1239
+ expect(emptyCsv!.extension).toBe("csv");
1240
+ expect(emptyCsv!.path).toBe("Tabular/skeleton");
1241
+
1242
+ const mdxRestored = await restored.getNote("mdx-1");
1243
+ expect(mdxRestored!.extension).toBe("mdx");
1244
+ expect(mdxRestored!.content).toBe("import X from './x';\n\n<X/>\n");
1245
+
1246
+ const mdRestored = await restored.getNote("md-1");
1247
+ expect(mdRestored!.extension).toBe("md");
1248
+ expect(mdRestored!.tags).toContain("inbox");
1249
+
1250
+ // Re-export B from the restored store.
1251
+ const outB = join(tmpBase, "outB");
1252
+ await exportVaultToDir(restored, {
1253
+ outDir: outB,
1254
+ vaultName: "test",
1255
+ exportedAt: "2026-05-15T00:00:00.000Z",
1256
+ });
1257
+
1258
+ // Byte-equivalence — every file in outA matches outB.
1259
+ const compareTree = (a: string, b: string, prefix = "") => {
1260
+ const aEntries = readdirSync(a).sort();
1261
+ const bEntries = readdirSync(b).sort();
1262
+ expect(bEntries).toEqual(aEntries);
1263
+ for (const entry of aEntries) {
1264
+ const aPath = join(a, entry);
1265
+ const bPath = join(b, entry);
1266
+ const aStat = statSync(aPath);
1267
+ const bStat = statSync(bPath);
1268
+ expect(bStat.isDirectory()).toBe(aStat.isDirectory());
1269
+ if (aStat.isDirectory()) {
1270
+ compareTree(aPath, bPath, prefix + entry + "/");
1271
+ } else {
1272
+ const aBuf = readFileSync(aPath, "utf-8");
1273
+ const bBuf = readFileSync(bPath, "utf-8");
1274
+ if (aBuf !== bBuf) {
1275
+ // eslint-disable-next-line no-console
1276
+ console.error(`drift at ${prefix}${entry}:\n--- outA ---\n${aBuf}\n--- outB ---\n${bBuf}`);
1277
+ }
1278
+ expect(bBuf).toBe(aBuf);
1279
+ }
1280
+ }
1281
+ };
1282
+ compareTree(outA, outB);
1283
+ });
1284
+
1285
+ it("records orphan sidecars in ImportStats.skipped_sidecars (vault#330 S2)", async () => {
1286
+ // Build a portable-md export by hand with an orphan sidecar: a
1287
+ // sidecar YAML at .parachute/notes-meta/<id>.yaml whose
1288
+ // (path, extension) doesn't point to any real content file on
1289
+ // disk. Import should record it in skipped_sidecars without
1290
+ // crashing.
1291
+ const outDir = join(tmpBase, "orphan-sidecar");
1292
+ mkdirSync(join(outDir, SIDECAR_DIR, NOTES_META_DIR), { recursive: true });
1293
+ writeFileSync(
1294
+ join(outDir, SIDECAR_DIR, "vault.yaml"),
1295
+ "export_format_version: 1\nexported_at: '2026-05-15T00:00:00.000Z'\n",
1296
+ );
1297
+ writeFileSync(
1298
+ join(outDir, SIDECAR_DIR, NOTES_META_DIR, "orphan-1.yaml"),
1299
+ "id: orphan-1\npath: Tabular/missing\nextension: csv\ncreated_at: '2026-05-15T00:00:00.000Z'\nupdated_at: '2026-05-15T00:00:00.000Z'\n",
1300
+ );
1301
+
1302
+ const restored = new SqliteStore(new Database(":memory:"));
1303
+ const stats = await importPortableVault(restored, { inDir: outDir });
1304
+ expect(stats.notes_created).toBe(0);
1305
+ expect(stats.skipped_sidecars).toHaveLength(1);
1306
+ expect(stats.skipped_sidecars[0]!.sidecar_id).toBe("orphan-1");
1307
+ expect(stats.skipped_sidecars[0]!.expected_path).toBe("Tabular/missing");
1308
+ expect(stats.skipped_sidecars[0]!.expected_extension).toBe("csv");
1309
+ expect(stats.skipped_sidecars[0]!.reason).toMatch(/no content file/);
1310
+ });
1311
+
1312
+ it("skipped_sidecars stays empty on a clean export-then-import", async () => {
1313
+ // Sanity pin: when every sidecar pairs with a content file, the
1314
+ // leftover-drain produces no entries.
1315
+ await store.createNote("month,total\n2026-01,9000", {
1316
+ id: "csv-1",
1317
+ path: "Tabular/budget",
1318
+ extension: "csv",
1319
+ });
1320
+ const outDir = join(tmpBase, "clean-sidecars");
1321
+ await exportVaultToDir(store, {
1322
+ outDir,
1323
+ vaultName: "test",
1324
+ exportedAt: "2026-05-15T00:00:00.000Z",
1325
+ });
1326
+ const restored = new SqliteStore(new Database(":memory:"));
1327
+ const stats = await importPortableVault(restored, { inDir: outDir, blowAway: true });
1328
+ expect(stats.skipped_sidecars).toEqual([]);
1329
+ });
1330
+
1331
+ it("import refuses content files lacking a sidecar (orphaned non-md file)", async () => {
1332
+ // Build a minimal portable-md directory by hand: a valid vault.yaml
1333
+ // + a .csv content file with NO matching sidecar. The importer
1334
+ // should skip the orphaned file rather than crashing or creating
1335
+ // a sidecar-less note.
1336
+ const outDir = join(tmpBase, "orphan");
1337
+ mkdirSync(join(outDir, SIDECAR_DIR), { recursive: true });
1338
+ writeFileSync(
1339
+ join(outDir, SIDECAR_DIR, "vault.yaml"),
1340
+ "export_format_version: 1\nexported_at: '2026-05-15T00:00:00.000Z'\n",
1341
+ );
1342
+ mkdirSync(join(outDir, "Tabular"), { recursive: true });
1343
+ writeFileSync(join(outDir, "Tabular/orphan.csv"), "a,b\n1,2\n");
1344
+
1345
+ const restored = new SqliteStore(new Database(":memory:"));
1346
+ const stats = await importPortableVault(restored, { inDir: outDir });
1347
+ // No sidecar → no DB row.
1348
+ expect(stats.notes_created).toBe(0);
1349
+ });
1350
+ });
1351
+
1352
+ // ---------------------------------------------------------------------------
1353
+ // Wikilink ambiguity policy (vault#328)
1354
+ // ---------------------------------------------------------------------------
1355
+ //
1356
+ // When two notes share a path differing only by extension (e.g. `Foo.md`
1357
+ // and `Foo.csv`), `[[Foo]]` is ambiguous. The resolver must:
1358
+ // - refuse to resolve the bare form and record it as unresolved
1359
+ // - resolve `[[Foo.md]]` and `[[Foo.csv]]` to their respective notes
1360
+
1361
+ describe("wikilink ambiguity across extensions (vault#328)", async () => {
1362
+ it("refuses ambiguous bare-form wikilinks when path collides on extension", async () => {
1363
+ const store = new SqliteStore(new Database(":memory:"));
1364
+ const md = await store.createNote("# MD note", { path: "Foo", id: "foo-md" });
1365
+ const csv = await store.createNote("a,b\n1,2", { path: "Foo", extension: "csv", id: "foo-csv" });
1366
+ // Sanity — both rows landed under the composite uniqueness key.
1367
+ expect(md.path).toBe("Foo");
1368
+ expect(csv.path).toBe("Foo");
1369
+
1370
+ // A third note linking to bare `[[Foo]]` should be UNRESOLVED.
1371
+ await store.createNote("see [[Foo]]", { id: "linker", path: "Linker" });
1372
+ const outboundLinks = await store.getLinks("linker", { direction: "outbound" });
1373
+ expect(outboundLinks).toHaveLength(0);
1374
+ });
1375
+
1376
+ it("resolves explicit-extension wikilinks unambiguously", async () => {
1377
+ const store = new SqliteStore(new Database(":memory:"));
1378
+ await store.createNote("# MD note", { path: "Foo", id: "foo-md" });
1379
+ await store.createNote("a,b\n1,2", { path: "Foo", extension: "csv", id: "foo-csv" });
1380
+
1381
+ await store.createNote("see [[Foo.csv]]", { id: "linker", path: "Linker" });
1382
+ const outbound = await store.getLinks("linker", { direction: "outbound" });
1383
+ expect(outbound).toHaveLength(1);
1384
+ expect(outbound[0]!.targetId).toBe("foo-csv");
1385
+ });
1386
+ });
1387
+
1388
+ // ---------------------------------------------------------------------------
1389
+ // Case-collision detection + auto-disambiguation (vault#327)
1390
+ // ---------------------------------------------------------------------------
1391
+ //
1392
+ // macOS APFS-default + Windows NTFS-default + FAT/exFAT are case-
1393
+ // insensitive — two notes whose paths differ only by case collapse
1394
+ // into one file on naive export. The fix probes the filesystem then
1395
+ // either ships as-is (case-sensitive) or disambiguates with an
1396
+ // `__<id-short>` filename suffix (case-insensitive). The note's
1397
+ // stored `path` stays canonical; only the on-disk filename is suffixed.
1398
+
1399
+ describe("case-collision detection (vault#327)", async () => {
1400
+ const tmpBase = join(tmpdir(), "parachute-portable-case");
1401
+ let store: SqliteStore;
1402
+
1403
+ beforeEach(() => {
1404
+ try { rmSync(tmpBase, { recursive: true }); } catch {}
1405
+ mkdirSync(tmpBase, { recursive: true });
1406
+ store = new SqliteStore(new Database(":memory:"));
1407
+ });
1408
+
1409
+ it("probeCaseSensitive runs cleanly and returns a boolean", () => {
1410
+ // Documenting behavior rather than asserting a specific value:
1411
+ // macOS dev environments + most Linux CI runners produce different
1412
+ // results. We just pin that the probe is well-formed (doesn't throw,
1413
+ // returns boolean, cleans up its tempfile).
1414
+ const result = probeCaseSensitive(tmpBase);
1415
+ expect(typeof result).toBe("boolean");
1416
+ // After the probe runs the dir should contain no leftover probe files.
1417
+ const leftovers = readdirSync(tmpBase).filter((e) => e.includes("_parachute_cs_probe_"));
1418
+ expect(leftovers).toEqual([]);
1419
+ });
1420
+
1421
+ it("ships as-is when FS is case-sensitive (override=true)", async () => {
1422
+ // Two notes with case-only-differing paths. With the case-sensitive
1423
+ // override, both files land at their canonical paths — no
1424
+ // disambiguation, no collisions in stats.
1425
+ await store.createNote("# in Balance", {
1426
+ id: "2025-05-26-09-15-42-aaaaaa",
1427
+ path: "Journal/2025-05-26 Technology in Balance",
1428
+ });
1429
+ await store.createNote("# in balance", {
1430
+ id: "2025-05-26-09-15-42-bbbbbb",
1431
+ path: "Journal/2025-05-26 Technology in balance",
1432
+ });
1433
+
1434
+ const outDir = join(tmpBase, "cs-on");
1435
+ const stats = await exportVaultToDir(store, {
1436
+ outDir,
1437
+ vaultName: "test",
1438
+ exportedAt: "2026-05-15T00:00:00.000Z",
1439
+ caseSensitiveOverride: true,
1440
+ });
1441
+ expect(stats.notes).toBe(2);
1442
+ expect(stats.case_insensitive_fs).toBe(false);
1443
+ expect(stats.disambiguated_paths).toHaveLength(0);
1444
+ });
1445
+
1446
+ it("disambiguates colliding paths on case-insensitive FS (override=false)", async () => {
1447
+ // Same fixture as above; force the case-insensitive code path. The
1448
+ // second note (deterministic order: queryNotes sorts ASC on
1449
+ // created_at — the IDs sort lexicographically so 'aaaaaa' lands
1450
+ // first, 'bbbbbb' second) gets its filename suffixed.
1451
+ await store.createNote("# in Balance", {
1452
+ id: "2025-05-26-09-15-42-aaaaaa",
1453
+ path: "Journal/2025-05-26 Technology in Balance",
1454
+ });
1455
+ await store.createNote("# in balance", {
1456
+ id: "2025-05-26-09-15-42-bbbbbb",
1457
+ path: "Journal/2025-05-26 Technology in balance",
1458
+ });
1459
+
1460
+ const outDir = join(tmpBase, "ci-on");
1461
+ const stats = await exportVaultToDir(store, {
1462
+ outDir,
1463
+ vaultName: "test",
1464
+ exportedAt: "2026-05-15T00:00:00.000Z",
1465
+ caseSensitiveOverride: false,
1466
+ });
1467
+ expect(stats.notes).toBe(2);
1468
+ expect(stats.case_insensitive_fs).toBe(true);
1469
+ expect(stats.disambiguated_paths).toHaveLength(1);
1470
+ expect(stats.disambiguated_paths[0]!.note_id).toBe("2025-05-26-09-15-42-bbbbbb");
1471
+ expect(stats.disambiguated_paths[0]!.disambiguated_filename).toMatch(/__2025-05-/);
1472
+
1473
+ // Both files exist on disk under their respective filenames.
1474
+ expect(existsSync(join(outDir, "Journal/2025-05-26 Technology in Balance.md"))).toBe(true);
1475
+ expect(existsSync(join(outDir, stats.disambiguated_paths[0]!.disambiguated_filename))).toBe(true);
1476
+
1477
+ // The disambiguated file's inline frontmatter still carries the
1478
+ // canonical (original) path, NOT the disambiguated filename — that
1479
+ // way an import on a case-sensitive filesystem (or a future re-
1480
+ // export on a case-sensitive FS) recovers the truth.
1481
+ const disambigFile = readFileSync(
1482
+ join(outDir, stats.disambiguated_paths[0]!.disambiguated_filename),
1483
+ "utf-8",
1484
+ );
1485
+ expect(disambigFile).toContain("path: Journal/2025-05-26 Technology in balance");
1486
+ });
1487
+
1488
+ it("disambiguated content round-trips through import (md inline frontmatter path)", async () => {
1489
+ await store.createNote("# upper", {
1490
+ id: "2025-05-26-09-15-42-aaaaaa",
1491
+ path: "Journal/Same-Case Different",
1492
+ });
1493
+ await store.createNote("# lower", {
1494
+ id: "2025-05-26-09-15-42-bbbbbb",
1495
+ path: "Journal/same-case different",
1496
+ });
1497
+
1498
+ const outDir = join(tmpBase, "rt-md");
1499
+ const stats = await exportVaultToDir(store, {
1500
+ outDir,
1501
+ vaultName: "test",
1502
+ exportedAt: "2026-05-15T00:00:00.000Z",
1503
+ caseSensitiveOverride: false,
1504
+ });
1505
+ expect(stats.disambiguated_paths).toHaveLength(1);
1506
+
1507
+ const restored = new SqliteStore(new Database(":memory:"));
1508
+ const importStats = await importPortableVault(restored, { inDir: outDir, blowAway: true });
1509
+ expect(importStats.notes_created).toBe(2);
1510
+
1511
+ // Both notes recovered their canonical paths from the frontmatter.
1512
+ const upper = await restored.getNote("2025-05-26-09-15-42-aaaaaa");
1513
+ const lower = await restored.getNote("2025-05-26-09-15-42-bbbbbb");
1514
+ expect(upper!.path).toBe("Journal/Same-Case Different");
1515
+ expect(lower!.path).toBe("Journal/same-case different");
1516
+ });
1517
+
1518
+ it("disambiguated sidecar-required content round-trips (csv with sidecar lookup fallback)", async () => {
1519
+ // The disambig fallback in the import loop kicks in: the sidecar
1520
+ // is keyed by canonical (path, ext) so the walker's
1521
+ // `<base>__<id8>.csv` filename doesn't match the index — but the
1522
+ // id-prefix lookup recovers the sidecar.
1523
+ await store.createNote("month,total\n2026-01,9000", {
1524
+ id: "2025-05-26-09-15-42-aaaaaa",
1525
+ path: "Tabular/Budget-2026",
1526
+ extension: "csv",
1527
+ });
1528
+ await store.createNote("month,total\n2026-01,1", {
1529
+ id: "2025-05-26-09-15-42-bbbbbb",
1530
+ path: "Tabular/budget-2026",
1531
+ extension: "csv",
1532
+ });
1533
+
1534
+ const outDir = join(tmpBase, "rt-csv");
1535
+ const stats = await exportVaultToDir(store, {
1536
+ outDir,
1537
+ vaultName: "test",
1538
+ exportedAt: "2026-05-15T00:00:00.000Z",
1539
+ caseSensitiveOverride: false,
1540
+ });
1541
+ expect(stats.disambiguated_paths).toHaveLength(1);
1542
+
1543
+ const restored = new SqliteStore(new Database(":memory:"));
1544
+ const importStats = await importPortableVault(restored, { inDir: outDir, blowAway: true });
1545
+ expect(importStats.notes_created).toBe(2);
1546
+
1547
+ const upper = await restored.getNote("2025-05-26-09-15-42-aaaaaa");
1548
+ const lower = await restored.getNote("2025-05-26-09-15-42-bbbbbb");
1549
+ expect(upper!.path).toBe("Tabular/Budget-2026");
1550
+ expect(upper!.content).toBe("month,total\n2026-01,9000");
1551
+ expect(lower!.path).toBe("Tabular/budget-2026");
1552
+ expect(lower!.content).toBe("month,total\n2026-01,1");
1553
+ });
1554
+ });