@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/src/cli/share.test.ts
CHANGED
|
@@ -71,6 +71,19 @@ function makeEntityContext(overrides: Partial<EntityContext> = {}): EntityContex
|
|
|
71
71
|
};
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
function deleteIntent(
|
|
75
|
+
remoteEtag: string,
|
|
76
|
+
localHash: string,
|
|
77
|
+
localKind: "file" | "symlink" = "file",
|
|
78
|
+
) {
|
|
79
|
+
return {
|
|
80
|
+
version: 1 as const,
|
|
81
|
+
remoteEtag,
|
|
82
|
+
localHash,
|
|
83
|
+
localKind,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
74
87
|
describe("wrapFilterWithIgnoreVisibility", () => {
|
|
75
88
|
it("records noteworthy base-ignore exclusions while counting all rejections", () => {
|
|
76
89
|
const hqRoot = path.join("/tmp", "hqroot");
|
|
@@ -1950,6 +1963,12 @@ describe("share", () => {
|
|
|
1950
1963
|
syncedAt: new Date().toISOString(),
|
|
1951
1964
|
direction: "up",
|
|
1952
1965
|
remoteEtag: "knowledge-etag",
|
|
1966
|
+
kind: "symlink",
|
|
1967
|
+
localDeleteIntent: deleteIntent(
|
|
1968
|
+
"knowledge-etag",
|
|
1969
|
+
"irrelevant-not-checked",
|
|
1970
|
+
"symlink",
|
|
1971
|
+
),
|
|
1953
1972
|
},
|
|
1954
1973
|
},
|
|
1955
1974
|
}),
|
|
@@ -2189,6 +2208,11 @@ describe("share", () => {
|
|
|
2189
2208
|
syncedAt: new Date().toISOString(),
|
|
2190
2209
|
direction: "up",
|
|
2191
2210
|
remoteEtag: "gone-etag",
|
|
2211
|
+
kind: "file",
|
|
2212
|
+
localDeleteIntent: deleteIntent(
|
|
2213
|
+
"gone-etag",
|
|
2214
|
+
"irrelevant-not-checked-here",
|
|
2215
|
+
),
|
|
2192
2216
|
},
|
|
2193
2217
|
},
|
|
2194
2218
|
}),
|
|
@@ -2231,6 +2255,9 @@ describe("share", () => {
|
|
|
2231
2255
|
size: 42,
|
|
2232
2256
|
syncedAt: new Date().toISOString(),
|
|
2233
2257
|
direction: "up",
|
|
2258
|
+
remoteEtag: "removed-etag",
|
|
2259
|
+
kind: "file",
|
|
2260
|
+
localDeleteIntent: deleteIntent("removed-etag", "h"),
|
|
2234
2261
|
},
|
|
2235
2262
|
},
|
|
2236
2263
|
}),
|
|
@@ -2318,6 +2345,9 @@ describe("share", () => {
|
|
|
2318
2345
|
size: 1,
|
|
2319
2346
|
syncedAt: new Date().toISOString(),
|
|
2320
2347
|
direction: "up",
|
|
2348
|
+
remoteEtag: "in-scope-etag",
|
|
2349
|
+
kind: "file",
|
|
2350
|
+
localDeleteIntent: deleteIntent("in-scope-etag", "h"),
|
|
2321
2351
|
},
|
|
2322
2352
|
"other/also-gone.md": {
|
|
2323
2353
|
hash: "h",
|
|
@@ -2371,6 +2401,9 @@ describe("share", () => {
|
|
|
2371
2401
|
size: 5,
|
|
2372
2402
|
syncedAt: new Date().toISOString(),
|
|
2373
2403
|
direction: "up",
|
|
2404
|
+
remoteEtag: "flaky-etag",
|
|
2405
|
+
kind: "file",
|
|
2406
|
+
localDeleteIntent: deleteIntent("flaky-etag", "h"),
|
|
2374
2407
|
},
|
|
2375
2408
|
},
|
|
2376
2409
|
}),
|
|
@@ -2446,6 +2479,9 @@ describe("share", () => {
|
|
|
2446
2479
|
"i-uploaded.md": {
|
|
2447
2480
|
hash: "h", size: 1, syncedAt: new Date().toISOString(),
|
|
2448
2481
|
direction: "up",
|
|
2482
|
+
remoteEtag: "uploaded-etag",
|
|
2483
|
+
kind: "file",
|
|
2484
|
+
localDeleteIntent: deleteIntent("uploaded-etag", "h"),
|
|
2449
2485
|
},
|
|
2450
2486
|
},
|
|
2451
2487
|
}),
|
|
@@ -2482,6 +2518,9 @@ describe("share", () => {
|
|
|
2482
2518
|
"pulled.md": {
|
|
2483
2519
|
hash: "h", size: 1, syncedAt: new Date().toISOString(),
|
|
2484
2520
|
direction: "down",
|
|
2521
|
+
remoteEtag: "pulled-etag",
|
|
2522
|
+
kind: "file",
|
|
2523
|
+
localDeleteIntent: deleteIntent("pulled-etag", "h"),
|
|
2485
2524
|
},
|
|
2486
2525
|
},
|
|
2487
2526
|
}),
|
|
@@ -2526,6 +2565,9 @@ describe("share", () => {
|
|
|
2526
2565
|
"active/current.md": {
|
|
2527
2566
|
hash: "h", size: 1, syncedAt: new Date().toISOString(),
|
|
2528
2567
|
direction: "up",
|
|
2568
|
+
remoteEtag: "active-etag",
|
|
2569
|
+
kind: "file",
|
|
2570
|
+
localDeleteIntent: deleteIntent("active-etag", "h"),
|
|
2529
2571
|
},
|
|
2530
2572
|
},
|
|
2531
2573
|
}),
|
|
@@ -2586,6 +2628,8 @@ describe("share", () => {
|
|
|
2586
2628
|
hash: "h", size: 100, syncedAt: new Date().toISOString(),
|
|
2587
2629
|
direction: "down",
|
|
2588
2630
|
remoteEtag: "abc123",
|
|
2631
|
+
kind: "file",
|
|
2632
|
+
localDeleteIntent: deleteIntent("abc123", "h"),
|
|
2589
2633
|
},
|
|
2590
2634
|
},
|
|
2591
2635
|
}),
|
|
@@ -2637,6 +2681,8 @@ describe("share", () => {
|
|
|
2637
2681
|
syncedAt: new Date().toISOString(),
|
|
2638
2682
|
direction: "down",
|
|
2639
2683
|
remoteEtag: "abc123",
|
|
2684
|
+
kind: "file",
|
|
2685
|
+
localDeleteIntent: deleteIntent("abc123", "h"),
|
|
2640
2686
|
removedAt: new Date().toISOString(),
|
|
2641
2687
|
removedReason: "local-delete",
|
|
2642
2688
|
},
|
|
@@ -2688,6 +2734,8 @@ describe("share", () => {
|
|
|
2688
2734
|
hash: "h", size: 50, syncedAt: new Date().toISOString(),
|
|
2689
2735
|
direction: "down",
|
|
2690
2736
|
remoteEtag: "stale-etag",
|
|
2737
|
+
kind: "file",
|
|
2738
|
+
localDeleteIntent: deleteIntent("stale-etag", "h"),
|
|
2691
2739
|
},
|
|
2692
2740
|
},
|
|
2693
2741
|
}),
|
|
@@ -2733,6 +2781,64 @@ describe("share", () => {
|
|
|
2733
2781
|
expect(result.filesTombstoned).toBe(0);
|
|
2734
2782
|
});
|
|
2735
2783
|
|
|
2784
|
+
it("currency-gated: preserves a file recreated after the delete plan", async () => {
|
|
2785
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
2786
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
2787
|
+
const recreatedPath = path.join(companyRoot, "racy.md");
|
|
2788
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
2789
|
+
fs.writeFileSync(
|
|
2790
|
+
journalPath,
|
|
2791
|
+
JSON.stringify({
|
|
2792
|
+
version: "2",
|
|
2793
|
+
lastSync: new Date().toISOString(),
|
|
2794
|
+
files: {
|
|
2795
|
+
"racy.md": {
|
|
2796
|
+
hash: "synced-hash",
|
|
2797
|
+
size: 10,
|
|
2798
|
+
syncedAt: new Date().toISOString(),
|
|
2799
|
+
direction: "up",
|
|
2800
|
+
remoteEtag: "racy-etag",
|
|
2801
|
+
kind: "file",
|
|
2802
|
+
localDeleteIntent: deleteIntent("racy-etag", "synced-hash"),
|
|
2803
|
+
},
|
|
2804
|
+
},
|
|
2805
|
+
pulls: [],
|
|
2806
|
+
}),
|
|
2807
|
+
);
|
|
2808
|
+
|
|
2809
|
+
// The currency HEAD establishes the remote revision, then a local writer
|
|
2810
|
+
// recreates the file before the planned DeleteObject can run.
|
|
2811
|
+
vi.mocked(headRemoteFile).mockImplementationOnce(async () => {
|
|
2812
|
+
fs.writeFileSync(recreatedPath, "recreated after planning");
|
|
2813
|
+
return { lastModified: new Date(), etag: '"racy-etag"', size: 10 };
|
|
2814
|
+
});
|
|
2815
|
+
|
|
2816
|
+
const events: Array<{ type: string; path?: string; reason?: string }> = [];
|
|
2817
|
+
const result = await share({
|
|
2818
|
+
paths: [companyRoot],
|
|
2819
|
+
company: "acme",
|
|
2820
|
+
vaultConfig: mockConfig,
|
|
2821
|
+
hqRoot: tmpDir,
|
|
2822
|
+
skipUnchanged: true,
|
|
2823
|
+
propagateDeletes: true,
|
|
2824
|
+
propagateDeletePolicy: "currency-gated",
|
|
2825
|
+
onEvent: (e) => events.push(e as { type: string }),
|
|
2826
|
+
});
|
|
2827
|
+
|
|
2828
|
+
expect(result.filesDeleted).toBe(0);
|
|
2829
|
+
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
2830
|
+
expect(fs.readFileSync(recreatedPath, "utf-8")).toBe("recreated after planning");
|
|
2831
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
2832
|
+
expect(journal.files["racy.md"]).toBeDefined();
|
|
2833
|
+
expect(events).toContainEqual(
|
|
2834
|
+
expect.objectContaining({
|
|
2835
|
+
type: "delete-refused-stale-etag",
|
|
2836
|
+
path: "racy.md",
|
|
2837
|
+
reason: "intent-changed",
|
|
2838
|
+
}),
|
|
2839
|
+
);
|
|
2840
|
+
});
|
|
2841
|
+
|
|
2736
2842
|
it("currency-gated: refuses delete for a divergent-local entry (journal-honesty — never delete a remote the local never genuinely matched)", async () => {
|
|
2737
2843
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
2738
2844
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
@@ -2814,6 +2920,8 @@ describe("share", () => {
|
|
|
2814
2920
|
hash: "h", size: 25, syncedAt: new Date().toISOString(),
|
|
2815
2921
|
direction: "down",
|
|
2816
2922
|
remoteEtag: "doesnt-matter",
|
|
2923
|
+
kind: "file",
|
|
2924
|
+
localDeleteIntent: deleteIntent("doesnt-matter", "h"),
|
|
2817
2925
|
},
|
|
2818
2926
|
},
|
|
2819
2927
|
}),
|
|
@@ -2894,12 +3002,13 @@ describe("share", () => {
|
|
|
2894
3002
|
|
|
2895
3003
|
expect(result.filesDeleted).toBe(0);
|
|
2896
3004
|
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
2897
|
-
// HEAD must NOT be called —
|
|
3005
|
+
// HEAD must NOT be called — the explicit-intent gate rejects legacy
|
|
3006
|
+
// entries before any remote operation.
|
|
2898
3007
|
expect(headRemoteFile).not.toHaveBeenCalled();
|
|
2899
3008
|
const refused = events.find((e) => e.type === "delete-refused-stale-etag");
|
|
2900
3009
|
expect(refused).toMatchObject({
|
|
2901
|
-
journalEtag: "<
|
|
2902
|
-
reason: "
|
|
3010
|
+
journalEtag: "<missing-delete-intent>",
|
|
3011
|
+
reason: "missing-delete-intent",
|
|
2903
3012
|
});
|
|
2904
3013
|
expect(result.filesRefusedStale).toBe(1);
|
|
2905
3014
|
});
|
|
@@ -2925,6 +3034,8 @@ describe("share", () => {
|
|
|
2925
3034
|
hash: "h", size: 200, syncedAt: new Date().toISOString(),
|
|
2926
3035
|
direction: "down",
|
|
2927
3036
|
remoteEtag: "upstream-etag",
|
|
3037
|
+
kind: "file",
|
|
3038
|
+
localDeleteIntent: deleteIntent("upstream-etag", "h"),
|
|
2928
3039
|
},
|
|
2929
3040
|
},
|
|
2930
3041
|
}),
|
|
@@ -3059,6 +3170,8 @@ describe("share", () => {
|
|
|
3059
3170
|
hash: "h", size: 5, syncedAt: new Date().toISOString(),
|
|
3060
3171
|
direction: "up",
|
|
3061
3172
|
remoteEtag: "regular-etag",
|
|
3173
|
+
kind: "file",
|
|
3174
|
+
localDeleteIntent: deleteIntent("regular-etag", "h"),
|
|
3062
3175
|
},
|
|
3063
3176
|
},
|
|
3064
3177
|
}),
|
|
@@ -3385,6 +3498,8 @@ describe("share", () => {
|
|
|
3385
3498
|
hash: "h", size: 1, syncedAt: new Date().toISOString(),
|
|
3386
3499
|
direction: "up",
|
|
3387
3500
|
remoteEtag: "abc",
|
|
3501
|
+
kind: "file",
|
|
3502
|
+
localDeleteIntent: deleteIntent("abc", "h"),
|
|
3388
3503
|
},
|
|
3389
3504
|
},
|
|
3390
3505
|
}),
|
|
@@ -3892,6 +4007,8 @@ describe("share", () => {
|
|
|
3892
4007
|
syncedAt: string;
|
|
3893
4008
|
direction: "up" | "down";
|
|
3894
4009
|
remoteEtag: string;
|
|
4010
|
+
kind: "file";
|
|
4011
|
+
localDeleteIntent: ReturnType<typeof deleteIntent>;
|
|
3895
4012
|
}
|
|
3896
4013
|
> = {};
|
|
3897
4014
|
for (let i = 0; i < total; i++) {
|
|
@@ -3902,6 +4019,8 @@ describe("share", () => {
|
|
|
3902
4019
|
syncedAt: new Date().toISOString(),
|
|
3903
4020
|
direction,
|
|
3904
4021
|
remoteEtag: `etag-${i}`,
|
|
4022
|
+
kind: "file",
|
|
4023
|
+
localDeleteIntent: deleteIntent(`etag-${i}`, "h"),
|
|
3905
4024
|
};
|
|
3906
4025
|
if (i >= missing) {
|
|
3907
4026
|
fs.writeFileSync(path.join(companyRoot, key), "hi");
|
|
@@ -4135,17 +4254,22 @@ describe("share", () => {
|
|
|
4135
4254
|
const files: Record<string, {
|
|
4136
4255
|
hash: string; size: number; syncedAt: string;
|
|
4137
4256
|
direction: "up" | "down"; remoteEtag: string;
|
|
4257
|
+
kind: "file"; localDeleteIntent: ReturnType<typeof deleteIntent>;
|
|
4138
4258
|
}> = {};
|
|
4139
4259
|
const now = new Date().toISOString();
|
|
4140
4260
|
for (let i = 0; i < 50; i++) {
|
|
4141
4261
|
files[`d-${i.toString().padStart(4, "0")}.md`] = {
|
|
4142
4262
|
hash: "h", size: 5, syncedAt: now,
|
|
4143
4263
|
direction: "down", remoteEtag: `down-etag-${i}`,
|
|
4264
|
+
kind: "file",
|
|
4265
|
+
localDeleteIntent: deleteIntent(`down-etag-${i}`, "h"),
|
|
4144
4266
|
};
|
|
4145
4267
|
}
|
|
4146
4268
|
files["only-up.md"] = {
|
|
4147
4269
|
hash: "h", size: 5, syncedAt: now,
|
|
4148
4270
|
direction: "up", remoteEtag: "up-etag",
|
|
4271
|
+
kind: "file",
|
|
4272
|
+
localDeleteIntent: deleteIntent("up-etag", "h"),
|
|
4149
4273
|
};
|
|
4150
4274
|
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
4151
4275
|
fs.writeFileSync(journalPath, JSON.stringify({ version: "1", lastSync: now, files }));
|
|
@@ -4293,6 +4417,8 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
4293
4417
|
syncedAt: new Date().toISOString(),
|
|
4294
4418
|
direction: "down",
|
|
4295
4419
|
remoteEtag: "v2-etag",
|
|
4420
|
+
kind: "file",
|
|
4421
|
+
localDeleteIntent: deleteIntent("v2-etag", "h"),
|
|
4296
4422
|
},
|
|
4297
4423
|
},
|
|
4298
4424
|
pulls: [],
|
package/src/cli/share.ts
CHANGED
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
wrapFilterWithPersonalVaultDefaults,
|
|
42
42
|
type PersonalVaultExclusion,
|
|
43
43
|
} from "../personal-vault-exclusions.js";
|
|
44
|
+
import { isPersonalVaultDeletionExcluded } from "../personal-vault.js";
|
|
44
45
|
import { resolveConflict } from "./conflict.js";
|
|
45
46
|
import type { ConflictStrategy } from "./conflict.js";
|
|
46
47
|
import type { SyncProgressEvent } from "./sync.js";
|
|
@@ -1101,7 +1102,7 @@ async function buildSharePlans(run: PushRunContext): Promise<SharePlans> {
|
|
|
1101
1102
|
run.journal,
|
|
1102
1103
|
run.options.decommissionPrefixes ?? [],
|
|
1103
1104
|
run.propagateDeletePolicy,
|
|
1104
|
-
new Set([...deletePlan.toDelete, ...deletePlan.toTombstone]),
|
|
1105
|
+
new Set([...deletePlan.toDelete.map((item) => item.key), ...deletePlan.toTombstone]),
|
|
1105
1106
|
)
|
|
1106
1107
|
: [];
|
|
1107
1108
|
|
|
@@ -1240,7 +1241,11 @@ async function executeUploads(
|
|
|
1240
1241
|
// second HQ root that shares this machine's sync journal) would otherwise read as
|
|
1241
1242
|
// `localChanged && !remoteChanged`, slip past the conflict-scoped guard below, and
|
|
1242
1243
|
// rewind the server's value — the `.last-run` watermark clobber. Skip before HEAD.
|
|
1243
|
-
|
|
1244
|
+
// board.json is the one push-side exception: local board appends are
|
|
1245
|
+
// legitimate, but must flow through the conditional PUT fence below. It
|
|
1246
|
+
// remains cloud-authoritative on pull so server automation still wins a
|
|
1247
|
+
// pull conflict.
|
|
1248
|
+
if (relativePath !== "board.json" && isCloudAuthoritative(relativePath)) {
|
|
1244
1249
|
run.emit({ type: "reconciled", path: relativePath, direction: "push" });
|
|
1245
1250
|
counters.filesSkipped++;
|
|
1246
1251
|
return;
|
|
@@ -1293,6 +1298,8 @@ async function executeUploads(
|
|
|
1293
1298
|
"up",
|
|
1294
1299
|
remoteMeta.etag,
|
|
1295
1300
|
lstat.mtimeMs,
|
|
1301
|
+
undefined,
|
|
1302
|
+
item.kind,
|
|
1296
1303
|
);
|
|
1297
1304
|
run.emit({ type: "reconciled", path: relativePath, direction: "push" });
|
|
1298
1305
|
counters.filesSkipped++;
|
|
@@ -1350,7 +1357,17 @@ async function executeUploads(
|
|
|
1350
1357
|
? await uploadSymlink(run.ctx, item.target, relativePath, run.options.author, pc)
|
|
1351
1358
|
: await uploadFile(run.ctx, absolutePath, relativePath, run.options.author, pc);
|
|
1352
1359
|
|
|
1353
|
-
updateEntry(
|
|
1360
|
+
updateEntry(
|
|
1361
|
+
run.journal,
|
|
1362
|
+
relativePath,
|
|
1363
|
+
localHash,
|
|
1364
|
+
size,
|
|
1365
|
+
"up",
|
|
1366
|
+
etag,
|
|
1367
|
+
mtimeMs,
|
|
1368
|
+
undefined,
|
|
1369
|
+
item.kind,
|
|
1370
|
+
);
|
|
1354
1371
|
if (run.message) {
|
|
1355
1372
|
run.journal.files[relativePath] = {
|
|
1356
1373
|
...run.journal.files[relativePath],
|
|
@@ -1501,14 +1518,39 @@ async function executeDeletes(
|
|
|
1501
1518
|
counters: ShareCounters,
|
|
1502
1519
|
filesRefusedStalePaths: string[],
|
|
1503
1520
|
): Promise<void> {
|
|
1504
|
-
const
|
|
1521
|
+
const deleteItems = [
|
|
1522
|
+
...deletePlan.toDelete.map((item) => ({ ...item, decommission: false })),
|
|
1523
|
+
...decommissionPlan.map((key) => ({ key, intentVersion: null, decommission: true })),
|
|
1524
|
+
];
|
|
1525
|
+
const deleteKeys = deleteItems.map((item) => item.key);
|
|
1505
1526
|
await primeObjectTransport(run.ctx, "delete", deleteKeys);
|
|
1506
|
-
for (const
|
|
1527
|
+
for (const item of deleteItems) {
|
|
1528
|
+
const { key: relativePath } = item;
|
|
1507
1529
|
if (run.vaultConfig && isExpiringSoon(run.ctx.expiresAt)) {
|
|
1508
1530
|
run.ctx = await refreshEntityContext(run.companyRef, run.vaultConfig);
|
|
1509
1531
|
}
|
|
1510
1532
|
try {
|
|
1511
1533
|
const entry = run.journal.files[relativePath];
|
|
1534
|
+
if (
|
|
1535
|
+
!item.decommission &&
|
|
1536
|
+
item.intentVersion !== null &&
|
|
1537
|
+
(!hasCurrentLocalDeleteIntent(entry) ||
|
|
1538
|
+
entry!.localDeleteIntent!.version !== item.intentVersion ||
|
|
1539
|
+
!isLocallyAbsent(path.join(run.syncRoot, relativePath)))
|
|
1540
|
+
) {
|
|
1541
|
+
counters.filesRefusedStale++;
|
|
1542
|
+
if (filesRefusedStalePaths.length < REFUSED_STALE_PATH_CAP) {
|
|
1543
|
+
filesRefusedStalePaths.push(relativePath);
|
|
1544
|
+
}
|
|
1545
|
+
run.emit({
|
|
1546
|
+
type: "delete-refused-stale-etag",
|
|
1547
|
+
path: relativePath,
|
|
1548
|
+
journalEtag: entry?.remoteEtag ?? "<missing-delete-intent>",
|
|
1549
|
+
remoteEtag: "<not-checked>",
|
|
1550
|
+
reason: "intent-changed",
|
|
1551
|
+
});
|
|
1552
|
+
continue;
|
|
1553
|
+
}
|
|
1512
1554
|
const size = entry?.size ?? 0;
|
|
1513
1555
|
await deleteRemoteFile(run.ctx, relativePath);
|
|
1514
1556
|
removeEntry(run.journal, relativePath);
|
|
@@ -2027,7 +2069,9 @@ type RefusedStaleReason =
|
|
|
2027
2069
|
| "stale-etag"
|
|
2028
2070
|
| "legacy-no-etag"
|
|
2029
2071
|
| "bulk-asymmetry"
|
|
2030
|
-
| "divergent-local"
|
|
2072
|
+
| "divergent-local"
|
|
2073
|
+
| "missing-delete-intent"
|
|
2074
|
+
| "intent-changed";
|
|
2031
2075
|
|
|
2032
2076
|
/**
|
|
2033
2077
|
* Bulk-asymmetry circuit-breaker — refuses to convert a suspiciously-large
|
|
@@ -2091,7 +2135,7 @@ function isBulkAsymmetryOverride(): boolean {
|
|
|
2091
2135
|
* uses. Emitted as `delete-refused-stale-etag` events.
|
|
2092
2136
|
*/
|
|
2093
2137
|
interface DeletePlan {
|
|
2094
|
-
toDelete: string
|
|
2138
|
+
toDelete: Array<{ key: string; intentVersion: 1 | null }>;
|
|
2095
2139
|
toTombstone: string[];
|
|
2096
2140
|
refusedStale: Array<{
|
|
2097
2141
|
key: string;
|
|
@@ -2113,6 +2157,34 @@ interface DeletePlan {
|
|
|
2113
2157
|
};
|
|
2114
2158
|
}
|
|
2115
2159
|
|
|
2160
|
+
function hasCurrentLocalDeleteIntent(
|
|
2161
|
+
entry: SyncJournal["files"][string] | undefined,
|
|
2162
|
+
): boolean {
|
|
2163
|
+
const intent = entry?.localDeleteIntent;
|
|
2164
|
+
return !!(
|
|
2165
|
+
intent &&
|
|
2166
|
+
intent.version === 1 &&
|
|
2167
|
+
entry.remoteEtag &&
|
|
2168
|
+
entry.kind &&
|
|
2169
|
+
intent.remoteEtag === entry.remoteEtag &&
|
|
2170
|
+
intent.localHash === entry.hash &&
|
|
2171
|
+
intent.localKind === entry.kind
|
|
2172
|
+
);
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
function isLocallyAbsent(localPath: string): boolean {
|
|
2176
|
+
try {
|
|
2177
|
+
fs.lstatSync(localPath);
|
|
2178
|
+
return false;
|
|
2179
|
+
} catch (err: unknown) {
|
|
2180
|
+
const code =
|
|
2181
|
+
err && typeof err === "object" && "code" in err
|
|
2182
|
+
? (err as { code?: string }).code
|
|
2183
|
+
: undefined;
|
|
2184
|
+
return code === "ENOENT";
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2116
2188
|
/**
|
|
2117
2189
|
* Concurrency cap for the per-file HEAD-O-meter (currency-gated). Sequential
|
|
2118
2190
|
* HEADs would add ~N×(50-200ms) to a sync — for the 261-mirror real-world
|
|
@@ -2216,7 +2288,7 @@ async function computeDeletePlan(
|
|
|
2216
2288
|
// all), or queue it for HEAD (currency-gated). Keeping this synchronous
|
|
2217
2289
|
// means the HEAD pass below sees a single, deduplicated candidate list
|
|
2218
2290
|
// and the journal-mutation buckets are already settled before any I/O.
|
|
2219
|
-
type HeadCandidate = { key: string; journalEtag: string };
|
|
2291
|
+
type HeadCandidate = { key: string; journalEtag: string; intentVersion: 1 };
|
|
2220
2292
|
const headCandidates: HeadCandidate[] = [];
|
|
2221
2293
|
// Litter drain bucket — kept separate from `plan.toDelete` so the
|
|
2222
2294
|
// bulk-asymmetry breaker (which moves toDelete + headCandidates into
|
|
@@ -2263,6 +2335,10 @@ async function computeDeletePlan(
|
|
|
2263
2335
|
}
|
|
2264
2336
|
if (presentLocally) continue;
|
|
2265
2337
|
|
|
2338
|
+
if (!companyScoped && isPersonalVaultDeletionExcluded(relativeKey)) {
|
|
2339
|
+
continue;
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2266
2342
|
// Vault-litter drain (6.0.2): conflict mirrors + rescue drift markers
|
|
2267
2343
|
// ALWAYS drain, bypassing `shouldSync` (which would skip them when the
|
|
2268
2344
|
// parent path is in personal-vault default exclusions like
|
|
@@ -2316,10 +2392,23 @@ async function computeDeletePlan(
|
|
|
2316
2392
|
continue;
|
|
2317
2393
|
}
|
|
2318
2394
|
|
|
2395
|
+
if (!hasCurrentLocalDeleteIntent(entry)) {
|
|
2396
|
+
plan.refusedStale.push({
|
|
2397
|
+
key: relativeKey,
|
|
2398
|
+
journalEtag: entry.remoteEtag ?? "<missing-delete-intent>",
|
|
2399
|
+
remoteEtag: "<not-checked>",
|
|
2400
|
+
reason: "missing-delete-intent",
|
|
2401
|
+
});
|
|
2402
|
+
continue;
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2319
2405
|
if (policy === "all") {
|
|
2320
2406
|
// policy:"all" is the explicit-opt-out emergency-reconcile mode; the
|
|
2321
2407
|
// bulk-asymmetry guard skips this branch (caller asserted intent).
|
|
2322
|
-
plan.toDelete.push(
|
|
2408
|
+
plan.toDelete.push({
|
|
2409
|
+
key: relativeKey,
|
|
2410
|
+
intentVersion: entry.localDeleteIntent!.version,
|
|
2411
|
+
});
|
|
2323
2412
|
continue;
|
|
2324
2413
|
}
|
|
2325
2414
|
bulkCandidatePicks++;
|
|
@@ -2332,7 +2421,10 @@ async function computeDeletePlan(
|
|
|
2332
2421
|
bulkCandidatePicks--;
|
|
2333
2422
|
continue;
|
|
2334
2423
|
}
|
|
2335
|
-
plan.toDelete.push(
|
|
2424
|
+
plan.toDelete.push({
|
|
2425
|
+
key: relativeKey,
|
|
2426
|
+
intentVersion: entry.localDeleteIntent!.version,
|
|
2427
|
+
});
|
|
2336
2428
|
continue;
|
|
2337
2429
|
}
|
|
2338
2430
|
// currency-gated: queue for HEAD unless the entry is legacy (no etag).
|
|
@@ -2346,7 +2438,11 @@ async function computeDeletePlan(
|
|
|
2346
2438
|
});
|
|
2347
2439
|
continue;
|
|
2348
2440
|
}
|
|
2349
|
-
headCandidates.push({
|
|
2441
|
+
headCandidates.push({
|
|
2442
|
+
key: relativeKey,
|
|
2443
|
+
journalEtag,
|
|
2444
|
+
intentVersion: entry.localDeleteIntent!.version,
|
|
2445
|
+
});
|
|
2350
2446
|
}
|
|
2351
2447
|
|
|
2352
2448
|
// Bulk-asymmetry circuit-breaker. See `BULK_ASYMMETRY_*` constants for
|
|
@@ -2375,7 +2471,7 @@ async function computeDeletePlan(
|
|
|
2375
2471
|
samplePaths.push(key);
|
|
2376
2472
|
}
|
|
2377
2473
|
};
|
|
2378
|
-
for (const
|
|
2474
|
+
for (const item of plan.toDelete) pushRefused(item.key);
|
|
2379
2475
|
for (const c of headCandidates) pushRefused(c.key);
|
|
2380
2476
|
plan.toDelete = [];
|
|
2381
2477
|
plan.bulkAsymmetry = {
|
|
@@ -2389,7 +2485,7 @@ async function computeDeletePlan(
|
|
|
2389
2485
|
// of user content; litter cleanup is orthogonal and should never be
|
|
2390
2486
|
// refused for the same reason new-litter producer-side exclusions
|
|
2391
2487
|
// shouldn't block it.
|
|
2392
|
-
plan.toDelete.push(...litterToDelete);
|
|
2488
|
+
plan.toDelete.push(...litterToDelete.map((key) => ({ key, intentVersion: null })));
|
|
2393
2489
|
return plan;
|
|
2394
2490
|
}
|
|
2395
2491
|
|
|
@@ -2436,7 +2532,10 @@ async function computeDeletePlan(
|
|
|
2436
2532
|
}
|
|
2437
2533
|
const currentEtag = normalizeEtag(remote.etag);
|
|
2438
2534
|
if (currentEtag === candidate.journalEtag) {
|
|
2439
|
-
plan.toDelete.push(
|
|
2535
|
+
plan.toDelete.push({
|
|
2536
|
+
key: candidate.key,
|
|
2537
|
+
intentVersion: candidate.intentVersion,
|
|
2538
|
+
});
|
|
2440
2539
|
} else {
|
|
2441
2540
|
plan.refusedStale.push({
|
|
2442
2541
|
key: candidate.key,
|
|
@@ -2453,7 +2552,7 @@ async function computeDeletePlan(
|
|
|
2453
2552
|
// delete loop tombstones each key identically (DeleteObject + remove from
|
|
2454
2553
|
// journal). Merged at the end so the bulk-asymmetry breaker above had its
|
|
2455
2554
|
// chance to NOT include litter in either the numerator or the sweep.
|
|
2456
|
-
plan.toDelete.push(...litterToDelete);
|
|
2555
|
+
plan.toDelete.push(...litterToDelete.map((key) => ({ key, intentVersion: null })));
|
|
2457
2556
|
|
|
2458
2557
|
return plan;
|
|
2459
2558
|
}
|