@indigoai-us/hq-cloud 6.14.9 → 6.14.11
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 +131 -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 +138 -41
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +271 -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-exclusions.d.ts +20 -4
- package/dist/personal-vault-exclusions.d.ts.map +1 -1
- package/dist/personal-vault-exclusions.js +37 -3
- package/dist/personal-vault-exclusions.js.map +1 -1
- package/dist/personal-vault-exclusions.test.js +19 -0
- package/dist/personal-vault-exclusions.test.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 +23 -1
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +74 -3
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.js +71 -1
- package/dist/s3.test.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 +176 -3
- package/src/cli/share.ts +114 -15
- package/src/cli/sync.test.ts +330 -24
- package/src/cli/sync.ts +184 -50
- package/src/journal.ts +28 -0
- package/src/personal-vault-exclusions.test.ts +23 -0
- package/src/personal-vault-exclusions.ts +39 -3
- package/src/personal-vault.ts +15 -0
- package/src/s3.test.ts +91 -0
- package/src/s3.ts +88 -2
- package/src/types.ts +13 -0
package/dist/cli/sync.d.ts
CHANGED
|
@@ -146,7 +146,7 @@ export type SyncProgressEvent = {
|
|
|
146
146
|
path: string;
|
|
147
147
|
journalEtag: string;
|
|
148
148
|
remoteEtag: string;
|
|
149
|
-
reason: "stale-etag" | "legacy-no-etag" | "bulk-asymmetry" | "divergent-local";
|
|
149
|
+
reason: "stale-etag" | "legacy-no-etag" | "bulk-asymmetry" | "divergent-local" | "missing-delete-intent" | "intent-changed";
|
|
150
150
|
} | {
|
|
151
151
|
/**
|
|
152
152
|
* Emitted at most ONCE per `share()` call when the bulk-asymmetry
|
package/dist/cli/sync.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sync.d.ts","sourceRoot":"","sources":["../../src/cli/sync.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAIH,OAAO,KAAK,EAEV,kBAAkB,EAEnB,MAAM,aAAa,CAAC;AACrB,OAAO,EAAe,KAAK,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAChE,OAAO,EAEL,KAAK,eAAe,EACrB,MAAM,wBAAwB,CAAC;
|
|
1
|
+
{"version":3,"file":"sync.d.ts","sourceRoot":"","sources":["../../src/cli/sync.ts"],"names":[],"mappings":"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"}
|
package/dist/cli/sync.js
CHANGED
|
@@ -14,6 +14,7 @@ import { localPathForVaultKey, vaultKeyForLocalPath } from "../local-path-codec.
|
|
|
14
14
|
import { downloadFile, listRemoteFiles, headRemoteFile, primeObjectTransport, toPosixKey, classifyVaultKey, } from "../s3.js";
|
|
15
15
|
import { readJournal, writeJournal, hashFile, hashSymlinkTarget, updateEntry, removeEntry, getEntry, normalizeEtag, migrateToV2, gcTombstones, isTombstone, tombstoneEntry, lastPullRecord, appendPullRecord, generatePullId, PERSONAL_VAULT_JOURNAL_SLUG, migratePersonalVaultJournal, } from "../journal.js";
|
|
16
16
|
import { PERSONAL_VAULT_MANIFEST_KEY } from "../personal-vault.js";
|
|
17
|
+
import { isPersonalVaultExcluded } from "../personal-vault-exclusions.js";
|
|
17
18
|
import { buildScopeShrinkPlan, applyScopeShrink, ScopeShrinkBlockedError, ScopeShrinkLargePruneError, } from "../scope-shrink.js";
|
|
18
19
|
import { coalescePrefixes, isCoveredByAny, } from "../prefix-coalesce.js";
|
|
19
20
|
import { createIgnoreFilter } from "../ignore.js";
|
|
@@ -461,12 +462,19 @@ async function executeConflictExecutor(run, plan, scopeRun, counters) {
|
|
|
461
462
|
}
|
|
462
463
|
function executeFileTombstoneDelete(run, item, counters) {
|
|
463
464
|
const tombstoneKey = item.remoteFile.key;
|
|
464
|
-
const tombstonePath =
|
|
465
|
-
if (
|
|
465
|
+
const tombstonePath = item.localPath;
|
|
466
|
+
if (!isDownloadWritePathStillContained(run.companyRoot, tombstoneKey, tombstonePath)) {
|
|
467
|
+
run.emit({
|
|
468
|
+
type: "error",
|
|
469
|
+
path: tombstoneKey,
|
|
470
|
+
message: "tombstone-suppress unlink skipped: local parent escaped the sync root",
|
|
471
|
+
});
|
|
466
472
|
return;
|
|
473
|
+
}
|
|
467
474
|
try {
|
|
468
475
|
const lstat = fs.lstatSync(tombstonePath);
|
|
469
|
-
if (
|
|
476
|
+
if (!matchesLocalSnapshot(tombstonePath, item.localSnapshot)) {
|
|
477
|
+
reportLocalSnapshotConflict(run, counters, tombstoneKey);
|
|
470
478
|
return;
|
|
471
479
|
}
|
|
472
480
|
if (lstat.isSymbolicLink() || lstat.isFile()) {
|
|
@@ -501,7 +509,13 @@ async function executeConflictItem(run, plan, scopeRun, counters, downloadItems,
|
|
|
501
509
|
// the `.conflict-` mirror loop AND the journal false-stamp (remote etag over
|
|
502
510
|
// divergent local content) that the keep path produced for these files.
|
|
503
511
|
if (isCloudAuthoritative(vaultKeyForLocalPath(run.hqRoot, localPath))) {
|
|
504
|
-
downloadItems.push({
|
|
512
|
+
downloadItems.push({
|
|
513
|
+
action: "download",
|
|
514
|
+
remoteFile,
|
|
515
|
+
localPath,
|
|
516
|
+
isNew: false,
|
|
517
|
+
localSnapshot: item.localSnapshot,
|
|
518
|
+
});
|
|
505
519
|
run.emit({ type: "reconciled", path: remoteFile.key, direction: "pull" });
|
|
506
520
|
return null;
|
|
507
521
|
}
|
|
@@ -547,7 +561,7 @@ async function executeConflictItem(run, plan, scopeRun, counters, downloadItems,
|
|
|
547
561
|
/* best-effort cleanup; a stray identical mirror is harmless */
|
|
548
562
|
}
|
|
549
563
|
}
|
|
550
|
-
updateEntry(run.journal, remoteFile.key, item.localHash, item.localSize, "down", remoteFile.etag, item.localMtime.getTime());
|
|
564
|
+
updateEntry(run.journal, remoteFile.key, item.localHash, item.localSize, "down", remoteFile.etag, item.localMtime.getTime(), item.localSnapshot.kind === "symlink" ? "symlink" : "file");
|
|
551
565
|
run.emit({ type: "reconciled", path: remoteFile.key, direction: "pull" });
|
|
552
566
|
counters.filesSkipped++;
|
|
553
567
|
return null;
|
|
@@ -620,7 +634,7 @@ async function executeConflictItem(run, plan, scopeRun, counters, downloadItems,
|
|
|
620
634
|
}
|
|
621
635
|
if (resolution === "keep" || resolution === "skip") {
|
|
622
636
|
counters.filesSkipped++;
|
|
623
|
-
updateEntry(run.journal, remoteFile.key, item.localHash, item.localSize, "down", remoteFile.etag, item.localMtime.getTime());
|
|
637
|
+
updateEntry(run.journal, remoteFile.key, item.localHash, item.localSize, "down", remoteFile.etag, item.localMtime.getTime(), item.localSnapshot.kind === "symlink" ? "symlink" : "file");
|
|
624
638
|
// Journal-honesty: we recorded the remote etag so this conflict can't
|
|
625
639
|
// re-fire (#137), but the KEPT local copy diverges from that remote — it
|
|
626
640
|
// never matched it. Flag the entry so the currency-gated delete planner
|
|
@@ -637,6 +651,7 @@ async function executeConflictItem(run, plan, scopeRun, counters, downloadItems,
|
|
|
637
651
|
remoteFile,
|
|
638
652
|
localPath,
|
|
639
653
|
isNew: false,
|
|
654
|
+
localSnapshot: item.localSnapshot,
|
|
640
655
|
});
|
|
641
656
|
return null;
|
|
642
657
|
}
|
|
@@ -685,7 +700,13 @@ async function downloadOne(run, downloadItem, counters) {
|
|
|
685
700
|
return;
|
|
686
701
|
}
|
|
687
702
|
try {
|
|
688
|
-
const { metadata, contentHash, contentSize, modeWarnings } = await downloadFile(run.ctx, remoteFile.key, localPath
|
|
703
|
+
const { metadata, contentHash, contentSize, modeWarnings } = await downloadFile(run.ctx, remoteFile.key, localPath, {
|
|
704
|
+
beforeReplace: () => {
|
|
705
|
+
if (!matchesLocalSnapshot(localPath, downloadItem.localSnapshot)) {
|
|
706
|
+
throw new LocalSnapshotChangedError(remoteFile.key);
|
|
707
|
+
}
|
|
708
|
+
},
|
|
709
|
+
});
|
|
689
710
|
reportModeWarnings(run, modeWarnings);
|
|
690
711
|
const author = metadata?.["created-by"] ?? null;
|
|
691
712
|
const createdBySub = metadata?.["created-by-sub"];
|
|
@@ -695,7 +716,7 @@ async function downloadOne(run, downloadItem, counters) {
|
|
|
695
716
|
? hashSymlinkTarget(fs.readlinkSync(localPath))
|
|
696
717
|
: (contentHash ?? hashFile(localPath));
|
|
697
718
|
const size = isLocalSymlink ? 0 : (contentSize ?? fs.statSync(localPath).size);
|
|
698
|
-
updateEntry(run.journal, remoteFile.key, hash, size, "down", remoteFile.etag, localLstat.mtimeMs, createdBySub);
|
|
719
|
+
updateEntry(run.journal, remoteFile.key, hash, size, "down", remoteFile.etag, localLstat.mtimeMs, createdBySub, isLocalSymlink ? "symlink" : "file");
|
|
699
720
|
const priorEntry = getEntry(run.journal, remoteFile.key);
|
|
700
721
|
const remoteJournalMessage = priorEntry?.message;
|
|
701
722
|
run.emit({
|
|
@@ -710,7 +731,10 @@ async function downloadOne(run, downloadItem, counters) {
|
|
|
710
731
|
counters.changedPaths.push(remoteFile.key);
|
|
711
732
|
}
|
|
712
733
|
catch (err) {
|
|
713
|
-
if (
|
|
734
|
+
if (err instanceof LocalSnapshotChangedError) {
|
|
735
|
+
reportLocalSnapshotConflict(run, counters, remoteFile.key);
|
|
736
|
+
}
|
|
737
|
+
else if (isAccessDenied(err)) {
|
|
714
738
|
counters.filesSkipped++;
|
|
715
739
|
}
|
|
716
740
|
else {
|
|
@@ -779,12 +803,12 @@ async function verifyPlannedJournalTombstones(run, plan) {
|
|
|
779
803
|
// the push leg ever re-uploading it (tombstone-not-reupload).
|
|
780
804
|
const autoVerified = [];
|
|
781
805
|
const toHeadVerify = [];
|
|
782
|
-
for (const
|
|
783
|
-
if (run.options.personalMode !== true && key.startsWith("companies/")) {
|
|
784
|
-
autoVerified.push(
|
|
806
|
+
for (const tombstone of plan.tombstones) {
|
|
807
|
+
if (run.options.personalMode !== true && tombstone.key.startsWith("companies/")) {
|
|
808
|
+
autoVerified.push(tombstone);
|
|
785
809
|
}
|
|
786
810
|
else {
|
|
787
|
-
toHeadVerify.push(
|
|
811
|
+
toHeadVerify.push(tombstone);
|
|
788
812
|
}
|
|
789
813
|
}
|
|
790
814
|
plan.tombstones = toHeadVerify;
|
|
@@ -792,43 +816,50 @@ async function verifyPlannedJournalTombstones(run, plan) {
|
|
|
792
816
|
plan.tombstones = autoVerified;
|
|
793
817
|
return;
|
|
794
818
|
}
|
|
795
|
-
await primeObjectTransport(run.ctx, "get", plan.tombstones);
|
|
819
|
+
await primeObjectTransport(run.ctx, "get", plan.tombstones.map((t) => t.key));
|
|
796
820
|
const HEAD_VERIFY_CONCURRENCY = 5;
|
|
797
821
|
const verified = [];
|
|
798
822
|
for (let i = 0; i < plan.tombstones.length; i += HEAD_VERIFY_CONCURRENCY) {
|
|
799
823
|
const batch = plan.tombstones.slice(i, i + HEAD_VERIFY_CONCURRENCY);
|
|
800
|
-
const results = await Promise.all(batch.map(async (
|
|
824
|
+
const results = await Promise.all(batch.map(async (tombstone) => {
|
|
801
825
|
try {
|
|
802
|
-
const head = await headRemoteFile(run.ctx, key);
|
|
803
|
-
return head === null ?
|
|
826
|
+
const head = await headRemoteFile(run.ctx, tombstone.key);
|
|
827
|
+
return head === null ? tombstone : null;
|
|
804
828
|
}
|
|
805
829
|
catch (err) {
|
|
806
830
|
if (isAccessDenied(err))
|
|
807
831
|
return null;
|
|
808
832
|
run.emit({
|
|
809
833
|
type: "error",
|
|
810
|
-
path: key,
|
|
834
|
+
path: tombstone.key,
|
|
811
835
|
message: `tombstone HEAD verify failed (deferring): ${err instanceof Error ? err.message : String(err)}`,
|
|
812
836
|
});
|
|
813
837
|
return null;
|
|
814
838
|
}
|
|
815
839
|
}));
|
|
816
|
-
for (const
|
|
817
|
-
if (
|
|
818
|
-
verified.push(
|
|
840
|
+
for (const tombstone of results) {
|
|
841
|
+
if (tombstone !== null)
|
|
842
|
+
verified.push(tombstone);
|
|
819
843
|
}
|
|
820
844
|
}
|
|
821
845
|
plan.tombstones = [...autoVerified, ...verified];
|
|
822
846
|
}
|
|
823
847
|
function executeJournalTombstoneDeletes(run, plan, counters) {
|
|
824
|
-
for (const
|
|
825
|
-
const localPath =
|
|
826
|
-
if (
|
|
848
|
+
for (const tombstone of plan.tombstones) {
|
|
849
|
+
const { key, localPath, localSnapshot } = tombstone;
|
|
850
|
+
if (!isDownloadWritePathStillContained(run.companyRoot, key, localPath)) {
|
|
851
|
+
run.emit({
|
|
852
|
+
type: "error",
|
|
853
|
+
path: key,
|
|
854
|
+
message: "tombstone unlink skipped: local parent escaped the sync root",
|
|
855
|
+
});
|
|
827
856
|
continue;
|
|
857
|
+
}
|
|
828
858
|
let removedSomething = false;
|
|
829
859
|
try {
|
|
830
860
|
const lstat = fs.lstatSync(localPath);
|
|
831
|
-
if (
|
|
861
|
+
if (!matchesLocalSnapshot(localPath, localSnapshot)) {
|
|
862
|
+
reportLocalSnapshotConflict(run, counters, key);
|
|
832
863
|
continue;
|
|
833
864
|
}
|
|
834
865
|
if (lstat.isSymbolicLink() || lstat.isFile()) {
|
|
@@ -1007,23 +1038,52 @@ function isDownloadWritePathStillContained(root, key, localPath) {
|
|
|
1007
1038
|
const resolved = resolveContainedVaultPath(root, key);
|
|
1008
1039
|
return resolved !== null && path.resolve(resolved) === path.resolve(localPath);
|
|
1009
1040
|
}
|
|
1010
|
-
function
|
|
1011
|
-
const journalEntry = journal.files[key];
|
|
1012
|
-
if (!journalEntry?.hash) {
|
|
1013
|
-
return lstat.isSymbolicLink() || lstat.isFile();
|
|
1014
|
-
}
|
|
1041
|
+
function snapshotLocalState(localPath) {
|
|
1015
1042
|
try {
|
|
1043
|
+
const lstat = fs.lstatSync(localPath);
|
|
1016
1044
|
if (lstat.isSymbolicLink()) {
|
|
1017
|
-
return hashSymlinkTarget(fs.readlinkSync(localPath))
|
|
1045
|
+
return { kind: "symlink", hash: hashSymlinkTarget(fs.readlinkSync(localPath)) };
|
|
1018
1046
|
}
|
|
1019
1047
|
if (lstat.isFile()) {
|
|
1020
|
-
return hashFile(localPath)
|
|
1048
|
+
return { kind: "file", hash: hashFile(localPath) };
|
|
1021
1049
|
}
|
|
1050
|
+
if (lstat.isDirectory())
|
|
1051
|
+
return { kind: "directory" };
|
|
1052
|
+
return { kind: "other" };
|
|
1022
1053
|
}
|
|
1023
|
-
catch {
|
|
1024
|
-
|
|
1054
|
+
catch (err) {
|
|
1055
|
+
const code = err && typeof err === "object" && "code" in err
|
|
1056
|
+
? err.code
|
|
1057
|
+
: undefined;
|
|
1058
|
+
return code === "ENOENT" ? { kind: "absent" } : null;
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
function matchesLocalSnapshot(localPath, expected) {
|
|
1062
|
+
const actual = snapshotLocalState(localPath);
|
|
1063
|
+
if (actual === null || actual.kind !== expected.kind)
|
|
1064
|
+
return false;
|
|
1065
|
+
return ((actual.kind !== "file" && actual.kind !== "symlink") ||
|
|
1066
|
+
actual.hash === expected.hash);
|
|
1067
|
+
}
|
|
1068
|
+
function hasCurrentLocalDeleteIntent(entry) {
|
|
1069
|
+
const intent = entry?.localDeleteIntent;
|
|
1070
|
+
return !!(intent &&
|
|
1071
|
+
intent.version === 1 &&
|
|
1072
|
+
entry.remoteEtag &&
|
|
1073
|
+
entry.kind &&
|
|
1074
|
+
intent.remoteEtag === entry.remoteEtag &&
|
|
1075
|
+
intent.localHash === entry.hash &&
|
|
1076
|
+
intent.localKind === entry.kind);
|
|
1077
|
+
}
|
|
1078
|
+
function reportLocalSnapshotConflict(run, counters, key) {
|
|
1079
|
+
counters.conflicts++;
|
|
1080
|
+
counters.conflictPaths.push(key);
|
|
1081
|
+
run.emit({ type: "conflict", path: key, direction: "pull", resolution: "skip" });
|
|
1082
|
+
}
|
|
1083
|
+
class LocalSnapshotChangedError extends Error {
|
|
1084
|
+
constructor(key) {
|
|
1085
|
+
super(`local state changed while downloading ${key}; replanning required`);
|
|
1025
1086
|
}
|
|
1026
|
-
return false;
|
|
1027
1087
|
}
|
|
1028
1088
|
/**
|
|
1029
1089
|
* Stage-1 planning pass: classify every remote file into download / skip /
|
|
@@ -1122,6 +1182,15 @@ excludePrefixes = []) {
|
|
|
1122
1182
|
items.push({ action: "skip-excluded-policy", remoteFile, localPath });
|
|
1123
1183
|
continue;
|
|
1124
1184
|
}
|
|
1185
|
+
// Personal-vault policy must be symmetric across both transfer legs.
|
|
1186
|
+
// The push walker refuses derived/machine-local/sensitive paths, but old
|
|
1187
|
+
// objects can remain in the bucket from clients that predate an exclusion.
|
|
1188
|
+
// Refuse those objects here too so a pull cannot rematerialize them (or,
|
|
1189
|
+
// for generated skill wrappers, fight reindex over a directory link).
|
|
1190
|
+
if (personalMode && isPersonalVaultExcluded(remoteFile.key)) {
|
|
1191
|
+
items.push({ action: "skip-excluded-policy", remoteFile, localPath });
|
|
1192
|
+
continue;
|
|
1193
|
+
}
|
|
1125
1194
|
if (personalMode &&
|
|
1126
1195
|
remoteFile.key.startsWith("companies/") &&
|
|
1127
1196
|
// EXEMPTION: companies/manifest.yaml is the routing source-of-truth
|
|
@@ -1213,6 +1282,7 @@ excludePrefixes = []) {
|
|
|
1213
1282
|
// path below — never to delete a file that is present locally.
|
|
1214
1283
|
const localTombstoneSuppresses = journalEntry !== undefined &&
|
|
1215
1284
|
isTombstone(journalEntry) &&
|
|
1285
|
+
hasCurrentLocalDeleteIntent(journalEntry) &&
|
|
1216
1286
|
journalEntry.removedAt !== undefined &&
|
|
1217
1287
|
!isRemoteRecreateAfterTombstone(remoteFile, {
|
|
1218
1288
|
deletedAt: journalEntry.removedAt,
|
|
@@ -1230,6 +1300,7 @@ excludePrefixes = []) {
|
|
|
1230
1300
|
// dangling link on every run.
|
|
1231
1301
|
// 3. A regular file: indistinguishable from current behaviour.
|
|
1232
1302
|
let localLstat = null;
|
|
1303
|
+
let plannedLocalSnapshot = { kind: "absent" };
|
|
1233
1304
|
let localPathBlockedByFileAncestor = false;
|
|
1234
1305
|
try {
|
|
1235
1306
|
localLstat = fs.lstatSync(localPath);
|
|
@@ -1327,6 +1398,10 @@ excludePrefixes = []) {
|
|
|
1327
1398
|
: hashFile(localPath);
|
|
1328
1399
|
}
|
|
1329
1400
|
const localChanged = !!journalEntry && journalEntry.hash !== localHash;
|
|
1401
|
+
plannedLocalSnapshot = {
|
|
1402
|
+
kind: isLocalSymlink ? "symlink" : "file",
|
|
1403
|
+
hash: localHash,
|
|
1404
|
+
};
|
|
1330
1405
|
const remoteChanged = !!journalEntry && hasRemoteChanged(remoteFile, journalEntry);
|
|
1331
1406
|
// ── Authoritative delete reaches a machine that still holds the file ──
|
|
1332
1407
|
// A FILE_TOMBSTONE marks this key deleted and the remote object is not a
|
|
@@ -1347,10 +1422,16 @@ excludePrefixes = []) {
|
|
|
1347
1422
|
localHash,
|
|
1348
1423
|
localMtime: localLstat.mtime,
|
|
1349
1424
|
localSize: isLocalSymlink ? 0 : localLstat.size,
|
|
1425
|
+
localSnapshot: plannedLocalSnapshot,
|
|
1350
1426
|
});
|
|
1351
1427
|
}
|
|
1352
1428
|
else {
|
|
1353
|
-
items.push({
|
|
1429
|
+
items.push({
|
|
1430
|
+
action: "tombstone-delete",
|
|
1431
|
+
remoteFile,
|
|
1432
|
+
localPath,
|
|
1433
|
+
localSnapshot: plannedLocalSnapshot,
|
|
1434
|
+
});
|
|
1354
1435
|
}
|
|
1355
1436
|
continue;
|
|
1356
1437
|
}
|
|
@@ -1375,6 +1456,7 @@ excludePrefixes = []) {
|
|
|
1375
1456
|
// every sync.
|
|
1376
1457
|
localMtime: localLstat.mtime,
|
|
1377
1458
|
localSize: isLocalSymlink ? 0 : localLstat.size,
|
|
1459
|
+
localSnapshot: plannedLocalSnapshot,
|
|
1378
1460
|
});
|
|
1379
1461
|
continue;
|
|
1380
1462
|
}
|
|
@@ -1432,6 +1514,7 @@ excludePrefixes = []) {
|
|
|
1432
1514
|
localHash,
|
|
1433
1515
|
localMtime: localLstat.mtime,
|
|
1434
1516
|
localSize: localLstat.size,
|
|
1517
|
+
localSnapshot: plannedLocalSnapshot,
|
|
1435
1518
|
});
|
|
1436
1519
|
continue;
|
|
1437
1520
|
}
|
|
@@ -1460,7 +1543,12 @@ excludePrefixes = []) {
|
|
|
1460
1543
|
if (journalEntry?.removedReason === "local-delete") {
|
|
1461
1544
|
continue;
|
|
1462
1545
|
}
|
|
1463
|
-
items.push({
|
|
1546
|
+
items.push({
|
|
1547
|
+
action: "tombstone-delete",
|
|
1548
|
+
remoteFile,
|
|
1549
|
+
localPath,
|
|
1550
|
+
localSnapshot: plannedLocalSnapshot,
|
|
1551
|
+
});
|
|
1464
1552
|
continue;
|
|
1465
1553
|
}
|
|
1466
1554
|
// Intentional-delete producer: a clean, CURRENT (etag matches), non-divergent
|
|
@@ -1471,13 +1559,18 @@ excludePrefixes = []) {
|
|
|
1471
1559
|
if (!localExists &&
|
|
1472
1560
|
journalEntry !== undefined &&
|
|
1473
1561
|
!isTombstone(journalEntry) &&
|
|
1474
|
-
|
|
1475
|
-
journalEntry.remoteEtag !== undefined &&
|
|
1562
|
+
hasCurrentLocalDeleteIntent(journalEntry) &&
|
|
1476
1563
|
normalizeEtag(remoteFile.etag) === journalEntry.remoteEtag) {
|
|
1477
1564
|
intentionalDeleteCandidates.push({ remoteFile, localPath });
|
|
1478
1565
|
continue;
|
|
1479
1566
|
}
|
|
1480
|
-
items.push({
|
|
1567
|
+
items.push({
|
|
1568
|
+
action: "download",
|
|
1569
|
+
remoteFile,
|
|
1570
|
+
localPath,
|
|
1571
|
+
isNew: !localExists,
|
|
1572
|
+
localSnapshot: plannedLocalSnapshot,
|
|
1573
|
+
});
|
|
1481
1574
|
}
|
|
1482
1575
|
// ── Intentional-delete bulk-asymmetry decision (pull-leg producer) ─────────
|
|
1483
1576
|
// Below the breaker → stamp a persisting `local-delete` tombstone (the
|
|
@@ -1497,6 +1590,7 @@ excludePrefixes = []) {
|
|
|
1497
1590
|
remoteFile: cand.remoteFile,
|
|
1498
1591
|
localPath: cand.localPath,
|
|
1499
1592
|
isNew: true,
|
|
1593
|
+
localSnapshot: { kind: "absent" },
|
|
1500
1594
|
});
|
|
1501
1595
|
}
|
|
1502
1596
|
else {
|
|
@@ -1648,7 +1742,10 @@ excludePrefixes = []) {
|
|
|
1648
1742
|
if (code !== "ENOENT")
|
|
1649
1743
|
continue;
|
|
1650
1744
|
}
|
|
1651
|
-
|
|
1745
|
+
const localSnapshot = snapshotLocalState(localPath);
|
|
1746
|
+
if (localSnapshot === null)
|
|
1747
|
+
continue;
|
|
1748
|
+
tombstones.push({ key, localPath, localSnapshot });
|
|
1652
1749
|
}
|
|
1653
1750
|
return {
|
|
1654
1751
|
items,
|