pushwork 1.0.11 → 1.0.15

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.
@@ -2,6 +2,7 @@ import {
2
2
  AutomergeUrl,
3
3
  Repo,
4
4
  DocHandle,
5
+ UrlHeads,
5
6
  parseAutomergeUrl,
6
7
  stringifyAutomergeUrl,
7
8
  } from "@automerge/automerge-repo";
@@ -33,6 +34,22 @@ import { ChangeDetector } from "./change-detection";
33
34
  import { MoveDetector } from "./move-detection";
34
35
  import { out } from "../utils/output";
35
36
 
37
+ /**
38
+ * Apply a change to a document handle, using changeAt when heads are available
39
+ * to branch from a known version, otherwise falling back to change.
40
+ */
41
+ function changeWithOptionalHeads<T>(
42
+ handle: DocHandle<T>,
43
+ heads: UrlHeads | undefined,
44
+ callback: A.ChangeFn<T>
45
+ ): void {
46
+ if (heads && heads.length > 0) {
47
+ handle.changeAt(heads, callback);
48
+ } else {
49
+ handle.change(callback);
50
+ }
51
+ }
52
+
36
53
  /**
37
54
  * Sync configuration constants
38
55
  */
@@ -242,12 +259,17 @@ export class SyncEngine {
242
259
  }
243
260
 
244
261
  if (this.handlesByPath.size > 0) {
245
- // Sort handles leaf-first (deepest paths first, then shallower)
246
- const sortedHandles = this.sortHandlesLeafFirst();
247
- await waitForSync(
248
- sortedHandles,
249
- this.config.sync_server_storage_id
250
- );
262
+ // Sync level-by-level: deepest paths first, then shallower.
263
+ // Within each level, documents sync in parallel. But we wait
264
+ // for each level to complete before starting the next, ensuring
265
+ // children are on the server before their parent directories.
266
+ const levels = this.groupHandlesByDepthLevel();
267
+ for (const handlesAtLevel of levels) {
268
+ await waitForSync(
269
+ handlesAtLevel,
270
+ this.config.sync_server_storage_id
271
+ );
272
+ }
251
273
  }
252
274
 
253
275
  // Wait for bidirectional sync to stabilize.
@@ -587,33 +609,18 @@ export class SyncEngine {
587
609
  const heads = fromEntry.head;
588
610
 
589
611
  // 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;
600
- }
612
+ changeWithOptionalHeads(handle, heads, (doc: FileDocument) => {
613
+ doc.name = toFileName;
614
+
615
+ // If new content is provided, update it (handles move + modification case)
616
+ if (move.newContent !== undefined) {
617
+ if (typeof move.newContent === "string") {
618
+ doc.content = new A.ImmutableString(move.newContent);
619
+ } else {
620
+ doc.content = move.newContent;
601
621
  }
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;
613
- }
614
- }
615
- });
616
- }
622
+ }
623
+ });
617
624
 
618
625
  // Get versioned URL after changes (includes current heads)
619
626
  const versionedUrl = this.getVersionedUrl(handle);
@@ -760,15 +767,9 @@ export class SyncEngine {
760
767
  if (snapshot && filePath) {
761
768
  heads = snapshot.files.get(filePath)?.head;
762
769
  }
763
- if (heads) {
764
- handle.changeAt(heads, (doc: FileDocument) => {
765
- doc.content = new A.ImmutableString("");
766
- });
767
- } else {
768
- handle.change((doc: FileDocument) => {
769
- doc.content = new A.ImmutableString("");
770
- });
771
- }
770
+ changeWithOptionalHeads(handle, heads, (doc: FileDocument) => {
771
+ doc.content = new A.ImmutableString("");
772
+ });
772
773
  }
773
774
 
