@indigoai-us/hq-cloud 6.15.18 → 6.15.20
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/bin/sync-runner.d.ts +1 -1
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +6 -3
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/cli/conflict.d.ts +25 -2
- package/dist/cli/conflict.d.ts.map +1 -1
- package/dist/cli/conflict.js +29 -4
- package/dist/cli/conflict.js.map +1 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +95 -45
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +343 -66
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +23 -29
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +97 -4
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/telemetry.d.ts +23 -5
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +264 -75
- package/dist/telemetry.js.map +1 -1
- package/dist/telemetry.test.js +391 -0
- package/dist/telemetry.test.js.map +1 -1
- package/dist/types.d.ts +4 -13
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/cli/share.test.js
CHANGED
|
@@ -1450,6 +1450,304 @@ describe("share", () => {
|
|
|
1450
1450
|
// fence then retry — either is consent. Must not leave the file unuploaded.
|
|
1451
1451
|
expect(uploadFile).toHaveBeenCalled();
|
|
1452
1452
|
});
|
|
1453
|
+
// ── publish-local: fenced promotion of a kept-local copy ────────────────
|
|
1454
|
+
//
|
|
1455
|
+
// `keep` leaves a file permanently unpublishable: push refuses it on the
|
|
1456
|
+
// localDiverges flag, pull never clears the flag (remote is unchanged), and
|
|
1457
|
+
// `keep` re-arms it every pass. The only existing escape — `overwrite` — is
|
|
1458
|
+
// a blunt force-write that will PUT unfenced after a 412, clobbering a
|
|
1459
|
+
// remote that genuinely advanced.
|
|
1460
|
+
//
|
|
1461
|
+
// `publish-local` is the missing middle: the operator explicitly elects
|
|
1462
|
+
// their local copy as the new authority, and the engine accepts it ONLY
|
|
1463
|
+
// while the remote still sits at the etag recorded when the keep happened.
|
|
1464
|
+
// If the remote moved, consent was given against a version that no longer
|
|
1465
|
+
// exists, so the write must fail closed instead of forcing.
|
|
1466
|
+
it("localDiverges under publish-local: promotes local under an If-Match fence on the journal etag", async () => {
|
|
1467
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1468
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1469
|
+
const testFile = path.join(companyRoot, "publish-divergent.md");
|
|
1470
|
+
const localBytes = "my kept divergent local, now elected authority";
|
|
1471
|
+
fs.writeFileSync(testFile, localBytes);
|
|
1472
|
+
const localHash = hashFile(testFile);
|
|
1473
|
+
const REMOTE = "peer-version-etag";
|
|
1474
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce({
|
|
1475
|
+
lastModified: new Date(),
|
|
1476
|
+
etag: `"${REMOTE}"`,
|
|
1477
|
+
size: 40,
|
|
1478
|
+
});
|
|
1479
|
+
vi.mocked(uploadFile).mockResolvedValueOnce({ etag: '"published-etag"' });
|
|
1480
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1481
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
1482
|
+
version: "1",
|
|
1483
|
+
lastSync: new Date().toISOString(),
|
|
1484
|
+
files: {
|
|
1485
|
+
"publish-divergent.md": {
|
|
1486
|
+
hash: localHash,
|
|
1487
|
+
size: localBytes.length,
|
|
1488
|
+
syncedAt: new Date().toISOString(),
|
|
1489
|
+
direction: "down",
|
|
1490
|
+
remoteEtag: REMOTE,
|
|
1491
|
+
localDiverges: true,
|
|
1492
|
+
},
|
|
1493
|
+
},
|
|
1494
|
+
}));
|
|
1495
|
+
const result = await share({
|
|
1496
|
+
paths: [testFile],
|
|
1497
|
+
company: "acme",
|
|
1498
|
+
vaultConfig: mockConfig,
|
|
1499
|
+
hqRoot: tmpDir,
|
|
1500
|
+
skipUnchanged: false,
|
|
1501
|
+
onConflict: "publish-local",
|
|
1502
|
+
});
|
|
1503
|
+
expect(result.filesUploaded).toBe(1);
|
|
1504
|
+
// Fenced on the etag the keep recorded — never unfenced, never live-HEAD.
|
|
1505
|
+
expect(uploadFile).toHaveBeenCalledTimes(1);
|
|
1506
|
+
expect(vi.mocked(uploadFile).mock.calls[0][4]).toEqual({ ifMatch: REMOTE });
|
|
1507
|
+
// The entry is rewritten as an ordinary upload: local is now authority,
|
|
1508
|
+
// so the journal-honesty flag must be gone rather than re-armed.
|
|
1509
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
1510
|
+
expect(journal.files["publish-divergent.md"].direction).toBe("up");
|
|
1511
|
+
expect(journal.files["publish-divergent.md"].localDiverges).toBeFalsy();
|
|
1512
|
+
expect(journal.files["publish-divergent.md"].remoteEtag).toBe("published-etag");
|
|
1513
|
+
expect(fs.readFileSync(testFile, "utf-8")).toBe(localBytes);
|
|
1514
|
+
});
|
|
1515
|
+
it("localDiverges under publish-local: a 412 fails closed and never retries unfenced", async () => {
|
|
1516
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1517
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1518
|
+
const testFile = path.join(companyRoot, "publish-raced.md");
|
|
1519
|
+
const localBytes = "my local, but the remote moved under me";
|
|
1520
|
+
fs.writeFileSync(testFile, localBytes);
|
|
1521
|
+
const localHash = hashFile(testFile);
|
|
1522
|
+
const REMOTE = "etag-at-keep-time";
|
|
1523
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce({
|
|
1524
|
+
lastModified: new Date(),
|
|
1525
|
+
etag: `"${REMOTE}"`,
|
|
1526
|
+
size: 40,
|
|
1527
|
+
});
|
|
1528
|
+
const precondition = Object.assign(new Error("At least one of the pre-conditions you specified did not hold"), {
|
|
1529
|
+
name: "PreconditionFailed",
|
|
1530
|
+
});
|
|
1531
|
+
vi.mocked(uploadFile).mockRejectedValueOnce(precondition);
|
|
1532
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1533
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
1534
|
+
version: "1",
|
|
1535
|
+
lastSync: new Date().toISOString(),
|
|
1536
|
+
files: {
|
|
1537
|
+
"publish-raced.md": {
|
|
1538
|
+
hash: localHash,
|
|
1539
|
+
size: localBytes.length,
|
|
1540
|
+
syncedAt: new Date().toISOString(),
|
|
1541
|
+
direction: "down",
|
|
1542
|
+
remoteEtag: REMOTE,
|
|
1543
|
+
localDiverges: true,
|
|
1544
|
+
},
|
|
1545
|
+
},
|
|
1546
|
+
}));
|
|
1547
|
+
const result = await share({
|
|
1548
|
+
paths: [testFile],
|
|
1549
|
+
company: "acme",
|
|
1550
|
+
vaultConfig: mockConfig,
|
|
1551
|
+
hqRoot: tmpDir,
|
|
1552
|
+
skipUnchanged: false,
|
|
1553
|
+
onConflict: "publish-local",
|
|
1554
|
+
});
|
|
1555
|
+
// Exactly one attempt: the fenced one. The `overwrite` escape hatch of
|
|
1556
|
+
// re-PUTting with no precondition must NOT be reachable from here.
|
|
1557
|
+
expect(uploadFile).toHaveBeenCalledTimes(1);
|
|
1558
|
+
expect(result.filesUploaded).toBe(0);
|
|
1559
|
+
expect(result.conflictPaths).toContain("publish-raced.md");
|
|
1560
|
+
// Consent was for a remote that no longer exists, so the flag stays put
|
|
1561
|
+
// and the operator is asked again rather than silently overruled.
|
|
1562
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
1563
|
+
expect(journal.files["publish-raced.md"].localDiverges).toBe(true);
|
|
1564
|
+
expect(fs.readFileSync(testFile, "utf-8")).toBe(localBytes);
|
|
1565
|
+
});
|
|
1566
|
+
// Codex review (PR #356, P1). The sync runner's push leg always calls
|
|
1567
|
+
// share() with `skipUnchanged: true`, and a pull-kept entry stores the
|
|
1568
|
+
// CURRENT local hash — that is exactly what the journal-honesty stamp
|
|
1569
|
+
// records. So `existing.hash === localHash` is true for every stuck file,
|
|
1570
|
+
// classifyPushCandidates() returns `skip-unchanged`, and executeUploads()
|
|
1571
|
+
// never runs: no HEAD, no conflict, no upload. Without this bypass the
|
|
1572
|
+
// whole recovery is a no-op in the one mode that can reach personal-vault
|
|
1573
|
+
// files, and the next pull leaves them stuck again. `hash === journal hash`
|
|
1574
|
+
// means "local is unchanged since the stamp", NOT "local matches remote" —
|
|
1575
|
+
// for a divergent entry those are different claims.
|
|
1576
|
+
it("localDiverges bypasses the skip-unchanged fast path so the recovery can run at all", async () => {
|
|
1577
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1578
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1579
|
+
const testFile = path.join(companyRoot, "stuck-under-skip-unchanged.md");
|
|
1580
|
+
const localBytes = "the body a pull-keep stamped and then refused to publish";
|
|
1581
|
+
fs.writeFileSync(testFile, localBytes);
|
|
1582
|
+
const localHash = hashFile(testFile);
|
|
1583
|
+
const REMOTE = "etag-at-keep-time";
|
|
1584
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce({
|
|
1585
|
+
lastModified: new Date(),
|
|
1586
|
+
etag: `"${REMOTE}"`,
|
|
1587
|
+
size: 40,
|
|
1588
|
+
});
|
|
1589
|
+
vi.mocked(uploadFile).mockResolvedValueOnce({ etag: '"published-etag"' });
|
|
1590
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1591
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
1592
|
+
version: "1",
|
|
1593
|
+
lastSync: new Date().toISOString(),
|
|
1594
|
+
files: {
|
|
1595
|
+
"stuck-under-skip-unchanged.md": {
|
|
1596
|
+
// Deliberately the CURRENT local hash: this is what a pull-keep
|
|
1597
|
+
// writes, and it is what makes the fast path swallow the file.
|
|
1598
|
+
hash: localHash,
|
|
1599
|
+
size: localBytes.length,
|
|
1600
|
+
syncedAt: new Date().toISOString(),
|
|
1601
|
+
direction: "down",
|
|
1602
|
+
remoteEtag: REMOTE,
|
|
1603
|
+
localDiverges: true,
|
|
1604
|
+
},
|
|
1605
|
+
},
|
|
1606
|
+
}));
|
|
1607
|
+
const result = await share({
|
|
1608
|
+
paths: [testFile],
|
|
1609
|
+
company: "acme",
|
|
1610
|
+
vaultConfig: mockConfig,
|
|
1611
|
+
hqRoot: tmpDir,
|
|
1612
|
+
skipUnchanged: true,
|
|
1613
|
+
onConflict: "publish-local",
|
|
1614
|
+
});
|
|
1615
|
+
expect(result.filesUploaded).toBe(1);
|
|
1616
|
+
expect(vi.mocked(uploadFile).mock.calls[0][4]).toEqual({ ifMatch: REMOTE });
|
|
1617
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
1618
|
+
expect(journal.files["stuck-under-skip-unchanged.md"].direction).toBe("up");
|
|
1619
|
+
expect(journal.files["stuck-under-skip-unchanged.md"].localDiverges).toBeFalsy();
|
|
1620
|
+
});
|
|
1621
|
+
// Codex review (PR #356, P2). A genuine two-sided conflict is not the
|
|
1622
|
+
// localDiverges recovery: the journal etag is stale BY DEFINITION here
|
|
1623
|
+
// (that is what remoteChanged means), so fencing consent on it guarantees a
|
|
1624
|
+
// 412 and the file can never publish. Consent given at this prompt is
|
|
1625
|
+
// consent against the remote the operator was just shown, so the fence must
|
|
1626
|
+
// be the freshly observed etag.
|
|
1627
|
+
it("publish-local on a fresh two-sided conflict fences on the OBSERVED remote etag, not the stale journal one", async () => {
|
|
1628
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1629
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1630
|
+
const testFile = path.join(companyRoot, "two-sided.md");
|
|
1631
|
+
fs.writeFileSync(testFile, "my newer local body");
|
|
1632
|
+
const STALE_JOURNAL_ETAG = "etag-from-last-sync";
|
|
1633
|
+
const OBSERVED_REMOTE_ETAG = "etag-the-peer-just-wrote";
|
|
1634
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce({
|
|
1635
|
+
lastModified: new Date(),
|
|
1636
|
+
etag: `"${OBSERVED_REMOTE_ETAG}"`,
|
|
1637
|
+
size: 40,
|
|
1638
|
+
});
|
|
1639
|
+
vi.mocked(uploadFile).mockResolvedValueOnce({ etag: '"published-etag"' });
|
|
1640
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1641
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
1642
|
+
version: "1",
|
|
1643
|
+
lastSync: new Date().toISOString(),
|
|
1644
|
+
files: {
|
|
1645
|
+
"two-sided.md": {
|
|
1646
|
+
hash: "a-hash-that-is-neither-side",
|
|
1647
|
+
size: 10,
|
|
1648
|
+
syncedAt: new Date().toISOString(),
|
|
1649
|
+
direction: "up",
|
|
1650
|
+
remoteEtag: STALE_JOURNAL_ETAG,
|
|
1651
|
+
},
|
|
1652
|
+
},
|
|
1653
|
+
}));
|
|
1654
|
+
const result = await share({
|
|
1655
|
+
paths: [testFile],
|
|
1656
|
+
company: "acme",
|
|
1657
|
+
vaultConfig: mockConfig,
|
|
1658
|
+
hqRoot: tmpDir,
|
|
1659
|
+
skipUnchanged: false,
|
|
1660
|
+
onConflict: "publish-local",
|
|
1661
|
+
});
|
|
1662
|
+
expect(result.filesUploaded).toBe(1);
|
|
1663
|
+
expect(vi.mocked(uploadFile).mock.calls[0][4]).toEqual({
|
|
1664
|
+
ifMatch: OBSERVED_REMOTE_ETAG,
|
|
1665
|
+
});
|
|
1666
|
+
});
|
|
1667
|
+
// Codex review (PR #356, P2). HEAD returning null skips the whole conflict
|
|
1668
|
+
// block, so the precondition becomes If-None-Match:* and the object is
|
|
1669
|
+
// RECREATED. For a divergent entry that silently overrules a peer's
|
|
1670
|
+
// deletion — the remote is emphatically no longer at the recorded etag, so
|
|
1671
|
+
// the strategy's own fence says fail closed. `keep`'s recreate-once
|
|
1672
|
+
// behaviour for non-divergent entries is unaffected.
|
|
1673
|
+
it("publish-local does not resurrect a remote deleted after the keep", async () => {
|
|
1674
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1675
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1676
|
+
const testFile = path.join(companyRoot, "peer-deleted.md");
|
|
1677
|
+
const localBytes = "my divergent local body";
|
|
1678
|
+
fs.writeFileSync(testFile, localBytes);
|
|
1679
|
+
const localHash = hashFile(testFile);
|
|
1680
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce(null);
|
|
1681
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1682
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
1683
|
+
version: "1",
|
|
1684
|
+
lastSync: new Date().toISOString(),
|
|
1685
|
+
files: {
|
|
1686
|
+
"peer-deleted.md": {
|
|
1687
|
+
hash: localHash,
|
|
1688
|
+
size: localBytes.length,
|
|
1689
|
+
syncedAt: new Date().toISOString(),
|
|
1690
|
+
direction: "down",
|
|
1691
|
+
remoteEtag: "etag-before-the-peer-deleted-it",
|
|
1692
|
+
localDiverges: true,
|
|
1693
|
+
},
|
|
1694
|
+
},
|
|
1695
|
+
}));
|
|
1696
|
+
const result = await share({
|
|
1697
|
+
paths: [testFile],
|
|
1698
|
+
company: "acme",
|
|
1699
|
+
vaultConfig: mockConfig,
|
|
1700
|
+
hqRoot: tmpDir,
|
|
1701
|
+
skipUnchanged: false,
|
|
1702
|
+
onConflict: "publish-local",
|
|
1703
|
+
});
|
|
1704
|
+
expect(uploadFile).not.toHaveBeenCalled();
|
|
1705
|
+
expect(result.filesUploaded).toBe(0);
|
|
1706
|
+
expect(result.conflictPaths).toContain("peer-deleted.md");
|
|
1707
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
1708
|
+
expect(journal.files["peer-deleted.md"].localDiverges).toBe(true);
|
|
1709
|
+
expect(fs.readFileSync(testFile, "utf-8")).toBe(localBytes);
|
|
1710
|
+
});
|
|
1711
|
+
it("publish-local on a non-divergent file behaves like an ordinary fenced upload", async () => {
|
|
1712
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1713
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1714
|
+
const testFile = path.join(companyRoot, "ordinary.md");
|
|
1715
|
+
fs.writeFileSync(testFile, "locally edited since last sync");
|
|
1716
|
+
const REMOTE = "unchanged-remote-etag";
|
|
1717
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce({
|
|
1718
|
+
lastModified: new Date(),
|
|
1719
|
+
etag: `"${REMOTE}"`,
|
|
1720
|
+
size: 10,
|
|
1721
|
+
});
|
|
1722
|
+
vi.mocked(uploadFile).mockResolvedValueOnce({ etag: '"next-etag"' });
|
|
1723
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1724
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
1725
|
+
version: "1",
|
|
1726
|
+
lastSync: new Date().toISOString(),
|
|
1727
|
+
files: {
|
|
1728
|
+
"ordinary.md": {
|
|
1729
|
+
hash: "stale-hash-from-a-previous-body",
|
|
1730
|
+
size: 10,
|
|
1731
|
+
syncedAt: new Date().toISOString(),
|
|
1732
|
+
direction: "up",
|
|
1733
|
+
remoteEtag: REMOTE,
|
|
1734
|
+
},
|
|
1735
|
+
},
|
|
1736
|
+
}));
|
|
1737
|
+
const result = await share({
|
|
1738
|
+
paths: [testFile],
|
|
1739
|
+
company: "acme",
|
|
1740
|
+
vaultConfig: mockConfig,
|
|
1741
|
+
hqRoot: tmpDir,
|
|
1742
|
+
skipUnchanged: false,
|
|
1743
|
+
onConflict: "publish-local",
|
|
1744
|
+
});
|
|
1745
|
+
// No localDiverges flag means no special casing: the normal push fence
|
|
1746
|
+
// applies and the file uploads without being routed through conflict.
|
|
1747
|
+
expect(result.filesUploaded).toBe(1);
|
|
1748
|
+
expect(result.conflictPaths ?? []).not.toContain("ordinary.md");
|
|
1749
|
+
expect(vi.mocked(uploadFile).mock.calls[0][4]).toEqual({ ifMatch: REMOTE });
|
|
1750
|
+
});
|
|
1453
1751
|
// Journal still carries remoteEtag from a prior sync, but the remote key
|
|
1454
1752
|
// is gone (HEAD null) — e.g. peer delete or operator purge. Recreation
|
|
1455
1753
|
// must use If-None-Match:* (create-only), NOT If-Match on the stale
|
|
@@ -5598,45 +5896,41 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
5598
5896
|
expect(result.filesDeleted).toBe(1);
|
|
5599
5897
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "core/policies/old.md");
|
|
5600
5898
|
});
|
|
5601
|
-
// ──
|
|
5602
|
-
|
|
5603
|
-
|
|
5604
|
-
// file AND (lstat.size, lstat.mtimeMs) match the recorded values, the
|
|
5605
|
-
// push planner classifies the file `unchanged` WITHOUT reading its
|
|
5606
|
-
// bytes. Same trade-off rsync/gitignore use; turns no-op syncs from
|
|
5607
|
-
// O(file bytes) into O(file count).
|
|
5608
|
-
//
|
|
5609
|
-
// We prove "the SHA256 path didn't run" by seeding the journal with an
|
|
5610
|
-
// intentionally WRONG hash but matching mtimeMs+size. If the fast-path
|
|
5611
|
-
// fires → file is classified `unchanged` purely on lstat (no hash
|
|
5612
|
-
// compare). If the fast-path is skipped → hashFile runs, hashes the
|
|
5613
|
-
// real file, fails the !== journal-hash check, and uploads. The
|
|
5614
|
-
// upload-vs-skip outcome is the diagnostic signal.
|
|
5615
|
-
describe("lstat fast-path (5.36.0)", () => {
|
|
5616
|
-
it("skips upload when mtimeMs + size match journal (even with wrong stored hash)", async () => {
|
|
5899
|
+
// ── Regular-file content identity ─────────────────────────────────────────
|
|
5900
|
+
describe("regular-file content identity", () => {
|
|
5901
|
+
it("uploads a same-size edit whose mtime matches the journal", async () => {
|
|
5617
5902
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
5618
5903
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
5619
5904
|
const testFile = path.join(companyRoot, "stable.md");
|
|
5620
5905
|
fs.writeFileSync(testFile, "stable bytes");
|
|
5621
|
-
const
|
|
5622
|
-
|
|
5623
|
-
|
|
5624
|
-
|
|
5906
|
+
const preservedMtime = new Date("2026-01-02T03:04:05.000Z");
|
|
5907
|
+
fs.utimesSync(testFile, preservedMtime, preservedMtime);
|
|
5908
|
+
const baselineLstat = fs.lstatSync(testFile);
|
|
5909
|
+
const { hashFile } = await import("../journal.js");
|
|
5910
|
+
const baselineHash = hashFile(testFile);
|
|
5625
5911
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
5626
5912
|
fs.writeFileSync(journalPath, JSON.stringify({
|
|
5627
5913
|
version: "2",
|
|
5628
5914
|
lastSync: new Date().toISOString(),
|
|
5629
5915
|
files: {
|
|
5630
5916
|
"stable.md": {
|
|
5631
|
-
hash:
|
|
5632
|
-
size:
|
|
5633
|
-
mtimeMs:
|
|
5917
|
+
hash: baselineHash,
|
|
5918
|
+
size: baselineLstat.size,
|
|
5919
|
+
mtimeMs: baselineLstat.mtimeMs,
|
|
5634
5920
|
syncedAt: new Date().toISOString(),
|
|
5635
5921
|
direction: "up",
|
|
5636
5922
|
},
|
|
5637
5923
|
},
|
|
5638
5924
|
pulls: [],
|
|
5639
5925
|
}));
|
|
5926
|
+
// Rewrite the bytes without changing either cheap stat signal. A planner
|
|
5927
|
+
// that treats (size, mtimeMs) as content identity will skip this forever.
|
|
5928
|
+
fs.writeFileSync(testFile, "edited bytes");
|
|
5929
|
+
fs.utimesSync(testFile, preservedMtime, preservedMtime);
|
|
5930
|
+
const editedLstat = fs.lstatSync(testFile);
|
|
5931
|
+
expect(editedLstat.size).toBe(baselineLstat.size);
|
|
5932
|
+
expect(editedLstat.mtimeMs).toBe(baselineLstat.mtimeMs);
|
|
5933
|
+
expect(hashFile(testFile)).not.toBe(baselineHash);
|
|
5640
5934
|
const result = await share({
|
|
5641
5935
|
paths: [testFile],
|
|
5642
5936
|
company: "acme",
|
|
@@ -5644,21 +5938,17 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
5644
5938
|
hqRoot: tmpDir,
|
|
5645
5939
|
skipUnchanged: true,
|
|
5646
5940
|
});
|
|
5647
|
-
|
|
5648
|
-
expect(result.
|
|
5649
|
-
expect(
|
|
5650
|
-
expect(uploadFile).not.toHaveBeenCalled();
|
|
5941
|
+
expect(result.filesUploaded).toBe(1);
|
|
5942
|
+
expect(result.filesSkipped).toBe(0);
|
|
5943
|
+
expect(uploadFile).toHaveBeenCalled();
|
|
5651
5944
|
});
|
|
5652
|
-
it("uploads when
|
|
5945
|
+
it("uploads when the content hash differs after an mtime change", async () => {
|
|
5653
5946
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
5654
5947
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
5655
5948
|
const testFile = path.join(companyRoot, "touched.md");
|
|
5656
5949
|
fs.writeFileSync(testFile, "original bytes!");
|
|
5657
5950
|
const originalLstat = fs.lstatSync(testFile);
|
|
5658
|
-
//
|
|
5659
|
-
// mtime stayed the same. We're about to bump mtime, so fast-path
|
|
5660
|
-
// MUST miss, hashFile MUST run, hash MUST mismatch the wrong stored
|
|
5661
|
-
// value, and the file MUST upload.
|
|
5951
|
+
// The recorded hash intentionally differs from the current bytes.
|
|
5662
5952
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
5663
5953
|
fs.writeFileSync(journalPath, JSON.stringify({
|
|
5664
5954
|
version: "2",
|
|
@@ -5674,7 +5964,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
5674
5964
|
},
|
|
5675
5965
|
pulls: [],
|
|
5676
5966
|
}));
|
|
5677
|
-
// Bump mtime forward
|
|
5967
|
+
// Bump mtime forward; content hashing remains the source of truth.
|
|
5678
5968
|
const futureTime = new Date(Date.now() + 60_000);
|
|
5679
5969
|
fs.utimesSync(testFile, futureTime, futureTime);
|
|
5680
5970
|
const result = await share({
|
|
@@ -5684,8 +5974,6 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
5684
5974
|
hqRoot: tmpDir,
|
|
5685
5975
|
skipUnchanged: true,
|
|
5686
5976
|
});
|
|
5687
|
-
// Fast-path missed → second-stage hash compare ran → wrong stored hash
|
|
5688
|
-
// forced the upload.
|
|
5689
5977
|
expect(result.filesUploaded).toBe(1);
|
|
5690
5978
|
expect(uploadFile).toHaveBeenCalled();
|
|
5691
5979
|
});
|
|
@@ -5693,13 +5981,11 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
5693
5981
|
// create a conflict/drift file." This is the push-side complement of the
|
|
5694
5982
|
// pull-side reconcile test (sync.test.ts "byte-identical ... no litter").
|
|
5695
5983
|
//
|
|
5696
|
-
// A pure `touch` (bytes identical, mtime bumped) MUST
|
|
5697
|
-
//
|
|
5698
|
-
// second-stage hash compare (real hash === CORRECT stored hash), and
|
|
5984
|
+
// A pure `touch` (bytes identical, mtime bumped) MUST hash the bytes,
|
|
5985
|
+
// compare the real hash with the correct stored hash, and
|
|
5699
5986
|
// classify the file `unchanged`. No upload, no conflict event, and no
|
|
5700
5987
|
// `.conflict-*`/`.drift-*` mirror on disk — mtime alone never manufactures
|
|
5701
|
-
// divergence.
|
|
5702
|
-
// this proves a CORRECT stored hash then reconciles instead of uploading.)
|
|
5988
|
+
// divergence.
|
|
5703
5989
|
it("mtime bumped but bytes identical → re-hash reconciles → no upload, no conflict, no mirror", async () => {
|
|
5704
5990
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
5705
5991
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
@@ -5707,8 +5993,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
5707
5993
|
fs.writeFileSync(testFile, "identical bytes, only mtime moves");
|
|
5708
5994
|
const originalLstat = fs.lstatSync(testFile);
|
|
5709
5995
|
// CORRECT stored hash (unlike 3722's sentinel) + matching size. Once the
|
|
5710
|
-
//
|
|
5711
|
-
// and the second-stage compare MUST classify the file unchanged.
|
|
5996
|
+
// The real hash MUST equal this value and classify the file unchanged.
|
|
5712
5997
|
const { hashFile } = await import("../journal.js");
|
|
5713
5998
|
const realHash = hashFile(testFile);
|
|
5714
5999
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
@@ -5726,7 +6011,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
5726
6011
|
},
|
|
5727
6012
|
pulls: [],
|
|
5728
6013
|
}));
|
|
5729
|
-
// Bump mtime forward only — bytes untouched.
|
|
6014
|
+
// Bump mtime forward only — bytes untouched.
|
|
5730
6015
|
const futureTime = new Date(Date.now() + 60_000);
|
|
5731
6016
|
fs.utimesSync(testFile, futureTime, futureTime);
|
|
5732
6017
|
const events = [];
|
|
@@ -5738,7 +6023,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
5738
6023
|
skipUnchanged: true,
|
|
5739
6024
|
onEvent: (e) => events.push(e),
|
|
5740
6025
|
});
|
|
5741
|
-
//
|
|
6026
|
+
// Hash matched → reconciled.
|
|
5742
6027
|
expect(result.filesUploaded).toBe(0);
|
|
5743
6028
|
expect(result.filesSkipped).toBe(1);
|
|
5744
6029
|
expect(uploadFile).not.toHaveBeenCalled();
|
|
@@ -5751,12 +6036,9 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
5751
6036
|
.filter((f) => f.includes(".conflict-") || f.includes(".drift-"));
|
|
5752
6037
|
expect(litter).toEqual([]);
|
|
5753
6038
|
});
|
|
5754
|
-
//
|
|
5755
|
-
//
|
|
5756
|
-
|
|
5757
|
-
// NEXT sync short-circuits on lstat alone. Pure performance — still no
|
|
5758
|
-
// upload, no conflict, no mirror.
|
|
5759
|
-
it("re-stamps journal mtimeMs after a no-op skip so the next sync hits the fast-path", async () => {
|
|
6039
|
+
// Keep stat metadata current after a touched-but-identical no-op. Content
|
|
6040
|
+
// identity still comes exclusively from the hash.
|
|
6041
|
+
it("re-stamps journal mtimeMs after a no-op hash match", async () => {
|
|
5760
6042
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
5761
6043
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
5762
6044
|
const testFile = path.join(companyRoot, "touched-then-fast.md");
|
|
@@ -5779,7 +6061,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
5779
6061
|
},
|
|
5780
6062
|
pulls: [],
|
|
5781
6063
|
}));
|
|
5782
|
-
// Bump mtime —
|
|
6064
|
+
// Bump mtime — hash still matches, so this is a no-op skip.
|
|
5783
6065
|
const futureTime = new Date(Date.now() + 120_000);
|
|
5784
6066
|
fs.utimesSync(testFile, futureTime, futureTime);
|
|
5785
6067
|
const bumpedLstat = fs.lstatSync(testFile);
|
|
@@ -5795,8 +6077,8 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
5795
6077
|
expect(result.filesUploaded).toBe(0);
|
|
5796
6078
|
expect(result.filesSkipped).toBe(1);
|
|
5797
6079
|
expect(uploadFile).not.toHaveBeenCalled();
|
|
5798
|
-
// Journal mtimeMs is now the
|
|
5799
|
-
//
|
|
6080
|
+
// Journal mtimeMs is now the bumped value, while content hashing remains
|
|
6081
|
+
// authoritative on the next sync.
|
|
5800
6082
|
const journalAfter = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
5801
6083
|
expect(journalAfter.files["touched-then-fast.md"].mtimeMs).toBe(bumpedLstat.mtimeMs);
|
|
5802
6084
|
expect(journalAfter.files["touched-then-fast.md"].size).toBe(bumpedLstat.size);
|
|
@@ -5822,11 +6104,9 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
5822
6104
|
expect(journal.files["fresh.md"].mtimeMs).toBe(lstat.mtimeMs);
|
|
5823
6105
|
expect(journal.files["fresh.md"].size).toBe(lstat.size);
|
|
5824
6106
|
});
|
|
5825
|
-
it("
|
|
5826
|
-
// Back-compat: pre-5.36 journals don't carry mtimeMs
|
|
5827
|
-
//
|
|
5828
|
-
// still work; the post-upload stamp adds the field so subsequent
|
|
5829
|
-
// syncs use the fast-path.
|
|
6107
|
+
it("hashes content when the journal has no mtimeMs (pre-5.36 entry)", async () => {
|
|
6108
|
+
// Back-compat: pre-5.36 journals don't carry mtimeMs, but the content
|
|
6109
|
+
// hash still decides whether the file is unchanged.
|
|
5830
6110
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
5831
6111
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
5832
6112
|
const testFile = path.join(companyRoot, "legacy.md");
|
|
@@ -5834,8 +6114,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
5834
6114
|
const { hashFile: realHashFile } = await import("../journal.js");
|
|
5835
6115
|
const realHash = realHashFile(testFile);
|
|
5836
6116
|
const realSize = fs.statSync(testFile).size;
|
|
5837
|
-
// Real hash but NO mtimeMs
|
|
5838
|
-
// second-stage compare skips upload.
|
|
6117
|
+
// Real hash but NO mtimeMs. Hash matches → skip upload.
|
|
5839
6118
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
5840
6119
|
fs.writeFileSync(journalPath, JSON.stringify({
|
|
5841
6120
|
version: "1",
|
|
@@ -5857,13 +6136,11 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
5857
6136
|
hqRoot: tmpDir,
|
|
5858
6137
|
skipUnchanged: true,
|
|
5859
6138
|
});
|
|
5860
|
-
//
|
|
5861
|
-
// matched → upload skipped at second stage. Same outcome as 5.35.0.
|
|
6139
|
+
// Real hash matched → upload skipped.
|
|
5862
6140
|
expect(result.filesSkipped).toBe(1);
|
|
5863
6141
|
expect(uploadFile).not.toHaveBeenCalled();
|
|
5864
|
-
//
|
|
5865
|
-
//
|
|
5866
|
-
// Verified separately by the "stamps mtimeMs" test above.
|
|
6142
|
+
// A follow-up upload still stamps mtimeMs for journal compatibility,
|
|
6143
|
+
// verified separately by the "stamps mtimeMs" test above.
|
|
5867
6144
|
});
|
|
5868
6145
|
});
|
|
5869
6146
|
// ── Fix #2 (5.36.0): bounded-parallel transfer pool ──────────────────────
|
|
@@ -6395,7 +6672,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
6395
6672
|
// entries for the 9 successful uploads. Pre-fix the journal was
|
|
6396
6673
|
// never written (the throw escaped before writeJournal()), so
|
|
6397
6674
|
// the next sync re-uploaded all 9 successful files unnecessarily
|
|
6398
|
-
// AND lost
|
|
6675
|
+
// AND lost their etag/content-hash/stat journal metadata.
|
|
6399
6676
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
6400
6677
|
expect(fs.existsSync(journalPath)).toBe(true);
|
|
6401
6678
|
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|