@indigoai-us/hq-cloud 6.10.1 → 6.11.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/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +34 -1
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +52 -0
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +217 -3
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +211 -1
- package/dist/cli/sync.test.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/share.test.ts +65 -0
- package/src/cli/share.ts +42 -1
- package/src/cli/sync.test.ts +241 -1
- package/src/cli/sync.ts +266 -1
- package/vitest.config.ts +22 -0
package/src/cli/share.test.ts
CHANGED
|
@@ -3839,6 +3839,71 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
3839
3839
|
expect(litter).toEqual([]);
|
|
3840
3840
|
});
|
|
3841
3841
|
|
|
3842
|
+
// Efficiency follow-up to the directive lock above: a touched-but-identical
|
|
3843
|
+
// file currently re-hashes on EVERY sync because the no-op skip never
|
|
3844
|
+
// refreshes the journal's (mtimeMs, size). Re-stamp them on the skip so the
|
|
3845
|
+
// NEXT sync short-circuits on lstat alone. Pure performance — still no
|
|
3846
|
+
// upload, no conflict, no mirror.
|
|
3847
|
+
it("re-stamps journal mtimeMs after a no-op skip so the next sync hits the fast-path", async () => {
|
|
3848
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3849
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3850
|
+
const testFile = path.join(companyRoot, "touched-then-fast.md");
|
|
3851
|
+
fs.writeFileSync(testFile, "bytes that never change");
|
|
3852
|
+
const originalLstat = fs.lstatSync(testFile);
|
|
3853
|
+
|
|
3854
|
+
const { hashFile } = await import("../journal.js");
|
|
3855
|
+
const realHash = hashFile(testFile);
|
|
3856
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
3857
|
+
fs.writeFileSync(
|
|
3858
|
+
journalPath,
|
|
3859
|
+
JSON.stringify({
|
|
3860
|
+
version: "2",
|
|
3861
|
+
lastSync: new Date().toISOString(),
|
|
3862
|
+
files: {
|
|
3863
|
+
"touched-then-fast.md": {
|
|
3864
|
+
hash: realHash,
|
|
3865
|
+
size: originalLstat.size,
|
|
3866
|
+
mtimeMs: originalLstat.mtimeMs,
|
|
3867
|
+
syncedAt: new Date().toISOString(),
|
|
3868
|
+
direction: "up",
|
|
3869
|
+
},
|
|
3870
|
+
},
|
|
3871
|
+
pulls: [],
|
|
3872
|
+
}),
|
|
3873
|
+
);
|
|
3874
|
+
|
|
3875
|
+
// Bump mtime — fast-path misses, hash runs, hash matches → no-op skip.
|
|
3876
|
+
const futureTime = new Date(Date.now() + 120_000);
|
|
3877
|
+
fs.utimesSync(testFile, futureTime, futureTime);
|
|
3878
|
+
const bumpedLstat = fs.lstatSync(testFile);
|
|
3879
|
+
expect(bumpedLstat.mtimeMs).not.toBe(originalLstat.mtimeMs);
|
|
3880
|
+
|
|
3881
|
+
const result = await share({
|
|
3882
|
+
paths: [testFile],
|
|
3883
|
+
company: "acme",
|
|
3884
|
+
vaultConfig: mockConfig,
|
|
3885
|
+
hqRoot: tmpDir,
|
|
3886
|
+
skipUnchanged: true,
|
|
3887
|
+
});
|
|
3888
|
+
|
|
3889
|
+
// Still a pure skip — never an upload or conflict.
|
|
3890
|
+
expect(result.filesUploaded).toBe(0);
|
|
3891
|
+
expect(result.filesSkipped).toBe(1);
|
|
3892
|
+
expect(uploadFile).not.toHaveBeenCalled();
|
|
3893
|
+
|
|
3894
|
+
// Journal mtimeMs is now the BUMPED value (not the stale original), so the
|
|
3895
|
+
// next sync's lstat fast-path matches and skips without re-hashing.
|
|
3896
|
+
const journalAfter = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
3897
|
+
expect(journalAfter.files["touched-then-fast.md"].mtimeMs).toBe(
|
|
3898
|
+
bumpedLstat.mtimeMs,
|
|
3899
|
+
);
|
|
3900
|
+
expect(journalAfter.files["touched-then-fast.md"].size).toBe(
|
|
3901
|
+
bumpedLstat.size,
|
|
3902
|
+
);
|
|
3903
|
+
// Content hash untouched — the file genuinely didn't change.
|
|
3904
|
+
expect(journalAfter.files["touched-then-fast.md"].hash).toBe(realHash);
|
|
3905
|
+
});
|
|
3906
|
+
|
|
3842
3907
|
it("stamps mtimeMs on the journal entry after a successful upload", async () => {
|
|
3843
3908
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3844
3909
|
fs.mkdirSync(companyRoot, { recursive: true });
|
package/src/cli/share.ts
CHANGED
|
@@ -288,6 +288,13 @@ type PushPlanItem =
|
|
|
288
288
|
action: "skip-unchanged";
|
|
289
289
|
absolutePath: string;
|
|
290
290
|
relativePath: string;
|
|
291
|
+
// Present only when the lstat fast-path MISSED but the re-hash confirmed
|
|
292
|
+
// the bytes are unchanged (a touched-but-identical file, or a pre-5.36
|
|
293
|
+
// entry with no mtimeMs). Carries the current (mtimeMs, size) so the
|
|
294
|
+
// apply loop can refresh the journal entry — otherwise the fast-path
|
|
295
|
+
// keeps missing and re-hashes the file on every future sync. No content
|
|
296
|
+
// change: this never uploads or conflicts.
|
|
297
|
+
restamp?: { mtimeMs: number; size: number };
|
|
291
298
|
};
|
|
292
299
|
|
|
293
300
|
interface PushPlan {
|
|
@@ -394,7 +401,27 @@ function computePushPlan(
|
|
|
394
401
|
if (skipUnchanged) {
|
|
395
402
|
const existing = journal.files[relativePath];
|
|
396
403
|
if (existing && existing.hash === localHash) {
|
|
397
|
-
|
|
404
|
+
// Reaching here means the fast-path did NOT short-circuit (mtime or
|
|
405
|
+
// size moved, or the entry predates mtimeMs) yet the re-hash proved the
|
|
406
|
+
// bytes are unchanged — a no-op skip. Capture the current (mtimeMs,
|
|
407
|
+
// size) so the apply loop refreshes the journal; without it the
|
|
408
|
+
// fast-path keeps missing and re-hashes this file on every sync. Only
|
|
409
|
+
// attach when the stored stat actually differs (avoid a needless
|
|
410
|
+
// journal write on an already-current entry). Stat failure → no
|
|
411
|
+
// restamp; the skip still stands.
|
|
412
|
+
let restamp: { mtimeMs: number; size: number } | undefined;
|
|
413
|
+
try {
|
|
414
|
+
const lstat = fs.lstatSync(absolutePath);
|
|
415
|
+
if (
|
|
416
|
+
lstat.isFile() &&
|
|
417
|
+
(existing.mtimeMs !== lstat.mtimeMs || existing.size !== lstat.size)
|
|
418
|
+
) {
|
|
419
|
+
restamp = { mtimeMs: lstat.mtimeMs, size: lstat.size };
|
|
420
|
+
}
|
|
421
|
+
} catch {
|
|
422
|
+
/* best-effort; a stat error just forgoes the fast-path refresh */
|
|
423
|
+
}
|
|
424
|
+
items.push({ action: "skip-unchanged", absolutePath, relativePath, restamp });
|
|
398
425
|
continue;
|
|
399
426
|
}
|
|
400
427
|
}
|
|
@@ -1045,6 +1072,20 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
1045
1072
|
continue;
|
|
1046
1073
|
}
|
|
1047
1074
|
if (item.action === "skip-unchanged") {
|
|
1075
|
+
// Refresh the journal's (mtimeMs, size) for a touched-but-identical file
|
|
1076
|
+
// so the next sync's lstat fast-path matches and skips without re-hashing.
|
|
1077
|
+
// Guarded on item.restamp (only set when the fast-path missed AND the
|
|
1078
|
+
// stored stat actually differs) and on the entry still carrying a hash —
|
|
1079
|
+
// never alters the content hash, so this stays a pure no-op skip. The
|
|
1080
|
+
// journal is persisted unconditionally by writeJournal at the end of the
|
|
1081
|
+
// run, so this survives even when nothing uploads.
|
|
1082
|
+
if (item.restamp) {
|
|
1083
|
+
const existing = journal.files[item.relativePath];
|
|
1084
|
+
if (existing && existing.hash) {
|
|
1085
|
+
existing.mtimeMs = item.restamp.mtimeMs;
|
|
1086
|
+
existing.size = item.restamp.size;
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1048
1089
|
filesSkipped++;
|
|
1049
1090
|
continue;
|
|
1050
1091
|
}
|
package/src/cli/sync.test.ts
CHANGED
|
@@ -69,9 +69,25 @@ const mockVendResponse = {
|
|
|
69
69
|
expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
|
|
70
70
|
};
|
|
71
71
|
|
|
72
|
-
function setupFetchMock(
|
|
72
|
+
function setupFetchMock(
|
|
73
|
+
opts: { tombstones?: Array<{ key: string; deletedAt: string }> } = {},
|
|
74
|
+
) {
|
|
73
75
|
const fetchMock = vi.fn().mockImplementation(async (url: string) => {
|
|
74
76
|
const urlStr = String(url);
|
|
77
|
+
// FILE_TOMBSTONE read surface (delete-resync). Defaults to an empty list so
|
|
78
|
+
// every legacy test exercises the no-suppression path; tests that need
|
|
79
|
+
// suppression pass `{ tombstones }`.
|
|
80
|
+
if (urlStr.includes("/v1/files/tombstones")) {
|
|
81
|
+
return {
|
|
82
|
+
ok: true,
|
|
83
|
+
status: 200,
|
|
84
|
+
json: async () => ({
|
|
85
|
+
companyUid: mockEntity.uid,
|
|
86
|
+
tombstones: opts.tombstones ?? [],
|
|
87
|
+
}),
|
|
88
|
+
text: async () => "",
|
|
89
|
+
};
|
|
90
|
+
}
|
|
75
91
|
// New per-user-namespace slug resolver (hq-pro PR 67). Returns the
|
|
76
92
|
// mockEntity's uid as the in-namespace match; the caller then
|
|
77
93
|
// re-fetches it via `/entity/{uid}`, which is matched by the
|
|
@@ -1049,6 +1065,230 @@ describe("sync", () => {
|
|
|
1049
1065
|
expect(journal.files["docs/really-deleted.md"]).toBeUndefined();
|
|
1050
1066
|
});
|
|
1051
1067
|
|
|
1068
|
+
// ── delete-resync: FILE_TOMBSTONE suppression (issue [6]) ──────────────────
|
|
1069
|
+
// These four cover the brief's regression contract: a scoped-delete tombstone
|
|
1070
|
+
// makes a delete STICK across a pull even when the object is still in the
|
|
1071
|
+
// remote LIST (a peer re-pushed it / the delete-marker hasn't propagated),
|
|
1072
|
+
// while a genuine re-create still syncs and a post-tombstone local edit is
|
|
1073
|
+
// never silently destroyed.
|
|
1074
|
+
|
|
1075
|
+
it("delete-resync: a tombstoned key present in the remote LIST is removed locally and NOT re-downloaded", async () => {
|
|
1076
|
+
// The core stays-deleted assertion. A peer ran `hq files delete docs/` (the
|
|
1077
|
+
// scoped-delete path), which wrote a FILE_TOMBSTONE. This machine still
|
|
1078
|
+
// holds the file and the object is still visible in the remote LIST (a peer
|
|
1079
|
+
// re-push, or an un-propagated delete-marker). Pre-fix the planner saw
|
|
1080
|
+
// "remote present, local present, unchanged" and KEPT it; worse, on a fresh
|
|
1081
|
+
// machine "remote present, local absent" re-DOWNLOADED it — the resurrection
|
|
1082
|
+
// loop. Now the FILE_TOMBSTONE (newer than the remote object) is
|
|
1083
|
+
// authoritative: delete locally, drop the journal entry, never download.
|
|
1084
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1085
|
+
fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
|
|
1086
|
+
const sharedPath = path.join(companyRoot, "docs", "shared.md");
|
|
1087
|
+
fs.writeFileSync(sharedPath, "shared content");
|
|
1088
|
+
const crypto = await import("node:crypto");
|
|
1089
|
+
const baseline = crypto
|
|
1090
|
+
.createHash("sha256")
|
|
1091
|
+
.update("shared content")
|
|
1092
|
+
.digest("hex");
|
|
1093
|
+
const now = Date.now();
|
|
1094
|
+
fs.writeFileSync(
|
|
1095
|
+
journalPath,
|
|
1096
|
+
JSON.stringify({
|
|
1097
|
+
version: "1",
|
|
1098
|
+
lastSync: new Date(now - 120_000).toISOString(),
|
|
1099
|
+
files: {
|
|
1100
|
+
"docs/shared.md": {
|
|
1101
|
+
hash: baseline,
|
|
1102
|
+
size: 14,
|
|
1103
|
+
syncedAt: new Date(now - 120_000).toISOString(),
|
|
1104
|
+
direction: "down",
|
|
1105
|
+
remoteEtag: "remote-old",
|
|
1106
|
+
},
|
|
1107
|
+
},
|
|
1108
|
+
}),
|
|
1109
|
+
);
|
|
1110
|
+
// Remote object is the STALE deleted version: lastModified BEFORE the
|
|
1111
|
+
// tombstone, still present in the LIST.
|
|
1112
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
1113
|
+
{
|
|
1114
|
+
key: "docs/shared.md",
|
|
1115
|
+
size: 14,
|
|
1116
|
+
lastModified: new Date(now - 60_000),
|
|
1117
|
+
etag: '"remote-old"',
|
|
1118
|
+
},
|
|
1119
|
+
]);
|
|
1120
|
+
// FILE_TOMBSTONE deleted AFTER the remote object's lastModified.
|
|
1121
|
+
setupFetchMock({
|
|
1122
|
+
tombstones: [
|
|
1123
|
+
{ key: "docs/shared.md", deletedAt: new Date(now).toISOString() },
|
|
1124
|
+
],
|
|
1125
|
+
});
|
|
1126
|
+
|
|
1127
|
+
const result = await sync({
|
|
1128
|
+
company: "acme",
|
|
1129
|
+
vaultConfig: mockConfig,
|
|
1130
|
+
hqRoot: tmpDir,
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
// Stays deleted locally …
|
|
1134
|
+
expect(fs.existsSync(sharedPath)).toBe(false);
|
|
1135
|
+
// … and was NEVER re-downloaded (no download, no conflict-probe download).
|
|
1136
|
+
expect(s3Module.downloadFile).not.toHaveBeenCalled();
|
|
1137
|
+
expect(result.filesDownloaded).toBe(0);
|
|
1138
|
+
expect(result.filesTombstoned).toBeGreaterThanOrEqual(1);
|
|
1139
|
+
// Journal entry dropped so it converges (no re-tombstone next run).
|
|
1140
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
1141
|
+
expect(journal.files["docs/shared.md"]).toBeUndefined();
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
it("delete-resync: a re-created object NEWER than the tombstone DOES sync (tombstone is not permanent)", async () => {
|
|
1145
|
+
// The tombstone must not permanently suppress a key. After a delete, a user
|
|
1146
|
+
// writes a brand-new object at the same path; its lastModified post-dates
|
|
1147
|
+
// the tombstone, so it is a genuine re-create and must download normally.
|
|
1148
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1149
|
+
fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
|
|
1150
|
+
const recreatedPath = path.join(companyRoot, "docs", "recreated.md");
|
|
1151
|
+
const now = Date.now();
|
|
1152
|
+
// Empty journal + absent local: the file was deleted earlier; this is a
|
|
1153
|
+
// fresh re-create arriving from the vault.
|
|
1154
|
+
fs.writeFileSync(
|
|
1155
|
+
journalPath,
|
|
1156
|
+
JSON.stringify({ version: "1", lastSync: new Date(now).toISOString(), files: {} }),
|
|
1157
|
+
);
|
|
1158
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
1159
|
+
{
|
|
1160
|
+
key: "docs/recreated.md",
|
|
1161
|
+
size: 17,
|
|
1162
|
+
lastModified: new Date(now), // newer than the tombstone below
|
|
1163
|
+
etag: '"remote-new"',
|
|
1164
|
+
},
|
|
1165
|
+
]);
|
|
1166
|
+
setupFetchMock({
|
|
1167
|
+
tombstones: [
|
|
1168
|
+
{
|
|
1169
|
+
key: "docs/recreated.md",
|
|
1170
|
+
deletedAt: new Date(now - 3_600_000).toISOString(), // an hour ago
|
|
1171
|
+
},
|
|
1172
|
+
],
|
|
1173
|
+
});
|
|
1174
|
+
|
|
1175
|
+
const result = await sync({
|
|
1176
|
+
company: "acme",
|
|
1177
|
+
vaultConfig: mockConfig,
|
|
1178
|
+
hqRoot: tmpDir,
|
|
1179
|
+
});
|
|
1180
|
+
|
|
1181
|
+
// Re-create wins: downloaded, present locally, not tombstoned.
|
|
1182
|
+
expect(s3Module.downloadFile).toHaveBeenCalled();
|
|
1183
|
+
expect(fs.existsSync(recreatedPath)).toBe(true);
|
|
1184
|
+
expect(result.filesDownloaded).toBeGreaterThanOrEqual(1);
|
|
1185
|
+
expect(result.filesTombstoned).toBe(0);
|
|
1186
|
+
});
|
|
1187
|
+
|
|
1188
|
+
it("delete-resync: a local edit made after the tombstone surfaces a CONFLICT, not a silent delete", async () => {
|
|
1189
|
+
// Safety guard: the authoritative delete must never destroy unsynced local
|
|
1190
|
+
// work. This machine edited the file locally (diverged from the synced
|
|
1191
|
+
// baseline) and only then learned of the tombstone — surface a conflict and
|
|
1192
|
+
// PRESERVE local, rather than unlinking it.
|
|
1193
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1194
|
+
fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
|
|
1195
|
+
const editedPath = path.join(companyRoot, "docs", "edited.md");
|
|
1196
|
+
fs.writeFileSync(editedPath, "my local edits");
|
|
1197
|
+
const crypto = await import("node:crypto");
|
|
1198
|
+
const baseline = crypto
|
|
1199
|
+
.createHash("sha256")
|
|
1200
|
+
.update("original synced content")
|
|
1201
|
+
.digest("hex");
|
|
1202
|
+
const now = Date.now();
|
|
1203
|
+
fs.writeFileSync(
|
|
1204
|
+
journalPath,
|
|
1205
|
+
JSON.stringify({
|
|
1206
|
+
version: "1",
|
|
1207
|
+
lastSync: new Date(now - 120_000).toISOString(),
|
|
1208
|
+
files: {
|
|
1209
|
+
"docs/edited.md": {
|
|
1210
|
+
hash: baseline, // local has since diverged → localChanged
|
|
1211
|
+
size: 23,
|
|
1212
|
+
syncedAt: new Date(now - 120_000).toISOString(),
|
|
1213
|
+
direction: "down",
|
|
1214
|
+
remoteEtag: "remote-old",
|
|
1215
|
+
},
|
|
1216
|
+
},
|
|
1217
|
+
}),
|
|
1218
|
+
);
|
|
1219
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
1220
|
+
{
|
|
1221
|
+
key: "docs/edited.md",
|
|
1222
|
+
size: 23,
|
|
1223
|
+
lastModified: new Date(now - 60_000),
|
|
1224
|
+
etag: '"remote-old"',
|
|
1225
|
+
},
|
|
1226
|
+
]);
|
|
1227
|
+
setupFetchMock({
|
|
1228
|
+
tombstones: [
|
|
1229
|
+
{ key: "docs/edited.md", deletedAt: new Date(now).toISOString() },
|
|
1230
|
+
],
|
|
1231
|
+
});
|
|
1232
|
+
|
|
1233
|
+
const result = await sync({
|
|
1234
|
+
company: "acme",
|
|
1235
|
+
onConflict: "keep",
|
|
1236
|
+
vaultConfig: mockConfig,
|
|
1237
|
+
hqRoot: tmpDir,
|
|
1238
|
+
});
|
|
1239
|
+
|
|
1240
|
+
// Conflict surfaced; local edits preserved; nothing tombstoned away.
|
|
1241
|
+
expect(result.conflicts).toBeGreaterThanOrEqual(1);
|
|
1242
|
+
expect(result.conflictPaths).toContain("docs/edited.md");
|
|
1243
|
+
expect(fs.existsSync(editedPath)).toBe(true);
|
|
1244
|
+
expect(fs.readFileSync(editedPath, "utf-8")).toBe("my local edits");
|
|
1245
|
+
expect(result.filesTombstoned).toBe(0);
|
|
1246
|
+
});
|
|
1247
|
+
|
|
1248
|
+
it("delete-resync: suppression is per-key and the fetch is company-scoped (no cross-tenant bleed)", async () => {
|
|
1249
|
+
// Two guards in one: (a) a tombstone for `docs/a.md` must NOT suppress a
|
|
1250
|
+
// different key `docs/b.md` — suppression is keyed exactly; (b) the
|
|
1251
|
+
// tombstone fetch is scoped to the ACTIVE company's uid, so a sync for one
|
|
1252
|
+
// company never consults another company's tombstones (the client half of
|
|
1253
|
+
// the cross-tenant isolation the server enforces via the
|
|
1254
|
+
// FILE_TOMBSTONE#<companyUid># begins_with query).
|
|
1255
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1256
|
+
fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
|
|
1257
|
+
const now = Date.now();
|
|
1258
|
+
fs.writeFileSync(
|
|
1259
|
+
journalPath,
|
|
1260
|
+
JSON.stringify({ version: "1", lastSync: new Date(now).toISOString(), files: {} }),
|
|
1261
|
+
);
|
|
1262
|
+
// a.md = stale tombstoned version (suppressed); b.md = a normal new file.
|
|
1263
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
1264
|
+
{ key: "docs/a.md", size: 10, lastModified: new Date(now - 60_000), etag: '"a-old"' },
|
|
1265
|
+
{ key: "docs/b.md", size: 10, lastModified: new Date(now), etag: '"b-new"' },
|
|
1266
|
+
]);
|
|
1267
|
+
const fetchMock = setupFetchMock({
|
|
1268
|
+
tombstones: [
|
|
1269
|
+
{ key: "docs/a.md", deletedAt: new Date(now).toISOString() },
|
|
1270
|
+
],
|
|
1271
|
+
});
|
|
1272
|
+
|
|
1273
|
+
const result = await sync({
|
|
1274
|
+
company: "acme",
|
|
1275
|
+
vaultConfig: mockConfig,
|
|
1276
|
+
hqRoot: tmpDir,
|
|
1277
|
+
});
|
|
1278
|
+
|
|
1279
|
+
// a.md suppressed (never downloaded); b.md downloaded normally.
|
|
1280
|
+
expect(fs.existsSync(path.join(companyRoot, "docs", "a.md"))).toBe(false);
|
|
1281
|
+
expect(fs.existsSync(path.join(companyRoot, "docs", "b.md"))).toBe(true);
|
|
1282
|
+
expect(result.filesDownloaded).toBe(1);
|
|
1283
|
+
|
|
1284
|
+
// The tombstone read was scoped to THIS company's uid.
|
|
1285
|
+
const tombstoneCall = fetchMock.mock.calls.find((c) =>
|
|
1286
|
+
String(c[0]).includes("/v1/files/tombstones"),
|
|
1287
|
+
);
|
|
1288
|
+
expect(tombstoneCall).toBeDefined();
|
|
1289
|
+
expect(String(tombstoneCall![0])).toContain(`company=${mockEntity.uid}`);
|
|
1290
|
+
});
|
|
1291
|
+
|
|
1052
1292
|
it("does NOT tombstone keys when HEAD returns AccessDenied (Codex P1 — defensive STS skip)", async () => {
|
|
1053
1293
|
// Same Codex P1, AccessDenied branch: a guest STS session may deny
|
|
1054
1294
|
// both LIST and HEAD on out-of-scope keys. The tombstone planner
|