pushwork 1.0.5 → 1.0.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/README.md +87 -335
- package/babel.config.js +5 -0
- package/dist/cli/commands.d.ts +9 -15
- package/dist/cli/commands.d.ts.map +1 -1
- package/dist/cli/commands.js +37 -170
- package/dist/cli/commands.js.map +1 -1
- package/dist/cli/output.d.ts +11 -25
- package/dist/cli/output.d.ts.map +1 -1
- package/dist/cli/output.js +55 -61
- package/dist/cli/output.js.map +1 -1
- package/dist/cli.js +208 -213
- package/dist/cli.js.map +1 -1
- package/dist/commands.d.ts +51 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/commands.js +799 -0
- package/dist/commands.js.map +1 -0
- package/dist/core/change-detection.d.ts +7 -23
- package/dist/core/change-detection.d.ts.map +1 -1
- package/dist/core/change-detection.js +108 -122
- package/dist/core/change-detection.js.map +1 -1
- package/dist/core/config.d.ts +81 -0
- package/dist/core/config.d.ts.map +1 -0
- package/dist/core/config.js +296 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +1 -1
- package/dist/core/index.js.map +1 -1
- package/dist/core/move-detection.d.ts +4 -3
- package/dist/core/move-detection.d.ts.map +1 -1
- package/dist/core/move-detection.js +8 -7
- package/dist/core/move-detection.js.map +1 -1
- package/dist/core/snapshot.d.ts +0 -4
- package/dist/core/snapshot.d.ts.map +1 -1
- package/dist/core/snapshot.js +2 -11
- package/dist/core/snapshot.js.map +1 -1
- package/dist/core/sync-engine.d.ts +41 -12
- package/dist/core/sync-engine.d.ts.map +1 -1
- package/dist/core/sync-engine.js +522 -359
- package/dist/core/sync-engine.js.map +1 -1
- package/dist/index.d.ts +0 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -6
- package/dist/index.js.map +1 -1
- package/dist/types/config.d.ts +24 -88
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/config.js +6 -0
- package/dist/types/config.js.map +1 -1
- package/dist/types/documents.d.ts +15 -2
- package/dist/types/documents.d.ts.map +1 -1
- package/dist/types/documents.js.map +1 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +0 -3
- package/dist/types/index.js.map +1 -1
- package/dist/types/snapshot.d.ts +0 -21
- package/dist/types/snapshot.d.ts.map +1 -1
- package/dist/types/snapshot.js +0 -14
- package/dist/types/snapshot.js.map +1 -1
- package/dist/utils/content.d.ts.map +1 -1
- package/dist/utils/content.js +2 -6
- package/dist/utils/content.js.map +1 -1
- package/dist/utils/directory.d.ts +24 -0
- package/dist/utils/directory.d.ts.map +1 -0
- package/dist/utils/directory.js +56 -0
- package/dist/utils/directory.js.map +1 -0
- package/dist/utils/fs.d.ts +15 -2
- package/dist/utils/fs.d.ts.map +1 -1
- package/dist/utils/fs.js +53 -20
- package/dist/utils/fs.js.map +1 -1
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +1 -3
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/keyhive.d.ts +9 -0
- package/dist/utils/keyhive.d.ts.map +1 -0
- package/dist/utils/keyhive.js +26 -0
- package/dist/utils/keyhive.js.map +1 -0
- package/dist/utils/mime-types.d.ts.map +1 -1
- package/dist/utils/mime-types.js +11 -4
- package/dist/utils/mime-types.js.map +1 -1
- package/dist/utils/network-sync.d.ts +16 -7
- package/dist/utils/network-sync.d.ts.map +1 -1
- package/dist/utils/network-sync.js +158 -99
- package/dist/utils/network-sync.js.map +1 -1
- package/dist/utils/output.d.ts +129 -0
- package/dist/utils/output.d.ts.map +1 -0
- package/dist/utils/output.js +375 -0
- package/dist/utils/output.js.map +1 -0
- package/dist/utils/repo-factory.d.ts +2 -6
- package/dist/utils/repo-factory.d.ts.map +1 -1
- package/dist/utils/repo-factory.js +8 -31
- package/dist/utils/repo-factory.js.map +1 -1
- package/dist/utils/string-similarity.js +2 -2
- package/dist/utils/string-similarity.js.map +1 -1
- package/dist/utils/trace.d.ts +19 -0
- package/dist/utils/trace.d.ts.map +1 -0
- package/dist/utils/trace.js +68 -0
- package/dist/utils/trace.js.map +1 -0
- package/package.json +21 -11
- package/src/cli.ts +276 -308
- package/src/commands.ts +988 -0
- package/src/core/change-detection.ts +226 -246
- package/src/{config/index.ts → core/config.ts} +65 -82
- package/src/core/index.ts +1 -1
- package/src/core/move-detection.ts +10 -8
- package/src/core/snapshot.ts +2 -12
- package/src/core/sync-engine.ts +630 -478
- package/src/index.ts +0 -10
- package/src/types/config.ts +28 -93
- package/src/types/documents.ts +16 -2
- package/src/types/index.ts +0 -5
- package/src/types/snapshot.ts +0 -23
- package/src/utils/content.ts +2 -6
- package/src/utils/directory.ts +73 -0
- package/src/utils/fs.ts +57 -23
- package/src/utils/index.ts +1 -5
- package/src/utils/mime-types.ts +12 -4
- package/src/utils/network-sync.ts +216 -138
- package/src/utils/output.ts +450 -0
- package/src/utils/repo-factory.ts +13 -44
- package/src/utils/string-similarity.ts +2 -2
- package/src/utils/trace.ts +70 -0
- package/test/integration/exclude-patterns.test.ts +6 -15
- package/test/integration/fuzzer.test.ts +308 -391
- package/test/integration/in-memory-sync.test.ts +435 -0
- package/test/integration/init-sync.test.ts +89 -0
- package/test/integration/sync-deletion.test.ts +2 -61
- package/test/integration/sync-flow.test.ts +4 -24
- package/test/jest.setup.ts +34 -0
- package/test/unit/deletion-behavior.test.ts +3 -14
- package/test/unit/enhanced-mime-detection.test.ts +0 -22
- package/test/unit/snapshot.test.ts +2 -29
- package/test/unit/sync-convergence.test.ts +3 -198
- package/test/unit/sync-timing.test.ts +0 -44
- package/test/unit/utils.test.ts +0 -2
- package/tsconfig.json +3 -3
- package/bench/filesystem.bench.ts +0 -78
- package/bench/hashing.bench.ts +0 -60
- package/bench/move-detection.bench.ts +0 -130
- package/bench/runner.ts +0 -49
- package/dist/browser/browser-sync-engine.d.ts +0 -64
- package/dist/browser/browser-sync-engine.d.ts.map +0 -1
- package/dist/browser/browser-sync-engine.js +0 -303
- package/dist/browser/browser-sync-engine.js.map +0 -1
- package/dist/browser/filesystem-adapter.d.ts +0 -84
- package/dist/browser/filesystem-adapter.d.ts.map +0 -1
- package/dist/browser/filesystem-adapter.js +0 -413
- package/dist/browser/filesystem-adapter.js.map +0 -1
- package/dist/browser/index.d.ts +0 -36
- package/dist/browser/index.d.ts.map +0 -1
- package/dist/browser/index.js +0 -90
- package/dist/browser/index.js.map +0 -1
- package/dist/browser/types.d.ts +0 -70
- package/dist/browser/types.d.ts.map +0 -1
- package/dist/browser/types.js +0 -6
- package/dist/browser/types.js.map +0 -1
- package/dist/config/remote-manager.d.ts +0 -65
- package/dist/config/remote-manager.d.ts.map +0 -1
- package/dist/config/remote-manager.js +0 -243
- package/dist/config/remote-manager.js.map +0 -1
- package/dist/core/isomorphic-snapshot.d.ts +0 -58
- package/dist/core/isomorphic-snapshot.d.ts.map +0 -1
- package/dist/core/isomorphic-snapshot.js +0 -204
- package/dist/core/isomorphic-snapshot.js.map +0 -1
- package/dist/platform/browser-filesystem.d.ts +0 -26
- package/dist/platform/browser-filesystem.d.ts.map +0 -1
- package/dist/platform/browser-filesystem.js +0 -91
- package/dist/platform/browser-filesystem.js.map +0 -1
- package/dist/platform/filesystem.d.ts +0 -29
- package/dist/platform/filesystem.d.ts.map +0 -1
- package/dist/platform/filesystem.js +0 -65
- package/dist/platform/filesystem.js.map +0 -1
- package/dist/platform/node-filesystem.d.ts +0 -21
- package/dist/platform/node-filesystem.d.ts.map +0 -1
- package/dist/platform/node-filesystem.js +0 -93
- package/dist/platform/node-filesystem.js.map +0 -1
- package/dist/utils/fs-browser.d.ts +0 -57
- package/dist/utils/fs-browser.d.ts.map +0 -1
- package/dist/utils/fs-browser.js +0 -311
- package/dist/utils/fs-browser.js.map +0 -1
- package/dist/utils/fs-node.d.ts +0 -53
- package/dist/utils/fs-node.d.ts.map +0 -1
- package/dist/utils/fs-node.js +0 -220
- package/dist/utils/fs-node.js.map +0 -1
- package/dist/utils/isomorphic.d.ts +0 -29
- package/dist/utils/isomorphic.d.ts.map +0 -1
- package/dist/utils/isomorphic.js +0 -139
- package/dist/utils/isomorphic.js.map +0 -1
- package/dist/utils/pure.d.ts +0 -25
- package/dist/utils/pure.d.ts.map +0 -1
- package/dist/utils/pure.js +0 -112
- package/dist/utils/pure.js.map +0 -1
- package/src/cli/commands.ts +0 -1030
- package/src/cli/index.ts +0 -2
- package/src/cli/output.ts +0 -244
- package/test/README-TESTING-GAPS.md +0 -174
package/src/core/sync-engine.ts
CHANGED
|
@@ -1,40 +1,42 @@
|
|
|
1
1
|
import {
|
|
2
2
|
AutomergeUrl,
|
|
3
3
|
Repo,
|
|
4
|
-
updateText,
|
|
5
4
|
DocHandle,
|
|
6
|
-
|
|
5
|
+
parseAutomergeUrl,
|
|
6
|
+
stringifyAutomergeUrl,
|
|
7
7
|
} from "@automerge/automerge-repo";
|
|
8
8
|
import * as A from "@automerge/automerge";
|
|
9
9
|
import {
|
|
10
10
|
SyncSnapshot,
|
|
11
11
|
SyncResult,
|
|
12
|
-
SyncError,
|
|
13
|
-
SyncOperation,
|
|
14
|
-
PendingSyncOperation,
|
|
15
12
|
FileDocument,
|
|
16
13
|
DirectoryDocument,
|
|
17
|
-
FileType,
|
|
18
14
|
ChangeType,
|
|
19
15
|
MoveCandidate,
|
|
16
|
+
DirectoryConfig,
|
|
17
|
+
DetectedChange,
|
|
20
18
|
} from "../types";
|
|
21
19
|
import {
|
|
22
|
-
readFileContent,
|
|
23
20
|
writeFileContent,
|
|
24
21
|
removePath,
|
|
25
|
-
movePath,
|
|
26
|
-
ensureDirectoryExists,
|
|
27
22
|
getFileExtension,
|
|
28
|
-
normalizePath,
|
|
29
|
-
getRelativePath,
|
|
30
23
|
getEnhancedMimeType,
|
|
31
|
-
|
|
24
|
+
formatRelativePath,
|
|
25
|
+
findFileInDirectoryHierarchy,
|
|
26
|
+
joinAndNormalizePath,
|
|
27
|
+
getPlainUrl,
|
|
32
28
|
} from "../utils";
|
|
33
29
|
import { isContentEqual } from "../utils/content";
|
|
34
|
-
import { waitForSync,
|
|
30
|
+
import { waitForSync, waitForBidirectionalSync } from "../utils/network-sync";
|
|
35
31
|
import { SnapshotManager } from "./snapshot";
|
|
36
|
-
import { ChangeDetector
|
|
32
|
+
import { ChangeDetector } from "./change-detection";
|
|
37
33
|
import { MoveDetector } from "./move-detection";
|
|
34
|
+
import { out } from "../utils/output";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Sync configuration constants
|
|
38
|
+
*/
|
|
39
|
+
const BIDIRECTIONAL_SYNC_TIMEOUT_MS = 5000; // Timeout for bidirectional sync stability check
|
|
38
40
|
|
|
39
41
|
/**
|
|
40
42
|
* Bidirectional sync engine implementing two-phase sync
|
|
@@ -43,22 +45,24 @@ export class SyncEngine {
|
|
|
43
45
|
private snapshotManager: SnapshotManager;
|
|
44
46
|
private changeDetector: ChangeDetector;
|
|
45
47
|
private moveDetector: MoveDetector;
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
private
|
|
48
|
+
// Map from path to handle for leaf-first sync ordering
|
|
49
|
+
// Path depth determines sync order (deepest first)
|
|
50
|
+
private handlesByPath: Map<string, DocHandle<unknown>> = new Map();
|
|
51
|
+
private config: DirectoryConfig;
|
|
49
52
|
|
|
50
53
|
constructor(
|
|
51
54
|
private repo: Repo,
|
|
52
55
|
private rootPath: string,
|
|
53
|
-
|
|
54
|
-
networkSyncEnabled: boolean = true,
|
|
55
|
-
syncServerStorageId?: string
|
|
56
|
+
config: DirectoryConfig
|
|
56
57
|
) {
|
|
58
|
+
this.config = config;
|
|
57
59
|
this.snapshotManager = new SnapshotManager(rootPath);
|
|
58
|
-
this.changeDetector = new ChangeDetector(
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
this.changeDetector = new ChangeDetector(
|
|
61
|
+
repo,
|
|
62
|
+
rootPath,
|
|
63
|
+
config.exclude_patterns
|
|
64
|
+
);
|
|
65
|
+
this.moveDetector = new MoveDetector(config.sync.move_detection_threshold);
|
|
62
66
|
}
|
|
63
67
|
|
|
64
68
|
/**
|
|
@@ -71,6 +75,16 @@ export class SyncEngine {
|
|
|
71
75
|
return typeof content === "string";
|
|
72
76
|
}
|
|
73
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Get a versioned URL from a handle (includes current heads).
|
|
80
|
+
* This ensures clients can fetch the exact version of the document.
|
|
81
|
+
*/
|
|
82
|
+
private getVersionedUrl(handle: DocHandle<unknown>): AutomergeUrl {
|
|
83
|
+
const { documentId } = parseAutomergeUrl(handle.url);
|
|
84
|
+
const heads = handle.heads();
|
|
85
|
+
return stringifyAutomergeUrl({ documentId, heads });
|
|
86
|
+
}
|
|
87
|
+
|
|
74
88
|
/**
|
|
75
89
|
* Set the root directory URL in the snapshot
|
|
76
90
|
*/
|
|
@@ -86,7 +100,7 @@ export class SyncEngine {
|
|
|
86
100
|
/**
|
|
87
101
|
* Commit local changes only (no network sync)
|
|
88
102
|
*/
|
|
89
|
-
async commitLocal(
|
|
103
|
+
async commitLocal(): Promise<SyncResult> {
|
|
90
104
|
const result: SyncResult = {
|
|
91
105
|
success: false,
|
|
92
106
|
filesChanged: 0,
|
|
@@ -102,27 +116,20 @@ export class SyncEngine {
|
|
|
102
116
|
snapshot = this.snapshotManager.createEmpty();
|
|
103
117
|
}
|
|
104
118
|
|
|
105
|
-
// Backup snapshot before starting
|
|
106
|
-
if (!dryRun) {
|
|
107
|
-
await this.snapshotManager.backup();
|
|
108
|
-
}
|
|
109
|
-
|
|
110
119
|
// Detect all changes
|
|
111
120
|
const changes = await this.changeDetector.detectChanges(snapshot);
|
|
112
121
|
|
|
113
122
|
// Detect moves
|
|
114
123
|
const { moves, remainingChanges } = await this.moveDetector.detectMoves(
|
|
115
124
|
changes,
|
|
116
|
-
snapshot
|
|
117
|
-
this.rootPath
|
|
125
|
+
snapshot
|
|
118
126
|
);
|
|
119
127
|
|
|
120
128
|
// Apply local changes only (no network sync)
|
|
121
129
|
const commitResult = await this.pushLocalChanges(
|
|
122
130
|
remainingChanges,
|
|
123
131
|
moves,
|
|
124
|
-
snapshot
|
|
125
|
-
dryRun
|
|
132
|
+
snapshot
|
|
126
133
|
);
|
|
127
134
|
|
|
128
135
|
result.filesChanged += commitResult.filesChanged;
|
|
@@ -130,17 +137,18 @@ export class SyncEngine {
|
|
|
130
137
|
result.errors.push(...commitResult.errors);
|
|
131
138
|
result.warnings.push(...commitResult.warnings);
|
|
132
139
|
|
|
140
|
+
// Update directory URLs with current heads after all children are populated
|
|
141
|
+
await this.updateDirectoryUrlsLeafFirst(snapshot);
|
|
142
|
+
|
|
133
143
|
// Touch root directory if any changes were made
|
|
134
144
|
const hasChanges =
|
|
135
145
|
result.filesChanged > 0 || result.directoriesChanged > 0;
|
|
136
146
|
if (hasChanges) {
|
|
137
|
-
await this.touchRootDirectory(snapshot
|
|
147
|
+
await this.touchRootDirectory(snapshot);
|
|
138
148
|
}
|
|
139
149
|
|
|
140
|
-
// Save updated snapshot
|
|
141
|
-
|
|
142
|
-
await this.snapshotManager.save(snapshot);
|
|
143
|
-
}
|
|
150
|
+
// Save updated snapshot
|
|
151
|
+
await this.snapshotManager.save(snapshot);
|
|
144
152
|
|
|
145
153
|
result.success = result.errors.length === 0;
|
|
146
154
|
|
|
@@ -160,10 +168,7 @@ export class SyncEngine {
|
|
|
160
168
|
/**
|
|
161
169
|
* Run full bidirectional sync
|
|
162
170
|
*/
|
|
163
|
-
async sync(
|
|
164
|
-
const syncStartTime = Date.now();
|
|
165
|
-
const timings: { [key: string]: number } = {};
|
|
166
|
-
|
|
171
|
+
async sync(): Promise<SyncResult> {
|
|
167
172
|
const result: SyncResult = {
|
|
168
173
|
success: false,
|
|
169
174
|
filesChanged: 0,
|
|
@@ -173,189 +178,161 @@ export class SyncEngine {
|
|
|
173
178
|
timings: {},
|
|
174
179
|
};
|
|
175
180
|
|
|
176
|
-
// Reset handles
|
|
177
|
-
this.
|
|
181
|
+
// Reset tracked handles for sync
|
|
182
|
+
this.handlesByPath = new Map();
|
|
178
183
|
|
|
179
184
|
try {
|
|
180
185
|
// Load current snapshot
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
if (!snapshot) {
|
|
185
|
-
snapshot = this.snapshotManager.createEmpty();
|
|
186
|
-
}
|
|
186
|
+
const snapshot =
|
|
187
|
+
(await this.snapshotManager.load()) ||
|
|
188
|
+
this.snapshotManager.createEmpty();
|
|
187
189
|
|
|
188
|
-
//
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
190
|
+
// Wait for initial sync to receive any pending remote changes
|
|
191
|
+
if (this.config.sync_enabled && snapshot.rootDirectoryUrl) {
|
|
192
|
+
try {
|
|
193
|
+
await waitForBidirectionalSync(
|
|
194
|
+
this.repo,
|
|
195
|
+
snapshot.rootDirectoryUrl,
|
|
196
|
+
this.config.sync_server_storage_id,
|
|
197
|
+
{
|
|
198
|
+
timeoutMs: 3000, // Short timeout for initial sync
|
|
199
|
+
pollIntervalMs: 100,
|
|
200
|
+
stableChecksRequired: 3,
|
|
201
|
+
}
|
|
202
|
+
);
|
|
203
|
+
} catch (error) {
|
|
204
|
+
out.taskLine(`Initial sync: ${error}`, true);
|
|
205
|
+
}
|
|
192
206
|
}
|
|
193
|
-
timings["backup_snapshot"] = Date.now() - t1;
|
|
194
207
|
|
|
195
208
|
// Detect all changes
|
|
196
|
-
const t2 = Date.now();
|
|
197
209
|
const changes = await this.changeDetector.detectChanges(snapshot);
|
|
198
|
-
timings["detect_changes"] = Date.now() - t2;
|
|
199
210
|
|
|
200
211
|
// Detect moves
|
|
201
|
-
const t3 = Date.now();
|
|
202
212
|
const { moves, remainingChanges } = await this.moveDetector.detectMoves(
|
|
203
213
|
changes,
|
|
204
|
-
snapshot
|
|
205
|
-
this.rootPath
|
|
214
|
+
snapshot
|
|
206
215
|
);
|
|
207
|
-
timings["detect_moves"] = Date.now() - t3;
|
|
208
216
|
|
|
209
217
|
// Phase 1: Push local changes to remote
|
|
210
|
-
const t4 = Date.now();
|
|
211
218
|
const phase1Result = await this.pushLocalChanges(
|
|
212
219
|
remainingChanges,
|
|
213
220
|
moves,
|
|
214
|
-
snapshot
|
|
215
|
-
dryRun
|
|
221
|
+
snapshot
|
|
216
222
|
);
|
|
217
|
-
timings["phase1_push"] = Date.now() - t4;
|
|
218
223
|
|
|
219
224
|
result.filesChanged += phase1Result.filesChanged;
|
|
220
225
|
result.directoriesChanged += phase1Result.directoriesChanged;
|
|
221
226
|
result.errors.push(...phase1Result.errors);
|
|
222
227
|
result.warnings.push(...phase1Result.warnings);
|
|
223
228
|
|
|
224
|
-
//
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
if (
|
|
229
|
+
// Update directory URLs with current heads after all children are populated
|
|
230
|
+
await this.updateDirectoryUrlsLeafFirst(snapshot);
|
|
231
|
+
|
|
232
|
+
// Wait for network sync (important for clone scenarios)
|
|
233
|
+
if (this.config.sync_enabled) {
|
|
229
234
|
try {
|
|
230
|
-
// If we have a root directory URL,
|
|
235
|
+
// If we have a root directory URL, add it to tracked handles
|
|
231
236
|
if (snapshot.rootDirectoryUrl) {
|
|
237
|
+
const rootDirUrl = snapshot.rootDirectoryUrl;
|
|
232
238
|
const rootHandle = await this.repo.find<DirectoryDocument>(
|
|
233
|
-
|
|
239
|
+
rootDirUrl
|
|
234
240
|
);
|
|
235
|
-
this.
|
|
241
|
+
this.handlesByPath.set("", rootHandle);
|
|
236
242
|
}
|
|
237
243
|
|
|
238
|
-
if (this.
|
|
239
|
-
|
|
244
|
+
if (this.handlesByPath.size > 0) {
|
|
245
|
+
// Sort handles leaf-first (deepest paths first, then shallower)
|
|
246
|
+
const sortedHandles = this.sortHandlesLeafFirst();
|
|
240
247
|
await waitForSync(
|
|
241
|
-
|
|
242
|
-
|
|
248
|
+
sortedHandles,
|
|
249
|
+
this.config.sync_server_storage_id
|
|
243
250
|
);
|
|
244
|
-
timings["network_sync"] = Date.now() - tWaitStart;
|
|
245
|
-
|
|
246
|
-
// CRITICAL: Wait a bit after our changes reach the server to allow
|
|
247
|
-
// time for WebSocket to deliver OTHER peers' changes to us.
|
|
248
|
-
// waitForSync only ensures OUR changes reached the server, not that
|
|
249
|
-
// we've RECEIVED changes from other peers. This delay allows the
|
|
250
|
-
// WebSocket protocol to propagate peer changes before we re-detect.
|
|
251
|
-
// Without this, concurrent operations on different peers can miss
|
|
252
|
-
// each other due to timing races.
|
|
253
|
-
//
|
|
254
|
-
// Optimization: Only wait if we pushed changes (shorter delay if no changes)
|
|
255
|
-
const tDelayStart = Date.now();
|
|
256
|
-
const delayMs = phase1Result.filesChanged > 0 ? 200 : 100;
|
|
257
|
-
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
258
|
-
timings["post_sync_delay"] = Date.now() - tDelayStart;
|
|
259
251
|
}
|
|
252
|
+
|
|
253
|
+
// Wait for bidirectional sync to stabilize.
|
|
254
|
+
// This polls document heads until they stop changing, which indicates
|
|
255
|
+
// that both our outgoing changes and any incoming peer changes have
|
|
256
|
+
// been received.
|
|
257
|
+
await waitForBidirectionalSync(
|
|
258
|
+
this.repo,
|
|
259
|
+
snapshot.rootDirectoryUrl,
|
|
260
|
+
this.config.sync_server_storage_id,
|
|
261
|
+
{
|
|
262
|
+
timeoutMs: BIDIRECTIONAL_SYNC_TIMEOUT_MS,
|
|
263
|
+
pollIntervalMs: 100,
|
|
264
|
+
stableChecksRequired: 3,
|
|
265
|
+
}
|
|
266
|
+
);
|
|
260
267
|
} catch (error) {
|
|
261
|
-
|
|
268
|
+
out.taskLine(`Network sync failed: ${error}`, true);
|
|
262
269
|
result.warnings.push(`Network sync failed: ${error}`);
|
|
263
270
|
}
|
|
264
271
|
}
|
|
265
|
-
timings["total_network"] = Date.now() - t5;
|
|
266
272
|
|
|
267
|
-
// Re-detect
|
|
268
|
-
// This fixes race conditions where we detect changes before server propagation
|
|
269
|
-
// NOTE: We DON'T update snapshot heads yet - that would prevent detecting remote changes!
|
|
270
|
-
const t6 = Date.now();
|
|
273
|
+
// Re-detect changes after network sync for fresh state
|
|
271
274
|
const freshChanges = await this.changeDetector.detectChanges(snapshot);
|
|
272
275
|
const freshRemoteChanges = freshChanges.filter(
|
|
273
276
|
(c) =>
|
|
274
277
|
c.changeType === ChangeType.REMOTE_ONLY ||
|
|
275
278
|
c.changeType === ChangeType.BOTH_CHANGED
|
|
276
279
|
);
|
|
277
|
-
timings["redetect_changes"] = Date.now() - t6;
|
|
278
280
|
|
|
279
281
|
// Phase 2: Pull remote changes to local using fresh detection
|
|
280
|
-
const t7 = Date.now();
|
|
281
282
|
const phase2Result = await this.pullRemoteChanges(
|
|
282
283
|
freshRemoteChanges,
|
|
283
|
-
snapshot
|
|
284
|
-
dryRun
|
|
284
|
+
snapshot
|
|
285
285
|
);
|
|
286
|
-
timings["phase2_pull"] = Date.now() - t7;
|
|
287
286
|
result.filesChanged += phase2Result.filesChanged;
|
|
288
287
|
result.directoriesChanged += phase2Result.directoriesChanged;
|
|
289
288
|
result.errors.push(...phase2Result.errors);
|
|
290
289
|
result.warnings.push(...phase2Result.warnings);
|
|
291
290
|
|
|
292
|
-
//
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
// Update snapshot with current heads after pulling changes
|
|
304
|
-
snapshot.files.set(filePath, {
|
|
305
|
-
...snapshotEntry,
|
|
306
|
-
head: currentHeads,
|
|
307
|
-
});
|
|
308
|
-
}
|
|
309
|
-
} catch (error) {
|
|
310
|
-
// Handle might not exist if file was deleted, skip
|
|
311
|
-
console.warn(`Could not update heads for ${filePath}: ${error}`);
|
|
291
|
+
// Update snapshot heads after pulling remote changes
|
|
292
|
+
for (const [filePath, snapshotEntry] of snapshot.files.entries()) {
|
|
293
|
+
try {
|
|
294
|
+
const handle = await this.repo.find(snapshotEntry.url);
|
|
295
|
+
const currentHeads = handle.heads();
|
|
296
|
+
if (!A.equals(currentHeads, snapshotEntry.head)) {
|
|
297
|
+
// Update snapshot with current heads after pulling changes
|
|
298
|
+
snapshot.files.set(filePath, {
|
|
299
|
+
...snapshotEntry,
|
|
300
|
+
head: currentHeads,
|
|
301
|
+
});
|
|
312
302
|
}
|
|
303
|
+
} catch (error) {
|
|
304
|
+
// Handle might not exist if file was deleted
|
|
313
305
|
}
|
|
306
|
+
}
|
|
314
307
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
}
|
|
327
|
-
} catch (error) {
|
|
328
|
-
// Handle might not exist if directory was deleted, skip
|
|
329
|
-
console.warn(
|
|
330
|
-
`Could not update heads for directory ${dirPath}: ${error}`
|
|
331
|
-
);
|
|
308
|
+
// Update directory document heads
|
|
309
|
+
for (const [dirPath, snapshotEntry] of snapshot.directories.entries()) {
|
|
310
|
+
try {
|
|
311
|
+
const handle = await this.repo.find(snapshotEntry.url);
|
|
312
|
+
const currentHeads = handle.heads();
|
|
313
|
+
if (!A.equals(currentHeads, snapshotEntry.head)) {
|
|
314
|
+
// Update snapshot with current heads after pulling changes
|
|
315
|
+
snapshot.directories.set(dirPath, {
|
|
316
|
+
...snapshotEntry,
|
|
317
|
+
head: currentHeads,
|
|
318
|
+
});
|
|
332
319
|
}
|
|
320
|
+
} catch (error) {
|
|
321
|
+
// Handle might not exist if directory was deleted
|
|
333
322
|
}
|
|
334
323
|
}
|
|
335
|
-
timings["update_snapshot_heads"] = Date.now() - t8;
|
|
336
324
|
|
|
337
325
|
// Touch root directory if any changes were made during sync
|
|
338
|
-
const t9 = Date.now();
|
|
339
326
|
const hasChanges =
|
|
340
327
|
result.filesChanged > 0 || result.directoriesChanged > 0;
|
|
341
328
|
if (hasChanges) {
|
|
342
|
-
await this.touchRootDirectory(snapshot
|
|
329
|
+
await this.touchRootDirectory(snapshot);
|
|
343
330
|
}
|
|
344
|
-
timings["touch_root"] = Date.now() - t9;
|
|
345
331
|
|
|
346
332
|
// Save updated snapshot if not dry run
|
|
347
|
-
|
|
348
|
-
if (!dryRun) {
|
|
349
|
-
await this.snapshotManager.save(snapshot);
|
|
350
|
-
}
|
|
351
|
-
timings["save_snapshot"] = Date.now() - t10;
|
|
352
|
-
|
|
353
|
-
// Calculate total time
|
|
354
|
-
const totalTime = Date.now() - syncStartTime;
|
|
355
|
-
timings["total"] = totalTime;
|
|
333
|
+
await this.snapshotManager.save(snapshot);
|
|
356
334
|
|
|
357
335
|
result.success = result.errors.length === 0;
|
|
358
|
-
result.timings = timings;
|
|
359
336
|
return result;
|
|
360
337
|
} catch (error) {
|
|
361
338
|
result.errors.push({
|
|
@@ -374,8 +351,7 @@ export class SyncEngine {
|
|
|
374
351
|
private async pushLocalChanges(
|
|
375
352
|
changes: DetectedChange[],
|
|
376
353
|
moves: MoveCandidate[],
|
|
377
|
-
snapshot: SyncSnapshot
|
|
378
|
-
dryRun: boolean
|
|
354
|
+
snapshot: SyncSnapshot
|
|
379
355
|
): Promise<SyncResult> {
|
|
380
356
|
const result: SyncResult = {
|
|
381
357
|
success: true,
|
|
@@ -388,7 +364,7 @@ export class SyncEngine {
|
|
|
388
364
|
// Process moves first - all detected moves are applied
|
|
389
365
|
for (const move of moves) {
|
|
390
366
|
try {
|
|
391
|
-
await this.applyMoveToRemote(move, snapshot
|
|
367
|
+
await this.applyMoveToRemote(move, snapshot);
|
|
392
368
|
result.filesChanged++;
|
|
393
369
|
} catch (error) {
|
|
394
370
|
result.errors.push({
|
|
@@ -409,7 +385,7 @@ export class SyncEngine {
|
|
|
409
385
|
|
|
410
386
|
for (const change of localChanges) {
|
|
411
387
|
try {
|
|
412
|
-
await this.applyLocalChangeToRemote(change, snapshot
|
|
388
|
+
await this.applyLocalChangeToRemote(change, snapshot);
|
|
413
389
|
result.filesChanged++;
|
|
414
390
|
} catch (error) {
|
|
415
391
|
result.errors.push({
|
|
@@ -429,8 +405,7 @@ export class SyncEngine {
|
|
|
429
405
|
*/
|
|
430
406
|
private async pullRemoteChanges(
|
|
431
407
|
changes: DetectedChange[],
|
|
432
|
-
snapshot: SyncSnapshot
|
|
433
|
-
dryRun: boolean
|
|
408
|
+
snapshot: SyncSnapshot
|
|
434
409
|
): Promise<SyncResult> {
|
|
435
410
|
const result: SyncResult = {
|
|
436
411
|
success: true,
|
|
@@ -452,7 +427,7 @@ export class SyncEngine {
|
|
|
452
427
|
|
|
453
428
|
for (const change of sortedChanges) {
|
|
454
429
|
try {
|
|
455
|
-
await this.applyRemoteChangeToLocal(change, snapshot
|
|
430
|
+
await this.applyRemoteChangeToLocal(change, snapshot);
|
|
456
431
|
result.filesChanged++;
|
|
457
432
|
} catch (error) {
|
|
458
433
|
result.errors.push({
|
|
@@ -472,47 +447,33 @@ export class SyncEngine {
|
|
|
472
447
|
*/
|
|
473
448
|
private async applyLocalChangeToRemote(
|
|
474
449
|
change: DetectedChange,
|
|
475
|
-
snapshot: SyncSnapshot
|
|
476
|
-
dryRun: boolean
|
|
450
|
+
snapshot: SyncSnapshot
|
|
477
451
|
): Promise<void> {
|
|
478
452
|
const snapshotEntry = snapshot.files.get(change.path);
|
|
479
453
|
|
|
480
|
-
//
|
|
481
|
-
// Empty strings "" and empty Uint8Array are valid file content!
|
|
454
|
+
// Check for null (empty string/Uint8Array are valid content)
|
|
482
455
|
if (change.localContent === null) {
|
|
483
456
|
// File was deleted locally
|
|
484
457
|
if (snapshotEntry) {
|
|
485
|
-
await this.deleteRemoteFile(
|
|
486
|
-
snapshotEntry.url,
|
|
487
|
-
dryRun,
|
|
488
|
-
snapshot,
|
|
489
|
-
change.path
|
|
490
|
-
);
|
|
458
|
+
await this.deleteRemoteFile(snapshotEntry.url, snapshot, change.path);
|
|
491
459
|
// Remove from directory document
|
|
492
|
-
await this.removeFileFromDirectory(snapshot, change.path
|
|
493
|
-
|
|
494
|
-
this.snapshotManager.removeFileEntry(snapshot, change.path);
|
|
495
|
-
}
|
|
460
|
+
await this.removeFileFromDirectory(snapshot, change.path);
|
|
461
|
+
this.snapshotManager.removeFileEntry(snapshot, change.path);
|
|
496
462
|
}
|
|
497
463
|
return;
|
|
498
464
|
}
|
|
499
465
|
|
|
500
466
|
if (!snapshotEntry) {
|
|
501
467
|
// New file
|
|
502
|
-
const handle = await this.createRemoteFile(change
|
|
503
|
-
if (
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
handle.url,
|
|
508
|
-
dryRun
|
|
509
|
-
);
|
|
468
|
+
const handle = await this.createRemoteFile(change);
|
|
469
|
+
if (handle) {
|
|
470
|
+
// Use versioned URL (includes heads) so clients fetch correct version
|
|
471
|
+
const versionedUrl = this.getVersionedUrl(handle);
|
|
472
|
+
await this.addFileToDirectory(snapshot, change.path, versionedUrl);
|
|
510
473
|
|
|
511
|
-
// CRITICAL FIX: Update snapshot with heads AFTER adding to directory
|
|
512
|
-
// The addFileToDirectory call above may have changed the document heads
|
|
513
474
|
this.snapshotManager.updateFileEntry(snapshot, change.path, {
|
|
514
|
-
path:
|
|
515
|
-
url:
|
|
475
|
+
path: joinAndNormalizePath(this.rootPath, change.path),
|
|
476
|
+
url: versionedUrl,
|
|
516
477
|
head: handle.heads(),
|
|
517
478
|
extension: getFileExtension(change.path),
|
|
518
479
|
mimeType: getEnhancedMimeType(change.path),
|
|
@@ -523,7 +484,6 @@ export class SyncEngine {
|
|
|
523
484
|
await this.updateRemoteFile(
|
|
524
485
|
snapshotEntry.url,
|
|
525
486
|
change.localContent,
|
|
526
|
-
dryRun,
|
|
527
487
|
snapshot,
|
|
528
488
|
change.path
|
|
529
489
|
);
|
|
@@ -535,10 +495,9 @@ export class SyncEngine {
|
|
|
535
495
|
*/
|
|
536
496
|
private async applyRemoteChangeToLocal(
|
|
537
497
|
change: DetectedChange,
|
|
538
|
-
snapshot: SyncSnapshot
|
|
539
|
-
dryRun: boolean
|
|
498
|
+
snapshot: SyncSnapshot
|
|
540
499
|
): Promise<void> {
|
|
541
|
-
const localPath =
|
|
500
|
+
const localPath = joinAndNormalizePath(this.rootPath, change.path);
|
|
542
501
|
|
|
543
502
|
if (!change.remoteHead) {
|
|
544
503
|
throw new Error(
|
|
@@ -546,50 +505,51 @@ export class SyncEngine {
|
|
|
546
505
|
);
|
|
547
506
|
}
|
|
548
507
|
|
|
549
|
-
//
|
|
550
|
-
// Empty strings "" and empty Uint8Array are valid file content!
|
|
508
|
+
// Check for null (empty string/Uint8Array are valid content)
|
|
551
509
|
if (change.remoteContent === null) {
|
|
552
510
|
// File was deleted remotely
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
this.snapshotManager.removeFileEntry(snapshot, change.path);
|
|
556
|
-
}
|
|
511
|
+
await removePath(localPath);
|
|
512
|
+
this.snapshotManager.removeFileEntry(snapshot, change.path);
|
|
557
513
|
return;
|
|
558
514
|
}
|
|
559
515
|
|
|
560
516
|
// Create or update local file
|
|
561
|
-
|
|
562
|
-
await writeFileContent(localPath, change.remoteContent);
|
|
517
|
+
await writeFileContent(localPath, change.remoteContent);
|
|
563
518
|
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
519
|
+
// Update or create snapshot entry for this file
|
|
520
|
+
const snapshotEntry = snapshot.files.get(change.path);
|
|
521
|
+
if (snapshotEntry) {
|
|
522
|
+
// Update existing entry
|
|
523
|
+
snapshotEntry.head = change.remoteHead;
|
|
524
|
+
} else {
|
|
525
|
+
// Create new snapshot entry for newly discovered remote file
|
|
526
|
+
// We need to find the remote file's URL from the directory hierarchy
|
|
527
|
+
if (snapshot.rootDirectoryUrl) {
|
|
528
|
+
try {
|
|
529
|
+
const fileEntry = await findFileInDirectoryHierarchy(
|
|
530
|
+
this.repo,
|
|
531
|
+
snapshot.rootDirectoryUrl,
|
|
532
|
+
change.path
|
|
533
|
+
);
|
|
578
534
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
`Failed to update snapshot for remote file ${change.path}: ${error}`
|
|
591
|
-
);
|
|
535
|
+
if (fileEntry) {
|
|
536
|
+
// Get versioned URL from handle (includes heads)
|
|
537
|
+
const fileHandle = await this.repo.find<FileDocument>(fileEntry.url);
|
|
538
|
+
const versionedUrl = this.getVersionedUrl(fileHandle);
|
|
539
|
+
this.snapshotManager.updateFileEntry(snapshot, change.path, {
|
|
540
|
+
path: localPath,
|
|
541
|
+
url: versionedUrl,
|
|
542
|
+
head: change.remoteHead,
|
|
543
|
+
extension: getFileExtension(change.path),
|
|
544
|
+
mimeType: getEnhancedMimeType(change.path),
|
|
545
|
+
});
|
|
592
546
|
}
|
|
547
|
+
} catch (error) {
|
|
548
|
+
// Failed to update snapshot - file may have been deleted
|
|
549
|
+
out.taskLine(
|
|
550
|
+
`Warning: Failed to update snapshot for remote file ${change.path}`,
|
|
551
|
+
true
|
|
552
|
+
);
|
|
593
553
|
}
|
|
594
554
|
}
|
|
595
555
|
}
|
|
@@ -600,90 +560,84 @@ export class SyncEngine {
|
|
|
600
560
|
*/
|
|
601
561
|
private async applyMoveToRemote(
|
|
602
562
|
move: MoveCandidate,
|
|
603
|
-
snapshot: SyncSnapshot
|
|
604
|
-
dryRun: boolean
|
|
563
|
+
snapshot: SyncSnapshot
|
|
605
564
|
): Promise<void> {
|
|
606
565
|
const fromEntry = snapshot.files.get(move.fromPath);
|
|
607
566
|
if (!fromEntry) return;
|
|
608
567
|
|
|
609
568
|
// Parse paths
|
|
610
|
-
const fromParts = move.fromPath.split("/");
|
|
611
|
-
const fromFileName = fromParts.pop() || "";
|
|
612
|
-
const fromDirPath = fromParts.join("/");
|
|
613
|
-
|
|
614
569
|
const toParts = move.toPath.split("/");
|
|
615
570
|
const toFileName = toParts.pop() || "";
|
|
616
571
|
const toDirPath = toParts.join("/");
|
|
617
572
|
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
}
|
|
573
|
+
// 1) Remove file entry from old directory document
|
|
574
|
+
if (move.fromPath !== move.toPath) {
|
|
575
|
+
await this.removeFileFromDirectory(snapshot, move.fromPath);
|
|
576
|
+
}
|
|
623
577
|
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
snapshot,
|
|
627
|
-
toDirPath,
|
|
628
|
-
dryRun
|
|
629
|
-
);
|
|
630
|
-
await this.addFileToDirectory(
|
|
631
|
-
snapshot,
|
|
632
|
-
move.toPath,
|
|
633
|
-
fromEntry.url,
|
|
634
|
-
dryRun
|
|
635
|
-
);
|
|
578
|
+
// 2) Ensure destination directory document exists
|
|
579
|
+
await this.ensureDirectoryDocument(snapshot, toDirPath);
|
|
636
580
|
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
581
|
+
// 3) Update the FileDocument name and content to match new location/state
|
|
582
|
+
try {
|
|
583
|
+
// Use plain URL for mutable handle
|
|
584
|
+
const handle = await this.repo.find<FileDocument>(
|
|
585
|
+
getPlainUrl(fromEntry.url)
|
|
586
|
+
);
|
|
587
|
+
const heads = fromEntry.head;
|
|
588
|
+
|
|
589
|
+
// Update both name and content (if content changed during move)
|
|
590
|
+
if (heads && heads.length > 0) {
|
|
591
|
+
handle.changeAt(heads, (doc: FileDocument) => {
|
|
592
|
+
doc.name = toFileName;
|
|
593
|
+
|
|
594
|
+
// If new content is provided, update it (handles move + modification case)
|
|
595
|
+
if (move.newContent !== undefined) {
|
|
596
|
+
if (typeof move.newContent === "string") {
|
|
597
|
+
doc.content = new A.ImmutableString(move.newContent);
|
|
598
|
+
} else {
|
|
599
|
+
doc.content = move.newContent;
|
|
655
600
|
}
|
|
656
|
-
}
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
}
|
|
601
|
+
}
|
|
602
|
+
});
|
|
603
|
+
} else {
|
|
604
|
+
handle.change((doc: FileDocument) => {
|
|
605
|
+
doc.name = toFileName;
|
|
606
|
+
|
|
607
|
+
// If new content is provided, update it (handles move + modification case)
|
|
608
|
+
if (move.newContent !== undefined) {
|
|
609
|
+
if (typeof move.newContent === "string") {
|
|
610
|
+
doc.content = new A.ImmutableString(move.newContent);
|
|
611
|
+
} else {
|
|
612
|
+
doc.content = move.newContent;
|
|
669
613
|
}
|
|
670
|
-
}
|
|
671
|
-
}
|
|
672
|
-
// Track file handle for network sync
|
|
673
|
-
this.handlesToWaitOn.push(handle);
|
|
674
|
-
} catch (e) {
|
|
675
|
-
console.warn(
|
|
676
|
-
`Failed to update file name for move ${move.fromPath} -> ${move.toPath}: ${e}`
|
|
677
|
-
);
|
|
614
|
+
}
|
|
615
|
+
});
|
|
678
616
|
}
|
|
679
617
|
|
|
680
|
-
//
|
|
618
|
+
// Get versioned URL after changes (includes current heads)
|
|
619
|
+
const versionedUrl = this.getVersionedUrl(handle);
|
|
620
|
+
|
|
621
|
+
// 4) Add file entry to destination directory with versioned URL
|
|
622
|
+
await this.addFileToDirectory(snapshot, move.toPath, versionedUrl);
|
|
623
|
+
|
|
624
|
+
// Track file handle for network sync
|
|
625
|
+
this.handlesByPath.set(move.toPath, handle);
|
|
626
|
+
|
|
627
|
+
// 5) Update snapshot entries
|
|
681
628
|
this.snapshotManager.removeFileEntry(snapshot, move.fromPath);
|
|
682
629
|
this.snapshotManager.updateFileEntry(snapshot, move.toPath, {
|
|
683
630
|
...fromEntry,
|
|
684
|
-
path:
|
|
685
|
-
|
|
631
|
+
path: joinAndNormalizePath(this.rootPath, move.toPath),
|
|
632
|
+
url: versionedUrl,
|
|
633
|
+
head: handle.heads(),
|
|
686
634
|
});
|
|
635
|
+
} catch (e) {
|
|
636
|
+
// Failed to update file name - file may have been deleted
|
|
637
|
+
out.taskLine(
|
|
638
|
+
`Warning: Failed to rename ${move.fromPath} to ${move.toPath}`,
|
|
639
|
+
true
|
|
640
|
+
);
|
|
687
641
|
}
|
|
688
642
|
}
|
|
689
643
|
|
|
@@ -691,12 +645,9 @@ export class SyncEngine {
|
|
|
691
645
|
* Create new remote file document
|
|
692
646
|
*/
|
|
693
647
|
private async createRemoteFile(
|
|
694
|
-
change: DetectedChange
|
|
695
|
-
dryRun: boolean
|
|
648
|
+
change: DetectedChange
|
|
696
649
|
): Promise<DocHandle<FileDocument> | null> {
|
|
697
|
-
|
|
698
|
-
// Empty strings "" and empty Uint8Array are valid file content!
|
|
699
|
-
if (dryRun || change.localContent === null) return null;
|
|
650
|
+
if (change.localContent === null) return null;
|
|
700
651
|
|
|
701
652
|
const isText = this.isTextContent(change.localContent);
|
|
702
653
|
|
|
@@ -706,7 +657,11 @@ export class SyncEngine {
|
|
|
706
657
|
name: change.path.split("/").pop() || "",
|
|
707
658
|
extension: getFileExtension(change.path),
|
|
708
659
|
mimeType: getEnhancedMimeType(change.path),
|
|
709
|
-
content: isText
|
|
660
|
+
content: isText
|
|
661
|
+
? new A.ImmutableString("")
|
|
662
|
+
: typeof change.localContent === "string"
|
|
663
|
+
? new A.ImmutableString(change.localContent)
|
|
664
|
+
: change.localContent, // Empty ImmutableString for text, wrap strings for safety, actual content for binary
|
|
710
665
|
metadata: {
|
|
711
666
|
permissions: 0o644,
|
|
712
667
|
},
|
|
@@ -714,16 +669,16 @@ export class SyncEngine {
|
|
|
714
669
|
|
|
715
670
|
const handle = this.repo.create(fileDoc);
|
|
716
671
|
|
|
717
|
-
// For text files, use
|
|
672
|
+
// For text files, use ImmutableString for better performance
|
|
718
673
|
if (isText && typeof change.localContent === "string") {
|
|
719
674
|
handle.change((doc: FileDocument) => {
|
|
720
|
-
|
|
675
|
+
doc.content = new A.ImmutableString(change.localContent as string);
|
|
721
676
|
});
|
|
722
677
|
}
|
|
723
678
|
|
|
724
679
|
// Always track newly created files for network sync
|
|
725
680
|
// (they always represent a change that needs to sync)
|
|
726
|
-
this.
|
|
681
|
+
this.handlesByPath.set(change.path, handle);
|
|
727
682
|
|
|
728
683
|
return handle;
|
|
729
684
|
}
|
|
@@ -734,21 +689,18 @@ export class SyncEngine {
|
|
|
734
689
|
private async updateRemoteFile(
|
|
735
690
|
url: AutomergeUrl,
|
|
736
691
|
content: string | Uint8Array,
|
|
737
|
-
dryRun: boolean,
|
|
738
692
|
snapshot: SyncSnapshot,
|
|
739
693
|
filePath: string
|
|
740
694
|
): Promise<void> {
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
const handle = await this.repo.find<FileDocument>(url);
|
|
695
|
+
// Use plain URL for mutable handle
|
|
696
|
+
const handle = await this.repo.find<FileDocument>(getPlainUrl(url));
|
|
744
697
|
|
|
745
698
|
// Check if content actually changed before tracking for sync
|
|
746
699
|
const doc = await handle.doc();
|
|
747
700
|
const currentContent = doc?.content;
|
|
748
701
|
const contentChanged = !isContentEqual(content, currentContent);
|
|
749
702
|
|
|
750
|
-
//
|
|
751
|
-
// This prevents stale head issues that cause false change detection
|
|
703
|
+
// Update snapshot heads even when content is identical
|
|
752
704
|
const snapshotEntry = snapshot.files.get(filePath);
|
|
753
705
|
if (snapshotEntry) {
|
|
754
706
|
// Update snapshot with current document heads
|
|
@@ -761,9 +713,6 @@ export class SyncEngine {
|
|
|
761
713
|
if (!contentChanged) {
|
|
762
714
|
// Content is identical, but we've updated the snapshot heads above
|
|
763
715
|
// This prevents fresh change detection from seeing stale heads
|
|
764
|
-
console.log(
|
|
765
|
-
`🔍 Content is identical, but we've updated the snapshot heads above`
|
|
766
|
-
);
|
|
767
716
|
return;
|
|
768
717
|
}
|
|
769
718
|
|
|
@@ -774,16 +723,15 @@ export class SyncEngine {
|
|
|
774
723
|
}
|
|
775
724
|
|
|
776
725
|
handle.changeAt(heads, (doc: FileDocument) => {
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
updateText(doc, ["content"], content);
|
|
726
|
+
if (typeof content === "string") {
|
|
727
|
+
doc.content = new A.ImmutableString(content);
|
|
780
728
|
} else {
|
|
781
729
|
doc.content = content;
|
|
782
730
|
}
|
|
783
731
|
});
|
|
784
732
|
|
|
785
733
|
// Update snapshot with new heads after content change
|
|
786
|
-
if (
|
|
734
|
+
if (snapshotEntry) {
|
|
787
735
|
snapshot.files.set(filePath, {
|
|
788
736
|
...snapshotEntry,
|
|
789
737
|
head: handle.heads(),
|
|
@@ -791,7 +739,7 @@ export class SyncEngine {
|
|
|
791
739
|
}
|
|
792
740
|
|
|
793
741
|
// Only track files that actually changed content
|
|
794
|
-
this.
|
|
742
|
+
this.handlesByPath.set(filePath, handle);
|
|
795
743
|
}
|
|
796
744
|
|
|
797
745
|
/**
|
|
@@ -799,16 +747,14 @@ export class SyncEngine {
|
|
|
799
747
|
*/
|
|
800
748
|
private async deleteRemoteFile(
|
|
801
749
|
url: AutomergeUrl,
|
|
802
|
-
dryRun: boolean,
|
|
803
750
|
snapshot?: SyncSnapshot,
|
|
804
751
|
filePath?: string
|
|
805
752
|
): Promise<void> {
|
|
806
|
-
if (dryRun) return;
|
|
807
|
-
|
|
808
753
|
// In Automerge, we don't actually delete documents
|
|
809
754
|
// They become orphaned and will be garbage collected
|
|
810
755
|
// For now, we just mark them as deleted by clearing content
|
|
811
|
-
|
|
756
|
+
// Use plain URL for mutable handle
|
|
757
|
+
const handle = await this.repo.find<FileDocument>(getPlainUrl(url));
|
|
812
758
|
// const doc = await handle.doc(); // no longer needed
|
|
813
759
|
let heads;
|
|
814
760
|
if (snapshot && filePath) {
|
|
@@ -816,11 +762,11 @@ export class SyncEngine {
|
|
|
816
762
|
}
|
|
817
763
|
if (heads) {
|
|
818
764
|
handle.changeAt(heads, (doc: FileDocument) => {
|
|
819
|
-
doc.content = "";
|
|
765
|
+
doc.content = new A.ImmutableString("");
|
|
820
766
|
});
|
|
821
767
|
} else {
|
|
822
768
|
handle.change((doc: FileDocument) => {
|
|
823
|
-
doc.content = "";
|
|
769
|
+
doc.content = new A.ImmutableString("");
|
|
824
770
|
});
|
|
825
771
|
}
|
|
826
772
|
}
|
|
@@ -831,10 +777,9 @@ export class SyncEngine {
|
|
|
831
777
|
private async addFileToDirectory(
|
|
832
778
|
snapshot: SyncSnapshot,
|
|
833
779
|
filePath: string,
|
|
834
|
-
fileUrl: AutomergeUrl
|
|
835
|
-
dryRun: boolean
|
|
780
|
+
fileUrl: AutomergeUrl
|
|
836
781
|
): Promise<void> {
|
|
837
|
-
if (
|
|
782
|
+
if (!snapshot.rootDirectoryUrl) return;
|
|
838
783
|
|
|
839
784
|
const pathParts = filePath.split("/");
|
|
840
785
|
const fileName = pathParts.pop() || "";
|
|
@@ -843,11 +788,13 @@ export class SyncEngine {
|
|
|
843
788
|
// Get or create the parent directory document
|
|
844
789
|
const parentDirUrl = await this.ensureDirectoryDocument(
|
|
845
790
|
snapshot,
|
|
846
|
-
directoryPath
|
|
847
|
-
dryRun
|
|
791
|
+
directoryPath
|
|
848
792
|
);
|
|
849
793
|
|
|
850
|
-
|
|
794
|
+
// Use plain URL for mutable handle
|
|
795
|
+
const dirHandle = await this.repo.find<DirectoryDocument>(
|
|
796
|
+
getPlainUrl(parentDirUrl)
|
|
797
|
+
);
|
|
851
798
|
|
|
852
799
|
let didChange = false;
|
|
853
800
|
const snapshotEntry = snapshot.directories.get(directoryPath);
|
|
@@ -881,14 +828,11 @@ export class SyncEngine {
|
|
|
881
828
|
}
|
|
882
829
|
});
|
|
883
830
|
}
|
|
884
|
-
if
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
if (snapshotEntry) {
|
|
890
|
-
snapshotEntry.head = dirHandle.heads();
|
|
891
|
-
}
|
|
831
|
+
// Always track the directory (even if unchanged) for proper leaf-first sync ordering
|
|
832
|
+
this.handlesByPath.set(directoryPath, dirHandle);
|
|
833
|
+
|
|
834
|
+
if (didChange && snapshotEntry) {
|
|
835
|
+
snapshotEntry.head = dirHandle.heads();
|
|
892
836
|
}
|
|
893
837
|
}
|
|
894
838
|
|
|
@@ -898,8 +842,7 @@ export class SyncEngine {
|
|
|
898
842
|
*/
|
|
899
843
|
private async ensureDirectoryDocument(
|
|
900
844
|
snapshot: SyncSnapshot,
|
|
901
|
-
directoryPath: string
|
|
902
|
-
dryRun: boolean
|
|
845
|
+
directoryPath: string
|
|
903
846
|
): Promise<AutomergeUrl> {
|
|
904
847
|
// Root directory case
|
|
905
848
|
if (!directoryPath || directoryPath === "") {
|
|
@@ -920,8 +863,7 @@ export class SyncEngine {
|
|
|
920
863
|
// Ensure parent directory exists first (recursive)
|
|
921
864
|
const parentDirUrl = await this.ensureDirectoryDocument(
|
|
922
865
|
snapshot,
|
|
923
|
-
parentPath
|
|
924
|
-
dryRun
|
|
866
|
+
parentPath
|
|
925
867
|
);
|
|
926
868
|
|
|
927
869
|
// DISCOVERY: Check if directory already exists in parent on server
|
|
@@ -944,35 +886,30 @@ export class SyncEngine {
|
|
|
944
886
|
const childDirHandle = await this.repo.find<DirectoryDocument>(
|
|
945
887
|
existingDirEntry.url
|
|
946
888
|
);
|
|
947
|
-
const childHeads = childDirHandle.heads();
|
|
948
|
-
|
|
949
|
-
// Update snapshot with discovered directory using validated heads
|
|
950
|
-
if (!dryRun) {
|
|
951
|
-
this.snapshotManager.updateDirectoryEntry(
|
|
952
|
-
snapshot,
|
|
953
|
-
directoryPath,
|
|
954
|
-
{
|
|
955
|
-
path: normalizePath(this.rootPath + "/" + directoryPath),
|
|
956
|
-
url: existingDirEntry.url,
|
|
957
|
-
head: childHeads,
|
|
958
|
-
entries: [],
|
|
959
|
-
}
|
|
960
|
-
);
|
|
961
|
-
}
|
|
962
889
|
|
|
963
|
-
|
|
890
|
+
// Track discovered directory for sync
|
|
891
|
+
this.handlesByPath.set(directoryPath, childDirHandle);
|
|
892
|
+
|
|
893
|
+
// Get versioned URL for storage (includes current heads)
|
|
894
|
+
const versionedUrl = this.getVersionedUrl(childDirHandle);
|
|
895
|
+
|
|
896
|
+
// Update snapshot with discovered directory using versioned URL
|
|
897
|
+
this.snapshotManager.updateDirectoryEntry(snapshot, directoryPath, {
|
|
898
|
+
path: joinAndNormalizePath(this.rootPath, directoryPath),
|
|
899
|
+
url: versionedUrl,
|
|
900
|
+
head: childDirHandle.heads(),
|
|
901
|
+
entries: [],
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
// Return versioned URL (callers use getPlainUrl() when they need to modify)
|
|
905
|
+
return versionedUrl;
|
|
964
906
|
} catch (resolveErr) {
|
|
965
|
-
|
|
966
|
-
`Failed to resolve child directory ${currentDirName} at ${directoryPath}: ${resolveErr}`
|
|
967
|
-
);
|
|
968
|
-
// Fall through to create a fresh directory document
|
|
907
|
+
// Failed to resolve directory - fall through to create a fresh directory document
|
|
969
908
|
}
|
|
970
909
|
}
|
|
971
910
|
}
|
|
972
911
|
} catch (error) {
|
|
973
|
-
|
|
974
|
-
`Failed to check for existing directory ${currentDirName}: ${error}`
|
|
975
|
-
);
|
|
912
|
+
// Failed to check for existing directory - will create new one
|
|
976
913
|
}
|
|
977
914
|
|
|
978
915
|
// CREATE: Directory doesn't exist, create new one
|
|
@@ -983,8 +920,14 @@ export class SyncEngine {
|
|
|
983
920
|
|
|
984
921
|
const dirHandle = this.repo.create(dirDoc);
|
|
985
922
|
|
|
923
|
+
// Get versioned URL for the new directory (includes heads)
|
|
924
|
+
const versionedDirUrl = this.getVersionedUrl(dirHandle);
|
|
925
|
+
|
|
986
926
|
// Add this directory to its parent
|
|
987
|
-
|
|
927
|
+
// Use plain URL for mutable handle
|
|
928
|
+
const parentHandle = await this.repo.find<DirectoryDocument>(
|
|
929
|
+
getPlainUrl(parentDirUrl)
|
|
930
|
+
);
|
|
988
931
|
|
|
989
932
|
let didChange = false;
|
|
990
933
|
parentHandle.change((doc: DirectoryDocument) => {
|
|
@@ -997,36 +940,33 @@ export class SyncEngine {
|
|
|
997
940
|
doc.docs.push({
|
|
998
941
|
name: currentDirName,
|
|
999
942
|
type: "folder",
|
|
1000
|
-
url:
|
|
943
|
+
url: versionedDirUrl,
|
|
1001
944
|
});
|
|
1002
945
|
didChange = true;
|
|
1003
946
|
}
|
|
1004
947
|
});
|
|
1005
948
|
|
|
1006
949
|
// Track directory handles for sync
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
this.handlesToWaitOn.push(parentHandle);
|
|
1011
|
-
|
|
1012
|
-
// CRITICAL FIX: Update parent directory heads in snapshot immediately
|
|
1013
|
-
// This prevents stale head issues when parent directory is modified
|
|
1014
|
-
const parentSnapshotEntry = snapshot.directories.get(parentPath);
|
|
1015
|
-
if (parentSnapshotEntry) {
|
|
1016
|
-
parentSnapshotEntry.head = parentHandle.heads();
|
|
1017
|
-
}
|
|
1018
|
-
}
|
|
950
|
+
this.handlesByPath.set(directoryPath, dirHandle);
|
|
951
|
+
if (didChange) {
|
|
952
|
+
this.handlesByPath.set(parentPath, parentHandle);
|
|
1019
953
|
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
head: dirHandle.heads(),
|
|
1025
|
-
entries: [],
|
|
1026
|
-
});
|
|
954
|
+
const parentSnapshotEntry = snapshot.directories.get(parentPath);
|
|
955
|
+
if (parentSnapshotEntry) {
|
|
956
|
+
parentSnapshotEntry.head = parentHandle.heads();
|
|
957
|
+
}
|
|
1027
958
|
}
|
|
1028
959
|
|
|
1029
|
-
|
|
960
|
+
// Update snapshot with new directory (use versioned URL for storage)
|
|
961
|
+
this.snapshotManager.updateDirectoryEntry(snapshot, directoryPath, {
|
|
962
|
+
path: joinAndNormalizePath(this.rootPath, directoryPath),
|
|
963
|
+
url: versionedDirUrl,
|
|
964
|
+
head: dirHandle.heads(),
|
|
965
|
+
entries: [],
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
// Return versioned URL (callers use getPlainUrl() when they need to modify)
|
|
969
|
+
return versionedDirUrl;
|
|
1030
970
|
}
|
|
1031
971
|
|
|
1032
972
|
/**
|
|
@@ -1034,10 +974,9 @@ export class SyncEngine {
|
|
|
1034
974
|
*/
|
|
1035
975
|
private async removeFileFromDirectory(
|
|
1036
976
|
snapshot: SyncSnapshot,
|
|
1037
|
-
filePath: string
|
|
1038
|
-
dryRun: boolean
|
|
977
|
+
filePath: string
|
|
1039
978
|
): Promise<void> {
|
|
1040
|
-
if (
|
|
979
|
+
if (!snapshot.rootDirectoryUrl) return;
|
|
1041
980
|
|
|
1042
981
|
const pathParts = filePath.split("/");
|
|
1043
982
|
const fileName = pathParts.pop() || "";
|
|
@@ -1050,19 +989,20 @@ export class SyncEngine {
|
|
|
1050
989
|
} else {
|
|
1051
990
|
const existingDir = snapshot.directories.get(directoryPath);
|
|
1052
991
|
if (!existingDir) {
|
|
1053
|
-
|
|
1054
|
-
`Directory ${directoryPath} not found in snapshot for file removal`
|
|
1055
|
-
);
|
|
992
|
+
// Directory not found - file may already be removed
|
|
1056
993
|
return;
|
|
1057
994
|
}
|
|
1058
995
|
parentDirUrl = existingDir.url;
|
|
1059
996
|
}
|
|
1060
997
|
|
|
1061
998
|
try {
|
|
1062
|
-
|
|
999
|
+
// Use plain URL for mutable handle
|
|
1000
|
+
const dirHandle = await this.repo.find<DirectoryDocument>(
|
|
1001
|
+
getPlainUrl(parentDirUrl)
|
|
1002
|
+
);
|
|
1063
1003
|
|
|
1064
1004
|
// Track this handle for network sync waiting
|
|
1065
|
-
this.
|
|
1005
|
+
this.handlesByPath.set(directoryPath, dirHandle);
|
|
1066
1006
|
const snapshotEntry = snapshot.directories.get(directoryPath);
|
|
1067
1007
|
const heads = snapshotEntry?.head;
|
|
1068
1008
|
let didChange = false;
|
|
@@ -1075,9 +1015,9 @@ export class SyncEngine {
|
|
|
1075
1015
|
if (indexToRemove !== -1) {
|
|
1076
1016
|
doc.docs.splice(indexToRemove, 1);
|
|
1077
1017
|
didChange = true;
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
directoryPath || "root"
|
|
1018
|
+
out.taskLine(
|
|
1019
|
+
`Removed ${fileName} from ${
|
|
1020
|
+
formatRelativePath(directoryPath) || "root"
|
|
1081
1021
|
}`
|
|
1082
1022
|
);
|
|
1083
1023
|
}
|
|
@@ -1090,83 +1030,23 @@ export class SyncEngine {
|
|
|
1090
1030
|
if (indexToRemove !== -1) {
|
|
1091
1031
|
doc.docs.splice(indexToRemove, 1);
|
|
1092
1032
|
didChange = true;
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
directoryPath || "root"
|
|
1033
|
+
out.taskLine(
|
|
1034
|
+
`Removed ${fileName} from ${
|
|
1035
|
+
formatRelativePath(directoryPath) || "root"
|
|
1096
1036
|
}`
|
|
1097
1037
|
);
|
|
1098
1038
|
}
|
|
1099
1039
|
});
|
|
1100
1040
|
}
|
|
1101
1041
|
|
|
1102
|
-
// CRITICAL FIX: Update snapshot with new directory heads immediately
|
|
1103
|
-
// This prevents stale head issues that cause convergence problems
|
|
1104
1042
|
if (didChange && snapshotEntry) {
|
|
1105
1043
|
snapshotEntry.head = dirHandle.heads();
|
|
1106
1044
|
}
|
|
1107
1045
|
} catch (error) {
|
|
1108
|
-
console.warn(
|
|
1109
|
-
`Failed to remove ${fileName} from directory ${
|
|
1110
|
-
directoryPath || "root"
|
|
1111
|
-
}: ${error}`
|
|
1112
|
-
);
|
|
1113
1046
|
throw error;
|
|
1114
1047
|
}
|
|
1115
1048
|
}
|
|
1116
1049
|
|
|
1117
|
-
/**
|
|
1118
|
-
* Find a file in the directory hierarchy by path
|
|
1119
|
-
*/
|
|
1120
|
-
private async findFileInDirectoryHierarchy(
|
|
1121
|
-
directoryUrl: AutomergeUrl,
|
|
1122
|
-
filePath: string
|
|
1123
|
-
): Promise<{ name: string; type: string; url: AutomergeUrl } | null> {
|
|
1124
|
-
try {
|
|
1125
|
-
const pathParts = filePath.split("/");
|
|
1126
|
-
let currentDirUrl = directoryUrl;
|
|
1127
|
-
|
|
1128
|
-
// Navigate through directories to find the parent directory
|
|
1129
|
-
for (let i = 0; i < pathParts.length - 1; i++) {
|
|
1130
|
-
const dirName = pathParts[i];
|
|
1131
|
-
const dirHandle = await this.repo.find<DirectoryDocument>(
|
|
1132
|
-
currentDirUrl
|
|
1133
|
-
);
|
|
1134
|
-
const dirDoc = await dirHandle.doc();
|
|
1135
|
-
|
|
1136
|
-
if (!dirDoc) return null;
|
|
1137
|
-
|
|
1138
|
-
const subDirEntry = dirDoc.docs.find(
|
|
1139
|
-
(entry: { name: string; type: string; url: AutomergeUrl }) =>
|
|
1140
|
-
entry.name === dirName && entry.type === "folder"
|
|
1141
|
-
);
|
|
1142
|
-
|
|
1143
|
-
if (!subDirEntry) return null;
|
|
1144
|
-
currentDirUrl = subDirEntry.url;
|
|
1145
|
-
}
|
|
1146
|
-
|
|
1147
|
-
// Now look for the file in the final directory
|
|
1148
|
-
const fileName = pathParts[pathParts.length - 1];
|
|
1149
|
-
const finalDirHandle = await this.repo.find<DirectoryDocument>(
|
|
1150
|
-
currentDirUrl
|
|
1151
|
-
);
|
|
1152
|
-
const finalDirDoc = await finalDirHandle.doc();
|
|
1153
|
-
|
|
1154
|
-
if (!finalDirDoc) return null;
|
|
1155
|
-
|
|
1156
|
-
const fileEntry = finalDirDoc.docs.find(
|
|
1157
|
-
(entry: { name: string; type: string; url: AutomergeUrl }) =>
|
|
1158
|
-
entry.name === fileName && entry.type === "file"
|
|
1159
|
-
);
|
|
1160
|
-
|
|
1161
|
-
return fileEntry || null;
|
|
1162
|
-
} catch (error) {
|
|
1163
|
-
console.warn(
|
|
1164
|
-
`Failed to find file ${filePath} in directory hierarchy: ${error}`
|
|
1165
|
-
);
|
|
1166
|
-
return null;
|
|
1167
|
-
}
|
|
1168
|
-
}
|
|
1169
|
-
|
|
1170
1050
|
/**
|
|
1171
1051
|
* Sort changes by dependency order
|
|
1172
1052
|
*/
|
|
@@ -1227,11 +1107,7 @@ export class SyncEngine {
|
|
|
1227
1107
|
}
|
|
1228
1108
|
|
|
1229
1109
|
const changes = await this.changeDetector.detectChanges(snapshot);
|
|
1230
|
-
const { moves } = await this.moveDetector.detectMoves(
|
|
1231
|
-
changes,
|
|
1232
|
-
snapshot,
|
|
1233
|
-
this.rootPath
|
|
1234
|
-
);
|
|
1110
|
+
const { moves } = await this.moveDetector.detectMoves(changes, snapshot);
|
|
1235
1111
|
|
|
1236
1112
|
const summary = this.generateChangeSummary(changes, moves);
|
|
1237
1113
|
|
|
@@ -1293,11 +1169,8 @@ export class SyncEngine {
|
|
|
1293
1169
|
/**
|
|
1294
1170
|
* Update the lastSyncAt timestamp on the root directory document
|
|
1295
1171
|
*/
|
|
1296
|
-
private async touchRootDirectory(
|
|
1297
|
-
snapshot
|
|
1298
|
-
dryRun: boolean
|
|
1299
|
-
): Promise<void> {
|
|
1300
|
-
if (dryRun || !snapshot.rootDirectoryUrl) {
|
|
1172
|
+
private async touchRootDirectory(snapshot: SyncSnapshot): Promise<void> {
|
|
1173
|
+
if (!snapshot.rootDirectoryUrl) {
|
|
1301
1174
|
return;
|
|
1302
1175
|
}
|
|
1303
1176
|
|
|
@@ -1322,21 +1195,300 @@ export class SyncEngine {
|
|
|
1322
1195
|
}
|
|
1323
1196
|
|
|
1324
1197
|
// Track root directory for network sync
|
|
1325
|
-
this.
|
|
1198
|
+
this.handlesByPath.set("", rootHandle);
|
|
1326
1199
|
|
|
1327
|
-
// CRITICAL FIX: Update root directory heads in snapshot immediately
|
|
1328
|
-
// This prevents stale head issues when root directory is modified
|
|
1329
1200
|
if (snapshotEntry) {
|
|
1330
1201
|
snapshotEntry.head = rootHandle.heads();
|
|
1331
1202
|
}
|
|
1332
|
-
|
|
1333
|
-
console.log(
|
|
1334
|
-
`🕒 Updated root directory lastSyncAt to ${new Date(
|
|
1335
|
-
timestamp
|
|
1336
|
-
).toISOString()}`
|
|
1337
|
-
);
|
|
1338
1203
|
} catch (error) {
|
|
1339
|
-
|
|
1204
|
+
// Failed to update root directory timestamp
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
/**
|
|
1209
|
+
* Sort tracked handles leaf-first (deepest paths first).
|
|
1210
|
+
* Returns handles in sorted order, logging URLs with heads for debugging.
|
|
1211
|
+
*/
|
|
1212
|
+
private sortHandlesLeafFirst(): DocHandle<unknown>[] {
|
|
1213
|
+
// Sort paths by depth (descending - deepest first), then alphabetically
|
|
1214
|
+
const sortedPaths = Array.from(this.handlesByPath.keys()).sort((a, b) => {
|
|
1215
|
+
const depthA = a ? a.split("/").length : 0;
|
|
1216
|
+
const depthB = b ? b.split("/").length : 0;
|
|
1217
|
+
|
|
1218
|
+
// Deepest first
|
|
1219
|
+
if (depthA !== depthB) {
|
|
1220
|
+
return depthB - depthA;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// Alphabetically by path
|
|
1224
|
+
return a.localeCompare(b);
|
|
1225
|
+
});
|
|
1226
|
+
|
|
1227
|
+
// Log the sync order with versioned URLs for debugging (keep on complete)
|
|
1228
|
+
const handles: DocHandle<unknown>[] = [];
|
|
1229
|
+
for (const path of sortedPaths) {
|
|
1230
|
+
const handle = this.handlesByPath.get(path)!;
|
|
1231
|
+
const versionedUrl = this.getVersionedUrl(handle);
|
|
1232
|
+
out.taskLine(`Sync: ${path || "(root)"} -> ${versionedUrl}`, true);
|
|
1233
|
+
handles.push(handle);
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
return handles;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
/**
|
|
1240
|
+
* Update all URLs (files and directories) in directory documents with current heads.
|
|
1241
|
+
*
|
|
1242
|
+
* This MUST be called AFTER all changes are applied but BEFORE network sync.
|
|
1243
|
+
* The problem it solves:
|
|
1244
|
+
* 1. When we create/update a file or directory and store its URL, the URL captures
|
|
1245
|
+
* the heads at that moment
|
|
1246
|
+
* 2. Later operations may advance the document's heads
|
|
1247
|
+
* 3. But the URL stored in the parent directory has stale heads
|
|
1248
|
+
* 4. Clients reading the directory would get old views of entries
|
|
1249
|
+
*
|
|
1250
|
+
* The fix: walk leaf-first and update all entry URLs with current heads,
|
|
1251
|
+
* AFTER all changes have been applied. This ensures clients get consistent,
|
|
1252
|
+
* up-to-date versioned URLs.
|
|
1253
|
+
*/
|
|
1254
|
+
private async updateDirectoryUrlsLeafFirst(
|
|
1255
|
+
snapshot: SyncSnapshot
|
|
1256
|
+
): Promise<void> {
|
|
1257
|
+
// First, update file URLs in their parent directories
|
|
1258
|
+
await this.updateFileUrlsInDirectories(snapshot);
|
|
1259
|
+
|
|
1260
|
+
// Then, update directory URLs in their parent directories (leaf-first)
|
|
1261
|
+
await this.updateSubdirectoryUrls(snapshot);
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
/**
|
|
1265
|
+
* Update file URLs in directory documents with current heads.
|
|
1266
|
+
*/
|
|
1267
|
+
private async updateFileUrlsInDirectories(
|
|
1268
|
+
snapshot: SyncSnapshot
|
|
1269
|
+
): Promise<void> {
|
|
1270
|
+
// Group files by their parent directory
|
|
1271
|
+
const filesByDir = new Map<string, string[]>();
|
|
1272
|
+
|
|
1273
|
+
for (const filePath of snapshot.files.keys()) {
|
|
1274
|
+
const pathParts = filePath.split("/");
|
|
1275
|
+
pathParts.pop(); // Remove filename
|
|
1276
|
+
const dirPath = pathParts.join("/");
|
|
1277
|
+
|
|
1278
|
+
if (!filesByDir.has(dirPath)) {
|
|
1279
|
+
filesByDir.set(dirPath, []);
|
|
1280
|
+
}
|
|
1281
|
+
filesByDir.get(dirPath)!.push(filePath);
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
// Process each directory that has files
|
|
1285
|
+
for (const [dirPath, filePaths] of filesByDir.entries()) {
|
|
1286
|
+
try {
|
|
1287
|
+
// Get the directory URL
|
|
1288
|
+
let dirUrl: AutomergeUrl;
|
|
1289
|
+
if (!dirPath || dirPath === "") {
|
|
1290
|
+
if (!snapshot.rootDirectoryUrl) continue;
|
|
1291
|
+
dirUrl = snapshot.rootDirectoryUrl;
|
|
1292
|
+
} else {
|
|
1293
|
+
const dirEntry = snapshot.directories.get(dirPath);
|
|
1294
|
+
if (!dirEntry) continue;
|
|
1295
|
+
dirUrl = dirEntry.url;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
// Get directory handle
|
|
1299
|
+
const dirHandle = await this.repo.find<DirectoryDocument>(
|
|
1300
|
+
getPlainUrl(dirUrl)
|
|
1301
|
+
);
|
|
1302
|
+
|
|
1303
|
+
// Get current heads for changeAt
|
|
1304
|
+
const snapshotEntry = snapshot.directories.get(dirPath);
|
|
1305
|
+
const heads = snapshotEntry?.head;
|
|
1306
|
+
|
|
1307
|
+
// Build a map of file names to their current versioned URLs
|
|
1308
|
+
const fileUrlUpdates = new Map<string, AutomergeUrl>();
|
|
1309
|
+
|
|
1310
|
+
for (const filePath of filePaths) {
|
|
1311
|
+
const fileEntry = snapshot.files.get(filePath);
|
|
1312
|
+
if (!fileEntry) continue;
|
|
1313
|
+
|
|
1314
|
+
// Get current handle for this file
|
|
1315
|
+
const fileHandle = await this.repo.find<FileDocument>(
|
|
1316
|
+
getPlainUrl(fileEntry.url)
|
|
1317
|
+
);
|
|
1318
|
+
|
|
1319
|
+
// Get versioned URL with current heads
|
|
1320
|
+
const currentVersionedUrl = this.getVersionedUrl(fileHandle);
|
|
1321
|
+
|
|
1322
|
+
// Update snapshot entry
|
|
1323
|
+
snapshot.files.set(filePath, {
|
|
1324
|
+
...fileEntry,
|
|
1325
|
+
url: currentVersionedUrl,
|
|
1326
|
+
head: fileHandle.heads(),
|
|
1327
|
+
});
|
|
1328
|
+
|
|
1329
|
+
// Store for directory update
|
|
1330
|
+
const fileName = filePath.split("/").pop() || "";
|
|
1331
|
+
fileUrlUpdates.set(fileName, currentVersionedUrl);
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
// Update all file entries in the directory document
|
|
1335
|
+
let didChange = false;
|
|
1336
|
+
if (heads) {
|
|
1337
|
+
dirHandle.changeAt(heads, (doc: DirectoryDocument) => {
|
|
1338
|
+
for (const [fileName, newUrl] of fileUrlUpdates) {
|
|
1339
|
+
const existingIndex = doc.docs.findIndex(
|
|
1340
|
+
(entry) => entry.name === fileName && entry.type === "file"
|
|
1341
|
+
);
|
|
1342
|
+
if (existingIndex !== -1 && doc.docs[existingIndex].url !== newUrl) {
|
|
1343
|
+
doc.docs[existingIndex].url = newUrl;
|
|
1344
|
+
didChange = true;
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
});
|
|
1348
|
+
} else {
|
|
1349
|
+
dirHandle.change((doc: DirectoryDocument) => {
|
|
1350
|
+
for (const [fileName, newUrl] of fileUrlUpdates) {
|
|
1351
|
+
const existingIndex = doc.docs.findIndex(
|
|
1352
|
+
(entry) => entry.name === fileName && entry.type === "file"
|
|
1353
|
+
);
|
|
1354
|
+
if (existingIndex !== -1 && doc.docs[existingIndex].url !== newUrl) {
|
|
1355
|
+
doc.docs[existingIndex].url = newUrl;
|
|
1356
|
+
didChange = true;
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
});
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
// Track directory and update heads
|
|
1363
|
+
if (didChange) {
|
|
1364
|
+
this.handlesByPath.set(dirPath, dirHandle);
|
|
1365
|
+
if (snapshotEntry) {
|
|
1366
|
+
snapshotEntry.head = dirHandle.heads();
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
} catch (error) {
|
|
1370
|
+
out.taskLine(
|
|
1371
|
+
`Warning: Failed to update file URLs in directory ${dirPath}`,
|
|
1372
|
+
true
|
|
1373
|
+
);
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
/**
|
|
1379
|
+
* Update subdirectory URLs in parent directories with current heads.
|
|
1380
|
+
* Processes leaf-first (deepest directories first).
|
|
1381
|
+
*/
|
|
1382
|
+
private async updateSubdirectoryUrls(snapshot: SyncSnapshot): Promise<void> {
|
|
1383
|
+
// Get all directory paths and sort leaf-first (deepest first)
|
|
1384
|
+
const directoryPaths = Array.from(snapshot.directories.keys()).sort(
|
|
1385
|
+
(a, b) => {
|
|
1386
|
+
const depthA = a ? a.split("/").length : 0;
|
|
1387
|
+
const depthB = b ? b.split("/").length : 0;
|
|
1388
|
+
|
|
1389
|
+
// Deepest first
|
|
1390
|
+
if (depthA !== depthB) {
|
|
1391
|
+
return depthB - depthA;
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
// Alphabetically by path
|
|
1395
|
+
return a.localeCompare(b);
|
|
1396
|
+
}
|
|
1397
|
+
);
|
|
1398
|
+
|
|
1399
|
+
// Update each directory's URL in its parent
|
|
1400
|
+
for (const dirPath of directoryPaths) {
|
|
1401
|
+
// Skip root directory (has no parent)
|
|
1402
|
+
if (!dirPath || dirPath === "") {
|
|
1403
|
+
continue;
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
const dirEntry = snapshot.directories.get(dirPath);
|
|
1407
|
+
if (!dirEntry) continue;
|
|
1408
|
+
|
|
1409
|
+
try {
|
|
1410
|
+
// Get current handle for this directory (use plain URL to get mutable handle)
|
|
1411
|
+
const dirHandle = await this.repo.find<DirectoryDocument>(
|
|
1412
|
+
getPlainUrl(dirEntry.url)
|
|
1413
|
+
);
|
|
1414
|
+
|
|
1415
|
+
// Get versioned URL with CURRENT heads (after all children populated)
|
|
1416
|
+
const currentVersionedUrl = this.getVersionedUrl(dirHandle);
|
|
1417
|
+
|
|
1418
|
+
// Update snapshot entry with current heads and versioned URL
|
|
1419
|
+
snapshot.directories.set(dirPath, {
|
|
1420
|
+
...dirEntry,
|
|
1421
|
+
url: currentVersionedUrl,
|
|
1422
|
+
head: dirHandle.heads(),
|
|
1423
|
+
});
|
|
1424
|
+
|
|
1425
|
+
// Get parent path
|
|
1426
|
+
const pathParts = dirPath.split("/");
|
|
1427
|
+
const dirName = pathParts.pop() || "";
|
|
1428
|
+
const parentPath = pathParts.join("/");
|
|
1429
|
+
|
|
1430
|
+
// Get parent directory handle
|
|
1431
|
+
let parentDirUrl: AutomergeUrl;
|
|
1432
|
+
if (!parentPath || parentPath === "") {
|
|
1433
|
+
// Parent is root
|
|
1434
|
+
if (!snapshot.rootDirectoryUrl) continue;
|
|
1435
|
+
parentDirUrl = snapshot.rootDirectoryUrl;
|
|
1436
|
+
} else {
|
|
1437
|
+
const parentEntry = snapshot.directories.get(parentPath);
|
|
1438
|
+
if (!parentEntry) continue;
|
|
1439
|
+
parentDirUrl = parentEntry.url;
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
// Update the directory entry in the parent with the new versioned URL
|
|
1443
|
+
const parentHandle = await this.repo.find<DirectoryDocument>(
|
|
1444
|
+
getPlainUrl(parentDirUrl)
|
|
1445
|
+
);
|
|
1446
|
+
|
|
1447
|
+
// Get parent's current heads for changeAt
|
|
1448
|
+
const parentSnapshotEntry =
|
|
1449
|
+
parentPath === ""
|
|
1450
|
+
? snapshot.directories.get("")
|
|
1451
|
+
: snapshot.directories.get(parentPath);
|
|
1452
|
+
const parentHeads = parentSnapshotEntry?.head;
|
|
1453
|
+
|
|
1454
|
+
let didChange = false;
|
|
1455
|
+
if (parentHeads) {
|
|
1456
|
+
parentHandle.changeAt(parentHeads, (doc: DirectoryDocument) => {
|
|
1457
|
+
const existingIndex = doc.docs.findIndex(
|
|
1458
|
+
(entry) => entry.name === dirName && entry.type === "folder"
|
|
1459
|
+
);
|
|
1460
|
+
if (existingIndex !== -1) {
|
|
1461
|
+
// Update the URL with current versioned URL
|
|
1462
|
+
doc.docs[existingIndex].url = currentVersionedUrl;
|
|
1463
|
+
didChange = true;
|
|
1464
|
+
}
|
|
1465
|
+
});
|
|
1466
|
+
} else {
|
|
1467
|
+
parentHandle.change((doc: DirectoryDocument) => {
|
|
1468
|
+
const existingIndex = doc.docs.findIndex(
|
|
1469
|
+
(entry) => entry.name === dirName && entry.type === "folder"
|
|
1470
|
+
);
|
|
1471
|
+
if (existingIndex !== -1) {
|
|
1472
|
+
// Update the URL with current versioned URL
|
|
1473
|
+
doc.docs[existingIndex].url = currentVersionedUrl;
|
|
1474
|
+
didChange = true;
|
|
1475
|
+
}
|
|
1476
|
+
});
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
// Track parent for sync and update its heads in snapshot
|
|
1480
|
+
if (didChange) {
|
|
1481
|
+
this.handlesByPath.set(parentPath, parentHandle);
|
|
1482
|
+
if (parentSnapshotEntry) {
|
|
1483
|
+
parentSnapshotEntry.head = parentHandle.heads();
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
} catch (error) {
|
|
1487
|
+
out.taskLine(
|
|
1488
|
+
`Warning: Failed to update directory URL for ${dirPath}`,
|
|
1489
|
+
true
|
|
1490
|
+
);
|
|
1491
|
+
}
|
|
1340
1492
|
}
|
|
1341
1493
|
}
|
|
1342
1494
|
}
|