@doubling/compound-sync 1.11.1 → 1.12.1

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.
Files changed (3) hide show
  1. package/files.js +95 -21
  2. package/org-sync.js +50 -3
  3. package/package.json +2 -2
package/files.js CHANGED
@@ -26,7 +26,7 @@
26
26
  // 2. Delete the Firestore metadata document.
27
27
 
28
28
  import {
29
- doc, getDocs, setDoc, updateDoc, deleteDoc, query, where,
29
+ doc, getDoc, getDocs, setDoc, updateDoc, deleteDoc, query, where,
30
30
  } from 'firebase/firestore';
31
31
  import {
32
32
  ref as storageRef, uploadBytes, deleteObject, getBytes,
@@ -206,18 +206,32 @@ export async function pushFolderToCloud({
206
206
  }
207
207
 
208
208
  /**
209
- * Delete the folder doc at `docPath` if it exists. Does NOT cascade
210
- * to children (the sync daemon's `unlinkDir` event fires after its
211
- * children's `unlink` events have already cleaned up child docs, so
212
- * by the time we reach the folder it should be empty).
209
+ * Delete the folder doc at `docPath` AND every descendant (files and
210
+ * sub-folders) in the same scope. Cascade is authoritative: it does
211
+ * not rely on per-child `unlink` events to clean up children first.
212
+ *
213
+ * Pre-DOU-223 this function deleted only the folder doc, on the
214
+ * (wrong) assumption that chokidar would emit `unlink` for every
215
+ * child before the `unlinkDir` for the folder. macOS FSEvents
216
+ * coalescing and burst-event ordering broke that assumption,
217
+ * leaving orphan file docs that reconstituted the folder on the web
218
+ * via `getFoldersAndFiles` (which derives folder names from paths).
219
+ * The cascade closes the gap; if children's `unlink` events also
220
+ * fire in time, the cascade is a no-op for them.
221
+ *
222
+ * Storage blobs for descendant files are deleted before their
223
+ * Firestore docs so the Storage rule (which reads the doc to
224
+ * authorize) still passes.
213
225
  *
214
226
  * Returns:
215
227
  * { action: 'skipped', reason: 'shared-scope' }
216
228
  * { action: 'not-found' }
217
- * { action: 'deleted', folderId }
229
+ * { action: 'deleted', folderId, childCount }
218
230
  */
219
231
  export async function deleteFolderFromCloud({
220
232
  filesRef,
233
+ storage,
234
+ orgId,
221
235
  scope,
222
236
  teamId,
223
237
  userId,
@@ -226,27 +240,65 @@ export async function deleteFolderFromCloud({
226
240
  if (scope === 'shared-with-me' || scope === 'shared-by-me') {
227
241
  return { action: 'skipped', reason: 'shared-scope' };
228
242
  }
229
- let q;
243
+ // Pull every doc in this scope; cheap because vaults are small
244
+ // (hundreds of docs at most). Filtering in memory avoids the
245
+ // Firestore composite-index requirement for a path-prefix range
246
+ // query and keeps the cascade contract dead simple to reason about.
247
+ let scopeQuery;
230
248
  if (scope === 'private') {
231
- q = query(filesRef,
232
- where('type', '==', 'folder'),
249
+ scopeQuery = query(filesRef,
233
250
  where('scope', '==', 'private'),
234
- where('ownerId', '==', userId),
235
- where('path', '==', docPath));
251
+ where('ownerId', '==', userId));
236
252
  } else if (scope === 'team' && teamId) {
237
- q = query(filesRef,
238
- where('type', '==', 'folder'),
253
+ scopeQuery = query(filesRef,
239
254
  where('scope', '==', 'team'),
240
- where('teamId', '==', teamId),
241
- where('path', '==', docPath));
255
+ where('teamId', '==', teamId));
242
256
  } else {
243
257
  return { action: 'not-found' };
244
258
  }
245
- const snap = await getDocs(q);
246
- if (snap.empty) return { action: 'not-found' };
247
- const docSnap = snap.docs[0];
248
- await deleteDoc(docSnap.ref);
249
- return { action: 'deleted', folderId: docSnap.id };
259
+ const snap = await getDocs(scopeQuery);
260
+
261
+ const prefix = docPath + '/';
262
+ let folderDocSnap = null;
263
+ const descendants = [];
264
+ snap.forEach((d) => {
265
+ const data = d.data();
266
+ const p = data.path;
267
+ if (!p) return;
268
+ if (data.type === 'folder' && p === docPath) {
269
+ folderDocSnap = d;
270
+ } else if (p.startsWith(prefix)) {
271
+ descendants.push({ id: d.id, ref: d.ref, data });
272
+ }
273
+ });
274
+
275
+ if (!folderDocSnap && descendants.length === 0) {
276
+ return { action: 'not-found' };
277
+ }
278
+
279
+ // Delete each descendant's blob (files only) then its doc.
280
+ for (const { id, ref, data } of descendants) {
281
+ if (data.type !== 'folder') {
282
+ 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.
288
+ }
289
+ }
290
+ await deleteDoc(ref);
291
+ }
292
+
293
+ if (folderDocSnap) {
294
+ await deleteDoc(folderDocSnap.ref);
295
+ }
296
+
297
+ return {
298
+ action: 'deleted',
299
+ folderId: folderDocSnap ? folderDocSnap.id : null,
300
+ childCount: descendants.length,
301
+ };
250
302
  }
251
303
 
252
304
  // ---- High-level coordinator ----
@@ -449,7 +501,29 @@ export async function deleteFileFromCloud({
449
501
  const ext = extFromPath(docPath);
450
502
  // Delete the Storage blob FIRST while the metadata doc still exists,
451
503
  // because the Storage rule's authorization check looks up the doc.
452
- await deleteBlob({ storage, orgId, fileId, ext });
504
+ //
505
+ // Race tolerance (DOU-231 follow-up): a concurrent delete can remove
506
+ // the metadata doc between our snapshot read and our blob-delete
507
+ // (most commonly the cascade in deleteFolderFromCloud firing first
508
+ // 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.
515
+ try {
516
+ await deleteBlob({ storage, orgId, fileId, ext });
517
+ } catch (err) {
518
+ const code = err && err.code;
519
+ if (code === 'storage/unauthorized') {
520
+ const recheck = await getDoc(docSnap.ref);
521
+ if (!recheck.exists()) {
522
+ return { action: 'not-found', fileId };
523
+ }
524
+ }
525
+ throw err;
526
+ }
453
527
  await deleteDoc(docSnap.ref);
454
528
  return { action: 'deleted', fileId };
455
529
  }
package/org-sync.js CHANGED
@@ -15,7 +15,7 @@
15
15
  import chokidar from 'chokidar';
16
16
  import fs from 'fs';
17
17
  import path from 'path';
18
- import { collection, query, where, onSnapshot } from 'firebase/firestore';
18
+ import { collection, query, where, onSnapshot, getDocs } from 'firebase/firestore';
19
19
  import {
20
20
  pushFileToCloud,
21
21
  deleteFileFromCloud,
@@ -600,6 +600,34 @@ export async function startOrgSync({
600
600
  }
601
601
  }
602
602
 
603
+ // DOU-217 helper: does a doc with this path + matching contentHash
604
+ // exist in Firestore right now? Used to verify a baseline entry
605
+ // before trusting it (so a stale baseline can't silently suppress
606
+ // re-upload of a file whose Firestore doc was deleted out-of-band).
607
+ async function firestoreDocExists({ scope, teamId, docPath, contentHash }) {
608
+ let q;
609
+ if (scope === 'private') {
610
+ q = query(filesRef,
611
+ where('scope', '==', 'private'),
612
+ where('ownerId', '==', userId),
613
+ where('path', '==', docPath));
614
+ } else if (scope === 'team' && teamId) {
615
+ q = query(filesRef,
616
+ where('scope', '==', 'team'),
617
+ where('teamId', '==', teamId),
618
+ where('path', '==', docPath));
619
+ } else {
620
+ return false;
621
+ }
622
+ const snap = await getDocs(q);
623
+ if (snap.empty) return false;
624
+ // Also require the doc's contentHash to match the baseline. A doc
625
+ // with the same path but a different hash means a web edit
626
+ // happened that the daemon didn't observe; treat as "missing" so
627
+ // the push side proceeds and the pull side can reconcile.
628
+ return snap.docs[0].data().contentHash === contentHash;
629
+ }
630
+
603
631
  async function pushToFirestore(filePath, data, mimeType, localModifiedAt) {
604
632
  const { scope, teamId, docPath } = scopeFromLocalPath(filePath, LOCAL_PATH, teamIdsByName);
605
633
  const trackingKey = `${scope}:${docPath}`;
@@ -609,8 +637,22 @@ export async function startOrgSync({
609
637
  // re-saving or touching a file (or the watcher re-emitting it on
610
638
  // startup) must not push stale bytes over a newer server edit. Only
611
639
  // a genuine content change proceeds.
640
+ //
641
+ // DOU-217: trust the baseline ONLY after verifying the Firestore
642
+ // doc actually exists. The baseline is persisted across daemon
643
+ // restarts, so a doc deleted out-of-band (web delete the daemon
644
+ // missed while offline, manual cleanup, etc.) leaves a permanently
645
+ // stale entry that would silently suppress re-upload forever.
646
+ // Verifying via a single indexed read per touch is cheap; if the
647
+ // doc is gone, clear the baseline and fall through to push.
612
648
  const contentHash = computeContentHash(data);
613
- if (lastSyncedHash.get(trackingKey) === contentHash) return;
649
+ if (lastSyncedHash.get(trackingKey) === contentHash) {
650
+ if (await firestoreDocExists({ scope, teamId, docPath, contentHash })) {
651
+ return;
652
+ }
653
+ console.log(` [${orgId}] [reconcile] dropping stale baseline for ${docPath} (Firestore doc missing); re-pushing`);
654
+ deleteBaseline(trackingKey);
655
+ }
614
656
 
615
657
  const now = new Date().toISOString();
616
658
 
@@ -728,6 +770,8 @@ export async function startOrgSync({
728
770
 
729
771
  const result = await deleteFolderFromCloud({
730
772
  filesRef,
773
+ storage,
774
+ orgId,
731
775
  scope,
732
776
  teamId,
733
777
  userId,
@@ -735,7 +779,10 @@ export async function startOrgSync({
735
779
  });
736
780
 
737
781
  if (result.action === 'deleted') {
738
- console.log(` [${orgId}] [local firestore] RMFOLDER ${docPath} (${scope})`);
782
+ // childCount > 0 means cascade did real work; useful in logs
783
+ // when investigating future folder-delete reports.
784
+ const childSuffix = result.childCount > 0 ? ` (+${result.childCount} children)` : '';
785
+ console.log(` [${orgId}] [local → firestore] RMFOLDER ${docPath} (${scope})${childSuffix}`);
739
786
  }
740
787
  }
741
788
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doubling/compound-sync",
3
- "version": "1.11.1",
3
+ "version": "1.12.1",
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.test.js org-sync.test.js two-user-sync.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'",
36
36
  "test": "npm run test:unit && npm run test:integration"
37
37
  },
38
38
  "dependencies": {