774
775
  /**
@@ -799,35 +800,19 @@ export class SyncEngine {
799
800
  let didChange = false;
800
801
  const snapshotEntry = snapshot.directories.get(directoryPath);
801
802
  const heads = snapshotEntry?.head;
802
- if (heads) {
803
- dirHandle.changeAt(heads, (doc: DirectoryDocument) => {
804
- const existingIndex = doc.docs.findIndex(
805
- (entry) => entry.name === fileName && entry.type === "file"
806
- );
807
- if (existingIndex === -1) {
808
- doc.docs.push({
809
- name: fileName,
810
- type: "file",
811
- url: fileUrl,
812
- });
813
- didChange = true;
814
- }
815
- });
816
- } else {
817
- dirHandle.change((doc: DirectoryDocument) => {
818
- const existingIndex = doc.docs.findIndex(
819
- (entry) => entry.name === fileName && entry.type === "file"
820
- );
821
- if (existingIndex === -1) {
822
- doc.docs.push({
823
- name: fileName,
824
- type: "file",
825
- url: fileUrl,
826
- });
827
- didChange = true;
828
- }
829
- });
830
- }
803
+ changeWithOptionalHeads(dirHandle, heads, (doc: DirectoryDocument) => {
804
+ const existingIndex = doc.docs.findIndex(
805
+ (entry) => entry.name === fileName && entry.type === "file"
806
+ );
807
+ if (existingIndex === -1) {
808
+ doc.docs.push({
809
+ name: fileName,
810
+ type: "file",
811
+ url: fileUrl,
812
+ });
813
+ didChange = true;
814
+ }
815
+ });
831
816
  // Always track the directory (even if unchanged) for proper leaf-first sync ordering
832
817
  this.handlesByPath.set(directoryPath, dirHandle);
833
818
 
@@ -1007,37 +992,20 @@ export class SyncEngine {
1007
992
  const heads = snapshotEntry?.head;
1008
993
  let didChange = false;
1009
994
 
1010
- if (heads) {
1011
- dirHandle.changeAt(heads, (doc: DirectoryDocument) => {
1012
- const indexToRemove = doc.docs.findIndex(
1013
- (entry) => entry.name === fileName && entry.type === "file"
1014
- );
1015
- if (indexToRemove !== -1) {
1016
- doc.docs.splice(indexToRemove, 1);
1017
- didChange = true;
1018
- out.taskLine(
1019
- `Removed ${fileName} from ${
1020
- formatRelativePath(directoryPath) || "root"
1021
- }`
1022
- );
1023
- }
1024
- });
1025
- } else {
1026
- dirHandle.change((doc: DirectoryDocument) => {
1027
- const indexToRemove = doc.docs.findIndex(
1028
- (entry) => entry.name === fileName && entry.type === "file"
995
+ changeWithOptionalHeads(dirHandle, heads, (doc: DirectoryDocument) => {
996
+ const indexToRemove = doc.docs.findIndex(
997
+ (entry) => entry.name === fileName && entry.type === "file"
998
+ );
999
+ if (indexToRemove !== -1) {
1000
+ doc.docs.splice(indexToRemove, 1);
1001
+ didChange = true;
1002
+ out.taskLine(
1003
+ `Removed ${fileName} from ${
1004
+ formatRelativePath(directoryPath) || "root"
1005
+ }`
1029
1006
  );
1030
- if (indexToRemove !== -1) {
1031
- doc.docs.splice(indexToRemove, 1);
1032
- didChange = true;
1033
- out.taskLine(
1034
- `Removed ${fileName} from ${
1035
- formatRelativePath(directoryPath) || "root"
1036
- }`
1037
- );
1038
- }
1039
- });
1040
- }
1007
+ }
1008
+ });
1041
1009
 
1042
1010
  if (didChange && snapshotEntry) {
1043
1011
  snapshotEntry.head = dirHandle.heads();
@@ -1184,15 +1152,9 @@ export class SyncEngine {
1184
1152
 
1185
1153
  const timestamp = Date.now();
1186
1154
 
1187
- if (heads) {
1188
- rootHandle.changeAt(heads, (doc: DirectoryDocument) => {
1189
- doc.lastSyncAt = timestamp;
1190
- });
1191
- } else {
1192
- rootHandle.change((doc: DirectoryDocument) => {
1193
- doc.lastSyncAt = timestamp;
1194
- });
1195
- }
1155
+ changeWithOptionalHeads(rootHandle, heads, (doc: DirectoryDocument) => {
1156
+ doc.lastSyncAt = timestamp;
1157
+ });
1196
1158
 
1197
1159
  // Track root directory for network sync
1198
1160
  this.handlesByPath.set("", rootHandle);
@@ -1206,34 +1168,45 @@ export class SyncEngine {
1206
1168
  }
1207
1169
 
1208
1170
  /**
1209
- * Sort tracked handles leaf-first (deepest paths first).
1210
- * Returns handles in sorted order, logging URLs with heads for debugging.
1171
+ * Group tracked handles by depth level, ordered leaf-first (deepest level first).
1172
+ * Returns an array of arrays, where each inner array contains all handles at the
1173
+ * same depth level. Levels are ordered from deepest to shallowest (root).
1174
+ *
1175
+ * This grouping enables level-by-level network sync: all documents at the deepest
1176
+ * level sync in parallel first, then the next level up, etc. This ensures children
1177
+ * are fully synced to the server before their parent directories.
1211
1178
  */
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;
1179
+ private groupHandlesByDepthLevel(): DocHandle<unknown>[][] {
1180
+ // Group paths by depth
1181
+ const pathsByDepth = new Map<number, string[]>();
1182
+ for (const path of this.handlesByPath.keys()) {
1183
+ const depth = path ? path.split("/").length : 0;
1184
+ if (!pathsByDepth.has(depth)) {
1185
+ pathsByDepth.set(depth, []);
1221
1186
  }
1187
+ pathsByDepth.get(depth)!.push(path);
1188
+ }
1222
1189
 
1223
- // Alphabetically by path
1224
- return a.localeCompare(b);
1225
- });
1190
+ // Sort depths descending (deepest first) to get leaf-first ordering
1191
+ const sortedDepths = Array.from(pathsByDepth.keys()).sort((a, b) => b - a);
1226
1192
 
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);
1193
+ // Build level groups, logging sync order for debugging
1194
+ const levels: DocHandle<unknown>[][] = [];
1195
+ for (const depth of sortedDepths) {
1196
+ const paths = pathsByDepth.get(depth)!;
1197
+ paths.sort((a, b) => a.localeCompare(b)); // Alphabetical within level
1198
+
1199
+ const handlesAtLevel: DocHandle<unknown>[] = [];
1200
+ for (const path of paths) {
1201
+ const handle = this.handlesByPath.get(path)!;
1202
+ const versionedUrl = this.getVersionedUrl(handle);
1203
+ out.taskLine(`Sync: ${path || "(root)"} -> ${versionedUrl}`, true);
1204
+ handlesAtLevel.push(handle);
1205
+ }
1206
+ levels.push(handlesAtLevel);
1234
1207
  }
