@indigoai-us/hq-cloud 6.14.9 → 6.14.11
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/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +89 -14
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +131 -3
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +138 -41
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +271 -25
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/journal.d.ts +7 -1
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +22 -1
- package/dist/journal.js.map +1 -1
- package/dist/personal-vault-exclusions.d.ts +20 -4
- package/dist/personal-vault-exclusions.d.ts.map +1 -1
- package/dist/personal-vault-exclusions.js +37 -3
- package/dist/personal-vault-exclusions.js.map +1 -1
- package/dist/personal-vault-exclusions.test.js +19 -0
- package/dist/personal-vault-exclusions.test.js.map +1 -1
- package/dist/personal-vault.d.ts +6 -0
- package/dist/personal-vault.d.ts.map +1 -1
- package/dist/personal-vault.js +12 -0
- package/dist/personal-vault.js.map +1 -1
- package/dist/s3.d.ts +23 -1
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +74 -3
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.js +71 -1
- package/dist/s3.test.js.map +1 -1
- package/dist/types.d.ts +13 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/share.test.ts +176 -3
- package/src/cli/share.ts +114 -15
- package/src/cli/sync.test.ts +330 -24
- package/src/cli/sync.ts +184 -50
- package/src/journal.ts +28 -0
- package/src/personal-vault-exclusions.test.ts +23 -0
- package/src/personal-vault-exclusions.ts +39 -3
- package/src/personal-vault.ts +15 -0
- package/src/s3.test.ts +91 -0
- package/src/s3.ts +88 -2
- package/src/types.ts +13 -0
package/dist/cli/sync.test.js
CHANGED
|
@@ -21,10 +21,12 @@ vi.mock("../s3.js", async (importOriginal) => {
|
|
|
21
21
|
...actual,
|
|
22
22
|
toPosixKey: (key) => key.split("\\").join("/"),
|
|
23
23
|
uploadFile: innerVi.fn().mockResolvedValue(undefined),
|
|
24
|
-
|
|
24
|
+
uploadSymlink: innerVi.fn().mockResolvedValue(undefined),
|
|
25
|
+
downloadFile: innerVi.fn().mockImplementation(async (_ctx, _key, localPath, options) => {
|
|
25
26
|
const dir = innerPath.dirname(localPath);
|
|
26
27
|
if (!innerFs.existsSync(dir))
|
|
27
28
|
innerFs.mkdirSync(dir, { recursive: true });
|
|
29
|
+
options?.beforeReplace?.();
|
|
28
30
|
innerFs.writeFileSync(localPath, "mock file content");
|
|
29
31
|
return { metadata: {} };
|
|
30
32
|
}),
|
|
@@ -32,6 +34,7 @@ vi.mock("../s3.js", async (importOriginal) => {
|
|
|
32
34
|
deleteRemoteFile: innerVi.fn().mockResolvedValue(undefined),
|
|
33
35
|
headRemoteFile: innerVi.fn().mockResolvedValue(null),
|
|
34
36
|
primeObjectTransport: innerVi.fn().mockResolvedValue(undefined),
|
|
37
|
+
primeUploads: innerVi.fn().mockResolvedValue(undefined),
|
|
35
38
|
};
|
|
36
39
|
});
|
|
37
40
|
// Mock the post-sync refresh so tests neither spawn the real reindex.sh nor
|
|
@@ -40,6 +43,7 @@ vi.mock("./reindex.js", () => ({
|
|
|
40
43
|
reindex: vi.fn(() => ({ status: 0 })),
|
|
41
44
|
}));
|
|
42
45
|
import { sync, reportNewFilesToNotify } from "./sync.js";
|
|
46
|
+
import { share } from "./share.js";
|
|
43
47
|
import * as s3Module from "../s3.js";
|
|
44
48
|
import { reindex } from "./reindex.js";
|
|
45
49
|
import { readSyncProgress } from "../sync-progress.js";
|
|
@@ -509,6 +513,8 @@ describe("sync", () => {
|
|
|
509
513
|
const localContent = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`).join("\n") + "\n";
|
|
510
514
|
fs.writeFileSync(localPath, localContent);
|
|
511
515
|
const st = fs.statSync(localPath);
|
|
516
|
+
const crypto = await import("node:crypto");
|
|
517
|
+
const localHash = crypto.createHash("sha256").update(localContent).digest("hex");
|
|
512
518
|
// Remote object is SMALLER (an older, truncated copy) with a CHANGED etag.
|
|
513
519
|
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
514
520
|
{ key: "docs/handoff.md", size: 12, lastModified: new Date(), etag: '"regressed-etag"' },
|
|
@@ -525,7 +531,7 @@ describe("sync", () => {
|
|
|
525
531
|
lastSync: new Date().toISOString(),
|
|
526
532
|
files: {
|
|
527
533
|
"docs/handoff.md": {
|
|
528
|
-
hash:
|
|
534
|
+
hash: localHash,
|
|
529
535
|
size: st.size,
|
|
530
536
|
mtimeMs: st.mtimeMs,
|
|
531
537
|
remoteEtag: "old-etag",
|
|
@@ -564,6 +570,8 @@ describe("sync", () => {
|
|
|
564
570
|
const localContent = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`).join("\n") + "\n";
|
|
565
571
|
fs.writeFileSync(localPath, localContent);
|
|
566
572
|
const st = fs.statSync(localPath);
|
|
573
|
+
const crypto = await import("node:crypto");
|
|
574
|
+
const localHash = crypto.createHash("sha256").update(localContent).digest("hex");
|
|
567
575
|
// Remote is SMALLER with a changed etag — a legitimate shorter rewrite.
|
|
568
576
|
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
569
577
|
{ key: "docs/handoff.md", size: 12, lastModified: new Date(), etag: '"shorter-etag"' },
|
|
@@ -573,7 +581,7 @@ describe("sync", () => {
|
|
|
573
581
|
lastSync: new Date().toISOString(),
|
|
574
582
|
files: {
|
|
575
583
|
"docs/handoff.md": {
|
|
576
|
-
hash:
|
|
584
|
+
hash: localHash,
|
|
577
585
|
size: st.size,
|
|
578
586
|
mtimeMs: st.mtimeMs,
|
|
579
587
|
remoteEtag: "old-etag",
|
|
@@ -607,6 +615,8 @@ describe("sync", () => {
|
|
|
607
615
|
const localContent = "short local\n";
|
|
608
616
|
fs.writeFileSync(localPath, localContent);
|
|
609
617
|
const st = fs.statSync(localPath);
|
|
618
|
+
const crypto = await import("node:crypto");
|
|
619
|
+
const localHash = crypto.createHash("sha256").update(localContent).digest("hex");
|
|
610
620
|
// Remote is LARGER with a changed etag → legitimate advance.
|
|
611
621
|
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
612
622
|
{ key: "docs/handoff.md", size: 9999, lastModified: new Date(), etag: '"advanced-etag"' },
|
|
@@ -616,7 +626,7 @@ describe("sync", () => {
|
|
|
616
626
|
lastSync: new Date().toISOString(),
|
|
617
627
|
files: {
|
|
618
628
|
"docs/handoff.md": {
|
|
619
|
-
hash:
|
|
629
|
+
hash: localHash,
|
|
620
630
|
size: st.size,
|
|
621
631
|
mtimeMs: st.mtimeMs,
|
|
622
632
|
remoteEtag: "old-etag",
|
|
@@ -798,12 +808,9 @@ describe("sync", () => {
|
|
|
798
808
|
expect(journal.files[untrackedKey]).toBeUndefined();
|
|
799
809
|
expect(journal.files[trackedKey]).toBeUndefined();
|
|
800
810
|
});
|
|
801
|
-
it("
|
|
802
|
-
//
|
|
803
|
-
//
|
|
804
|
-
// FILE_TOMBSTONE fetch is degraded (empty map — the logged failure mode).
|
|
805
|
-
// The only intent record is the LOCAL journal tombstone (`removedAt`).
|
|
806
|
-
// The drift-restore path must honor it and NOT re-download the key.
|
|
811
|
+
it("fails closed for a legacy local-delete tombstone and restores the remote key", async () => {
|
|
812
|
+
// A legacy `removedReason:local-delete` was inferred from an ENOENT. It
|
|
813
|
+
// has no version-bound explicit intent, so it must never suppress a pull.
|
|
807
814
|
const key = "docs/handoff.md";
|
|
808
815
|
const localPath = path.join(tmpDir, "companies", "acme", key);
|
|
809
816
|
// Remote still lists the object, etag matching the journal, lastModified
|
|
@@ -843,15 +850,10 @@ describe("sync", () => {
|
|
|
843
850
|
hqRoot: tmpDir,
|
|
844
851
|
onEvent: () => { },
|
|
845
852
|
});
|
|
846
|
-
|
|
847
|
-
expect(
|
|
848
|
-
expect(fs.existsSync(localPath)).toBe(false);
|
|
853
|
+
expect(s3Module.downloadFile).toHaveBeenCalledWith(expect.anything(), key, expect.anything(), expect.anything());
|
|
854
|
+
expect(fs.readFileSync(localPath, "utf-8")).toBe("mock file content");
|
|
849
855
|
});
|
|
850
|
-
it("
|
|
851
|
-
// The user deleted a genuinely-synced file. Its entry is clean (etag matches
|
|
852
|
-
// the current remote — currency holds), not divergent. The pull leg must
|
|
853
|
-
// treat the absence as an intentional delete: stamp a persisting
|
|
854
|
-
// `local-delete` tombstone and SKIP the re-download — not resurrect it.
|
|
856
|
+
it("does not infer local-delete intent from a single missing file", async () => {
|
|
855
857
|
const key = "docs/gone.md";
|
|
856
858
|
const localPath = path.join(tmpDir, "companies", "acme", key);
|
|
857
859
|
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
@@ -873,11 +875,10 @@ describe("sync", () => {
|
|
|
873
875
|
pulls: [],
|
|
874
876
|
}));
|
|
875
877
|
await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
|
|
876
|
-
expect(s3Module.downloadFile).
|
|
877
|
-
expect(fs.
|
|
878
|
+
expect(s3Module.downloadFile).toHaveBeenCalledWith(expect.anything(), key, expect.anything(), expect.anything());
|
|
879
|
+
expect(fs.readFileSync(localPath, "utf-8")).toBe("mock file content");
|
|
878
880
|
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
879
|
-
expect(journal.files[key]?.removedAt).
|
|
880
|
-
expect(journal.files[key]?.removedReason).toBe("local-delete");
|
|
881
|
+
expect(journal.files[key]?.removedAt).toBeUndefined();
|
|
881
882
|
});
|
|
882
883
|
it("pull producer: a BULK disappearance is drift-restored, NOT tombstoned (ridge-incident guard in the pull leg)", async () => {
|
|
883
884
|
// 12 clean files vanish at once (corrupt mirror / bulk rm / checkout). The
|
|
@@ -893,7 +894,14 @@ describe("sync", () => {
|
|
|
893
894
|
size: 10,
|
|
894
895
|
syncedAt: new Date().toISOString(),
|
|
895
896
|
direction: "down",
|
|
897
|
+
kind: "file",
|
|
896
898
|
remoteEtag: `e-${k}`,
|
|
899
|
+
localDeleteIntent: {
|
|
900
|
+
version: 1,
|
|
901
|
+
remoteEtag: `e-${k}`,
|
|
902
|
+
localHash: "h",
|
|
903
|
+
localKind: "file",
|
|
904
|
+
},
|
|
897
905
|
};
|
|
898
906
|
}
|
|
899
907
|
fs.writeFileSync(journalPath, JSON.stringify({
|
|
@@ -929,9 +937,16 @@ describe("sync", () => {
|
|
|
929
937
|
size: 42,
|
|
930
938
|
syncedAt: new Date().toISOString(),
|
|
931
939
|
direction: "down",
|
|
940
|
+
kind: "file",
|
|
932
941
|
remoteEtag: "cur123",
|
|
933
942
|
removedAt: new Date().toISOString(),
|
|
934
943
|
removedReason: "local-delete",
|
|
944
|
+
localDeleteIntent: {
|
|
945
|
+
version: 1,
|
|
946
|
+
remoteEtag: "cur123",
|
|
947
|
+
localHash: "h",
|
|
948
|
+
localKind: "file",
|
|
949
|
+
},
|
|
935
950
|
},
|
|
936
951
|
},
|
|
937
952
|
pulls: [],
|
|
@@ -1019,6 +1034,36 @@ describe("sync", () => {
|
|
|
1019
1034
|
expect(fs.existsSync(path.join(tmpDir, "docs", "readme.md"))).toBe(true);
|
|
1020
1035
|
expect(fs.existsSync(path.join(tmpDir, "companies", "acme", "docs", "readme.md"))).toBe(false);
|
|
1021
1036
|
});
|
|
1037
|
+
it("personalMode skips generated namespaced skill-wrapper objects and still pulls local skills", async () => {
|
|
1038
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
1039
|
+
{
|
|
1040
|
+
key: ".claude/skills/acme:demo/scripts",
|
|
1041
|
+
size: 0,
|
|
1042
|
+
lastModified: new Date(),
|
|
1043
|
+
etag: '"generated"',
|
|
1044
|
+
},
|
|
1045
|
+
{
|
|
1046
|
+
key: ".claude/skills/local-skill/SKILL.md",
|
|
1047
|
+
size: 30,
|
|
1048
|
+
lastModified: new Date(),
|
|
1049
|
+
etag: '"local"',
|
|
1050
|
+
},
|
|
1051
|
+
]);
|
|
1052
|
+
const result = await sync({
|
|
1053
|
+
company: "acme",
|
|
1054
|
+
vaultConfig: mockConfig,
|
|
1055
|
+
hqRoot: tmpDir,
|
|
1056
|
+
personalMode: true,
|
|
1057
|
+
});
|
|
1058
|
+
expect(result.filesDownloaded).toBe(1);
|
|
1059
|
+
expect(result.filesExcludedByPolicy).toBeGreaterThanOrEqual(1);
|
|
1060
|
+
expect(fs.existsSync(path.join(tmpDir, ".claude", "skills", "acme:demo", "scripts"))).toBe(false);
|
|
1061
|
+
expect(fs.existsSync(path.join(tmpDir, ".claude", "skills", "local-skill", "SKILL.md"))).toBe(true);
|
|
1062
|
+
const downloadedKeys = vi
|
|
1063
|
+
.mocked(s3Module.downloadFile)
|
|
1064
|
+
.mock.calls.map((call) => call[1]);
|
|
1065
|
+
expect(downloadedKeys).not.toContain(".claude/skills/acme:demo/scripts");
|
|
1066
|
+
});
|
|
1022
1067
|
it("personalMode: downloads + journals companies/manifest.yaml (carve-out round-trips) while still skipping team-synced orphan keys", async () => {
|
|
1023
1068
|
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
1024
1069
|
{ key: "companies/foo/bar.md", size: 50, lastModified: new Date(), etag: '"xyz789"' },
|
|
@@ -1465,6 +1510,207 @@ describe("sync", () => {
|
|
|
1465
1510
|
expect(journal.files["docs/racy-delete.md"]).toBeDefined();
|
|
1466
1511
|
expect(journal.files["docs/racy-delete.md"].hash).toBe(baselineHash);
|
|
1467
1512
|
});
|
|
1513
|
+
it("CAS: preserves a file created after a remote-delete plan", async () => {
|
|
1514
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1515
|
+
const localPath = path.join(companyRoot, "docs", "created-after-plan.md");
|
|
1516
|
+
const baseline = "synced baseline";
|
|
1517
|
+
const crypto = await import("node:crypto");
|
|
1518
|
+
const baselineHash = crypto.createHash("sha256").update(baseline).digest("hex");
|
|
1519
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
1520
|
+
version: "2",
|
|
1521
|
+
lastSync: new Date().toISOString(),
|
|
1522
|
+
files: {
|
|
1523
|
+
"docs/created-after-plan.md": {
|
|
1524
|
+
hash: baselineHash,
|
|
1525
|
+
size: baseline.length,
|
|
1526
|
+
syncedAt: new Date().toISOString(),
|
|
1527
|
+
direction: "down",
|
|
1528
|
+
kind: "file",
|
|
1529
|
+
remoteEtag: "before-delete",
|
|
1530
|
+
},
|
|
1531
|
+
},
|
|
1532
|
+
pulls: [],
|
|
1533
|
+
}));
|
|
1534
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([]);
|
|
1535
|
+
vi.mocked(s3Module.headRemoteFile).mockImplementationOnce(async () => {
|
|
1536
|
+
fs.mkdirSync(path.dirname(localPath), { recursive: true });
|
|
1537
|
+
// Same bytes as the journal baseline: hash-only revalidation would unlink
|
|
1538
|
+
// this newly-created file, but the planned snapshot was "absent".
|
|
1539
|
+
fs.writeFileSync(localPath, baseline);
|
|
1540
|
+
return null;
|
|
1541
|
+
});
|
|
1542
|
+
const result = await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
|
|
1543
|
+
expect(result.filesTombstoned).toBe(0);
|
|
1544
|
+
expect(result.conflicts).toBe(1);
|
|
1545
|
+
expect(fs.readFileSync(localPath, "utf-8")).toBe(baseline);
|
|
1546
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
1547
|
+
expect(journal.files["docs/created-after-plan.md"]).toBeDefined();
|
|
1548
|
+
});
|
|
1549
|
+
it("CAS: preserves a concurrent local edit when a staged download is ready to replace it", async () => {
|
|
1550
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1551
|
+
const localPath = path.join(companyRoot, "docs", "staged-race.md");
|
|
1552
|
+
const baseline = "synced baseline";
|
|
1553
|
+
fs.mkdirSync(path.dirname(localPath), { recursive: true });
|
|
1554
|
+
fs.writeFileSync(localPath, baseline);
|
|
1555
|
+
const crypto = await import("node:crypto");
|
|
1556
|
+
const baselineHash = crypto.createHash("sha256").update(baseline).digest("hex");
|
|
1557
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
1558
|
+
version: "2",
|
|
1559
|
+
lastSync: new Date().toISOString(),
|
|
1560
|
+
files: {
|
|
1561
|
+
"docs/staged-race.md": {
|
|
1562
|
+
hash: baselineHash,
|
|
1563
|
+
size: baseline.length,
|
|
1564
|
+
syncedAt: new Date(Date.now() - 60_000).toISOString(),
|
|
1565
|
+
direction: "down",
|
|
1566
|
+
kind: "file",
|
|
1567
|
+
remoteEtag: "old-etag",
|
|
1568
|
+
},
|
|
1569
|
+
},
|
|
1570
|
+
pulls: [],
|
|
1571
|
+
}));
|
|
1572
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
1573
|
+
{ key: "docs/staged-race.md", size: baseline.length + 10, lastModified: new Date(), etag: '"new-etag"' },
|
|
1574
|
+
]);
|
|
1575
|
+
vi.mocked(s3Module.downloadFile).mockImplementationOnce(async (_ctx, _key, destination, options) => {
|
|
1576
|
+
fs.writeFileSync(destination, "concurrent local edit");
|
|
1577
|
+
options?.beforeReplace?.();
|
|
1578
|
+
fs.writeFileSync(destination, "remote replacement");
|
|
1579
|
+
return { metadata: {} };
|
|
1580
|
+
});
|
|
1581
|
+
const result = await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
|
|
1582
|
+
expect(result.filesDownloaded).toBe(0);
|
|
1583
|
+
expect(result.conflicts).toBe(1);
|
|
1584
|
+
expect(fs.readFileSync(localPath, "utf-8")).toBe("concurrent local edit");
|
|
1585
|
+
});
|
|
1586
|
+
it("personal vault delete planning excludes release core and generated skill mirrors", async () => {
|
|
1587
|
+
const personalJournalPath = path.join(stateDir, "sync-journal.personal-delete.json");
|
|
1588
|
+
const intent = (remoteEtag) => ({
|
|
1589
|
+
version: 1,
|
|
1590
|
+
remoteEtag,
|
|
1591
|
+
localHash: "synced-hash",
|
|
1592
|
+
localKind: "file",
|
|
1593
|
+
});
|
|
1594
|
+
fs.writeFileSync(personalJournalPath, JSON.stringify({
|
|
1595
|
+
version: "2",
|
|
1596
|
+
lastSync: new Date().toISOString(),
|
|
1597
|
+
files: {
|
|
1598
|
+
"core/core.yaml": {
|
|
1599
|
+
hash: "synced-hash",
|
|
1600
|
+
size: 1,
|
|
1601
|
+
syncedAt: new Date().toISOString(),
|
|
1602
|
+
direction: "up",
|
|
1603
|
+
kind: "file",
|
|
1604
|
+
remoteEtag: "core-etag",
|
|
1605
|
+
localDeleteIntent: intent("core-etag"),
|
|
1606
|
+
},
|
|
1607
|
+
".claude/skills/core:demo/SKILL.md": {
|
|
1608
|
+
hash: "synced-hash",
|
|
1609
|
+
size: 1,
|
|
1610
|
+
syncedAt: new Date().toISOString(),
|
|
1611
|
+
direction: "up",
|
|
1612
|
+
kind: "file",
|
|
1613
|
+
remoteEtag: "mirror-etag",
|
|
1614
|
+
localDeleteIntent: intent("mirror-etag"),
|
|
1615
|
+
},
|
|
1616
|
+
},
|
|
1617
|
+
pulls: [],
|
|
1618
|
+
}));
|
|
1619
|
+
await share({
|
|
1620
|
+
company: "acme",
|
|
1621
|
+
vaultConfig: mockConfig,
|
|
1622
|
+
hqRoot: tmpDir,
|
|
1623
|
+
paths: [tmpDir],
|
|
1624
|
+
personalMode: true,
|
|
1625
|
+
journalSlug: "personal-delete",
|
|
1626
|
+
propagateDeletes: true,
|
|
1627
|
+
propagateDeletePolicy: "all",
|
|
1628
|
+
});
|
|
1629
|
+
expect(s3Module.deleteRemoteFile).not.toHaveBeenCalled();
|
|
1630
|
+
const journal = JSON.parse(fs.readFileSync(personalJournalPath, "utf-8"));
|
|
1631
|
+
expect(journal.files["core/core.yaml"]).toBeDefined();
|
|
1632
|
+
expect(journal.files[".claude/skills/core:demo/SKILL.md"]).toBeDefined();
|
|
1633
|
+
});
|
|
1634
|
+
it("does not issue DeleteObject for two ordinary missing journal files without explicit intent", async () => {
|
|
1635
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1636
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1637
|
+
const entry = (remoteEtag) => ({
|
|
1638
|
+
hash: "synced-hash",
|
|
1639
|
+
size: 1,
|
|
1640
|
+
syncedAt: new Date().toISOString(),
|
|
1641
|
+
direction: "up",
|
|
1642
|
+
kind: "file",
|
|
1643
|
+
remoteEtag,
|
|
1644
|
+
});
|
|
1645
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
1646
|
+
version: "2",
|
|
1647
|
+
lastSync: new Date().toISOString(),
|
|
1648
|
+
files: {
|
|
1649
|
+
"docs/a.md": entry("a-etag"),
|
|
1650
|
+
"docs/b.md": entry("b-etag"),
|
|
1651
|
+
},
|
|
1652
|
+
pulls: [],
|
|
1653
|
+
}));
|
|
1654
|
+
const result = await share({
|
|
1655
|
+
company: "acme",
|
|
1656
|
+
vaultConfig: mockConfig,
|
|
1657
|
+
hqRoot: tmpDir,
|
|
1658
|
+
paths: [companyRoot],
|
|
1659
|
+
propagateDeletes: true,
|
|
1660
|
+
propagateDeletePolicy: "all",
|
|
1661
|
+
});
|
|
1662
|
+
expect(s3Module.deleteRemoteFile).not.toHaveBeenCalled();
|
|
1663
|
+
expect(result.filesRefusedStale).toBe(2);
|
|
1664
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
1665
|
+
expect(journal.files["docs/a.md"]).toBeDefined();
|
|
1666
|
+
expect(journal.files["docs/b.md"]).toBeDefined();
|
|
1667
|
+
});
|
|
1668
|
+
it("uses a conditional PUT conflict for concurrent board.json appends", async () => {
|
|
1669
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1670
|
+
const boardPath = path.join(companyRoot, "board.json");
|
|
1671
|
+
const baseline = '{"items":[]}';
|
|
1672
|
+
const localAppend = '{"items":["local append"]}';
|
|
1673
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1674
|
+
fs.writeFileSync(boardPath, localAppend);
|
|
1675
|
+
const crypto = await import("node:crypto");
|
|
1676
|
+
const baselineHash = crypto.createHash("sha256").update(baseline).digest("hex");
|
|
1677
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
1678
|
+
version: "2",
|
|
1679
|
+
lastSync: new Date().toISOString(),
|
|
1680
|
+
files: {
|
|
1681
|
+
"board.json": {
|
|
1682
|
+
hash: baselineHash,
|
|
1683
|
+
size: baseline.length,
|
|
1684
|
+
syncedAt: new Date(Date.now() - 60_000).toISOString(),
|
|
1685
|
+
direction: "up",
|
|
1686
|
+
kind: "file",
|
|
1687
|
+
remoteEtag: "old-etag",
|
|
1688
|
+
},
|
|
1689
|
+
},
|
|
1690
|
+
pulls: [],
|
|
1691
|
+
}));
|
|
1692
|
+
vi.mocked(s3Module.headRemoteFile).mockResolvedValueOnce({
|
|
1693
|
+
etag: "old-etag",
|
|
1694
|
+
size: baseline.length,
|
|
1695
|
+
lastModified: new Date(),
|
|
1696
|
+
});
|
|
1697
|
+
const preconditionError = Object.assign(new Error("remote changed"), {
|
|
1698
|
+
name: "PreconditionFailed",
|
|
1699
|
+
});
|
|
1700
|
+
vi.mocked(s3Module.uploadFile).mockRejectedValueOnce(preconditionError);
|
|
1701
|
+
const { isCloudAuthoritative } = await import("../lib/cloud-authoritative.js");
|
|
1702
|
+
expect(isCloudAuthoritative("companies/acme/board.json")).toBe(true);
|
|
1703
|
+
const result = await share({
|
|
1704
|
+
company: "acme",
|
|
1705
|
+
vaultConfig: mockConfig,
|
|
1706
|
+
hqRoot: tmpDir,
|
|
1707
|
+
paths: [companyRoot],
|
|
1708
|
+
onConflict: "keep",
|
|
1709
|
+
});
|
|
1710
|
+
expect(result.conflictPaths).toContain("board.json");
|
|
1711
|
+
expect(fs.readFileSync(boardPath, "utf-8")).toBe(localAppend);
|
|
1712
|
+
expect(s3Module.uploadFile).toHaveBeenCalledWith(expect.anything(), boardPath, "board.json", undefined, { ifMatch: "old-etag" });
|
|
1713
|
+
});
|
|
1468
1714
|
it("does NOT tombstone symlinks whose readlink target has diverged from the journal (Codex P1 round 4)", async () => {
|
|
1469
1715
|
// Codex review on PR #24 round 4 caught: the round-3 local-edit
|
|
1470
1716
|
// divergence guard only covered regular files (`isFile()` is false
|
|
@@ -2179,7 +2425,7 @@ describe("sync", () => {
|
|
|
2179
2425
|
throw new Error("missing default download mock");
|
|
2180
2426
|
try {
|
|
2181
2427
|
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce(remoteFiles);
|
|
2182
|
-
vi.mocked(s3Module.downloadFile).mockImplementation(async (ctx, key, localPath) => {
|
|
2428
|
+
vi.mocked(s3Module.downloadFile).mockImplementation(async (ctx, key, localPath, options) => {
|
|
2183
2429
|
if (key === "bulk/file-8.md") {
|
|
2184
2430
|
// This models the next transfer crashing the process. The first
|
|
2185
2431
|
// eight entries must already be durable before the final writer.
|
|
@@ -2187,7 +2433,7 @@ describe("sync", () => {
|
|
|
2187
2433
|
expect(Object.keys(checkpoint.files)).toHaveLength(8);
|
|
2188
2434
|
throw new Error("simulated crash after checkpoint");
|
|
2189
2435
|
}
|
|
2190
|
-
return defaultDownload(ctx, key, localPath);
|
|
2436
|
+
return defaultDownload(ctx, key, localPath, options);
|
|
2191
2437
|
});
|
|
2192
2438
|
const first = await sync({
|
|
2193
2439
|
company: "acme",
|
|
@@ -2210,7 +2456,7 @@ describe("sync", () => {
|
|
|
2210
2456
|
onEvent: () => undefined,
|
|
2211
2457
|
});
|
|
2212
2458
|
expect(vi.mocked(s3Module.downloadFile)).toHaveBeenCalledTimes(1);
|
|
2213
|
-
expect(vi.mocked(s3Module.downloadFile)).toHaveBeenCalledWith(expect.anything(), "bulk/file-8.md", expect.any(String));
|
|
2459
|
+
expect(vi.mocked(s3Module.downloadFile)).toHaveBeenCalledWith(expect.anything(), "bulk/file-8.md", expect.any(String), expect.objectContaining({ beforeReplace: expect.any(Function) }));
|
|
2214
2460
|
}
|
|
2215
2461
|
finally {
|
|
2216
2462
|
vi.mocked(s3Module.downloadFile).mockImplementation(defaultDownload);
|