@doubling/compound-sync 1.12.1 → 1.12.2

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/files.js CHANGED
@@ -37,6 +37,8 @@ import {
37
37
  extFromPath,
38
38
  contentByteSize,
39
39
  computeContentHash,
40
+ folderDocId,
41
+ fileDocId,
40
42
  toBytes,
41
43
  } from './storage-helpers.js';
42
44
  import { planFolderChain, splitPath } from './folder-chain.js';
@@ -127,15 +129,28 @@ async function loadScopedFolders({ filesRef, scope, teamId, userId }) {
127
129
  async function applyFolderChainPlan({ filesRef, plan, now }) {
128
130
  const tempToReal = new Map();
129
131
  for (const create of plan.creates) {
130
- const newRef = doc(filesRef);
132
+ // Deterministic id by {scope, teamId, ownerId, path}; concurrent
133
+ // sibling creates for the same path collapse onto the same doc
134
+ // instead of producing duplicates (DOU-240).
135
+ const realId = folderDocId({
136
+ scope: create.doc.scope,
137
+ teamId: create.doc.teamId,
138
+ ownerId: create.doc.ownerId,
139
+ path: create.doc.path,
140
+ });
141
+ const newRef = doc(filesRef, realId);
131
142
  const docData = {
132
143
  ...create.doc,
133
144
  parentId: tempToReal.get(create.doc.parentId) || create.doc.parentId,
134
145
  createdAt: now,
135
146
  updatedAt: now,
136
147
  };
148
+ // setDoc with merge:false is idempotent on identical input; the
149
+ // last writer wins on differing fields. For folder docs the only
150
+ // fields that vary between concurrent callers are timestamps, so
151
+ // any tie-break is acceptable.
137
152
  await setDoc(newRef, docData);
138
- tempToReal.set(create.tempId, newRef.id);
153
+ tempToReal.set(create.tempId, realId);
139
154
  }
140
155
  for (const bf of plan.backfills) {
141
156
  await updateDoc(doc(filesRef, bf.id), { path: bf.path, updatedAt: now });
@@ -280,11 +295,25 @@ export async function deleteFolderFromCloud({
280
295
  for (const { id, ref, data } of descendants) {
281
296
  if (data.type !== 'folder') {
282
297
  const ext = extFromPath(data.path);
283
- try {
284
- await deleteBlob({ storage, orgId, fileId: id, ext });
285
- } catch (err) {
286
- // Orphan blob is a soft failure; the doc delete is the
287
- // load-bearing state transition for the user's view.
298
+ // Re-check the doc still exists right before calling the
299
+ // Storage SDK. If a concurrent caller (chokidar's per-file
300
+ // unlink fan-out, or another cascade) already deleted the
301
+ // metadata doc, the Storage rule's cross-service
302
+ // firestore.get(...).data.scope reads null and throws "Null
303
+ // value error", and the Storage SDK retries for several
304
+ // seconds before surfacing storage/unauthorized. The retry
305
+ // latency is what blew the test budget on DOU-242 + DOU-251.
306
+ // Skipping the SDK call avoids both the rule throw and the
307
+ // retry; the orphan blob (if any) is cleaned up by the
308
+ // data-integrity audit (DOU-244).
309
+ const recheck = await getDoc(ref);
310
+ if (recheck.exists()) {
311
+ try {
312
+ await deleteBlob({ storage, orgId, fileId: id, ext });
313
+ } catch (err) {
314
+ // Orphan blob is a soft failure; the doc delete is the
315
+ // load-bearing state transition for the user's view.
316
+ }
288
317
  }
289
318
  }
290
319
  await deleteDoc(ref);
@@ -387,8 +416,16 @@ export async function pushFileToCloud({
387
416
  : null;
388
417
 
389
418
  if (!snapshot || snapshot.empty) {
390
- const newDocRef = doc(filesRef);
391
- const fileId = newDocRef.id;
419
+ // Deterministic id by {scope, teamId, ownerId, path}; concurrent
420
+ // sibling creates for the same path collapse onto the same doc
421
+ // instead of producing duplicates. Mirrors the DOU-240 folder fix.
422
+ const fileId = fileDocId({
423
+ scope,
424
+ teamId: scope === 'team' ? (teamId || null) : null,
425
+ ownerId: scope === 'private' ? userId : null,
426
+ path: docPath,
427
+ });
428
+ const newDocRef = doc(filesRef, fileId);
392
429
  const resolvedMime = mimeType || 'text/markdown';
393
430
  const fileData = {
394
431
  type: 'file',
@@ -502,21 +539,45 @@ export async function deleteFileFromCloud({
502
539
  // Delete the Storage blob FIRST while the metadata doc still exists,
503
540
  // because the Storage rule's authorization check looks up the doc.
504
541
  //
505
- // Race tolerance (DOU-231 follow-up): a concurrent delete can remove
542
+ // Race tolerance (DOU-242 / DOU-252): a concurrent delete can remove
506
543
  // the metadata doc between our snapshot read and our blob-delete
507
544
  // (most commonly the cascade in deleteFolderFromCloud firing first
508
545
  // for a path chokidar's per-file `unlink` event then re-tries via
509
- // this function). After that delete, the Storage rule's
510
- // `firestore.get(...).data.scope` reads null, the rule throws
511
- // "Null value error", and the Storage SDK surfaces it as
512
- // `storage/unauthorized`. In that specific case the desired end
513
- // state (no doc, no blob) is already achieved, so treat as
514
- // already-deleted instead of propagating a noisy auth error.
546
+ // this function). Pre-check the doc still exists right before the
547
+ // Storage SDK call: if it's gone, skip the blob delete entirely.
548
+ // This avoids the Storage rule's cross-service firestore.get(...)
549
+ // reading null and throwing "Null value error", and avoids the
550
+ // Storage SDK's multi-second retry chain that breaks test budgets.
551
+ const preRecheck = await getDoc(docSnap.ref);
552
+ if (!preRecheck.exists()) {
553
+ return { action: 'not-found', fileId };
554
+ }
515
555
  try {
516
556
  await deleteBlob({ storage, orgId, fileId, ext });
517
557
  } catch (err) {
518
558
  const code = err && err.code;
519
559
  if (code === 'storage/unauthorized') {
560
+ // Last-resort safety net for the narrow window between
561
+ // preRecheck above and the SDK actually firing. Treat as
562
+ // already-deleted if the doc is now gone.
563
+ const recheck = await getDoc(docSnap.ref);
564
+ if (!recheck.exists()) {
565
+ return { action: 'not-found', fileId };
566
+ }
567
+ }
568
+ throw err;
569
+ }
570
+ // Concurrent-delete tolerance (DOU-242): the Firestore delete rule
571
+ // evaluates canAccessFile() which reads resource.data; if another
572
+ // caller already deleted this doc, resource is null and the rule
573
+ // throws "Null value error" → SDK surfaces permission-denied. The
574
+ // desired end state (no doc) is already achieved, so treat as
575
+ // already-deleted instead of propagating.
576
+ try {
577
+ await deleteDoc(docSnap.ref);
578
+ } catch (err) {
579
+ const code = err && err.code;
580
+ if (code === 'permission-denied') {
520
581
  const recheck = await getDoc(docSnap.ref);
521
582
  if (!recheck.exists()) {
522
583
  return { action: 'not-found', fileId };
@@ -524,6 +585,5 @@ export async function deleteFileFromCloud({
524
585
  }
525
586
  throw err;
526
587
  }
527
- await deleteDoc(docSnap.ref);
528
588
  return { action: 'deleted', fileId };
529
589
  }
package/org-sync.js CHANGED
@@ -135,13 +135,49 @@ export async function startOrgSync({
135
135
  // Track which fileIds already have a binding (or one being set
136
136
  // up) so we never double-create. `stoppedRef` is read by the
137
137
  // background retry loops so they can abandon work after sync.stop.
138
- // `pendingPromises` lets stop() await any in-flight setup before
139
- // returning so the next test's clearFirestore does not race
140
- // dangling onSnapshot streams.
138
+ // `pendingPromises` lets stop() await any in-flight setup OR any
139
+ // fire-and-forget snapshot/chokidar handler work before returning.
140
+ // Without this, the next test's clearFirestore + terminate can
141
+ // race a handler that's still flushing storage from this test,
142
+ // surfacing as "Firebase app was deleted" errors in the daemon log
143
+ // and (suspected) accumulating gRPC stream state that the next
144
+ // listener attach receives as a single oversize Listen message.
141
145
  const pendingBindings = new Set();
142
146
  const pendingPromises = new Set();
143
147
  const stoppedRef = { value: false };
144
148
 
149
+ // Helper: register a promise with pendingPromises so stop() awaits
150
+ // it, and auto-remove on settle. Returns the same promise so call
151
+ // sites can keep their chaining shape.
152
+ function track(p) {
153
+ pendingPromises.add(p);
154
+ p.finally(() => pendingPromises.delete(p));
155
+ return p;
156
+ }
157
+
158
+ // DOU-254: per-local-path operation queue. chokidar can fire `add`
159
+ // then `unlink` for the same path within a few hundred ms (a test
160
+ // writes a file, waits only for the Firestore doc to appear, then
161
+ // deletes locally). Without serialization, the push (setDoc +
162
+ // uploadBlob) and the delete (preRecheck + deleteBlob + deleteDoc)
163
+ // race against each other on the same fileId, and uploadBlob can
164
+ // land AFTER deleteBlob, leaving an orphaned blob after the doc is
165
+ // gone. Chain ops keyed by local path so the next op starts only
166
+ // after the previous one settles.
167
+ const localOpQueues = new Map();
168
+ function enqueueLocalOp(filePath, op) {
169
+ const prev = localOpQueues.get(filePath) || Promise.resolve();
170
+ // Swallow the previous op's rejection so a failed push doesn't
171
+ // skip the subsequent delete (or vice versa); each op handles
172
+ // its own errors via the .catch() at the call site.
173
+ const next = prev.catch(() => {}).then(op);
174
+ localOpQueues.set(filePath, next);
175
+ next.finally(() => {
176
+ if (localOpQueues.get(filePath) === next) localOpQueues.delete(filePath);
177
+ });
178
+ return next;
179
+ }
180
+
145
181
  // Fire-and-forget binding setup. Returns nothing because the
146
182
  // common shape "ensureBinding then read its ytext" runs straight
147
183
  // into a race: the daemon's onSnapshot fires on Alice's own
@@ -512,9 +548,9 @@ export async function startOrgSync({
512
548
  const q = query(filesRef, where('scope', '==', 'team'), where('teamId', '==', teamId));
513
549
  const unsubscribe = onSnapshot(q, snapshot => {
514
550
  snapshot.docChanges().forEach(change => {
515
- handleFirestoreChange(change, data =>
551
+ track(handleFirestoreChange(change, data =>
516
552
  path.join(LOCAL_PATH, teamFolder, data.path),
517
- ).catch(err => console.error(` [${orgId}] Firestore change handler error:`, err && err.message));
553
+ ).catch(err => console.error(` [${orgId}] Firestore change handler error:`, err && err.message)));
518
554
  });
519
555
  }, err => console.error(` [${orgId}] Team "${teamFolder}" listener error:`, err));
520
556
 
@@ -563,8 +599,8 @@ export async function startOrgSync({
563
599
  const data = change.doc.data();
564
600
  const realPath = path.join(LOCAL_PATH, PRIVATE_FOLDER, data.path);
565
601
 
566
- handleFirestoreChange(change, () => realPath)
567
- .catch(err => console.error(` [${orgId}] Firestore change handler error:`, err && err.message));
602
+ track(handleFirestoreChange(change, () => realPath)
603
+ .catch(err => console.error(` [${orgId}] Firestore change handler error:`, err && err.message)));
568
604
 
569
605
  if (change.type !== 'removed') {
570
606
  updateSharedByMeSymlink(data, realPath);
@@ -590,9 +626,9 @@ export async function startOrgSync({
590
626
  snapshot.docChanges().forEach(change => {
591
627
  const data = change.doc.data();
592
628
  if (data.ownerId === userId) return;
593
- handleFirestoreChange(change, () =>
629
+ track(handleFirestoreChange(change, () =>
594
630
  path.join(LOCAL_PATH, SHARED_WITH_ME_FOLDER, data.path),
595
- ).catch(err => console.error(` [${orgId}] Firestore change handler error:`, err && err.message));
631
+ ).catch(err => console.error(` [${orgId}] Firestore change handler error:`, err && err.message)));
596
632
  });
597
633
  }, err => console.error(` [${orgId}] Shared with me listener error:`, err));
598
634
  unsubscribers.push(sharedUnsub);
@@ -827,27 +863,29 @@ export async function startOrgSync({
827
863
  if (binding) binding.applyDiskText(data);
828
864
  }
829
865
 
830
- pushToFirestore(filePath, data, mimeType, localModifiedAt).catch(err =>
831
- console.error(` [${orgId}] Push error for ${rel}: ${err.message}`)
832
- );
866
+ track(enqueueLocalOp(filePath, () =>
867
+ pushToFirestore(filePath, data, mimeType, localModifiedAt).catch(err =>
868
+ console.error(` [${orgId}] Push error for ${rel}: ${err.message}`)
869
+ )
870
+ ));
833
871
  }
834
872
 
835
873
  function handleLocalDirAdded(dirPath) {
836
874
  if (suppressLocal.has(dirPath)) return;
837
875
  const rel = path.relative(LOCAL_PATH, dirPath);
838
876
  if (!isSyncableLocalDir(rel)) return;
839
- pushDirToFirestore(dirPath).catch(err =>
877
+ track(pushDirToFirestore(dirPath).catch(err =>
840
878
  console.error(` [${orgId}] Folder push error for ${rel}: ${err.message}`)
841
- );
879
+ ));
842
880
  }
843
881
 
844
882
  function handleLocalDirRemoved(dirPath) {
845
883
  if (suppressLocal.has(dirPath)) return;
846
884
  const rel = path.relative(LOCAL_PATH, dirPath);
847
885
  if (!isSyncableLocalDir(rel)) return;
848
- deleteDirFromFirestore(dirPath).catch(err =>
886
+ track(deleteDirFromFirestore(dirPath).catch(err =>
849
887
  console.error(` [${orgId}] Folder delete error for ${rel}: ${err.message}`)
850
- );
888
+ ));
851
889
  }
852
890
 
853
891
  function startLocalWatcher() {
@@ -871,15 +909,44 @@ export async function startOrgSync({
871
909
  },
872
910
  });
873
911
 
874
- watcher.on('add', filePath => handleLocalChange(filePath));
875
- watcher.on('change', filePath => handleLocalChange(filePath));
912
+ // Reusable per-event trace logging, gated by COMPOUND_CHOKIDAR_DEBUG.
913
+ // Writes to both stdout AND a file (default /tmp/chokidar-debug.log)
914
+ // so traces survive a process kill or buffered stdout. Used by the
915
+ // Test-on-demand workflow's `Dump diagnostic logs` step for
916
+ // chokidar-related diagnostic runs. Zero cost when the env var is
917
+ // unset.
918
+ const chokidarDebug = process.env.COMPOUND_CHOKIDAR_DEBUG === '1';
919
+ const chokidarLogPath = process.env.COMPOUND_CHOKIDAR_LOG || '/tmp/chokidar-debug.log';
920
+ function logEvent(event, p) {
921
+ if (chokidarDebug) {
922
+ const rel = path.relative(LOCAL_PATH, p);
923
+ const line = ` [${orgId}] [chokidar ${event}] ${rel} @ ${new Date().toISOString()}\n`;
924
+ try { fs.appendFileSync(chokidarLogPath, line); } catch (_) {}
925
+ console.log(line.trimEnd());
926
+ }
927
+ }
928
+
929
+ watcher.on('add', filePath => { logEvent('add', filePath); handleLocalChange(filePath); });
930
+ watcher.on('change', filePath => { logEvent('change', filePath); handleLocalChange(filePath); });
876
931
  watcher.on('unlink', filePath => {
932
+ logEvent('unlink', filePath);
877
933
  if (suppressLocal.has(filePath)) return;
878
934
  const rel = path.relative(LOCAL_PATH, filePath);
879
- deleteFromFirestore(filePath).catch(err => console.error(` [${orgId}] Delete error for ${rel}: ${err.message}`));
935
+ track(enqueueLocalOp(filePath, () =>
936
+ deleteFromFirestore(filePath).catch(err =>
937
+ console.error(` [${orgId}] Delete error for ${rel}: ${err.message}`)
938
+ )
939
+ ));
880
940
  });
881
- watcher.on('addDir', dirPath => handleLocalDirAdded(dirPath));
882
- watcher.on('unlinkDir', dirPath => handleLocalDirRemoved(dirPath));
941
+ watcher.on('addDir', dirPath => { logEvent('addDir', dirPath); handleLocalDirAdded(dirPath); });
942
+ watcher.on('unlinkDir', dirPath => { logEvent('unlinkDir', dirPath); handleLocalDirRemoved(dirPath); });
943
+ if (chokidarDebug) {
944
+ watcher.on('raw', (event, p, details) => {
945
+ const line = ` [${orgId}] [chokidar raw ${event}] ${p} @ ${new Date().toISOString()} ${JSON.stringify(details)}\n`;
946
+ try { fs.appendFileSync(chokidarLogPath, line); } catch (_) {}
947
+ console.log(line.trimEnd());
948
+ });
949
+ }
883
950
 
884
951
  // Resolve only once chokidar has finished its initial scan and
885
952
  // registered inotify (or FSEvents) watches on every existing
@@ -899,9 +966,10 @@ export async function startOrgSync({
899
966
  await startLocalWatcher();
900
967
 
901
968
  return {
902
- // Tear down all listeners and the watcher. Returns the watcher's
903
- // close() promise so callers/tests can await full shutdown.
904
- stop() {
969
+ // Tear down all listeners and the watcher. Awaits every tracked
970
+ // in-flight handler so callers/tests get a strong "no daemon work
971
+ // is still running" guarantee on the resolved promise.
972
+ async stop() {
905
973
  stoppedRef.value = true;
906
974
  for (const unsub of unsubscribers) {
907
975
  try { unsub(); } catch (e) { /* ignore */ }
@@ -910,15 +978,32 @@ export async function startOrgSync({
910
978
  try { unsub(); } catch (e) { /* ignore */ }
911
979
  }
912
980
  teamUnsubscribers.clear();
913
- const setupDrain = Promise.all([...pendingPromises]);
914
- const bindingDrain = setupDrain.then(() => Promise.all(
981
+
982
+ // Drain tracked promises in a loop, bounded by a 5s ceiling.
983
+ // A handler currently in flight (e.g., a snapshot callback
984
+ // already dispatched before unsub()) can chain follow-up work
985
+ // that gets added back to pendingPromises. Loop until the set
986
+ // quiesces so no daemon task outlives stop() in the common
987
+ // case. The cap protects against tests where a handler is
988
+ // genuinely stuck (e.g., emulator pause), so stop() never
989
+ // hangs indefinitely; anything still pending past 5s is
990
+ // abandoned and the next test's clearFirestore will sweep it.
991
+ const drainDeadline = Date.now() + 5000;
992
+ while (pendingPromises.size > 0 && Date.now() < drainDeadline) {
993
+ const remaining = Math.max(0, drainDeadline - Date.now());
994
+ await Promise.race([
995
+ Promise.all([...pendingPromises]),
996
+ new Promise((r) => setTimeout(r, remaining)),
997
+ ]);
998
+ }
999
+
1000
+ await Promise.all(
915
1001
  [...bindings.values()].map((b) => b.stop().catch(() => {})),
916
- )).then(() => {
917
- bindings.clear();
918
- bindingsByPath.clear();
919
- });
920
- const watcherClose = watcher ? watcher.close() : Promise.resolve();
921
- return Promise.all([bindingDrain, watcherClose]).then(() => undefined);
1002
+ );
1003
+ bindings.clear();
1004
+ bindingsByPath.clear();
1005
+
1006
+ if (watcher) await watcher.close();
922
1007
  },
923
1008
  };
924
1009
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doubling/compound-sync",
3
- "version": "1.12.1",
3
+ "version": "1.12.2",
4
4
  "description": "Bidirectional sync between Compound and local markdown files",
5
5
  "type": "module",
6
6
  "bin": {
@@ -32,7 +32,7 @@
32
32
  "typecheck": "tsc --noEmit",
33
33
  "prepack": "npm run build",
34
34
  "test:unit": "node --import tsx --test config.test.js paths.test.js storage-helpers.test.js folder-chain.test.js team-registry.test.js auth-persistence.test.js pre-mint-app-check.test.js setup-auth-with-pre-mint.test.js sync-state.test.js yjs-file-binding.test.js yjs-binding-state.test.js",
35
- "test:integration": "firebase emulators:exec --only firestore,storage 'node --import tsx --test --test-concurrency=1 files-helpers.test.js files-push-create-private.test.js files-push-create-team.test.js files-push-update-private.test.js files-push-binary.test.js files-push-staleness.test.js files-push-storage-fail.test.js files-push-no-content.test.js files-push-stamps.test.js files-push-folder.test.js files-delete.test.js files-delete-folder.test.js files-read-blob.test.js org-sync-local-edit.test.js org-sync-file-ops.test.js org-sync-mtime.test.js org-sync-rename.test.js org-sync-yjs-pull.test.js org-sync-yjs-push.test.js org-sync-cold-start.test.js two-user-sync.test.js'",
35
+ "test:integration": "firebase emulators:exec --only firestore,storage 'node --import tsx --test --test-concurrency=1 --test-timeout=120000 files-helpers.test.js files-push-create-private.test.js files-push-create-team.test.js files-push-update-private.test.js files-push-binary.test.js files-push-staleness.test.js files-push-storage-fail.test.js files-push-no-content.test.js files-push-stamps.test.js files-push-folder.test.js files-delete.test.js files-delete-folder.test.js files-read-blob.test.js org-sync-local-edit.test.js org-sync-file-ops.test.js org-sync-mtime.test.js org-sync-rename.test.js org-sync-yjs-pull.test.js org-sync-yjs-push.test.js org-sync-cold-start.test.js org-sync-special-chars.test.js org-sync-copy.test.js two-user-happy-paths.test.js two-user-yjs-convergence.test.js files-concurrency-folder-create.test.js files-concurrency-file-create.test.js files-concurrency-file-delete.test.js files-concurrency-folder-delete.test.js'",
36
36
  "test": "npm run test:unit && npm run test:integration"
37
37
  },
38
38
  "dependencies": {
@@ -36,6 +36,30 @@ export function computeContentHash(data) {
36
36
  return crypto.createHash('sha256').update(buf).digest('hex');
37
37
  }
38
38
 
39
+ // Deterministic Firestore document id for a folder doc identified by
40
+ // scope + ownership + path. Two concurrent creates collapse onto the
41
+ // same id, so a setDoc by all of them is idempotent (no duplicates).
42
+ // See DOU-240 for the race this prevents.
43
+ export function folderDocId({ scope, teamId, ownerId, path }) {
44
+ const key = `folder|${scope}|${teamId ?? ''}|${ownerId ?? ''}|${path}`;
45
+ return 'f-' + crypto.createHash('sha256').update(key).digest('hex').slice(0, 20);
46
+ }
47
+
48
+ // Deterministic Firestore document id for a file doc identified by
49
+ // scope + ownership + path. Same purpose as folderDocId: concurrent
50
+ // creates at the same path collapse onto one doc.
51
+ //
52
+ // Caveat: rename happens as unlink(old) + add(new) (DOU-205 / chokidar
53
+ // event sequence), so each rename produces a fresh doc with a new
54
+ // path-derived id. Two distinct files at the same path AT THE SAME
55
+ // TIME are the impossible state we want to prevent; sequential
56
+ // reuse of a path (e.g., delete then recreate) is fine because the
57
+ // old doc was already removed by unlink before the new add fires.
58
+ export function fileDocId({ scope, teamId, ownerId, path }) {
59
+ const key = `file|${scope}|${teamId ?? ''}|${ownerId ?? ''}|${path}`;
60
+ return 'x-' + crypto.createHash('sha256').update(key).digest('hex').slice(0, 20);
61
+ }
62
+
39
63
  export function toBytes(data) {
40
64
  if (data == null) return Buffer.alloc(0);
41
65
  if (Buffer.isBuffer(data)) return data;