1235
1208
 
1236
- return handles;
1209
+ return levels;
1237
1210
  }
1238
1211
 
1239
1212
  /**
@@ -1281,8 +1254,17 @@ export class SyncEngine {
1281
1254
  filesByDir.get(dirPath)!.push(filePath);
1282
1255
  }
1283
1256
 
1284
- // Process each directory that has files
1285
- for (const [dirPath, filePaths] of filesByDir.entries()) {
1257
+ // Process directories leaf-first (deepest first) so children are
1258
+ // up-to-date before their parents are processed
1259
+ const sortedDirPaths = Array.from(filesByDir.keys()).sort((a, b) => {
1260
+ const depthA = a ? a.split("/").length : 0;
1261
+ const depthB = b ? b.split("/").length : 0;
1262
+ if (depthA !== depthB) return depthB - depthA;
1263
+ return a.localeCompare(b);
1264
+ });
1265
+
1266
+ for (const dirPath of sortedDirPaths) {
1267
+ const filePaths = filesByDir.get(dirPath)!;
1286
1268
  try {
1287
1269
  // Get the directory URL
1288
1270
  let dirUrl: AutomergeUrl;
@@ -1333,31 +1315,17 @@ export class SyncEngine {
1333
1315
 
1334
1316
  // Update all file entries in the directory document
1335
1317
  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
- }
1318
+ changeWithOptionalHeads(dirHandle, heads, (doc: DirectoryDocument) => {
1319
+ for (const [fileName, newUrl] of fileUrlUpdates) {
1320
+ const existingIndex = doc.docs.findIndex(
1321
+ (entry) => entry.name === fileName && entry.type === "file"
1322
+ );
1323
+ if (existingIndex !== -1 && doc.docs[existingIndex].url !== newUrl) {
1324
+ doc.docs[existingIndex].url = newUrl;
1325
+ didChange = true;
1358
1326
  }
1359
- });
1360
- }
1327
+ }
1328
+ });
1361
1329
 
1362
1330
  // Track directory and update heads
1363
1331
  if (didChange) {
@@ -1452,29 +1420,15 @@ export class SyncEngine {
1452
1420
  const parentHeads = parentSnapshotEntry?.head;
1453
1421
 
1454
1422
  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
- }
1423
+ changeWithOptionalHeads(parentHandle, parentHeads, (doc: DirectoryDocument) => {
1424
+ const existingIndex = doc.docs.findIndex(
1425
+ (entry) => entry.name === dirName && entry.type === "folder"
1426
+ );
1427
+ if (existingIndex !== -1) {
1428
+ doc.docs[existingIndex].url = currentVersionedUrl;
1429
+ didChange = true;
1430
+ }
1431
+ });
1478
1432
 
1479
1433
  // Track parent for sync and update its heads in snapshot
1480
1434
  if (didChange) {
@@ -1,4 +1,9 @@
1
- import { DocHandle, StorageId, Repo, AutomergeUrl } from "@automerge/automerge-repo";
1
+ import {
2
+ DocHandle,
3
+ StorageId,
4
+ Repo,
5
+ AutomergeUrl,
6
+ } from "@automerge/automerge-repo";
2
7
  import * as A from "@automerge/automerge";
3
8
  import { out } from "./output";
4
9
  import { DirectoryDocument } from "../types";
@@ -8,7 +13,7 @@ import { getPlainUrl } from "./directory";
8
13
  * Wait for bidirectional sync to stabilize.
9
14
  * This function waits until document heads stop changing, indicating that
10
15
  * both outgoing and incoming sync has completed.
11
- *
16
+ *
12
17
  * @param repo - The Automerge repository
13
18
  * @param rootDirectoryUrl - The root directory URL to start traversal from
14
19
  * @param syncServerStorageId - The sync server storage ID
@@ -22,7 +27,7 @@ export async function waitForBidirectionalSync(
22
27
  timeoutMs?: number;
23
28
  pollIntervalMs?: number;
24
29
  stableChecksRequired?: number;
25
- } = {}
30
+ } = {},
26
31
  ): Promise<void> {
27
32
  const {
28
33
  timeoutMs = 10000,
@@ -70,7 +75,7 @@ export async function waitForBidirectionalSync(
70
75
  */
