@indigoai-us/hq-cloud 6.14.9 → 6.14.10
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 +105 -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 +128 -41
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +241 -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.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 +3 -1
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +3 -1
- package/dist/s3.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 +129 -3
- package/src/cli/share.ts +114 -15
- package/src/cli/sync.test.ts +293 -24
- package/src/cli/sync.ts +173 -50
- package/src/journal.ts +28 -0
- package/src/personal-vault.ts +15 -0
- package/src/s3.ts +3 -0
- 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: [],
|
|
@@ -1465,6 +1480,207 @@ describe("sync", () => {
|
|
|
1465
1480
|
expect(journal.files["docs/racy-delete.md"]).toBeDefined();
|
|
1466
1481
|
expect(journal.files["docs/racy-delete.md"].hash).toBe(baselineHash);
|
|
1467
1482
|
});
|
|
1483
|
+
it("CAS: preserves a file created after a remote-delete plan", async () => {
|
|
1484
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1485
|
+
const localPath = path.join(companyRoot, "docs", "created-after-plan.md");
|
|
1486
|
+
const baseline = "synced baseline";
|
|
1487
|
+
const crypto = await import("node:crypto");
|
|
1488
|
+
const baselineHash = crypto.createHash("sha256").update(baseline).digest("hex");
|
|
1489
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
1490
|
+
version: "2",
|
|
1491
|
+
lastSync: new Date().toISOString(),
|
|
1492
|
+
files: {
|
|
1493
|
+
"docs/created-after-plan.md": {
|
|
1494
|
+
hash: baselineHash,
|
|
1495
|
+
size: baseline.length,
|
|
1496
|
+
syncedAt: new Date().toISOString(),
|
|
1497
|
+
direction: "down",
|
|
1498
|
+
kind: "file",
|
|
1499
|
+
remoteEtag: "before-delete",
|
|
1500
|
+
},
|
|
1501
|
+
},
|
|
1502
|
+
pulls: [],
|
|
1503
|
+
}));
|
|
1504
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([]);
|
|
1505
|
+
vi.mocked(s3Module.headRemoteFile).mockImplementationOnce(async () => {
|
|
1506
|
+
fs.mkdirSync(path.dirname(localPath), { recursive: true });
|
|
1507
|
+
// Same bytes as the journal baseline: hash-only revalidation would unlink
|
|
1508
|
+
// this newly-created file, but the planned snapshot was "absent".
|
|
1509
|
+
fs.writeFileSync(localPath, baseline);
|
|
1510
|
+
return null;
|
|
1511
|
+
});
|
|
1512
|
+
const result = await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
|
|
1513
|
+
expect(result.filesTombstoned).toBe(0);
|
|
1514
|
+
expect(result.conflicts).toBe(1);
|
|
1515
|
+
expect(fs.readFileSync(localPath, "utf-8")).toBe(baseline);
|
|
1516
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
1517
|
+
expect(journal.files["docs/created-after-plan.md"]).toBeDefined();
|
|
1518
|
+
});
|
|
1519
|
+
it("CAS: preserves a concurrent local edit when a staged download is ready to replace it", async () => {
|
|
1520
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1521
|
+
const localPath = path.join(companyRoot, "docs", "staged-race.md");
|
|
1522
|
+
const baseline = "synced baseline";
|
|
1523
|
+
fs.mkdirSync(path.dirname(localPath), { recursive: true });
|
|
1524
|
+
fs.writeFileSync(localPath, baseline);
|
|
1525
|
+
const crypto = await import("node:crypto");
|
|
1526
|
+
const baselineHash = crypto.createHash("sha256").update(baseline).digest("hex");
|
|
1527
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
1528
|
+
version: "2",
|
|
1529
|
+
lastSync: new Date().toISOString(),
|
|
1530
|
+
files: {
|
|
1531
|
+
"docs/staged-race.md": {
|
|
1532
|
+
hash: baselineHash,
|
|
1533
|
+
size: baseline.length,
|
|
1534
|
+
syncedAt: new Date(Date.now() - 60_000).toISOString(),
|
|
1535
|
+
direction: "down",
|
|
1536
|
+
kind: "file",
|
|
1537
|
+
remoteEtag: "old-etag",
|
|
1538
|
+
},
|
|
1539
|
+
},
|
|
1540
|
+
pulls: [],
|
|
1541
|
+
}));
|
|
1542
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
1543
|
+
{ key: "docs/staged-race.md", size: baseline.length + 10, lastModified: new Date(), etag: '"new-etag"' },
|
|
1544
|
+
]);
|
|
1545
|
+
vi.mocked(s3Module.downloadFile).mockImplementationOnce(async (_ctx, _key, destination, options) => {
|
|
1546
|
+
fs.writeFileSync(destination, "concurrent local edit");
|
|
1547
|
+
options?.beforeReplace?.();
|
|
1548
|
+
fs.writeFileSync(destination, "remote replacement");
|
|
1549
|
+
return { metadata: {} };
|
|
1550
|
+
});
|
|
1551
|
+
const result = await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
|
|
1552
|
+
expect(result.filesDownloaded).toBe(0);
|
|
1553
|
+
expect(result.conflicts).toBe(1);
|
|
1554
|
+
expect(fs.readFileSync(localPath, "utf-8")).toBe("concurrent local edit");
|
|
1555
|
+
});
|
|
1556
|
+
it("personal vault delete planning excludes release core and generated skill mirrors", async () => {
|
|
1557
|
+
const personalJournalPath = path.join(stateDir, "sync-journal.personal-delete.json");
|
|
1558
|
+
const intent = (remoteEtag) => ({
|
|
1559
|
+
version: 1,
|
|
1560
|
+
remoteEtag,
|
|
1561
|
+
localHash: "synced-hash",
|
|
1562
|
+
localKind: "file",
|
|
1563
|
+
});
|
|
1564
|
+
fs.writeFileSync(personalJournalPath, JSON.stringify({
|
|
1565
|
+
version: "2",
|
|
1566
|
+
lastSync: new Date().toISOString(),
|
|
1567
|
+
files: {
|
|
1568
|
+
"core/core.yaml": {
|
|
1569
|
+
hash: "synced-hash",
|
|
1570
|
+
size: 1,
|
|
1571
|
+
syncedAt: new Date().toISOString(),
|
|
1572
|
+
direction: "up",
|
|
1573
|
+
kind: "file",
|
|
1574
|
+
remoteEtag: "core-etag",
|
|
1575
|
+
localDeleteIntent: intent("core-etag"),
|
|
1576
|
+
},
|
|
1577
|
+
".claude/skills/core:demo/SKILL.md": {
|
|
1578
|
+
hash: "synced-hash",
|
|
1579
|
+
size: 1,
|
|
1580
|
+
syncedAt: new Date().toISOString(),
|
|
1581
|
+
direction: "up",
|
|
1582
|
+
kind: "file",
|
|
1583
|
+
remoteEtag: "mirror-etag",
|
|
1584
|
+
localDeleteIntent: intent("mirror-etag"),
|
|
1585
|
+
},
|
|
1586
|
+
},
|
|
1587
|
+
pulls: [],
|
|
1588
|
+
}));
|
|
1589
|
+
await share({
|
|
1590
|
+
company: "acme",
|
|
1591
|
+
vaultConfig: mockConfig,
|
|
1592
|
+
hqRoot: tmpDir,
|
|
1593
|
+
paths: [tmpDir],
|
|
1594
|
+
personalMode: true,
|
|
1595
|
+
journalSlug: "personal-delete",
|
|
1596
|
+
propagateDeletes: true,
|
|
1597
|
+
propagateDeletePolicy: "all",
|
|
1598
|
+
});
|
|
1599
|
+
expect(s3Module.deleteRemoteFile).not.toHaveBeenCalled();
|
|
1600
|
+
const journal = JSON.parse(fs.readFileSync(personalJournalPath, "utf-8"));
|
|
1601
|
+
expect(journal.files["core/core.yaml"]).toBeDefined();
|
|
1602
|
+
expect(journal.files[".claude/skills/core:demo/SKILL.md"]).toBeDefined();
|
|
1603
|
+
});
|
|
1604
|
+
it("does not issue DeleteObject for two ordinary missing journal files without explicit intent", async () => {
|
|
1605
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1606
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1607
|
+
const entry = (remoteEtag) => ({
|
|
1608
|
+
hash: "synced-hash",
|
|
1609
|
+
size: 1,
|
|
1610
|
+
syncedAt: new Date().toISOString(),
|
|
1611
|
+
direction: "up",
|
|
1612
|
+
kind: "file",
|
|
1613
|
+
remoteEtag,
|
|
1614
|
+
});
|
|
1615
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
1616
|
+
version: "2",
|
|
1617
|
+
lastSync: new Date().toISOString(),
|
|
1618
|
+
files: {
|
|
1619
|
+
"docs/a.md": entry("a-etag"),
|
|
1620
|
+
"docs/b.md": entry("b-etag"),
|
|
1621
|
+
},
|
|
1622
|
+
pulls: [],
|
|
1623
|
+
}));
|
|
1624
|
+
const result = await share({
|
|
1625
|
+
company: "acme",
|
|
1626
|
+
vaultConfig: mockConfig,
|
|
1627
|
+
hqRoot: tmpDir,
|
|
1628
|
+
paths: [companyRoot],
|
|
1629
|
+
propagateDeletes: true,
|
|
1630
|
+
propagateDeletePolicy: "all",
|
|
1631
|
+
});
|
|
1632
|
+
expect(s3Module.deleteRemoteFile).not.toHaveBeenCalled();
|
|
1633
|
+
expect(result.filesRefusedStale).toBe(2);
|
|
1634
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
1635
|
+
expect(journal.files["docs/a.md"]).toBeDefined();
|
|
1636
|
+
expect(journal.files["docs/b.md"]).toBeDefined();
|
|
1637
|
+
});
|
|
1638
|
+
it("uses a conditional PUT conflict for concurrent board.json appends", async () => {
|
|
1639
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1640
|
+
const boardPath = path.join(companyRoot, "board.json");
|
|
1641
|
+
const baseline = '{"items":[]}';
|
|
1642
|
+
const localAppend = '{"items":["local append"]}';
|
|
1643
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1644
|
+
fs.writeFileSync(boardPath, localAppend);
|
|
1645
|
+
const crypto = await import("node:crypto");
|
|
1646
|
+
const baselineHash = crypto.createHash("sha256").update(baseline).digest("hex");
|
|
1647
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
1648
|
+
version: "2",
|
|
1649
|
+
lastSync: new Date().toISOString(),
|
|
1650
|
+
files: {
|
|
1651
|
+
"board.json": {
|
|
1652
|
+
hash: baselineHash,
|
|
1653
|
+
size: baseline.length,
|
|
1654
|
+
syncedAt: new Date(Date.now() - 60_000).toISOString(),
|
|
1655
|
+
direction: "up",
|
|
1656
|
+
kind: "file",
|
|
1657
|
+
remoteEtag: "old-etag",
|
|
1658
|
+
},
|
|
1659
|
+
},
|
|
1660
|
+
pulls: [],
|
|
1661
|
+
}));
|
|
1662
|
+
vi.mocked(s3Module.headRemoteFile).mockResolvedValueOnce({
|
|
1663
|
+
etag: "old-etag",
|
|
1664
|
+
size: baseline.length,
|
|
1665
|
+
lastModified: new Date(),
|
|
1666
|
+
});
|
|
1667
|
+
const preconditionError = Object.assign(new Error("remote changed"), {
|
|
1668
|
+
name: "PreconditionFailed",
|
|
1669
|
+
});
|
|
1670
|
+
vi.mocked(s3Module.uploadFile).mockRejectedValueOnce(preconditionError);
|
|
1671
|
+
const { isCloudAuthoritative } = await import("../lib/cloud-authoritative.js");
|
|
1672
|
+
expect(isCloudAuthoritative("companies/acme/board.json")).toBe(true);
|
|
1673
|
+
const result = await share({
|
|
1674
|
+
company: "acme",
|
|
1675
|
+
vaultConfig: mockConfig,
|
|
1676
|
+
hqRoot: tmpDir,
|
|
1677
|
+
paths: [companyRoot],
|
|
1678
|
+
onConflict: "keep",
|
|
1679
|
+
});
|
|
1680
|
+
expect(result.conflictPaths).toContain("board.json");
|
|
1681
|
+
expect(fs.readFileSync(boardPath, "utf-8")).toBe(localAppend);
|
|
1682
|
+
expect(s3Module.uploadFile).toHaveBeenCalledWith(expect.anything(), boardPath, "board.json", undefined, { ifMatch: "old-etag" });
|
|
1683
|
+
});
|
|
1468
1684
|
it("does NOT tombstone symlinks whose readlink target has diverged from the journal (Codex P1 round 4)", async () => {
|
|
1469
1685
|
// Codex review on PR #24 round 4 caught: the round-3 local-edit
|
|
1470
1686
|
// divergence guard only covered regular files (`isFile()` is false
|
|
@@ -2179,7 +2395,7 @@ describe("sync", () => {
|
|
|
2179
2395
|
throw new Error("missing default download mock");
|
|
2180
2396
|
try {
|
|
2181
2397
|
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce(remoteFiles);
|
|
2182
|
-
vi.mocked(s3Module.downloadFile).mockImplementation(async (ctx, key, localPath) => {
|
|
2398
|
+
vi.mocked(s3Module.downloadFile).mockImplementation(async (ctx, key, localPath, options) => {
|
|
2183
2399
|
if (key === "bulk/file-8.md") {
|
|
2184
2400
|
// This models the next transfer crashing the process. The first
|
|
2185
2401
|
// eight entries must already be durable before the final writer.
|
|
@@ -2187,7 +2403,7 @@ describe("sync", () => {
|
|
|
2187
2403
|
expect(Object.keys(checkpoint.files)).toHaveLength(8);
|
|
2188
2404
|
throw new Error("simulated crash after checkpoint");
|
|
2189
2405
|
}
|
|
2190
|
-
return defaultDownload(ctx, key, localPath);
|
|
2406
|
+
return defaultDownload(ctx, key, localPath, options);
|
|
2191
2407
|
});
|
|
2192
2408
|
const first = await sync({
|
|
2193
2409
|
company: "acme",
|
|
@@ -2210,7 +2426,7 @@ describe("sync", () => {
|
|
|
2210
2426
|
onEvent: () => undefined,
|
|
2211
2427
|
});
|
|
2212
2428
|
expect(vi.mocked(s3Module.downloadFile)).toHaveBeenCalledTimes(1);
|
|
2213
|
-
expect(vi.mocked(s3Module.downloadFile)).toHaveBeenCalledWith(expect.anything(), "bulk/file-8.md", expect.any(String));
|
|
2429
|
+
expect(vi.mocked(s3Module.downloadFile)).toHaveBeenCalledWith(expect.anything(), "bulk/file-8.md", expect.any(String), expect.objectContaining({ beforeReplace: expect.any(Function) }));
|
|
2214
2430
|
}
|
|
2215
2431
|
finally {
|
|
2216
2432
|
vi.mocked(s3Module.downloadFile).mockImplementation(defaultDownload);
|