pushwork 1.0.22 → 1.0.25
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/CLAUDE.md +24 -2
- package/dist/cli.js +10 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands.d.ts +6 -0
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +40 -6
- package/dist/commands.js.map +1 -1
- package/dist/core/change-detection.d.ts +8 -1
- package/dist/core/change-detection.d.ts.map +1 -1
- package/dist/core/change-detection.js +69 -1
- package/dist/core/change-detection.js.map +1 -1
- package/dist/core/sync-engine.d.ts +6 -0
- package/dist/core/sync-engine.d.ts.map +1 -1
- package/dist/core/sync-engine.js +180 -43
- package/dist/core/sync-engine.js.map +1 -1
- package/dist/types/snapshot.d.ts +1 -0
- package/dist/types/snapshot.d.ts.map +1 -1
- package/dist/utils/content.d.ts +5 -0
- package/dist/utils/content.d.ts.map +1 -1
- package/dist/utils/content.js +9 -0
- package/dist/utils/content.js.map +1 -1
- package/dist/utils/network-sync.d.ts +11 -2
- package/dist/utils/network-sync.d.ts.map +1 -1
- package/dist/utils/network-sync.js +103 -74
- package/dist/utils/network-sync.js.map +1 -1
- package/dist/utils/repo-factory.d.ts.map +1 -1
- package/dist/utils/repo-factory.js +0 -1
- package/dist/utils/repo-factory.js.map +1 -1
- package/package.json +1 -1
- package/src/cli.ts +19 -0
- package/src/commands.ts +52 -5
- package/src/core/change-detection.ts +81 -2
- package/src/core/sync-engine.ts +212 -49
- package/src/types/snapshot.ts +1 -0
- package/src/utils/content.ts +10 -0
- package/src/utils/network-sync.ts +133 -92
- package/src/utils/repo-factory.ts +0 -1
package/src/core/sync-engine.ts
CHANGED
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
updateTextContent,
|
|
31
31
|
readDocContent,
|
|
32
32
|
} from "../utils"
|
|
33
|
-
import {isContentEqual} from "../utils/content"
|
|
33
|
+
import {isContentEqual, contentHash} from "../utils/content"
|
|
34
34
|
import {waitForSync, waitForBidirectionalSync} from "../utils/network-sync"
|
|
35
35
|
import {SnapshotManager} from "./snapshot"
|
|
36
36
|
import {ChangeDetector} from "./change-detection"
|
|
@@ -86,7 +86,8 @@ export class SyncEngine {
|
|
|
86
86
|
this.changeDetector = new ChangeDetector(
|
|
87
87
|
repo,
|
|
88
88
|
rootPath,
|
|
89
|
-
config.exclude_patterns
|
|
89
|
+
config.exclude_patterns,
|
|
90
|
+
config.artifact_directories || []
|
|
90
91
|
)
|
|
91
92
|
this.moveDetector = new MoveDetector(config.sync.move_detection_threshold)
|
|
92
93
|
}
|
|
@@ -248,6 +249,113 @@ export class SyncEngine {
|
|
|
248
249
|
}
|
|
249
250
|
}
|
|
250
251
|
|
|
252
|
+
/**
|
|
253
|
+
* Recreate documents that failed to sync. Creates new Automerge documents
|
|
254
|
+
* with the same content and updates all references (snapshot, parent directory).
|
|
255
|
+
* Returns new handles that should be retried for sync.
|
|
256
|
+
*/
|
|
257
|
+
private async recreateFailedDocuments(
|
|
258
|
+
failedHandles: DocHandle<unknown>[],
|
|
259
|
+
snapshot: SyncSnapshot
|
|
260
|
+
): Promise<DocHandle<unknown>[]> {
|
|
261
|
+
const failedUrls = new Set(failedHandles.map(h => getPlainUrl(h.url)))
|
|
262
|
+
const newHandles: DocHandle<unknown>[] = []
|
|
263
|
+
|
|
264
|
+
// Find which paths correspond to the failed handles
|
|
265
|
+
for (const [filePath, entry] of snapshot.files.entries()) {
|
|
266
|
+
const plainUrl = getPlainUrl(entry.url)
|
|
267
|
+
if (!failedUrls.has(plainUrl)) continue
|
|
268
|
+
|
|
269
|
+
debug(`recreate: recreating document for ${filePath} (${plainUrl.slice(0, 20)}...)`)
|
|
270
|
+
out.taskLine(`Recreating document for ${filePath}`)
|
|
271
|
+
|
|
272
|
+
try {
|
|
273
|
+
// Read the current content from the old handle
|
|
274
|
+
const oldHandle = await this.repo.find<FileDocument>(plainUrl)
|
|
275
|
+
const doc = await oldHandle.doc()
|
|
276
|
+
if (!doc) {
|
|
277
|
+
debug(`recreate: could not read doc for ${filePath}, skipping`)
|
|
278
|
+
continue
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const content = readDocContent(doc.content)
|
|
282
|
+
if (content === null) {
|
|
283
|
+
debug(`recreate: null content for ${filePath}, skipping`)
|
|
284
|
+
continue
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Create a fresh document
|
|
288
|
+
const fakeChange: DetectedChange = {
|
|
289
|
+
path: filePath,
|
|
290
|
+
changeType: ChangeType.LOCAL_ONLY,
|
|
291
|
+
fileType: this.isTextContent(content) ? FileType.TEXT : FileType.BINARY,
|
|
292
|
+
localContent: content,
|
|
293
|
+
remoteContent: null,
|
|
294
|
+
}
|
|
295
|
+
const newHandle = await this.createRemoteFile(fakeChange)
|
|
296
|
+
if (!newHandle) continue
|
|
297
|
+
|
|
298
|
+
const entryUrl = this.getEntryUrl(newHandle, filePath)
|
|
299
|
+
|
|
300
|
+
// Update snapshot entry
|
|
301
|
+
this.snapshotManager.updateFileEntry(snapshot, filePath, {
|
|
302
|
+
...entry,
|
|
303
|
+
url: entryUrl,
|
|
304
|
+
head: newHandle.heads(),
|
|
305
|
+
...(this.isArtifactPath(filePath) ? {contentHash: contentHash(content)} : {}),
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
// Update parent directory entry to point to new document
|
|
309
|
+
const pathParts = filePath.split("/")
|
|
310
|
+
const fileName = pathParts.pop() || ""
|
|
311
|
+
const dirPath = pathParts.join("/")
|
|
312
|
+
|
|
313
|
+
let dirUrl: AutomergeUrl
|
|
314
|
+
if (!dirPath || dirPath === "") {
|
|
315
|
+
dirUrl = snapshot.rootDirectoryUrl!
|
|
316
|
+
} else {
|
|
317
|
+
const dirEntry = snapshot.directories.get(dirPath)
|
|
318
|
+
if (!dirEntry) continue
|
|
319
|
+
dirUrl = dirEntry.url
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const dirHandle = await this.repo.find<DirectoryDocument>(getPlainUrl(dirUrl))
|
|
323
|
+
dirHandle.change((d: DirectoryDocument) => {
|
|
324
|
+
const idx = d.docs.findIndex(
|
|
325
|
+
e => e.name === fileName && e.type === "file"
|
|
326
|
+
)
|
|
327
|
+
if (idx !== -1) {
|
|
328
|
+
d.docs[idx].url = entryUrl
|
|
329
|
+
}
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
// Track new handles
|
|
333
|
+
this.handlesByPath.set(filePath, newHandle)
|
|
334
|
+
this.handlesByPath.set(dirPath, dirHandle)
|
|
335
|
+
newHandles.push(newHandle)
|
|
336
|
+
newHandles.push(dirHandle)
|
|
337
|
+
|
|
338
|
+
debug(`recreate: created new doc for ${filePath} -> ${newHandle.url.slice(0, 20)}...`)
|
|
339
|
+
} catch (error) {
|
|
340
|
+
debug(`recreate: failed for ${filePath}: ${error}`)
|
|
341
|
+
out.taskLine(`Failed to recreate ${filePath}: ${error}`, true)
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Also check directory documents
|
|
346
|
+
for (const [dirPath, entry] of snapshot.directories.entries()) {
|
|
347
|
+
const plainUrl = getPlainUrl(entry.url)
|
|
348
|
+
if (!failedUrls.has(plainUrl)) continue
|
|
349
|
+
|
|
350
|
+
// Directory docs can't be easily recreated (they reference children).
|
|
351
|
+
// Just log a warning — the child recreation above should handle most cases.
|
|
352
|
+
debug(`recreate: directory ${dirPath || "(root)"} failed to sync, cannot recreate`)
|
|
353
|
+
out.taskLine(`Warning: directory ${dirPath || "(root)"} failed to sync`, true)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return newHandles
|
|
357
|
+
}
|
|
358
|
+
|
|
251
359
|
/**
|
|
252
360
|
* Push local changes to server without pulling remote changes.
|
|
253
361
|
* Detect changes, push to Automerge docs, upload to server. No bidirectional wait, no pull.
|
|
@@ -324,11 +432,30 @@ export class SyncEngine {
|
|
|
324
432
|
)
|
|
325
433
|
debug(`pushToRemote: waiting for ${allHandles.length} handles to sync to server`)
|
|
326
434
|
out.update(`Uploading ${allHandles.length} documents to sync server`)
|
|
327
|
-
await waitForSync(
|
|
435
|
+
const {failed} = await waitForSync(
|
|
328
436
|
allHandles,
|
|
329
437
|
this.config.sync_server_storage_id
|
|
330
438
|
)
|
|
331
|
-
|
|
439
|
+
|
|
440
|
+
// Recreate failed documents and retry once
|
|
441
|
+
if (failed.length > 0) {
|
|
442
|
+
debug(`pushToRemote: ${failed.length} documents failed, recreating`)
|
|
443
|
+
out.update(`Recreating ${failed.length} failed documents`)
|
|
444
|
+
const retryHandles = await this.recreateFailedDocuments(failed, snapshot)
|
|
445
|
+
if (retryHandles.length > 0) {
|
|
446
|
+
debug(`pushToRemote: retrying ${retryHandles.length} recreated handles`)
|
|
447
|
+
out.update(`Retrying ${retryHandles.length} recreated documents`)
|
|
448
|
+
const retry = await waitForSync(
|
|
449
|
+
retryHandles,
|
|
450
|
+
this.config.sync_server_storage_id
|
|
451
|
+
)
|
|
452
|
+
if (retry.failed.length > 0) {
|
|
453
|
+
result.warnings.push(`${retry.failed.length} documents still failed after recreation`)
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
debug("pushToRemote: sync to server complete")
|
|
332
459
|
}
|
|
333
460
|
} catch (error) {
|
|
334
461
|
debug(`pushToRemote: network sync error: ${error}`)
|
|
@@ -445,10 +572,29 @@ export class SyncEngine {
|
|
|
445
572
|
const handlePaths = Array.from(this.handlesByPath.keys())
|
|
446
573
|
debug(`sync: waiting for ${allHandles.length} handles to sync to server: ${handlePaths.slice(0, 10).map(p => p || "(root)").join(", ")}${handlePaths.length > 10 ? ` ...and ${handlePaths.length - 10} more` : ""}`)
|
|
447
574
|
out.update(`Uploading ${allHandles.length} documents to sync server`)
|
|
448
|
-
await waitForSync(
|
|
575
|
+
const {failed} = await waitForSync(
|
|
449
576
|
allHandles,
|
|
450
577
|
this.config.sync_server_storage_id
|
|
451
578
|
)
|
|
579
|
+
|
|
580
|
+
// Recreate failed documents and retry once
|
|
581
|
+
if (failed.length > 0) {
|
|
582
|
+
debug(`sync: ${failed.length} documents failed, recreating`)
|
|
583
|
+
out.update(`Recreating ${failed.length} failed documents`)
|
|
584
|
+
const retryHandles = await this.recreateFailedDocuments(failed, snapshot)
|
|
585
|
+
if (retryHandles.length > 0) {
|
|
586
|
+
debug(`sync: retrying ${retryHandles.length} recreated handles`)
|
|
587
|
+
out.update(`Retrying ${retryHandles.length} recreated documents`)
|
|
588
|
+
const retry = await waitForSync(
|
|
589
|
+
retryHandles,
|
|
590
|
+
this.config.sync_server_storage_id
|
|
591
|
+
)
|
|
592
|
+
if (retry.failed.length > 0) {
|
|
593
|
+
result.warnings.push(`${retry.failed.length} documents still failed after recreation`)
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
452
598
|
debug("sync: all handles synced to server")
|
|
453
599
|
}
|
|
454
600
|
|
|
@@ -714,6 +860,9 @@ export class SyncEngine {
|
|
|
714
860
|
head: handle.heads(),
|
|
715
861
|
extension: getFileExtension(change.path),
|
|
716
862
|
mimeType: getEnhancedMimeType(change.path),
|
|
863
|
+
...(this.isArtifactPath(change.path) && change.localContent
|
|
864
|
+
? {contentHash: contentHash(change.localContent)}
|
|
865
|
+
: {}),
|
|
717
866
|
}
|
|
718
867
|
)
|
|
719
868
|
result.filesChanged++
|
|
@@ -936,42 +1085,66 @@ export class SyncEngine {
|
|
|
936
1085
|
|
|
937
1086
|
// 3) Update the FileDocument name and content to match new location/state
|
|
938
1087
|
try {
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
)
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
} else {
|
|
954
|
-
doc.content = move.newContent
|
|
955
|
-
}
|
|
1088
|
+
let entryUrl: AutomergeUrl
|
|
1089
|
+
let finalHeads: UrlHeads
|
|
1090
|
+
|
|
1091
|
+
if (this.isArtifactPath(move.toPath)) {
|
|
1092
|
+
// Artifact files use RawString — no diffing needed, just create a fresh doc
|
|
1093
|
+
const content = move.newContent !== undefined
|
|
1094
|
+
? move.newContent
|
|
1095
|
+
: readDocContent((await (await this.repo.find<FileDocument>(getPlainUrl(fromEntry.url))).doc())?.content)
|
|
1096
|
+
const fakeChange: DetectedChange = {
|
|
1097
|
+
path: move.toPath,
|
|
1098
|
+
changeType: ChangeType.LOCAL_ONLY,
|
|
1099
|
+
fileType: content != null && typeof content === "string" ? FileType.TEXT : FileType.BINARY,
|
|
1100
|
+
localContent: content,
|
|
1101
|
+
remoteContent: null,
|
|
956
1102
|
}
|
|
957
|
-
|
|
1103
|
+
const newHandle = await this.createRemoteFile(fakeChange)
|
|
1104
|
+
if (!newHandle) return
|
|
1105
|
+
entryUrl = this.getEntryUrl(newHandle, move.toPath)
|
|
1106
|
+
finalHeads = newHandle.heads()
|
|
1107
|
+
} else {
|
|
1108
|
+
// Use plain URL for mutable handle
|
|
1109
|
+
const handle = await this.repo.find<FileDocument>(
|
|
1110
|
+
getPlainUrl(fromEntry.url)
|
|
1111
|
+
)
|
|
1112
|
+
const heads = fromEntry.head
|
|
1113
|
+
|
|
1114
|
+
// Update both name and content (if content changed during move)
|
|
1115
|
+
changeWithOptionalHeads(handle, heads, (doc: FileDocument) => {
|
|
1116
|
+
doc.name = toFileName
|
|
1117
|
+
|
|
1118
|
+
// If new content is provided, update it (handles move + modification case)
|
|
1119
|
+
if (move.newContent !== undefined) {
|
|
1120
|
+
if (typeof move.newContent === "string") {
|
|
1121
|
+
updateTextContent(doc, ["content"], move.newContent)
|
|
1122
|
+
} else {
|
|
1123
|
+
doc.content = move.newContent
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
})
|
|
1127
|
+
|
|
1128
|
+
entryUrl = this.getEntryUrl(handle, move.toPath)
|
|
1129
|
+
finalHeads = handle.heads()
|
|
958
1130
|
|
|
959
|
-
|
|
960
|
-
|
|
1131
|
+
// Track file handle for network sync
|
|
1132
|
+
this.handlesByPath.set(move.toPath, handle)
|
|
1133
|
+
}
|
|
961
1134
|
|
|
962
1135
|
// 4) Add file entry to destination directory
|
|
963
1136
|
await this.addFileToDirectory(snapshot, move.toPath, entryUrl)
|
|
964
1137
|
|
|
965
|
-
// Track file handle for network sync
|
|
966
|
-
this.handlesByPath.set(move.toPath, handle)
|
|
967
|
-
|
|
968
1138
|
// 5) Update snapshot entries
|
|
969
1139
|
this.snapshotManager.removeFileEntry(snapshot, move.fromPath)
|
|
970
1140
|
this.snapshotManager.updateFileEntry(snapshot, move.toPath, {
|
|
971
1141
|
...fromEntry,
|
|
972
1142
|
path: joinAndNormalizePath(this.rootPath, move.toPath),
|
|
973
1143
|
url: entryUrl,
|
|
974
|
-
head:
|
|
1144
|
+
head: finalHeads,
|
|
1145
|
+
...(this.isArtifactPath(move.toPath) && move.newContent != null
|
|
1146
|
+
? {contentHash: contentHash(move.newContent)}
|
|
1147
|
+
: {}),
|
|
975
1148
|
})
|
|
976
1149
|
} catch (e) {
|
|
977
1150
|
// Failed to update file name - file may have been deleted
|
|
@@ -1076,6 +1249,9 @@ export class SyncEngine {
|
|
|
1076
1249
|
head: newHandle.heads(),
|
|
1077
1250
|
extension: getFileExtension(filePath),
|
|
1078
1251
|
mimeType: getEnhancedMimeType(filePath),
|
|
1252
|
+
...(this.isArtifactPath(filePath)
|
|
1253
|
+
? {contentHash: contentHash(content)}
|
|
1254
|
+
: {}),
|
|
1079
1255
|
})
|
|
1080
1256
|
}
|
|
1081
1257
|
return
|
|
@@ -1130,27 +1306,14 @@ export class SyncEngine {
|
|
|
1130
1306
|
* Delete remote file document
|
|
1131
1307
|
*/
|
|
1132
1308
|
private async deleteRemoteFile(
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1309
|
+
_url: AutomergeUrl,
|
|
1310
|
+
_snapshot?: SyncSnapshot,
|
|
1311
|
+
_filePath?: string
|
|
1136
1312
|
): Promise<void> {
|
|
1137
|
-
// In Automerge, we don't actually delete documents
|
|
1138
|
-
//
|
|
1139
|
-
//
|
|
1140
|
-
//
|
|
1141
|
-
const handle = await this.repo.find<FileDocument>(getPlainUrl(url))
|
|
1142
|
-
// const doc = await handle.doc(); // no longer needed
|
|
1143
|
-
let heads
|
|
1144
|
-
if (snapshot && filePath) {
|
|
1145
|
-
heads = snapshot.files.get(filePath)?.head
|
|
1146
|
-
}
|
|
1147
|
-
changeWithOptionalHeads(handle, heads, (doc: FileDocument) => {
|
|
1148
|
-
if (doc.content instanceof Uint8Array) {
|
|
1149
|
-
doc.content = new Uint8Array(0)
|
|
1150
|
-
} else {
|
|
1151
|
-
updateTextContent(doc, ["content"], "")
|
|
1152
|
-
}
|
|
1153
|
-
})
|
|
1313
|
+
// In Automerge, we don't actually delete documents.
|
|
1314
|
+
// The file entry is removed from its parent directory, making the
|
|
1315
|
+
// document orphaned. Clearing content via splice is expensive for
|
|
1316
|
+
// large text files (every character is a CRDT op), so we skip it.
|
|
1154
1317
|
}
|
|
1155
1318
|
|
|
1156
1319
|
/**
|
package/src/types/snapshot.ts
CHANGED
|
@@ -9,6 +9,7 @@ export interface SnapshotFileEntry {
|
|
|
9
9
|
head: UrlHeads; // Document head at last sync
|
|
10
10
|
extension: string; // File extension
|
|
11
11
|
mimeType: string; // MIME type
|
|
12
|
+
contentHash?: string; // SHA-256 of content at last sync (used by artifact files to skip remote reads)
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
/**
|
package/src/utils/content.ts
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
import { createHash } from "crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Compute a SHA-256 hash of file content.
|
|
5
|
+
* Used to detect local changes for artifact files without reading remote docs.
|
|
6
|
+
*/
|
|
7
|
+
export function contentHash(content: string | Uint8Array): string {
|
|
8
|
+
return createHash("sha256").update(content).digest("hex");
|
|
9
|
+
}
|
|
10
|
+
|
|
1
11
|
/**
|
|
2
12
|
* Compare two content pieces for equality
|
|
3
13
|
*/
|
|
@@ -61,12 +61,13 @@ export async function waitForBidirectionalSync(
|
|
|
61
61
|
? getHandleHeads(handles)
|
|
62
62
|
: await getAllDocumentHeads(repo, rootDirectoryUrl);
|
|
63
63
|
|
|
64
|
-
//
|
|
64
|
+
// After first scan: scale timeout to tree size and reset the clock.
|
|
65
|
+
// The first scan is just establishing a baseline — its duration
|
|
66
|
+
// shouldn't count against the stability-wait timeout.
|
|
65
67
|
if (pollCount === 1) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
68
|
+
const scanDuration = Date.now() - startTime;
|
|
69
|
+
dynamicTimeoutMs = Math.max(timeoutMs, 5000 + currentHeads.size * 50) + scanDuration;
|
|
70
|
+
debug(`waitForBidirectionalSync: first scan took ${scanDuration}ms, timeout now ${dynamicTimeoutMs}ms for ${currentHeads.size} docs`);
|
|
70
71
|
}
|
|
71
72
|
|
|
72
73
|
// Check if heads are stable (no changes since last check)
|
|
@@ -206,126 +207,166 @@ function headsMapEqual(
|
|
|
206
207
|
}
|
|
207
208
|
|
|
208
209
|
/**
|
|
209
|
-
*
|
|
210
|
+
* Result of waitForSync — lists which handles failed to sync.
|
|
211
|
+
*/
|
|
212
|
+
export interface SyncWaitResult {
|
|
213
|
+
failed: DocHandle<unknown>[];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Maximum documents to sync concurrently to avoid flooding the server */
|
|
217
|
+
const SYNC_BATCH_SIZE = 10;
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Wait for a single document handle to sync to the server.
|
|
221
|
+
* Resolves with the handle on success, rejects with the handle on timeout.
|
|
222
|
+
*/
|
|
223
|
+
function waitForHandleSync(
|
|
224
|
+
handle: DocHandle<unknown>,
|
|
225
|
+
syncServerStorageId: StorageId,
|
|
226
|
+
timeoutMs: number,
|
|
227
|
+
startTime: number,
|
|
228
|
+
): Promise<DocHandle<unknown>> {
|
|
229
|
+
return new Promise<DocHandle<unknown>>((resolve, reject) => {
|
|
230
|
+
let pollInterval: NodeJS.Timeout;
|
|
231
|
+
|
|
232
|
+
const cleanup = () => {
|
|
233
|
+
clearTimeout(timeout);
|
|
234
|
+
clearInterval(pollInterval);
|
|
235
|
+
handle.off("remote-heads", onRemoteHeads);
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const onConverged = () => {
|
|
239
|
+
debug(`waitForSync: ${handle.url.slice(0, 20)}... converged in ${Date.now() - startTime}ms`);
|
|
240
|
+
cleanup();
|
|
241
|
+
resolve(handle);
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const timeout = setTimeout(() => {
|
|
245
|
+
debug(`waitForSync: ${handle.url.slice(0, 20)}... timed out after ${timeoutMs}ms`);
|
|
246
|
+
cleanup();
|
|
247
|
+
reject(handle);
|
|
248
|
+
}, timeoutMs);
|
|
249
|
+
|
|
250
|
+
const isConverged = () => {
|
|
251
|
+
const localHeads = handle.heads();
|
|
252
|
+
const info = handle.getSyncInfo(syncServerStorageId);
|
|
253
|
+
return A.equals(localHeads, info?.lastHeads);
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const onRemoteHeads = ({
|
|
257
|
+
storageId,
|
|
258
|
+
}: {
|
|
259
|
+
storageId: StorageId;
|
|
260
|
+
heads: any;
|
|
261
|
+
}) => {
|
|
262
|
+
if (storageId === syncServerStorageId && isConverged()) {
|
|
263
|
+
onConverged();
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
// Initial check
|
|
268
|
+
if (isConverged()) {
|
|
269
|
+
cleanup();
|
|
270
|
+
resolve(handle);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Start polling and event listening
|
|
275
|
+
pollInterval = setInterval(() => {
|
|
276
|
+
if (isConverged()) {
|
|
277
|
+
onConverged();
|
|
278
|
+
}
|
|
279
|
+
}, 100);
|
|
280
|
+
|
|
281
|
+
handle.on("remote-heads", onRemoteHeads);
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Wait for documents to sync to the remote server.
|
|
287
|
+
* Processes handles in batches to avoid flooding the server.
|
|
288
|
+
* Returns a result with any failed handles instead of throwing,
|
|
289
|
+
* so callers can attempt recovery (e.g. recreating documents).
|
|
210
290
|
*/
|
|
211
291
|
export async function waitForSync(
|
|
212
292
|
handlesToWaitOn: DocHandle<unknown>[],
|
|
213
293
|
syncServerStorageId?: StorageId,
|
|
214
294
|
timeoutMs: number = 60000,
|
|
215
|
-
): Promise<
|
|
295
|
+
): Promise<SyncWaitResult> {
|
|
216
296
|
const startTime = Date.now();
|
|
217
297
|
|
|
218
298
|
if (!syncServerStorageId) {
|
|
219
299
|
debug("waitForSync: no sync server storage ID, skipping");
|
|
220
|
-
return;
|
|
300
|
+
return { failed: [] };
|
|
221
301
|
}
|
|
222
302
|
|
|
223
303
|
if (handlesToWaitOn.length === 0) {
|
|
224
304
|
debug("waitForSync: no documents to sync");
|
|
225
|
-
return;
|
|
305
|
+
return { failed: [] };
|
|
226
306
|
}
|
|
227
307
|
|
|
228
|
-
debug(`waitForSync: waiting for ${handlesToWaitOn.length} documents (timeout=${timeoutMs}ms)`);
|
|
308
|
+
debug(`waitForSync: waiting for ${handlesToWaitOn.length} documents (timeout=${timeoutMs}ms, batchSize=${SYNC_BATCH_SIZE})`);
|
|
229
309
|
|
|
310
|
+
// Separate already-synced from needs-sync
|
|
311
|
+
const needsSync: DocHandle<unknown>[] = [];
|
|
230
312
|
let alreadySynced = 0;
|
|
231
313
|
|
|
232
|
-
const
|
|
233
|
-
// Check if already synced
|
|
314
|
+
for (const handle of handlesToWaitOn) {
|
|
234
315
|
const heads = handle.heads();
|
|
235
316
|
const syncInfo = handle.getSyncInfo(syncServerStorageId);
|
|
236
317
|
const remoteHeads = syncInfo?.lastHeads;
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
if (wasAlreadySynced) {
|
|
318
|
+
if (A.equals(heads, remoteHeads)) {
|
|
240
319
|
alreadySynced++;
|
|
241
320
|
debug(`waitForSync: ${handle.url.slice(0, 20)}... already synced`);
|
|
242
|
-
|
|
321
|
+
} else {
|
|
322
|
+
debug(`waitForSync: ${handle.url.slice(0, 20)}... needs sync (remoteHeads=${remoteHeads ? 'present' : 'missing'})`);
|
|
323
|
+
needsSync.push(handle);
|
|
243
324
|
}
|
|
325
|
+
}
|
|
244
326
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
clearTimeout(timeout);
|
|
253
|
-
clearInterval(pollInterval);
|
|
254
|
-
handle.off("remote-heads", onRemoteHeads);
|
|
255
|
-
};
|
|
256
|
-
|
|
257
|
-
const onConverged = () => {
|
|
258
|
-
debug(`waitForSync: ${handle.url.slice(0, 20)}... converged in ${Date.now() - startTime}ms`);
|
|
259
|
-
cleanup();
|
|
260
|
-
resolve();
|
|
261
|
-
};
|
|
262
|
-
|
|
263
|
-
const timeout = setTimeout(() => {
|
|
264
|
-
debug(`waitForSync: ${handle.url.slice(0, 20)}... timed out after ${timeoutMs}ms`);
|
|
265
|
-
cleanup();
|
|
266
|
-
reject(
|
|
267
|
-
new Error(
|
|
268
|
-
`Sync timeout after ${timeoutMs}ms for document ${handle.url}`,
|
|
269
|
-
),
|
|
270
|
-
);
|
|
271
|
-
}, timeoutMs);
|
|
272
|
-
|
|
273
|
-
const isConverged = () => {
|
|
274
|
-
const localHeads = handle.heads();
|
|
275
|
-
const info = handle.getSyncInfo(syncServerStorageId);
|
|
276
|
-
return A.equals(localHeads, info?.lastHeads);
|
|
277
|
-
};
|
|
278
|
-
|
|
279
|
-
const onRemoteHeads = ({
|
|
280
|
-
storageId,
|
|
281
|
-
}: {
|
|
282
|
-
storageId: StorageId;
|
|
283
|
-
heads: any;
|
|
284
|
-
}) => {
|
|
285
|
-
if (storageId === syncServerStorageId && isConverged()) {
|
|
286
|
-
onConverged();
|
|
287
|
-
}
|
|
288
|
-
};
|
|
327
|
+
if (needsSync.length > 0) {
|
|
328
|
+
debug(`waitForSync: ${alreadySynced} already synced, ${needsSync.length} need sync`);
|
|
329
|
+
out.taskLine(`Uploading: ${alreadySynced}/${handlesToWaitOn.length} already synced, waiting for ${needsSync.length} more`);
|
|
330
|
+
} else {
|
|
331
|
+
debug(`waitForSync: all ${handlesToWaitOn.length} already synced`);
|
|
332
|
+
return { failed: [] };
|
|
333
|
+
}
|
|
289
334
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
return true;
|
|
294
|
-
}
|
|
295
|
-
return false;
|
|
296
|
-
};
|
|
335
|
+
// Process in batches to avoid flooding the server
|
|
336
|
+
const failed: DocHandle<unknown>[] = [];
|
|
337
|
+
let synced = alreadySynced;
|
|
297
338
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
339
|
+
for (let i = 0; i < needsSync.length; i += SYNC_BATCH_SIZE) {
|
|
340
|
+
const batch = needsSync.slice(i, i + SYNC_BATCH_SIZE);
|
|
341
|
+
const batchNum = Math.floor(i / SYNC_BATCH_SIZE) + 1;
|
|
342
|
+
const totalBatches = Math.ceil(needsSync.length / SYNC_BATCH_SIZE);
|
|
302
343
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
344
|
+
if (totalBatches > 1) {
|
|
345
|
+
debug(`waitForSync: batch ${batchNum}/${totalBatches} (${batch.length} docs)`);
|
|
346
|
+
out.update(`Uploading batch ${batchNum}/${totalBatches} (${synced}/${handlesToWaitOn.length} done)`);
|
|
347
|
+
}
|
|
307
348
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
349
|
+
const results = await Promise.allSettled(
|
|
350
|
+
batch.map(handle => waitForHandleSync(handle, syncServerStorageId, timeoutMs, startTime))
|
|
351
|
+
);
|
|
311
352
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
353
|
+
for (const result of results) {
|
|
354
|
+
if (result.status === "rejected") {
|
|
355
|
+
failed.push(result.reason as DocHandle<unknown>);
|
|
356
|
+
} else {
|
|
357
|
+
synced++;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
318
360
|
}
|
|
319
361
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
362
|
+
const elapsed = Date.now() - startTime;
|
|
363
|
+
if (failed.length > 0) {
|
|
364
|
+
debug(`waitForSync: ${failed.length} documents failed after ${elapsed}ms`);
|
|
365
|
+
out.taskLine(`Upload: ${synced} synced, ${failed.length} failed after ${(elapsed / 1000).toFixed(1)}s`, true);
|
|
366
|
+
} else {
|
|
323
367
|
debug(`waitForSync: all ${handlesToWaitOn.length} documents synced in ${elapsed}ms (${alreadySynced} were already synced)`);
|
|
324
368
|
out.taskLine(`All ${handlesToWaitOn.length} documents uploaded to server (${(elapsed / 1000).toFixed(1)}s)`);
|
|
325
|
-
} catch (error) {
|
|
326
|
-
const elapsed = Date.now() - startTime;
|
|
327
|
-
debug(`waitForSync: failed after ${elapsed}ms: ${error}`);
|
|
328
|
-
out.taskLine(`Upload to server failed after ${(elapsed / 1000).toFixed(1)}s: ${error}`, true);
|
|
329
|
-
throw error;
|
|
330
369
|
}
|
|
370
|
+
|
|
371
|
+
return { failed };
|
|
331
372
|
}
|