71
76
  async function getAllDocumentHeads(
72
77
  repo: Repo,
73
- rootDirectoryUrl: AutomergeUrl
78
+ rootDirectoryUrl: AutomergeUrl,
74
79
  ): Promise<Map<string, string>> {
75
80
  const heads = new Map<string, string>();
76
81
  // Pass URL as-is; collectHeadsRecursive will strip heads
@@ -85,13 +90,13 @@ async function getAllDocumentHeads(
85
90
  async function collectHeadsRecursive(
86
91
  repo: Repo,
87
92
  directoryUrl: AutomergeUrl,
88
- heads: Map<string, string>
93
+ heads: Map<string, string>,
89
94
  ): Promise<void> {
90
95
  try {
91
96
  const plainUrl = getPlainUrl(directoryUrl);
92
97
  const handle = await repo.find<DirectoryDocument>(plainUrl);
93
98
  const doc = await handle.doc();
94
-
99
+
95
100
  // Record this directory's heads (use plain URL as key for consistency)
96
101
  heads.set(plainUrl, JSON.stringify(handle.heads()));
97
102
 
@@ -125,7 +130,7 @@ async function collectHeadsRecursive(
125
130
  */
126
131
  function headsMapEqual(
127
132
  a: Map<string, string>,
128
- b: Map<string, string>
133
+ b: Map<string, string>,
129
134
  ): boolean {
130
135
  if (a.size !== b.size) {
131
136
  return false;
@@ -144,7 +149,7 @@ function headsMapEqual(
144
149
  export async function waitForSync(
145
150
  handlesToWaitOn: DocHandle<unknown>[],
146
151
  syncServerStorageId?: StorageId,
147
- timeoutMs: number = 60000
152
+ timeoutMs: number = 1000000,
148
153
  ): Promise<void> {
149
154
  const startTime = Date.now();
150
155
 
@@ -192,8 +197,8 @@ export async function waitForSync(
192
197
  cleanup();
193
198
  reject(
194
199
  new Error(
195
- `Sync timeout after ${timeoutMs}ms for document ${handle.url}`
196
- )
200
+ `Sync timeout after ${timeoutMs}ms for document ${handle.url}`,
201
+ ),
197
202
  );
198
203
  }, timeoutMs);
199
204