@indigoai-us/hq-cloud 5.10.0 → 5.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/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +7 -0
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +3 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/share.d.ts +24 -0
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +103 -4
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +192 -1
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +11 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +1 -0
- package/dist/cli/sync.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +3 -0
- package/src/bin/sync-runner.ts +7 -0
- package/src/cli/share.test.ts +238 -1
- package/src/cli/share.ts +135 -4
- package/src/cli/sync.ts +18 -1
package/src/cli/share.test.ts
CHANGED
|
@@ -21,7 +21,7 @@ vi.mock("../s3.js", () => ({
|
|
|
21
21
|
}));
|
|
22
22
|
|
|
23
23
|
import { share } from "./share.js";
|
|
24
|
-
import { headRemoteFile, uploadFile } from "../s3.js";
|
|
24
|
+
import { deleteRemoteFile, headRemoteFile, uploadFile } from "../s3.js";
|
|
25
25
|
import type { EntityContext } from "../types.js";
|
|
26
26
|
|
|
27
27
|
const mockConfig: VaultServiceConfig = {
|
|
@@ -797,4 +797,241 @@ describe("share", () => {
|
|
|
797
797
|
filesToSkip: 1,
|
|
798
798
|
});
|
|
799
799
|
});
|
|
800
|
+
|
|
801
|
+
// ── Delete propagation (propagateDeletes) ──────────────────────────────────
|
|
802
|
+
//
|
|
803
|
+
// The bug: when a user deletes a local file, the next pull re-downloads it
|
|
804
|
+
// from S3 because the remote object is still listable and the pull plan
|
|
805
|
+
// can't tell "never synced" from "synced then deleted". The fix is to
|
|
806
|
+
// propagate local deletes to S3 on the push side. The vault buckets have
|
|
807
|
+
// versioning enabled, so DeleteObject is soft (a delete-marker becomes the
|
|
808
|
+
// current version; prior object versions remain recoverable).
|
|
809
|
+
|
|
810
|
+
it("propagateDeletes: deletes journal-tracked files whose local copy is gone", async () => {
|
|
811
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
812
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
813
|
+
// Only "kept.md" exists locally; "gone.md" was previously synced and then
|
|
814
|
+
// deleted by the user.
|
|
815
|
+
fs.writeFileSync(path.join(companyRoot, "kept.md"), "still here");
|
|
816
|
+
|
|
817
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
818
|
+
fs.writeFileSync(
|
|
819
|
+
journalPath,
|
|
820
|
+
JSON.stringify({
|
|
821
|
+
version: "1",
|
|
822
|
+
lastSync: new Date().toISOString(),
|
|
823
|
+
files: {
|
|
824
|
+
"kept.md": {
|
|
825
|
+
hash: "irrelevant-not-checked-here",
|
|
826
|
+
size: 10,
|
|
827
|
+
syncedAt: new Date().toISOString(),
|
|
828
|
+
direction: "up",
|
|
829
|
+
remoteEtag: "kept-etag",
|
|
830
|
+
},
|
|
831
|
+
"gone.md": {
|
|
832
|
+
hash: "irrelevant-not-checked-here",
|
|
833
|
+
size: 7,
|
|
834
|
+
syncedAt: new Date().toISOString(),
|
|
835
|
+
direction: "up",
|
|
836
|
+
remoteEtag: "gone-etag",
|
|
837
|
+
},
|
|
838
|
+
},
|
|
839
|
+
}),
|
|
840
|
+
);
|
|
841
|
+
|
|
842
|
+
const result = await share({
|
|
843
|
+
paths: [companyRoot],
|
|
844
|
+
company: "acme",
|
|
845
|
+
vaultConfig: mockConfig,
|
|
846
|
+
hqRoot: tmpDir,
|
|
847
|
+
skipUnchanged: true,
|
|
848
|
+
propagateDeletes: true,
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
expect(result.filesDeleted).toBe(1);
|
|
852
|
+
expect(deleteRemoteFile).toHaveBeenCalledTimes(1);
|
|
853
|
+
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "gone.md");
|
|
854
|
+
|
|
855
|
+
// Journal entry for the gone file is removed; the kept entry stays.
|
|
856
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
857
|
+
expect(journal.files["gone.md"]).toBeUndefined();
|
|
858
|
+
expect(journal.files["kept.md"]).toBeDefined();
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
it("propagateDeletes: emits a `progress` event with deleted:true and bytes from the journal", async () => {
|
|
862
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
863
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
864
|
+
|
|
865
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
866
|
+
fs.writeFileSync(
|
|
867
|
+
journalPath,
|
|
868
|
+
JSON.stringify({
|
|
869
|
+
version: "1",
|
|
870
|
+
lastSync: new Date().toISOString(),
|
|
871
|
+
files: {
|
|
872
|
+
"removed.md": {
|
|
873
|
+
hash: "h",
|
|
874
|
+
size: 42,
|
|
875
|
+
syncedAt: new Date().toISOString(),
|
|
876
|
+
direction: "up",
|
|
877
|
+
},
|
|
878
|
+
},
|
|
879
|
+
}),
|
|
880
|
+
);
|
|
881
|
+
|
|
882
|
+
const events: Array<{ type: string; path?: string; bytes?: number; deleted?: boolean }> = [];
|
|
883
|
+
await share({
|
|
884
|
+
paths: [companyRoot],
|
|
885
|
+
company: "acme",
|
|
886
|
+
vaultConfig: mockConfig,
|
|
887
|
+
hqRoot: tmpDir,
|
|
888
|
+
skipUnchanged: true,
|
|
889
|
+
propagateDeletes: true,
|
|
890
|
+
onEvent: (e) => events.push(e as { type: string }),
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
const planEvent = events.find((e) => e.type === "plan") as { filesToDelete?: number } | undefined;
|
|
894
|
+
expect(planEvent?.filesToDelete).toBe(1);
|
|
895
|
+
|
|
896
|
+
const deleteProgress = events.find(
|
|
897
|
+
(e) => e.type === "progress" && e.deleted === true,
|
|
898
|
+
);
|
|
899
|
+
expect(deleteProgress).toMatchObject({
|
|
900
|
+
type: "progress",
|
|
901
|
+
path: "removed.md",
|
|
902
|
+
bytes: 42,
|
|
903
|
+
deleted: true,
|
|
904
|
+
});
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
it("propagateDeletes=false (default): missing local files do NOT trigger a remote delete", async () => {
|
|
908
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
909
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
910
|
+
|
|
911
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
912
|
+
fs.writeFileSync(
|
|
913
|
+
journalPath,
|
|
914
|
+
JSON.stringify({
|
|
915
|
+
version: "1",
|
|
916
|
+
lastSync: new Date().toISOString(),
|
|
917
|
+
files: {
|
|
918
|
+
"gone.md": {
|
|
919
|
+
hash: "h",
|
|
920
|
+
size: 7,
|
|
921
|
+
syncedAt: new Date().toISOString(),
|
|
922
|
+
direction: "up",
|
|
923
|
+
},
|
|
924
|
+
},
|
|
925
|
+
}),
|
|
926
|
+
);
|
|
927
|
+
|
|
928
|
+
const result = await share({
|
|
929
|
+
paths: [companyRoot],
|
|
930
|
+
company: "acme",
|
|
931
|
+
vaultConfig: mockConfig,
|
|
932
|
+
hqRoot: tmpDir,
|
|
933
|
+
skipUnchanged: true,
|
|
934
|
+
// propagateDeletes omitted ⇒ defaults to false
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
expect(result.filesDeleted).toBe(0);
|
|
938
|
+
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
939
|
+
// Journal entry survives so the next opt-in run can still propagate.
|
|
940
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
941
|
+
expect(journal.files["gone.md"]).toBeDefined();
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
it("propagateDeletes: scope is constrained to the supplied paths — sibling deletes are not swept", async () => {
|
|
945
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
946
|
+
fs.mkdirSync(path.join(companyRoot, "in-scope"), { recursive: true });
|
|
947
|
+
fs.mkdirSync(path.join(companyRoot, "other"), { recursive: true });
|
|
948
|
+
|
|
949
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
950
|
+
fs.writeFileSync(
|
|
951
|
+
journalPath,
|
|
952
|
+
JSON.stringify({
|
|
953
|
+
version: "1",
|
|
954
|
+
lastSync: new Date().toISOString(),
|
|
955
|
+
files: {
|
|
956
|
+
"in-scope/gone.md": {
|
|
957
|
+
hash: "h",
|
|
958
|
+
size: 1,
|
|
959
|
+
syncedAt: new Date().toISOString(),
|
|
960
|
+
direction: "up",
|
|
961
|
+
},
|
|
962
|
+
"other/also-gone.md": {
|
|
963
|
+
hash: "h",
|
|
964
|
+
size: 1,
|
|
965
|
+
syncedAt: new Date().toISOString(),
|
|
966
|
+
direction: "up",
|
|
967
|
+
},
|
|
968
|
+
},
|
|
969
|
+
}),
|
|
970
|
+
);
|
|
971
|
+
|
|
972
|
+
const result = await share({
|
|
973
|
+
paths: [path.join(companyRoot, "in-scope")],
|
|
974
|
+
company: "acme",
|
|
975
|
+
vaultConfig: mockConfig,
|
|
976
|
+
hqRoot: tmpDir,
|
|
977
|
+
skipUnchanged: true,
|
|
978
|
+
propagateDeletes: true,
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
expect(result.filesDeleted).toBe(1);
|
|
982
|
+
expect(deleteRemoteFile).toHaveBeenCalledTimes(1);
|
|
983
|
+
expect(deleteRemoteFile).toHaveBeenCalledWith(
|
|
984
|
+
expect.anything(),
|
|
985
|
+
"in-scope/gone.md",
|
|
986
|
+
);
|
|
987
|
+
|
|
988
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
989
|
+
expect(journal.files["in-scope/gone.md"]).toBeUndefined();
|
|
990
|
+
// Sibling tree's journal entry is untouched — `hq share <subtree>` must
|
|
991
|
+
// not act on files outside the named scope.
|
|
992
|
+
expect(journal.files["other/also-gone.md"]).toBeDefined();
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
it("propagateDeletes: a failed DeleteObject leaves the journal entry intact for retry", async () => {
|
|
996
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
997
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
998
|
+
|
|
999
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1000
|
+
fs.writeFileSync(
|
|
1001
|
+
journalPath,
|
|
1002
|
+
JSON.stringify({
|
|
1003
|
+
version: "1",
|
|
1004
|
+
lastSync: new Date().toISOString(),
|
|
1005
|
+
files: {
|
|
1006
|
+
"flaky.md": {
|
|
1007
|
+
hash: "h",
|
|
1008
|
+
size: 5,
|
|
1009
|
+
syncedAt: new Date().toISOString(),
|
|
1010
|
+
direction: "up",
|
|
1011
|
+
},
|
|
1012
|
+
},
|
|
1013
|
+
}),
|
|
1014
|
+
);
|
|
1015
|
+
|
|
1016
|
+
vi.mocked(deleteRemoteFile).mockRejectedValueOnce(new Error("S3 down"));
|
|
1017
|
+
|
|
1018
|
+
const events: Array<{ type: string; path?: string; message?: string }> = [];
|
|
1019
|
+
const result = await share({
|
|
1020
|
+
paths: [companyRoot],
|
|
1021
|
+
company: "acme",
|
|
1022
|
+
vaultConfig: mockConfig,
|
|
1023
|
+
hqRoot: tmpDir,
|
|
1024
|
+
skipUnchanged: true,
|
|
1025
|
+
propagateDeletes: true,
|
|
1026
|
+
onEvent: (e) => events.push(e as { type: string }),
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
expect(result.filesDeleted).toBe(0);
|
|
1030
|
+
const errorEvent = events.find((e) => e.type === "error");
|
|
1031
|
+
expect(errorEvent).toMatchObject({ path: "flaky.md", message: expect.stringContaining("S3 down") });
|
|
1032
|
+
|
|
1033
|
+
// Entry survives — next run will retry the delete.
|
|
1034
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
1035
|
+
expect(journal.files["flaky.md"]).toBeDefined();
|
|
1036
|
+
});
|
|
800
1037
|
});
|
package/src/cli/share.ts
CHANGED
|
@@ -9,8 +9,8 @@ import * as fs from "fs";
|
|
|
9
9
|
import * as path from "path";
|
|
10
10
|
import type { EntityContext, VaultServiceConfig, SyncJournal } from "../types.js";
|
|
11
11
|
import { resolveEntityContext, isExpiringSoon, refreshEntityContext } from "../context.js";
|
|
12
|
-
import { uploadFile, headRemoteFile } from "../s3.js";
|
|
13
|
-
import { readJournal, writeJournal, hashFile, updateEntry, normalizeEtag } from "../journal.js";
|
|
12
|
+
import { uploadFile, headRemoteFile, deleteRemoteFile } from "../s3.js";
|
|
13
|
+
import { readJournal, writeJournal, hashFile, updateEntry, removeEntry, normalizeEtag } from "../journal.js";
|
|
14
14
|
import { createIgnoreFilter, isWithinSizeLimit } from "../ignore.js";
|
|
15
15
|
import { resolveConflict } from "./conflict.js";
|
|
16
16
|
import type { ConflictStrategy } from "./conflict.js";
|
|
@@ -166,12 +166,36 @@ export interface ShareOptions {
|
|
|
166
166
|
* hash matches the last-sync state (e.g. to re-heal a bucket).
|
|
167
167
|
*/
|
|
168
168
|
skipUnchanged?: boolean;
|
|
169
|
+
/**
|
|
170
|
+
* When true, journal entries whose local file is gone trigger a remote
|
|
171
|
+
* `DeleteObject`. Only entries whose key falls under one of the supplied
|
|
172
|
+
* `paths` (after resolution to absolute paths under `companies/{slug}/`)
|
|
173
|
+
* are considered, so `hq share <file>` can never sweep deletes outside
|
|
174
|
+
* the named scope.
|
|
175
|
+
*
|
|
176
|
+
* Vault buckets have versioning enabled, so the delete is soft: a
|
|
177
|
+
* delete-marker becomes the current version and prior object versions
|
|
178
|
+
* remain recoverable indefinitely. The pull-side `listRemoteFiles` skips
|
|
179
|
+
* objects whose current version is a delete-marker (default
|
|
180
|
+
* `ListObjectsV2` behavior), so a deletion stops the next pull from
|
|
181
|
+
* re-downloading the file on this and any other machine.
|
|
182
|
+
*
|
|
183
|
+
* Default false to preserve `hq share <file>` semantics — only the
|
|
184
|
+
* full-tree bidirectional runner opts in.
|
|
185
|
+
*/
|
|
186
|
+
propagateDeletes?: boolean;
|
|
169
187
|
}
|
|
170
188
|
|
|
171
189
|
export interface ShareResult {
|
|
172
190
|
filesUploaded: number;
|
|
173
191
|
bytesUploaded: number;
|
|
174
192
|
filesSkipped: number;
|
|
193
|
+
/**
|
|
194
|
+
* Number of remote `DeleteObject` calls that succeeded this run. Always 0
|
|
195
|
+
* when `propagateDeletes` is false. The corresponding journal entries are
|
|
196
|
+
* removed in the same pass so the next sync sees the key as truly gone.
|
|
197
|
+
*/
|
|
198
|
+
filesDeleted: number;
|
|
175
199
|
/**
|
|
176
200
|
* Paths (company-relative) that were detected as push conflicts. Mirrors
|
|
177
201
|
* `SyncResult.conflictPaths` so push and pull surface conflicts the same
|
|
@@ -185,7 +209,7 @@ export interface ShareResult {
|
|
|
185
209
|
* Share local file(s) to the entity vault.
|
|
186
210
|
*/
|
|
187
211
|
export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
188
|
-
const { paths, company, message, onConflict, vaultConfig, entityContext, hqRoot, skipUnchanged } = options;
|
|
212
|
+
const { paths, company, message, onConflict, vaultConfig, entityContext, hqRoot, skipUnchanged, propagateDeletes } = options;
|
|
189
213
|
const emit = options.onEvent ?? defaultConsoleLogger;
|
|
190
214
|
|
|
191
215
|
// Exactly-one-of contract: either we vend (vaultConfig) or the caller did
|
|
@@ -237,6 +261,7 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
237
261
|
let filesUploaded = 0;
|
|
238
262
|
let bytesUploaded = 0;
|
|
239
263
|
let filesSkipped = 0;
|
|
264
|
+
let filesDeleted = 0;
|
|
240
265
|
const conflictPaths: string[] = [];
|
|
241
266
|
|
|
242
267
|
// Collect all files to share
|
|
@@ -249,6 +274,17 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
249
274
|
// knowable here). The final `complete` event reports authoritative counts.
|
|
250
275
|
const plan = computePushPlan(filesToShare, journal, skipUnchanged === true);
|
|
251
276
|
|
|
277
|
+
// Delete-propagation plan: walk journal entries whose key falls under the
|
|
278
|
+
// requested scope; any whose local file is gone is a candidate for a
|
|
279
|
+
// remote DeleteObject. Only computed when the caller opts in — `hq share
|
|
280
|
+
// <file>` must never sweep deletes outside the explicit path list.
|
|
281
|
+
const deleteScopeRoots = propagateDeletes === true
|
|
282
|
+
? resolveDeleteScopeRoots(paths, hqRoot, syncRoot)
|
|
283
|
+
: [];
|
|
284
|
+
const deletePlan = propagateDeletes === true
|
|
285
|
+
? computeDeletePlan(journal, syncRoot, deleteScopeRoots)
|
|
286
|
+
: [];
|
|
287
|
+
|
|
252
288
|
emit({
|
|
253
289
|
type: "plan",
|
|
254
290
|
// share() is push-only; pull counts are sourced from sync()'s plan event.
|
|
@@ -260,6 +296,7 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
260
296
|
// Push conflicts require a remote HEAD; we don't yet do that in Stage 1,
|
|
261
297
|
// so this stays 0. V1.5 (single LIST) will let us classify them up-front.
|
|
262
298
|
filesToConflict: 0,
|
|
299
|
+
filesToDelete: deletePlan.length,
|
|
263
300
|
});
|
|
264
301
|
|
|
265
302
|
// Stage 2: execute. Skip items pre-classified as no-ops, then for each
|
|
@@ -329,6 +366,7 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
329
366
|
filesUploaded,
|
|
330
367
|
bytesUploaded,
|
|
331
368
|
filesSkipped,
|
|
369
|
+
filesDeleted,
|
|
332
370
|
conflictPaths,
|
|
333
371
|
aborted: true,
|
|
334
372
|
};
|
|
@@ -375,6 +413,36 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
375
413
|
}
|
|
376
414
|
}
|
|
377
415
|
|
|
416
|
+
// Stage 3: propagate deletes. Each call writes a delete-marker (versioning
|
|
417
|
+
// is enabled on the bucket) and removes the corresponding journal entry so
|
|
418
|
+
// the next sync sees the key as truly gone on this machine. A failed
|
|
419
|
+
// DeleteObject leaves both the journal entry and the remote object intact
|
|
420
|
+
// — the next run will retry.
|
|
421
|
+
for (const relativePath of deletePlan) {
|
|
422
|
+
if (vaultConfig && isExpiringSoon(ctx.expiresAt)) {
|
|
423
|
+
ctx = await refreshEntityContext(companyRef, vaultConfig);
|
|
424
|
+
}
|
|
425
|
+
try {
|
|
426
|
+
const entry = journal.files[relativePath];
|
|
427
|
+
const size = entry?.size ?? 0;
|
|
428
|
+
await deleteRemoteFile(ctx, relativePath);
|
|
429
|
+
removeEntry(journal, relativePath);
|
|
430
|
+
filesDeleted++;
|
|
431
|
+
emit({
|
|
432
|
+
type: "progress",
|
|
433
|
+
path: relativePath,
|
|
434
|
+
bytes: size,
|
|
435
|
+
deleted: true,
|
|
436
|
+
});
|
|
437
|
+
} catch (err) {
|
|
438
|
+
emit({
|
|
439
|
+
type: "error",
|
|
440
|
+
path: relativePath,
|
|
441
|
+
message: err instanceof Error ? err.message : String(err),
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
378
446
|
// See cli/sync.ts: stamp lastSync on completion so a no-op share still
|
|
379
447
|
// ticks the "Last sync" indicator.
|
|
380
448
|
journal.lastSync = new Date().toISOString();
|
|
@@ -384,6 +452,7 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
384
452
|
filesUploaded,
|
|
385
453
|
bytesUploaded,
|
|
386
454
|
filesSkipped,
|
|
455
|
+
filesDeleted,
|
|
387
456
|
conflictPaths,
|
|
388
457
|
aborted: false,
|
|
389
458
|
};
|
|
@@ -401,7 +470,9 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
|
|
|
401
470
|
);
|
|
402
471
|
}
|
|
403
472
|
} else if (event.type === "progress") {
|
|
404
|
-
if (event.
|
|
473
|
+
if (event.deleted) {
|
|
474
|
+
console.log(` ✗ ${event.path} (deleted)`);
|
|
475
|
+
} else if (event.message) {
|
|
405
476
|
console.log(` ✓ ${event.path} — "${event.message}"`);
|
|
406
477
|
} else {
|
|
407
478
|
console.log(` ✓ ${event.path}`);
|
|
@@ -536,3 +607,63 @@ function hasRemoteChanged(
|
|
|
536
607
|
const syncedAt = new Date(entry.syncedAt).getTime();
|
|
537
608
|
return remote.lastModified.getTime() > syncedAt;
|
|
538
609
|
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Resolve each user-supplied share path to a directory under `syncRoot`,
|
|
613
|
+
* returning the company-relative prefix that constrains delete propagation.
|
|
614
|
+
* Files (non-directories) and paths outside the company root are dropped —
|
|
615
|
+
* a delete sweep keyed off a single file or a sibling tree would surprise
|
|
616
|
+
* users who expected deletes to mirror the targeted scope.
|
|
617
|
+
*
|
|
618
|
+
* Returns `[""]` (whole-tree) when any input path resolves to `syncRoot`
|
|
619
|
+
* itself; this is the bidirectional-runner case.
|
|
620
|
+
*/
|
|
621
|
+
function resolveDeleteScopeRoots(
|
|
622
|
+
paths: string[],
|
|
623
|
+
hqRoot: string,
|
|
624
|
+
syncRoot: string,
|
|
625
|
+
): string[] {
|
|
626
|
+
const prefixes = new Set<string>();
|
|
627
|
+
for (const p of paths) {
|
|
628
|
+
const absolutePath = path.isAbsolute(p) ? p : path.resolve(hqRoot, p);
|
|
629
|
+
if (!fs.existsSync(absolutePath)) continue;
|
|
630
|
+
if (!isWithin(syncRoot, absolutePath)) continue;
|
|
631
|
+
const stat = fs.statSync(absolutePath);
|
|
632
|
+
if (!stat.isDirectory()) continue;
|
|
633
|
+
const rel = path.relative(syncRoot, absolutePath);
|
|
634
|
+
if (rel === "" || rel === ".") {
|
|
635
|
+
return [""];
|
|
636
|
+
}
|
|
637
|
+
prefixes.add(rel.split(path.sep).join("/"));
|
|
638
|
+
}
|
|
639
|
+
return Array.from(prefixes);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Walk every journal key in `scopeRoots` whose local file is missing from
|
|
644
|
+
* disk, and return the keys to delete. A key is in-scope when it matches
|
|
645
|
+
* (or sits beneath) one of the resolved prefixes. Empty `scopeRoots` ⇒
|
|
646
|
+
* empty plan (caller didn't opt in).
|
|
647
|
+
*/
|
|
648
|
+
function computeDeletePlan(
|
|
649
|
+
journal: SyncJournal,
|
|
650
|
+
syncRoot: string,
|
|
651
|
+
scopeRoots: string[],
|
|
652
|
+
): string[] {
|
|
653
|
+
if (scopeRoots.length === 0) return [];
|
|
654
|
+
const out: string[] = [];
|
|
655
|
+
for (const relativeKey of Object.keys(journal.files)) {
|
|
656
|
+
const inScope = scopeRoots.some(
|
|
657
|
+
(root) =>
|
|
658
|
+
root === "" ||
|
|
659
|
+
relativeKey === root ||
|
|
660
|
+
relativeKey.startsWith(`${root}/`),
|
|
661
|
+
);
|
|
662
|
+
if (!inScope) continue;
|
|
663
|
+
const localPath = path.join(syncRoot, relativeKey);
|
|
664
|
+
if (!fs.existsSync(localPath)) {
|
|
665
|
+
out.push(relativeKey);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
return out;
|
|
669
|
+
}
|
package/src/cli/sync.ts
CHANGED
|
@@ -56,8 +56,24 @@ export type SyncProgressEvent =
|
|
|
56
56
|
* conflict detection requires a remote HEAD that runs in Stage 2.
|
|
57
57
|
*/
|
|
58
58
|
filesToConflict: number;
|
|
59
|
+
/**
|
|
60
|
+
* Remote keys this run intends to delete from S3 (push-only with
|
|
61
|
+
* `propagateDeletes`; 0 from sync). A key is scheduled for deletion when
|
|
62
|
+
* its journal entry exists, the local file is gone, and the key falls
|
|
63
|
+
* within the share scope. The bucket has versioning enabled so the
|
|
64
|
+
* delete is soft (a delete-marker is written; prior versions remain
|
|
65
|
+
* recoverable).
|
|
66
|
+
*/
|
|
67
|
+
filesToDelete: number;
|
|
68
|
+
}
|
|
69
|
+
| {
|
|
70
|
+
type: "progress";
|
|
71
|
+
path: string;
|
|
72
|
+
bytes: number;
|
|
73
|
+
message?: string;
|
|
74
|
+
/** True when this event reports a remote DeleteObject (no upload). */
|
|
75
|
+
deleted?: boolean;
|
|
59
76
|
}
|
|
60
|
-
| { type: "progress"; path: string; bytes: number; message?: string }
|
|
61
77
|
| { type: "error"; path: string; message: string }
|
|
62
78
|
| {
|
|
63
79
|
type: "conflict";
|
|
@@ -173,6 +189,7 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
173
189
|
bytesToUpload: 0,
|
|
174
190
|
filesToSkip: plan.filesToSkip,
|
|
175
191
|
filesToConflict: plan.filesToConflict,
|
|
192
|
+
filesToDelete: 0,
|
|
176
193
|
});
|
|
177
194
|
|
|
178
195
|
// Stage 2: execute the plan. Per-item branching mirrors the pre-refactor
|