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.
@@ -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
- debug("pushToRemote: all handles synced to server")
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
- // Use plain URL for mutable handle
940
- const handle = await this.repo.find<FileDocument>(
941
- getPlainUrl(fromEntry.url)
942
- )
943
- const heads = fromEntry.head
944
-
945
- // Update both name and content (if content changed during move)
946
- changeWithOptionalHeads(handle, heads, (doc: FileDocument) => {
947
- doc.name = toFileName
948
-
949
- // If new content is provided, update it (handles move + modification case)
950
- if (move.newContent !== undefined) {
951
- if (typeof move.newContent === "string") {
952
- updateTextContent(doc, ["content"], move.newContent)
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
- // Get appropriate URL for directory entry
960
- const entryUrl = this.getEntryUrl(handle, move.toPath)
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: handle.heads(),
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
- url: AutomergeUrl,
1134
- snapshot?: SyncSnapshot,
1135
- filePath?: string
1309
+ _url: AutomergeUrl,
1310
+ _snapshot?: SyncSnapshot,
1311
+ _filePath?: string
1136
1312
  ): Promise<void> {
1137
- // In Automerge, we don't actually delete documents
1138
- // They become orphaned and will be garbage collected
1139
- // For now, we just mark them as deleted by clearing content
1140
- // Use plain URL for mutable handle
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
  /**
@@ -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
  /**
@@ -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
- // Scale timeout proportionally to tree size after first scan
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
- dynamicTimeoutMs = Math.max(timeoutMs, 5000 + currentHeads.size * 50);
67
- if (dynamicTimeoutMs !== timeoutMs) {
68
- debug(`waitForBidirectionalSync: scaled timeout to ${dynamicTimeoutMs}ms for ${currentHeads.size} docs`);
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
- * Wait for documents to sync to the remote server
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<void> {
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 promises = handlesToWaitOn.map((handle) => {
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
- const wasAlreadySynced = A.equals(heads, remoteHeads);
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
- return Promise.resolve();
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
- debug(`waitForSync: ${handle.url.slice(0, 20)}... waiting for convergence (remoteHeads=${remoteHeads ? 'present' : 'missing'})`);
246
-
247
- // Wait for convergence
248
- return new Promise<void>((resolve, reject) => {
249
- let pollInterval: NodeJS.Timeout;
250
-
251
- const cleanup = () => {
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
- const poll = () => {
291
- if (isConverged()) {
292
- onConverged();
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
- // Initial check
299
- if (poll()) {
300
- return;
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
- // Start polling and event listening
304
- pollInterval = setInterval(() => {
305
- poll();
306
- }, 100);
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
- handle.on("remote-heads", onRemoteHeads);
309
- });
310
- });
349
+ const results = await Promise.allSettled(
350
+ batch.map(handle => waitForHandleSync(handle, syncServerStorageId, timeoutMs, startTime))
351
+ );
311
352
 
312
- const needSync = handlesToWaitOn.length - alreadySynced;
313
- if (needSync > 0) {
314
- debug(`waitForSync: ${alreadySynced} already synced, waiting for ${needSync} remaining`);
315
- out.taskLine(`Uploading: ${alreadySynced}/${handlesToWaitOn.length} already synced, waiting for ${needSync} more`);
316
- } else {
317
- debug(`waitForSync: all ${handlesToWaitOn.length} already synced`);
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
- try {
321
- await Promise.all(promises);
322
- const elapsed = Date.now() - startTime;
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
  }
@@ -22,7 +22,6 @@ export async function createRepo(
22
22
  config.sync_server
23
23
  );
24
24
  repoConfig.network = [networkAdapter];
25
- repoConfig.enableRemoteHeadsGossiping = true;
26
25
  }
27
26
 
28
27
  const repo = new Repo(repoConfig);