@openparachute/vault 0.4.6 → 0.4.7-rc.2
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/portable-md.test.ts +247 -0
- package/core/src/portable-md.ts +118 -1
- package/package.json +1 -1
- package/src/cli.ts +94 -2
- package/src/config.ts +24 -0
- package/src/export-watch.test.ts +99 -0
- package/src/mirror-config.test.ts +328 -0
- package/src/mirror-config.ts +470 -0
- package/src/mirror-deps.ts +88 -0
- package/src/mirror-manager.test.ts +550 -0
- package/src/mirror-manager.ts +521 -0
- package/src/mirror-registry.ts +26 -0
- package/src/mirror-routes.test.ts +380 -0
- package/src/mirror-routes.ts +152 -0
- package/src/routing.test.ts +76 -0
- package/src/routing.ts +46 -0
- package/src/server.ts +52 -0
|
@@ -25,6 +25,7 @@ import { tmpdir } from "os";
|
|
|
25
25
|
|
|
26
26
|
import { SqliteStore } from "./store.js";
|
|
27
27
|
import {
|
|
28
|
+
CaseCollisionError,
|
|
28
29
|
emitYamlDoc,
|
|
29
30
|
exportVaultToDir,
|
|
30
31
|
importPortableVault,
|
|
@@ -1551,4 +1552,250 @@ describe("case-collision detection (vault#327)", async () => {
|
|
|
1551
1552
|
expect(lower!.path).toBe("Tabular/budget-2026");
|
|
1552
1553
|
expect(lower!.content).toBe("month,total\n2026-01,1");
|
|
1553
1554
|
});
|
|
1555
|
+
|
|
1556
|
+
// ---------------------------------------------------------------------------
|
|
1557
|
+
// failOnCaseCollision — strict mode (vault#327 Phase 2)
|
|
1558
|
+
// ---------------------------------------------------------------------------
|
|
1559
|
+
//
|
|
1560
|
+
// The default behavior (auto-disambiguate) is lossless but silent on the
|
|
1561
|
+
// wire — the CLI didn't surface it before #vault-rc.2. Strict mode is the
|
|
1562
|
+
// opt-in fail-fast path: throws `CaseCollisionError` with every colliding
|
|
1563
|
+
// path enumerated, so the operator can rename one of each pair in the
|
|
1564
|
+
// vault before re-exporting.
|
|
1565
|
+
|
|
1566
|
+
it("failOnCaseCollision throws CaseCollisionError on case-insensitive FS", async () => {
|
|
1567
|
+
await store.createNote("# in Balance", {
|
|
1568
|
+
id: "2025-05-26-09-15-42-aaaaaa",
|
|
1569
|
+
path: "Journal/2025-05-26 Technology in Balance",
|
|
1570
|
+
});
|
|
1571
|
+
await store.createNote("# in balance", {
|
|
1572
|
+
id: "2025-05-26-09-15-42-bbbbbb",
|
|
1573
|
+
path: "Journal/2025-05-26 Technology in balance",
|
|
1574
|
+
});
|
|
1575
|
+
|
|
1576
|
+
const outDir = join(tmpBase, "strict-throw");
|
|
1577
|
+
let thrown: unknown;
|
|
1578
|
+
try {
|
|
1579
|
+
await exportVaultToDir(store, {
|
|
1580
|
+
outDir,
|
|
1581
|
+
vaultName: "test",
|
|
1582
|
+
exportedAt: "2026-05-15T00:00:00.000Z",
|
|
1583
|
+
caseSensitiveOverride: false,
|
|
1584
|
+
failOnCaseCollision: true,
|
|
1585
|
+
});
|
|
1586
|
+
} catch (err) {
|
|
1587
|
+
thrown = err;
|
|
1588
|
+
}
|
|
1589
|
+
expect(thrown).toBeInstanceOf(CaseCollisionError);
|
|
1590
|
+
const err = thrown as CaseCollisionError;
|
|
1591
|
+
expect(err.collisions).toHaveLength(1);
|
|
1592
|
+
expect(err.collisions[0]).toHaveLength(2);
|
|
1593
|
+
const ids = err.collisions[0]!.map((c) => c.note_id).sort();
|
|
1594
|
+
expect(ids).toEqual(["2025-05-26-09-15-42-aaaaaa", "2025-05-26-09-15-42-bbbbbb"]);
|
|
1595
|
+
// Error message names BOTH paths + the actionable instruction.
|
|
1596
|
+
expect(err.message).toContain("Journal/2025-05-26 Technology in Balance");
|
|
1597
|
+
expect(err.message).toContain("Journal/2025-05-26 Technology in balance");
|
|
1598
|
+
expect(err.message).toContain("Rename one of them");
|
|
1599
|
+
// Pre-scan throws BEFORE any per-note file write. The .parachute/
|
|
1600
|
+
// sidecar dir is still created (cheap, idempotent), but no per-note
|
|
1601
|
+
// .md file landed.
|
|
1602
|
+
expect(existsSync(join(outDir, "Journal/2025-05-26 Technology in Balance.md"))).toBe(false);
|
|
1603
|
+
});
|
|
1604
|
+
|
|
1605
|
+
it("failOnCaseCollision is a no-op when FS is case-sensitive", async () => {
|
|
1606
|
+
// Same fixture as above, but force the case-sensitive code path. The
|
|
1607
|
+
// strict flag becomes a no-op — both files land at their canonical
|
|
1608
|
+
// paths, no error.
|
|
1609
|
+
await store.createNote("# in Balance", {
|
|
1610
|
+
id: "2025-05-26-09-15-42-aaaaaa",
|
|
1611
|
+
path: "Journal/2025-05-26 Technology in Balance",
|
|
1612
|
+
});
|
|
1613
|
+
await store.createNote("# in balance", {
|
|
1614
|
+
id: "2025-05-26-09-15-42-bbbbbb",
|
|
1615
|
+
path: "Journal/2025-05-26 Technology in balance",
|
|
1616
|
+
});
|
|
1617
|
+
|
|
1618
|
+
const outDir = join(tmpBase, "strict-cs");
|
|
1619
|
+
const stats = await exportVaultToDir(store, {
|
|
1620
|
+
outDir,
|
|
1621
|
+
vaultName: "test",
|
|
1622
|
+
exportedAt: "2026-05-15T00:00:00.000Z",
|
|
1623
|
+
caseSensitiveOverride: true,
|
|
1624
|
+
failOnCaseCollision: true,
|
|
1625
|
+
});
|
|
1626
|
+
expect(stats.notes).toBe(2);
|
|
1627
|
+
expect(stats.disambiguated_paths).toHaveLength(0);
|
|
1628
|
+
});
|
|
1629
|
+
|
|
1630
|
+
it("failOnCaseCollision is a no-op on case-insensitive FS when nothing collides", async () => {
|
|
1631
|
+
// One note. Strict mode + case-insensitive FS — should not throw,
|
|
1632
|
+
// should ship clean. Pre-scan walks the (single-note) list and
|
|
1633
|
+
// finds no collision groups.
|
|
1634
|
+
await store.createNote("# solo", {
|
|
1635
|
+
id: "2025-05-26-09-15-42-aaaaaa",
|
|
1636
|
+
path: "Journal/Solo Note",
|
|
1637
|
+
});
|
|
1638
|
+
|
|
1639
|
+
const outDir = join(tmpBase, "strict-solo");
|
|
1640
|
+
const stats = await exportVaultToDir(store, {
|
|
1641
|
+
outDir,
|
|
1642
|
+
vaultName: "test",
|
|
1643
|
+
exportedAt: "2026-05-15T00:00:00.000Z",
|
|
1644
|
+
caseSensitiveOverride: false,
|
|
1645
|
+
failOnCaseCollision: true,
|
|
1646
|
+
});
|
|
1647
|
+
expect(stats.notes).toBe(1);
|
|
1648
|
+
expect(stats.disambiguated_paths).toHaveLength(0);
|
|
1649
|
+
expect(existsSync(join(outDir, "Journal/Solo Note.md"))).toBe(true);
|
|
1650
|
+
});
|
|
1651
|
+
|
|
1652
|
+
it("three-way collision lists all three paths in the error", async () => {
|
|
1653
|
+
// Foo.md + foo.md + FOO.md — all share the same lowercased
|
|
1654
|
+
// `(path, ext)` slot. CaseCollisionError.collisions[0] should
|
|
1655
|
+
// include every one of them so the operator sees the full set in
|
|
1656
|
+
// a single error report, not a paint-by-numbers re-export cycle.
|
|
1657
|
+
await store.createNote("# upper-camel", {
|
|
1658
|
+
id: "2025-05-26-09-15-42-aaaaaa",
|
|
1659
|
+
path: "Journal/Foo",
|
|
1660
|
+
});
|
|
1661
|
+
await store.createNote("# lower", {
|
|
1662
|
+
id: "2025-05-26-09-15-42-bbbbbb",
|
|
1663
|
+
path: "Journal/foo",
|
|
1664
|
+
});
|
|
1665
|
+
await store.createNote("# all-caps", {
|
|
1666
|
+
id: "2025-05-26-09-15-42-cccccc",
|
|
1667
|
+
path: "Journal/FOO",
|
|
1668
|
+
});
|
|
1669
|
+
|
|
1670
|
+
const outDir = join(tmpBase, "strict-3way");
|
|
1671
|
+
let thrown: unknown;
|
|
1672
|
+
try {
|
|
1673
|
+
await exportVaultToDir(store, {
|
|
1674
|
+
outDir,
|
|
1675
|
+
vaultName: "test",
|
|
1676
|
+
exportedAt: "2026-05-15T00:00:00.000Z",
|
|
1677
|
+
caseSensitiveOverride: false,
|
|
1678
|
+
failOnCaseCollision: true,
|
|
1679
|
+
});
|
|
1680
|
+
} catch (err) {
|
|
1681
|
+
thrown = err;
|
|
1682
|
+
}
|
|
1683
|
+
expect(thrown).toBeInstanceOf(CaseCollisionError);
|
|
1684
|
+
const err = thrown as CaseCollisionError;
|
|
1685
|
+
expect(err.collisions).toHaveLength(1);
|
|
1686
|
+
expect(err.collisions[0]).toHaveLength(3);
|
|
1687
|
+
const paths = err.collisions[0]!.map((c) => c.path).sort();
|
|
1688
|
+
expect(paths).toEqual(["Journal/FOO", "Journal/Foo", "Journal/foo"]);
|
|
1689
|
+
expect(err.message).toContain("Journal/FOO");
|
|
1690
|
+
expect(err.message).toContain("Journal/Foo");
|
|
1691
|
+
expect(err.message).toContain("Journal/foo");
|
|
1692
|
+
});
|
|
1693
|
+
|
|
1694
|
+
it("multiple independent collision groups all surface", async () => {
|
|
1695
|
+
// Two distinct collision groups: (Bar.md, bar.md) and (Baz.md,
|
|
1696
|
+
// baz.md). Both groups should appear in the error so the operator
|
|
1697
|
+
// doesn't have to fix-rebuild-fix in a loop. Pairs are independent —
|
|
1698
|
+
// resolving one doesn't reveal the other.
|
|
1699
|
+
await store.createNote("# bar-upper", {
|
|
1700
|
+
id: "2025-05-26-09-15-42-aaaaaa",
|
|
1701
|
+
path: "Bar",
|
|
1702
|
+
});
|
|
1703
|
+
await store.createNote("# bar-lower", {
|
|
1704
|
+
id: "2025-05-26-09-15-42-bbbbbb",
|
|
1705
|
+
path: "bar",
|
|
1706
|
+
});
|
|
1707
|
+
await store.createNote("# baz-upper", {
|
|
1708
|
+
id: "2025-05-26-09-15-42-cccccc",
|
|
1709
|
+
path: "Baz",
|
|
1710
|
+
});
|
|
1711
|
+
await store.createNote("# baz-lower", {
|
|
1712
|
+
id: "2025-05-26-09-15-42-dddddd",
|
|
1713
|
+
path: "baz",
|
|
1714
|
+
});
|
|
1715
|
+
|
|
1716
|
+
const outDir = join(tmpBase, "strict-multi-group");
|
|
1717
|
+
let thrown: unknown;
|
|
1718
|
+
try {
|
|
1719
|
+
await exportVaultToDir(store, {
|
|
1720
|
+
outDir,
|
|
1721
|
+
vaultName: "test",
|
|
1722
|
+
exportedAt: "2026-05-15T00:00:00.000Z",
|
|
1723
|
+
caseSensitiveOverride: false,
|
|
1724
|
+
failOnCaseCollision: true,
|
|
1725
|
+
});
|
|
1726
|
+
} catch (err) {
|
|
1727
|
+
thrown = err;
|
|
1728
|
+
}
|
|
1729
|
+
expect(thrown).toBeInstanceOf(CaseCollisionError);
|
|
1730
|
+
const err = thrown as CaseCollisionError;
|
|
1731
|
+
expect(err.collisions).toHaveLength(2);
|
|
1732
|
+
const allIds = err.collisions.flat().map((c) => c.note_id).sort();
|
|
1733
|
+
expect(allIds).toEqual([
|
|
1734
|
+
"2025-05-26-09-15-42-aaaaaa",
|
|
1735
|
+
"2025-05-26-09-15-42-bbbbbb",
|
|
1736
|
+
"2025-05-26-09-15-42-cccccc",
|
|
1737
|
+
"2025-05-26-09-15-42-dddddd",
|
|
1738
|
+
]);
|
|
1739
|
+
});
|
|
1740
|
+
|
|
1741
|
+
it("directory-level case difference triggers collision detection", async () => {
|
|
1742
|
+
// Two notes at `Notes/foo` and `notes/foo` — the basename matches
|
|
1743
|
+
// but the parent dir differs only by case. On a case-insensitive
|
|
1744
|
+
// FS, both files would land in the same directory because
|
|
1745
|
+
// `Notes/` and `notes/` resolve to the same inode. Verify the
|
|
1746
|
+
// lowercased-path key catches this: `notes/foo.md`.
|
|
1747
|
+
await store.createNote("# notes-upper", {
|
|
1748
|
+
id: "2025-05-26-09-15-42-aaaaaa",
|
|
1749
|
+
path: "Notes/foo",
|
|
1750
|
+
});
|
|
1751
|
+
await store.createNote("# notes-lower", {
|
|
1752
|
+
id: "2025-05-26-09-15-42-bbbbbb",
|
|
1753
|
+
path: "notes/foo",
|
|
1754
|
+
});
|
|
1755
|
+
|
|
1756
|
+
const outDir = join(tmpBase, "strict-dir-case");
|
|
1757
|
+
let thrown: unknown;
|
|
1758
|
+
try {
|
|
1759
|
+
await exportVaultToDir(store, {
|
|
1760
|
+
outDir,
|
|
1761
|
+
vaultName: "test",
|
|
1762
|
+
exportedAt: "2026-05-15T00:00:00.000Z",
|
|
1763
|
+
caseSensitiveOverride: false,
|
|
1764
|
+
failOnCaseCollision: true,
|
|
1765
|
+
});
|
|
1766
|
+
} catch (err) {
|
|
1767
|
+
thrown = err;
|
|
1768
|
+
}
|
|
1769
|
+
expect(thrown).toBeInstanceOf(CaseCollisionError);
|
|
1770
|
+
const err = thrown as CaseCollisionError;
|
|
1771
|
+
expect(err.collisions).toHaveLength(1);
|
|
1772
|
+
expect(err.collisions[0]).toHaveLength(2);
|
|
1773
|
+
});
|
|
1774
|
+
|
|
1775
|
+
it("default (no failOnCaseCollision) still auto-disambiguates — back-compat", async () => {
|
|
1776
|
+
// The new strict mode is opt-in. Leaving failOnCaseCollision unset
|
|
1777
|
+
// (or false) keeps the existing lossless auto-disambiguation path
|
|
1778
|
+
// unchanged. This pins the back-compat contract — watch/mirror
|
|
1779
|
+
// loops that don't opt in to strict mode never see a thrown
|
|
1780
|
+
// CaseCollisionError on a colliding vault.
|
|
1781
|
+
await store.createNote("# upper", {
|
|
1782
|
+
id: "2025-05-26-09-15-42-aaaaaa",
|
|
1783
|
+
path: "Journal/Note",
|
|
1784
|
+
});
|
|
1785
|
+
await store.createNote("# lower", {
|
|
1786
|
+
id: "2025-05-26-09-15-42-bbbbbb",
|
|
1787
|
+
path: "Journal/note",
|
|
1788
|
+
});
|
|
1789
|
+
|
|
1790
|
+
const outDir = join(tmpBase, "default-disambig");
|
|
1791
|
+
const stats = await exportVaultToDir(store, {
|
|
1792
|
+
outDir,
|
|
1793
|
+
vaultName: "test",
|
|
1794
|
+
exportedAt: "2026-05-15T00:00:00.000Z",
|
|
1795
|
+
caseSensitiveOverride: false,
|
|
1796
|
+
// failOnCaseCollision deliberately omitted — default behavior.
|
|
1797
|
+
});
|
|
1798
|
+
expect(stats.notes).toBe(2);
|
|
1799
|
+
expect(stats.disambiguated_paths).toHaveLength(1);
|
|
1800
|
+
});
|
|
1554
1801
|
});
|
package/core/src/portable-md.ts
CHANGED
|
@@ -631,6 +631,78 @@ export interface ExportOptions {
|
|
|
631
631
|
* run on. When unset (the production default), the probe runs.
|
|
632
632
|
*/
|
|
633
633
|
caseSensitiveOverride?: boolean;
|
|
634
|
+
/**
|
|
635
|
+
* Strict mode for case-collision handling on case-insensitive
|
|
636
|
+
* filesystems (vault#327 Phase 2). When `true`, the first detected
|
|
637
|
+
* collision aborts the export by throwing a `CaseCollisionError`
|
|
638
|
+
* naming every colliding path. When `false` (the default), the
|
|
639
|
+
* existing lossless behavior is preserved — the colliding note is
|
|
640
|
+
* written to a disambiguated filename (`<base>__<id-short>.<ext>`)
|
|
641
|
+
* and recorded in `ExportStats.disambiguated_paths`.
|
|
642
|
+
*
|
|
643
|
+
* Use `true` for one-shot CLI flows where the operator wants to be
|
|
644
|
+
* forced to fix the source-of-truth (rename one of the colliding
|
|
645
|
+
* notes in the vault before re-exporting). Leave `false` for
|
|
646
|
+
* long-running watch / mirror loops where a hard failure mid-loop
|
|
647
|
+
* would block the operator's actual work.
|
|
648
|
+
*/
|
|
649
|
+
failOnCaseCollision?: boolean;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Thrown by `exportVaultToDir` when `failOnCaseCollision: true` is set
|
|
654
|
+
* and the export detects two-or-more notes whose paths differ only by
|
|
655
|
+
* case on a case-insensitive filesystem (vault#327).
|
|
656
|
+
*
|
|
657
|
+
* The error names every colliding path (full N-way group, not just
|
|
658
|
+
* the first pair) so the operator can audit the whole set in one
|
|
659
|
+
* pass. Caller catches by type and surfaces `.collisions` for a
|
|
660
|
+
* clean error report:
|
|
661
|
+
*
|
|
662
|
+
* ```ts
|
|
663
|
+
* try {
|
|
664
|
+
* await exportVaultToDir(store, { ..., failOnCaseCollision: true });
|
|
665
|
+
* } catch (err) {
|
|
666
|
+
* if (err instanceof CaseCollisionError) {
|
|
667
|
+
* for (const group of err.collisions) {
|
|
668
|
+
* console.error(`collision: ${group.map((g) => g.path).join(", ")}`);
|
|
669
|
+
* }
|
|
670
|
+
* }
|
|
671
|
+
* }
|
|
672
|
+
* ```
|
|
673
|
+
*/
|
|
674
|
+
export class CaseCollisionError extends Error {
|
|
675
|
+
/**
|
|
676
|
+
* Each entry is one collision group — every note that shares the same
|
|
677
|
+
* lowercased `(path, extension)` slot. Two notes per group is the
|
|
678
|
+
* common case; three-or-more (`Foo.md` + `foo.md` + `FOO.md`) is rare
|
|
679
|
+
* but supported. Notes are listed in the order they were encountered
|
|
680
|
+
* during the export walk (deterministic — `queryNotes` sorts ASC).
|
|
681
|
+
*/
|
|
682
|
+
readonly collisions: Array<Array<{ note_id: string; path: string; extension: string }>>;
|
|
683
|
+
constructor(collisions: CaseCollisionError["collisions"]) {
|
|
684
|
+
const lines: string[] = [
|
|
685
|
+
"Export failed: case-collision detected on case-insensitive filesystem.",
|
|
686
|
+
"The following notes have paths that differ only by case:",
|
|
687
|
+
];
|
|
688
|
+
// Separate distinct collision groups with ` ---` so operators
|
|
689
|
+
// reading the error in a terminal can tell where one (Foo.md /
|
|
690
|
+
// foo.md) pair ends and the next (Bar.md / bar.md) begins. Without
|
|
691
|
+
// a separator, multi-group output runs together as an unbroken
|
|
692
|
+
// bullet list. vault#350.
|
|
693
|
+
collisions.forEach((group, idx) => {
|
|
694
|
+
if (idx > 0) lines.push(" ---");
|
|
695
|
+
for (const entry of group) {
|
|
696
|
+
lines.push(` - ${entry.path}.${entry.extension} (note id: ${entry.note_id})`);
|
|
697
|
+
}
|
|
698
|
+
});
|
|
699
|
+
lines.push(
|
|
700
|
+
"Rename one of them in the vault before re-exporting, or run from a case-sensitive filesystem.",
|
|
701
|
+
);
|
|
702
|
+
super(lines.join("\n"));
|
|
703
|
+
this.name = "CaseCollisionError";
|
|
704
|
+
this.collisions = collisions;
|
|
705
|
+
}
|
|
634
706
|
}
|
|
635
707
|
|
|
636
708
|
/**
|
|
@@ -733,9 +805,54 @@ export async function exportVaultToDir(
|
|
|
733
805
|
// case-insensitive filesystems.
|
|
734
806
|
const seenLowerKeys = new Map<string, string>();
|
|
735
807
|
|
|
808
|
+
// Strict-mode pre-scan (vault#327 Phase 2). When the caller passes
|
|
809
|
+
// `failOnCaseCollision: true`, surface every collision group in one
|
|
810
|
+
// typed error BEFORE any write lands on disk — partial-export-then-
|
|
811
|
+
// throw would leave the operator with a half-mirrored output dir to
|
|
812
|
+
// clean up. The pre-scan walks every note in the vault (NOT
|
|
813
|
+
// since-filtered: a since-filter belongs in the write loop —
|
|
814
|
+
// collisions can involve one old + one new path, and a since-only
|
|
815
|
+
// pre-scan would miss those entirely, silently degrading the strict
|
|
816
|
+
// guarantee on every poll cycle after the initial export). When no
|
|
817
|
+
// collisions exist on a case-insensitive FS, the pre-scan is a
|
|
818
|
+
// no-op (cheap); on a case-sensitive FS it's skipped entirely.
|
|
819
|
+
//
|
|
820
|
+
// Perf: the pre-scan calls `noteToPortable` (3 DB queries per note:
|
|
821
|
+
// links, attachments, content). The main loop below also calls
|
|
822
|
+
// `noteToPortable` — without caching, every note that ALSO passes
|
|
823
|
+
// the since-filter would be serialized twice (~2x the DB round-
|
|
824
|
+
// trips on a large strict-mode export). Stash every pre-scan result
|
|
825
|
+
// in `prescanPortables` and reuse it below; cache-miss falls back
|
|
826
|
+
// to a fresh `noteToPortable` for safety. vault#350.
|
|
827
|
+
const prescanPortables = new Map<string, PortableNote>();
|
|
828
|
+
if (opts.failOnCaseCollision && !caseSensitive) {
|
|
829
|
+
const groups = new Map<string, Array<{ note_id: string; path: string; extension: string }>>();
|
|
830
|
+
for (const note of allNotes) {
|
|
831
|
+
const portable = await noteToPortable(note, store);
|
|
832
|
+
prescanPortables.set(portable.id, portable);
|
|
833
|
+
if (!portable.path) continue; // _unpathed/<id>.<ext> is case-stable
|
|
834
|
+
const ext = portable.extension ?? "md";
|
|
835
|
+
const key = `${portable.path.toLowerCase()}|${ext.toLowerCase()}`;
|
|
836
|
+
const entry = { note_id: portable.id, path: portable.path, extension: ext };
|
|
837
|
+
const existing = groups.get(key);
|
|
838
|
+
if (existing) {
|
|
839
|
+
existing.push(entry);
|
|
840
|
+
} else {
|
|
841
|
+
groups.set(key, [entry]);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
const collisions = Array.from(groups.values()).filter((g) => g.length > 1);
|
|
845
|
+
if (collisions.length > 0) {
|
|
846
|
+
throw new CaseCollisionError(collisions);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
736
850
|
for (const note of allNotes) {
|
|
737
851
|
if (since && !shouldIncludeForSince(note, since)) continue;
|
|
738
|
-
|
|
852
|
+
// Reuse a pre-scan result when strict-mode populated the cache;
|
|
853
|
+
// otherwise serialize fresh. Same PortableNote shape either way,
|
|
854
|
+
// so the rest of the loop is untouched. vault#350.
|
|
855
|
+
const portable = prescanPortables.get(note.id) ?? (await noteToPortable(note, store));
|
|
739
856
|
let relPath = portableExportFilePath(portable);
|
|
740
857
|
|
|
741
858
|
// Decide whether this note's filename needs disambiguation
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -2872,6 +2872,7 @@ async function cmdExport(args: string[]) {
|
|
|
2872
2872
|
const { DEFAULT_COMMIT_TEMPLATE } = await import("./export-watch.ts");
|
|
2873
2873
|
let gitMessageTemplate = DEFAULT_COMMIT_TEMPLATE;
|
|
2874
2874
|
let gitPush = false;
|
|
2875
|
+
let strictCaseCollision = false;
|
|
2875
2876
|
|
|
2876
2877
|
const positional: string[] = [];
|
|
2877
2878
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -2923,6 +2924,8 @@ async function cmdExport(args: string[]) {
|
|
|
2923
2924
|
gitMessageTemplate = v;
|
|
2924
2925
|
} else if (arg === "--git-push") {
|
|
2925
2926
|
gitPush = true;
|
|
2927
|
+
} else if (arg === "--strict-case-collision") {
|
|
2928
|
+
strictCaseCollision = true;
|
|
2926
2929
|
} else {
|
|
2927
2930
|
positional.push(arg);
|
|
2928
2931
|
}
|
|
@@ -2952,6 +2955,12 @@ async function cmdExport(args: string[]) {
|
|
|
2952
2955
|
console.error(" {{first_note_title}}, {{vault_name}}");
|
|
2953
2956
|
console.error(" (default: \"export: {{date}} ({{notes_changed}} note{{plural}})\")");
|
|
2954
2957
|
console.error(" --git-push After commit, run `git push` (non-fatal on failure).");
|
|
2958
|
+
console.error(" --strict-case-collision On case-insensitive filesystems (macOS APFS-default,");
|
|
2959
|
+
console.error(" Windows NTFS, FAT/exFAT), abort the export if two notes");
|
|
2960
|
+
console.error(" have paths differing only by case (vault#327).");
|
|
2961
|
+
console.error(" Without this flag, colliding notes are auto-disambiguated");
|
|
2962
|
+
console.error(" with an `__<id-short>` filename suffix (lossless; both");
|
|
2963
|
+
console.error(" files land, canonical path preserved in frontmatter).");
|
|
2955
2964
|
process.exit(1);
|
|
2956
2965
|
}
|
|
2957
2966
|
|
|
@@ -3010,6 +3019,7 @@ async function cmdExport(args: string[]) {
|
|
|
3010
3019
|
assetsDir: assetsDirPath,
|
|
3011
3020
|
...(vaultDescription ? { vaultDescription } : {}),
|
|
3012
3021
|
...(opts.sinceCursor ? { since: opts.sinceCursor } : {}),
|
|
3022
|
+
...(strictCaseCollision ? { failOnCaseCollision: true } : {}),
|
|
3013
3023
|
});
|
|
3014
3024
|
|
|
3015
3025
|
if (opts.isInitial) {
|
|
@@ -3030,6 +3040,28 @@ async function cmdExport(args: string[]) {
|
|
|
3030
3040
|
`Note: ${stats.skipped_attachments.length} attachment(s) skipped. See [export] warnings above.`,
|
|
3031
3041
|
);
|
|
3032
3042
|
}
|
|
3043
|
+
// vault#327 Phase 2 — surface case-collision auto-disambiguation
|
|
3044
|
+
// explicitly. The previous fix landed the logic silently; the
|
|
3045
|
+
// operator had no signal that filenames had been munged. Now we
|
|
3046
|
+
// print the count + every disambiguated path so the operator can
|
|
3047
|
+
// (a) audit, (b) decide whether to rename a colliding note in
|
|
3048
|
+
// the vault and re-export, or (c) re-run with
|
|
3049
|
+
// --strict-case-collision to refuse the disambiguation entirely.
|
|
3050
|
+
if (stats.disambiguated_paths.length > 0) {
|
|
3051
|
+
const n = stats.disambiguated_paths.length;
|
|
3052
|
+
console.warn(
|
|
3053
|
+
`Warning: ${n} note${n === 1 ? "" : "s"} had path${n === 1 ? "" : "s"} that ` +
|
|
3054
|
+
`differ only by case on this case-insensitive filesystem. ` +
|
|
3055
|
+
`Auto-disambiguated on disk (canonical paths preserved in frontmatter):`,
|
|
3056
|
+
);
|
|
3057
|
+
for (const d of stats.disambiguated_paths) {
|
|
3058
|
+
console.warn(` - ${d.original_path} → ${d.disambiguated_filename} (id: ${d.note_id})`);
|
|
3059
|
+
}
|
|
3060
|
+
console.warn(
|
|
3061
|
+
`Re-run with --strict-case-collision to abort instead, or rename one of each ` +
|
|
3062
|
+
`colliding pair in the vault before re-exporting. See vault#327.`,
|
|
3063
|
+
);
|
|
3064
|
+
}
|
|
3033
3065
|
} else {
|
|
3034
3066
|
// Watch-mode status line: keep tight; the loop logs every interval.
|
|
3035
3067
|
if (stats.notes > 0) {
|
|
@@ -3039,6 +3071,17 @@ async function cmdExport(args: string[]) {
|
|
|
3039
3071
|
} else {
|
|
3040
3072
|
console.log(`[watch] no changes`);
|
|
3041
3073
|
}
|
|
3074
|
+
// Watch-mode: log new disambiguations per cycle. Operators running
|
|
3075
|
+
// a long-lived watch loop want to be notified the moment a
|
|
3076
|
+
// collision shows up — they can fix it at the source without
|
|
3077
|
+
// waiting for the next manual full export.
|
|
3078
|
+
if (stats.disambiguated_paths.length > 0) {
|
|
3079
|
+
const n = stats.disambiguated_paths.length;
|
|
3080
|
+
console.warn(
|
|
3081
|
+
`[watch] case-collision: ${n} disambiguated path${n === 1 ? "" : "s"} this cycle ` +
|
|
3082
|
+
`(see vault#327; --strict-case-collision to abort instead).`,
|
|
3083
|
+
);
|
|
3084
|
+
}
|
|
3042
3085
|
}
|
|
3043
3086
|
|
|
3044
3087
|
let committed = false;
|
|
@@ -3058,15 +3101,44 @@ async function cmdExport(args: string[]) {
|
|
|
3058
3101
|
return { stats, nextCursor, committed };
|
|
3059
3102
|
}
|
|
3060
3103
|
|
|
3104
|
+
// Import the typed CaseCollisionError once so both the single-shot and
|
|
3105
|
+
// watch-initial paths can render it the same way. vault#327 Phase 2.
|
|
3106
|
+
const { CaseCollisionError } = await import("../core/src/portable-md.ts");
|
|
3107
|
+
|
|
3061
3108
|
// ---- Single-shot mode ----
|
|
3062
3109
|
if (!watch) {
|
|
3063
|
-
|
|
3110
|
+
try {
|
|
3111
|
+
await runCycle({ sinceCursor: since, isInitial: true });
|
|
3112
|
+
} catch (err) {
|
|
3113
|
+
if (err instanceof CaseCollisionError) {
|
|
3114
|
+
// The error's own message already includes the actionable
|
|
3115
|
+
// instruction ("Rename one of them..."). Print verbatim + exit
|
|
3116
|
+
// non-zero so scripts catch the failure deterministically.
|
|
3117
|
+
console.error(err.message);
|
|
3118
|
+
process.exit(1);
|
|
3119
|
+
}
|
|
3120
|
+
throw err;
|
|
3121
|
+
}
|
|
3064
3122
|
return;
|
|
3065
3123
|
}
|
|
3066
3124
|
|
|
3067
3125
|
// ---- Watch mode ----
|
|
3068
3126
|
// Initial full (or since-filtered) export, then poll every interval.
|
|
3069
|
-
|
|
3127
|
+
let initial: Awaited<ReturnType<typeof runCycle>>;
|
|
3128
|
+
try {
|
|
3129
|
+
initial = await runCycle({ sinceCursor: since, isInitial: true });
|
|
3130
|
+
} catch (err) {
|
|
3131
|
+
if (err instanceof CaseCollisionError) {
|
|
3132
|
+
console.error(err.message);
|
|
3133
|
+
console.error(
|
|
3134
|
+
"\n--strict-case-collision is enabled; refusing to start the watch loop until the " +
|
|
3135
|
+
"collision is resolved in the vault. Re-export without --strict-case-collision to " +
|
|
3136
|
+
"auto-disambiguate instead.",
|
|
3137
|
+
);
|
|
3138
|
+
process.exit(1);
|
|
3139
|
+
}
|
|
3140
|
+
throw err;
|
|
3141
|
+
}
|
|
3070
3142
|
let cursor = initial.nextCursor;
|
|
3071
3143
|
console.log(`[watch] polling every ${intervalSeconds}s; press Ctrl-C to stop.`);
|
|
3072
3144
|
|
|
@@ -3095,6 +3167,26 @@ async function cmdExport(args: string[]) {
|
|
|
3095
3167
|
const cycle = await runCycle({ sinceCursor: cursor, isInitial: false });
|
|
3096
3168
|
cursor = cycle.nextCursor;
|
|
3097
3169
|
} catch (err) {
|
|
3170
|
+
// CaseCollisionError under --strict-case-collision means the
|
|
3171
|
+
// operator opted into "abort on any collision". A new collision
|
|
3172
|
+
// that appears mid-watch (e.g. an LLM client just wrote
|
|
3173
|
+
// `Inbox/Foo` to a vault that already had `Inbox/foo`) must NOT
|
|
3174
|
+
// be swallowed into the generic [watch] export-error log — the
|
|
3175
|
+
// strict-mode guarantee would silently degrade after the initial
|
|
3176
|
+
// export. Print the full collision message + actionable hint and
|
|
3177
|
+
// stop the loop via the same SIGINT-style pathway used by Ctrl-C
|
|
3178
|
+
// (clears timer, gives in-flight work a 250ms settle window).
|
|
3179
|
+
// Exits non-zero so supervisors / git-watch sidecars catch it.
|
|
3180
|
+
if (err instanceof CaseCollisionError) {
|
|
3181
|
+
console.error(err.message);
|
|
3182
|
+
console.error(
|
|
3183
|
+
"Resolve the collision in the vault or re-run without --strict-case-collision to fall back to auto-disambiguate mode.",
|
|
3184
|
+
);
|
|
3185
|
+
stopping = true;
|
|
3186
|
+
if (timer) clearInterval(timer);
|
|
3187
|
+
setTimeout(() => process.exit(1), 0);
|
|
3188
|
+
return;
|
|
3189
|
+
}
|
|
3098
3190
|
// Don't kill the loop on a transient export error — log and keep
|
|
3099
3191
|
// polling. Operator can Ctrl-C if they want to bail.
|
|
3100
3192
|
console.error(`[watch] export error: ${(err as Error).message ?? err}`);
|
package/src/config.ts
CHANGED
|
@@ -33,6 +33,12 @@ import { join } from "path";
|
|
|
33
33
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, renameSync } from "fs";
|
|
34
34
|
import crypto from "node:crypto";
|
|
35
35
|
|
|
36
|
+
import {
|
|
37
|
+
parseMirrorConfig as parseMirrorSectionFromYaml,
|
|
38
|
+
serializeMirrorConfig as serializeMirrorSection,
|
|
39
|
+
type MirrorConfig as MirrorConfigType,
|
|
40
|
+
} from "./mirror-config.ts";
|
|
41
|
+
|
|
36
42
|
// ---------------------------------------------------------------------------
|
|
37
43
|
// Paths
|
|
38
44
|
//
|
|
@@ -263,6 +269,14 @@ export interface GlobalConfig {
|
|
|
263
269
|
autostart?: boolean;
|
|
264
270
|
/** Backup configuration: schedule, retention, destinations. */
|
|
265
271
|
backup?: BackupConfig;
|
|
272
|
+
/**
|
|
273
|
+
* Persistent vault-managed mirror configuration (vault-sync Phase A1).
|
|
274
|
+
* Unset when the operator has never touched the mirror block; defaults to
|
|
275
|
+
* `enabled: false` semantics in that case. When `enabled: true`, the
|
|
276
|
+
* vault server bootstraps and optionally watches a git mirror at the
|
|
277
|
+
* resolved path. See `./mirror-config.ts`.
|
|
278
|
+
*/
|
|
279
|
+
mirror?: MirrorConfigType;
|
|
266
280
|
}
|
|
267
281
|
|
|
268
282
|
// ---------------------------------------------------------------------------
|
|
@@ -1187,6 +1201,12 @@ export function readGlobalConfig(): GlobalConfig {
|
|
|
1187
1201
|
// Parse backup section
|
|
1188
1202
|
config.backup = parseBackup(yaml);
|
|
1189
1203
|
|
|
1204
|
+
// Parse mirror section (vault-sync Phase A1). Imported lazily via a
|
|
1205
|
+
// narrow helper to keep config.ts free of a top-level cycle into the
|
|
1206
|
+
// mirror module — `mirror-config.ts` imports `vaultDir` from here.
|
|
1207
|
+
const mirror = parseMirrorSectionFromYaml(yaml);
|
|
1208
|
+
if (mirror) config.mirror = mirror;
|
|
1209
|
+
|
|
1190
1210
|
return config;
|
|
1191
1211
|
}
|
|
1192
1212
|
} catch {}
|
|
@@ -1273,6 +1293,10 @@ export function writeGlobalConfig(config: GlobalConfig): void {
|
|
|
1273
1293
|
lines.push(...serializeBackup(config.backup));
|
|
1274
1294
|
}
|
|
1275
1295
|
|
|
1296
|
+
if (config.mirror) {
|
|
1297
|
+
lines.push(...serializeMirrorSection(config.mirror));
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1276
1300
|
// 0600 — owner read/write only. This file may contain the bcrypt password
|
|
1277
1301
|
// hash and plaintext TOTP secret; it must not be world- or group-readable.
|
|
1278
1302
|
writeFileSync(globalConfigPath(), lines.join("\n") + "\n", { mode: 0o600 });
|