@indigoai-us/hq-cloud 6.12.4 → 6.13.0
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.test.js +35 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +26 -0
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +100 -0
- 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 +100 -3
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +246 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/journal.d.ts +1 -1
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js.map +1 -1
- package/dist/lib/cloud-authoritative.d.ts +45 -0
- package/dist/lib/cloud-authoritative.d.ts.map +1 -0
- package/dist/lib/cloud-authoritative.js +58 -0
- package/dist/lib/cloud-authoritative.js.map +1 -0
- package/dist/lib/cloud-authoritative.test.d.ts +2 -0
- package/dist/lib/cloud-authoritative.test.d.ts.map +1 -0
- package/dist/lib/cloud-authoritative.test.js +42 -0
- package/dist/lib/cloud-authoritative.test.js.map +1 -0
- package/dist/types.d.ts +11 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +41 -0
- package/src/cli/share.test.ts +119 -0
- package/src/cli/share.ts +32 -1
- package/src/cli/sync.test.ts +308 -0
- package/src/cli/sync.ts +111 -3
- package/src/journal.ts +1 -1
- package/src/lib/cloud-authoritative.test.ts +45 -0
- package/src/lib/cloud-authoritative.ts +59 -0
- package/src/types.ts +11 -1
package/dist/cli/sync.test.js
CHANGED
|
@@ -235,6 +235,64 @@ describe("sync", () => {
|
|
|
235
235
|
expect(result.filesSkipped).toBeGreaterThanOrEqual(1);
|
|
236
236
|
expect(fs.readFileSync(path.join(companyDocs, "handoff.md"), "utf-8")).toBe("local version");
|
|
237
237
|
});
|
|
238
|
+
it("conflict keep marks the entry localDiverges (journal-honesty: kept copy never matched the recorded remote etag)", async () => {
|
|
239
|
+
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
240
|
+
fs.mkdirSync(companyDocs, { recursive: true });
|
|
241
|
+
fs.writeFileSync(path.join(companyDocs, "handoff.md"), "local version");
|
|
242
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
243
|
+
version: "1",
|
|
244
|
+
lastSync: new Date().toISOString(),
|
|
245
|
+
files: {
|
|
246
|
+
"docs/handoff.md": {
|
|
247
|
+
hash: "old-hash-from-last-sync",
|
|
248
|
+
size: 20,
|
|
249
|
+
syncedAt: new Date(Date.now() - 3600000).toISOString(),
|
|
250
|
+
direction: "down",
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
}));
|
|
254
|
+
const result = await sync({
|
|
255
|
+
company: "acme",
|
|
256
|
+
onConflict: "keep",
|
|
257
|
+
vaultConfig: mockConfig,
|
|
258
|
+
hqRoot: tmpDir,
|
|
259
|
+
});
|
|
260
|
+
expect(result.conflicts).toBe(1);
|
|
261
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
262
|
+
// remoteEtag still recorded so the conflict can't re-fire (#137 invariant)…
|
|
263
|
+
expect(journal.files["docs/handoff.md"].remoteEtag).toBeTruthy();
|
|
264
|
+
// …but the kept-local copy is flagged divergent so a later currency-gated
|
|
265
|
+
// delete cannot fire on a journal that lies about local==remote.
|
|
266
|
+
expect(journal.files["docs/handoff.md"].localDiverges).toBe(true);
|
|
267
|
+
});
|
|
268
|
+
it("a genuine download clears a stale localDiverges flag (entry is now truly local==remote)", async () => {
|
|
269
|
+
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
270
|
+
fs.mkdirSync(companyDocs, { recursive: true });
|
|
271
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
272
|
+
version: "2",
|
|
273
|
+
lastSync: new Date().toISOString(),
|
|
274
|
+
files: {
|
|
275
|
+
"docs/handoff.md": {
|
|
276
|
+
hash: "old-divergent-hash",
|
|
277
|
+
size: 20,
|
|
278
|
+
syncedAt: new Date(Date.now() - 3600000).toISOString(),
|
|
279
|
+
direction: "down",
|
|
280
|
+
remoteEtag: "old-etag",
|
|
281
|
+
localDiverges: true,
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
pulls: [],
|
|
285
|
+
}));
|
|
286
|
+
// Remote advanced to a new etag; local absent → genuine download lands the
|
|
287
|
+
// remote bytes, so local==remote now and the divergence flag must clear.
|
|
288
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
289
|
+
{ key: "docs/handoff.md", size: 42, lastModified: new Date(), etag: '"new-etag"' },
|
|
290
|
+
]);
|
|
291
|
+
await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
|
|
292
|
+
expect(fs.existsSync(path.join(companyDocs, "handoff.md"))).toBe(true);
|
|
293
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
294
|
+
expect(journal.files["docs/handoff.md"]?.localDiverges).toBeFalsy();
|
|
295
|
+
});
|
|
238
296
|
it("emits a conflict event with path + resolution on hash mismatch", async () => {
|
|
239
297
|
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
240
298
|
fs.mkdirSync(companyDocs, { recursive: true });
|
|
@@ -370,6 +428,48 @@ describe("sync", () => {
|
|
|
370
428
|
.filter((f) => f.includes(".conflict-"));
|
|
371
429
|
expect(litter).toHaveLength(1);
|
|
372
430
|
});
|
|
431
|
+
it("cloud-authoritative file is PULL-WINS on conflict: overwrites local, no mirror, no false-stamp", async () => {
|
|
432
|
+
// company-brief.md is server-regenerated (gardener), so a divergence is not
|
|
433
|
+
// a real conflict — the cloud copy wins. Even under --on-conflict keep, the
|
|
434
|
+
// conflict path must short-circuit to a plain overwrite: NO `.conflict-`
|
|
435
|
+
// mirror (the loop) and NO journal stamp of the remote etag over divergent
|
|
436
|
+
// local content (Finding B). Contrast the docs/handoff.md test above, which
|
|
437
|
+
// (correctly) keeps local + mirrors for a client-owned file.
|
|
438
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
439
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
440
|
+
fs.writeFileSync(path.join(companyRoot, "company-brief.md"), "local richer brief");
|
|
441
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
442
|
+
{ key: "company-brief.md", size: 17, lastModified: new Date(), etag: '"newbrief"' },
|
|
443
|
+
]);
|
|
444
|
+
// Default downloadFile mock writes "mock file content" — the cloud copy.
|
|
445
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
446
|
+
version: "1",
|
|
447
|
+
lastSync: new Date().toISOString(),
|
|
448
|
+
files: {
|
|
449
|
+
"company-brief.md": {
|
|
450
|
+
hash: "stale-hash",
|
|
451
|
+
size: 18,
|
|
452
|
+
syncedAt: new Date(Date.now() - 3600000).toISOString(),
|
|
453
|
+
direction: "down",
|
|
454
|
+
},
|
|
455
|
+
},
|
|
456
|
+
}));
|
|
457
|
+
const result = await sync({
|
|
458
|
+
company: "acme",
|
|
459
|
+
onConflict: "keep",
|
|
460
|
+
vaultConfig: mockConfig,
|
|
461
|
+
hqRoot: tmpDir,
|
|
462
|
+
});
|
|
463
|
+
// Cloud wins: not counted as a conflict; local overwritten with the cloud copy.
|
|
464
|
+
expect(result.conflicts).toBe(0);
|
|
465
|
+
expect(result.conflictPaths).toEqual([]);
|
|
466
|
+
expect(fs.readFileSync(path.join(companyRoot, "company-brief.md"), "utf-8")).toBe("mock file content");
|
|
467
|
+
// No conflict mirror littered.
|
|
468
|
+
const litter = fs
|
|
469
|
+
.readdirSync(companyRoot)
|
|
470
|
+
.filter((f) => f.includes(".conflict-"));
|
|
471
|
+
expect(litter).toHaveLength(0);
|
|
472
|
+
});
|
|
373
473
|
it("pre-primes new-file GET presigns before per-file created-by HEADs (avoids the presign-burst breaker trip)", async () => {
|
|
374
474
|
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
375
475
|
{ key: "alpha.md", size: 10, lastModified: new Date(), etag: '"a1"' },
|
|
@@ -693,6 +793,152 @@ describe("sync", () => {
|
|
|
693
793
|
expect(journal.files[untrackedKey]).toBeUndefined();
|
|
694
794
|
expect(journal.files[trackedKey]).toBeUndefined();
|
|
695
795
|
});
|
|
796
|
+
it("local journal tombstone suppresses drift-restore: a deleted-but-still-remote key is NOT re-downloaded (no respawn)", async () => {
|
|
797
|
+
// Repro of the intermittent delete-respawn: the user deleted a clean file,
|
|
798
|
+
// its delete has not (yet) cleared the S3 object, and the SERVER
|
|
799
|
+
// FILE_TOMBSTONE fetch is degraded (empty map — the logged failure mode).
|
|
800
|
+
// The only intent record is the LOCAL journal tombstone (`removedAt`).
|
|
801
|
+
// The drift-restore path must honor it and NOT re-download the key.
|
|
802
|
+
const key = "docs/handoff.md";
|
|
803
|
+
const localPath = path.join(tmpDir, "companies", "acme", key);
|
|
804
|
+
// Remote still lists the object, etag matching the journal, lastModified
|
|
805
|
+
// BEFORE the local delete — a stale resurrection of a deleted key, not a
|
|
806
|
+
// genuine remote re-create.
|
|
807
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
808
|
+
{
|
|
809
|
+
key,
|
|
810
|
+
size: 42,
|
|
811
|
+
lastModified: new Date(Date.now() - 2 * 86_400_000),
|
|
812
|
+
etag: '"abc123"',
|
|
813
|
+
},
|
|
814
|
+
]);
|
|
815
|
+
// Degraded-fetch case: server FILE_TOMBSTONE map is EMPTY.
|
|
816
|
+
setupFetchMock({ tombstones: [] });
|
|
817
|
+
// Local file ABSENT (user deleted it). Journal entry is a TOMBSTONE,
|
|
818
|
+
// recent enough to survive TTL GC.
|
|
819
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
820
|
+
version: "2",
|
|
821
|
+
lastSync: "2026-06-19T00:00:00.000Z",
|
|
822
|
+
files: {
|
|
823
|
+
[key]: {
|
|
824
|
+
hash: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
|
|
825
|
+
size: 42,
|
|
826
|
+
syncedAt: "2026-06-19T00:00:00.000Z",
|
|
827
|
+
direction: "down",
|
|
828
|
+
remoteEtag: "abc123",
|
|
829
|
+
removedAt: new Date().toISOString(),
|
|
830
|
+
removedReason: "local-delete",
|
|
831
|
+
},
|
|
832
|
+
},
|
|
833
|
+
pulls: [],
|
|
834
|
+
}));
|
|
835
|
+
await sync({
|
|
836
|
+
company: "acme",
|
|
837
|
+
vaultConfig: mockConfig,
|
|
838
|
+
hqRoot: tmpDir,
|
|
839
|
+
onEvent: () => { },
|
|
840
|
+
});
|
|
841
|
+
// The intentionally-deleted key must NOT be re-materialised.
|
|
842
|
+
expect(s3Module.downloadFile).not.toHaveBeenCalledWith(expect.anything(), key, expect.anything());
|
|
843
|
+
expect(fs.existsSync(localPath)).toBe(false);
|
|
844
|
+
});
|
|
845
|
+
it("pull producer: a single clean/current/non-divergent missing file is tombstoned (intentional delete), not drift-restored", async () => {
|
|
846
|
+
// The user deleted a genuinely-synced file. Its entry is clean (etag matches
|
|
847
|
+
// the current remote — currency holds), not divergent. The pull leg must
|
|
848
|
+
// treat the absence as an intentional delete: stamp a persisting
|
|
849
|
+
// `local-delete` tombstone and SKIP the re-download — not resurrect it.
|
|
850
|
+
const key = "docs/gone.md";
|
|
851
|
+
const localPath = path.join(tmpDir, "companies", "acme", key);
|
|
852
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
853
|
+
{ key, size: 42, lastModified: new Date(), etag: '"cur123"' },
|
|
854
|
+
]);
|
|
855
|
+
setupFetchMock({ tombstones: [] });
|
|
856
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
857
|
+
version: "2",
|
|
858
|
+
lastSync: new Date().toISOString(),
|
|
859
|
+
files: {
|
|
860
|
+
[key]: {
|
|
861
|
+
hash: "h",
|
|
862
|
+
size: 42,
|
|
863
|
+
syncedAt: new Date().toISOString(),
|
|
864
|
+
direction: "down",
|
|
865
|
+
remoteEtag: "cur123",
|
|
866
|
+
},
|
|
867
|
+
},
|
|
868
|
+
pulls: [],
|
|
869
|
+
}));
|
|
870
|
+
await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
|
|
871
|
+
expect(s3Module.downloadFile).not.toHaveBeenCalledWith(expect.anything(), key, expect.anything());
|
|
872
|
+
expect(fs.existsSync(localPath)).toBe(false);
|
|
873
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
874
|
+
expect(journal.files[key]?.removedAt).toBeTruthy();
|
|
875
|
+
expect(journal.files[key]?.removedReason).toBe("local-delete");
|
|
876
|
+
});
|
|
877
|
+
it("pull producer: a BULK disappearance is drift-restored, NOT tombstoned (ridge-incident guard in the pull leg)", async () => {
|
|
878
|
+
// 12 clean files vanish at once (corrupt mirror / bulk rm / checkout). The
|
|
879
|
+
// bulk-asymmetry guard refuses to treat this as intent — every file is
|
|
880
|
+
// healed (re-downloaded), none tombstoned.
|
|
881
|
+
const keys = Array.from({ length: 12 }, (_, i) => `docs/f${i}.md`);
|
|
882
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce(keys.map((k) => ({ key: k, size: 10, lastModified: new Date(), etag: `"e-${k}"` })));
|
|
883
|
+
setupFetchMock({ tombstones: [] });
|
|
884
|
+
const files = {};
|
|
885
|
+
for (const k of keys) {
|
|
886
|
+
files[k] = {
|
|
887
|
+
hash: "h",
|
|
888
|
+
size: 10,
|
|
889
|
+
syncedAt: new Date().toISOString(),
|
|
890
|
+
direction: "down",
|
|
891
|
+
remoteEtag: `e-${k}`,
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
895
|
+
version: "2",
|
|
896
|
+
lastSync: new Date().toISOString(),
|
|
897
|
+
files,
|
|
898
|
+
pulls: [],
|
|
899
|
+
}));
|
|
900
|
+
await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
|
|
901
|
+
// All restored, none tombstoned.
|
|
902
|
+
expect(s3Module.downloadFile).toHaveBeenCalledTimes(12);
|
|
903
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
904
|
+
for (const k of keys) {
|
|
905
|
+
expect(journal.files[k]?.removedAt).toBeUndefined();
|
|
906
|
+
}
|
|
907
|
+
});
|
|
908
|
+
it("pull producer: a local-delete tombstone persists across pulls (not consumed/removed), so the delete stays propagatable", async () => {
|
|
909
|
+
// After the intentional-delete tombstone is stamped, a later pull must NOT
|
|
910
|
+
// drop the entry (the push leg still needs it to propagate the S3 delete).
|
|
911
|
+
// It stays skipped + preserved until the push propagates or TTL GC.
|
|
912
|
+
const key = "docs/gone.md";
|
|
913
|
+
const localPath = path.join(tmpDir, "companies", "acme", key);
|
|
914
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
915
|
+
{ key, size: 42, lastModified: new Date(Date.now() - 2 * 86_400_000), etag: '"cur123"' },
|
|
916
|
+
]);
|
|
917
|
+
setupFetchMock({ tombstones: [] });
|
|
918
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
919
|
+
version: "2",
|
|
920
|
+
lastSync: new Date().toISOString(),
|
|
921
|
+
files: {
|
|
922
|
+
[key]: {
|
|
923
|
+
hash: "h",
|
|
924
|
+
size: 42,
|
|
925
|
+
syncedAt: new Date().toISOString(),
|
|
926
|
+
direction: "down",
|
|
927
|
+
remoteEtag: "cur123",
|
|
928
|
+
removedAt: new Date().toISOString(),
|
|
929
|
+
removedReason: "local-delete",
|
|
930
|
+
},
|
|
931
|
+
},
|
|
932
|
+
pulls: [],
|
|
933
|
+
}));
|
|
934
|
+
await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
|
|
935
|
+
expect(s3Module.downloadFile).not.toHaveBeenCalledWith(expect.anything(), key, expect.anything());
|
|
936
|
+
expect(fs.existsSync(localPath)).toBe(false);
|
|
937
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
938
|
+
// Entry PRESERVED as a tombstone, not removed.
|
|
939
|
+
expect(journal.files[key]?.removedAt).toBeTruthy();
|
|
940
|
+
expect(journal.files[key]?.removedReason).toBe("local-delete");
|
|
941
|
+
});
|
|
696
942
|
it("aborts on --on-conflict abort", async () => {
|
|
697
943
|
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
698
944
|
fs.mkdirSync(companyDocs, { recursive: true });
|