@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.
- package/files.js +95 -21
- package/org-sync.js +50 -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`
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
246
|
-
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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.
|
|
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": {
|