@doubling/compound-sync 1.12.6 → 1.13.0
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/org-sync.js +96 -55
- package/package.json +2 -2
package/org-sync.js
CHANGED
|
@@ -559,9 +559,30 @@ export async function startOrgSync({ db, storage, userId, orgId, localPath, maxU
|
|
|
559
559
|
// route local paths once the watcher starts. Per-team file listeners
|
|
560
560
|
// and the live teams-collection onSnapshot are wired after reconcile
|
|
561
561
|
// completes so their initial snapshots cannot race with startup cleanup.
|
|
562
|
-
|
|
562
|
+
//
|
|
563
|
+
// Filter to teams the user has an explicit teamMember doc for. Any
|
|
564
|
+
// orgMember can list every team in `orgs/{orgId}/teams`
|
|
565
|
+
// (firestore.rules:77), but file reads require canAccessTeam, which
|
|
566
|
+
// means isOrgOwner OR isTeamMember. Without this filter, manifest
|
|
567
|
+
// reconcile's per-team `where teamId == X` query throws
|
|
568
|
+
// permission-denied on any team the user is not in and the daemon
|
|
569
|
+
// process crashes (DOU-269). Restricting teamRegistry to teams the
|
|
570
|
+
// user has joined matches the daemon's "only sync what I'm in"
|
|
571
|
+
// semantic and prevents the crash.
|
|
572
|
+
const [teamsSnap, teamMembersSnap] = await Promise.all([
|
|
573
|
+
getDocs(collection(db, `orgs/${orgId}/teams`)),
|
|
574
|
+
getDocs(query(collection(db, `orgs/${orgId}/teamMembers`), where('userId', '==', userId))),
|
|
575
|
+
]);
|
|
576
|
+
const memberTeamIds = new Set();
|
|
577
|
+
for (const memberDoc of teamMembersSnap.docs) {
|
|
578
|
+
const teamIdValue = memberDoc.data()['teamId'];
|
|
579
|
+
if (typeof teamIdValue === 'string')
|
|
580
|
+
memberTeamIds.add(teamIdValue);
|
|
581
|
+
}
|
|
563
582
|
for (const teamDoc of teamsSnap.docs) {
|
|
564
|
-
|
|
583
|
+
if (memberTeamIds.has(teamDoc.id)) {
|
|
584
|
+
teamRegistry.bootstrap(teamDoc.id, teamDoc.data());
|
|
585
|
+
}
|
|
565
586
|
}
|
|
566
587
|
console.log(` [${orgId}] Teams: ${teamRegistry.names().join(', ') || 'none'}`);
|
|
567
588
|
function trackingKeyFromDocData(data) {
|
|
@@ -741,66 +762,86 @@ export async function startOrgSync({ db, storage, userId, orgId, localPath, maxU
|
|
|
741
762
|
...cloudByKey.keys(),
|
|
742
763
|
]);
|
|
743
764
|
for (const trackingKey of allKeys) {
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
await pushLocalFileAtPath(localFilePath);
|
|
764
|
-
}
|
|
765
|
-
else {
|
|
766
|
-
deleteLocalReconcile(localFilePath, entry.fileId, trackingKey);
|
|
765
|
+
// Each per-file reconcile decision (push, remote delete, local
|
|
766
|
+
// delete, manifest upsert) is independently fallible. Per-file
|
|
767
|
+
// permission failures are surprisingly common in long-lived orgs:
|
|
768
|
+
// stale teamIds on docs created before a team was reorganized,
|
|
769
|
+
// private files whose ownership shifted to a deleted account,
|
|
770
|
+
// sharedWith arrays with uids the user can read but not write,
|
|
771
|
+
// etc. Without per-iteration error isolation, a single bad doc
|
|
772
|
+
// crashes the daemon during reconcile and the supervisor
|
|
773
|
+
// relaunches in an indefinite backoff loop (DOU-269).
|
|
774
|
+
//
|
|
775
|
+
// Catching here means: one bad file is logged and skipped, the
|
|
776
|
+
// rest of reconcile proceeds, the daemon reaches "Sync running",
|
|
777
|
+
// and a human reading the log can identify which file is stale.
|
|
778
|
+
try {
|
|
779
|
+
const entry = manifestStore.getEntry(trackingKey);
|
|
780
|
+
let localFilePath = localByKey.get(trackingKey) ?? null;
|
|
781
|
+
const cloud = cloudByKey.get(trackingKey) ?? null;
|
|
782
|
+
if (!localFilePath && cloud) {
|
|
783
|
+
localFilePath = localPathForCloudData(cloud.data);
|
|
767
784
|
}
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
785
|
+
const onDisk = !!(localFilePath && fs.existsSync(localFilePath));
|
|
786
|
+
if (entry && onDisk && !cloud) {
|
|
787
|
+
let localHash;
|
|
788
|
+
try {
|
|
789
|
+
({ contentHash: localHash } = readLocalFilePayload(localFilePath));
|
|
790
|
+
}
|
|
791
|
+
catch {
|
|
792
|
+
localHash = null;
|
|
793
|
+
}
|
|
794
|
+
if (localHash === null) {
|
|
795
|
+
console.warn(` [${orgId}] [manifest reconcile] skip unreadable local file ${trackingKey}`);
|
|
796
|
+
}
|
|
797
|
+
else if (entry.contentHash && localHash !== entry.contentHash) {
|
|
798
|
+
await pushLocalFileAtPath(localFilePath);
|
|
799
|
+
}
|
|
800
|
+
else {
|
|
801
|
+
deleteLocalReconcile(localFilePath, entry.fileId, trackingKey);
|
|
802
|
+
}
|
|
774
803
|
}
|
|
775
|
-
|
|
776
|
-
|
|
804
|
+
else if (!entry && onDisk && !cloud) {
|
|
805
|
+
const baselineHash = lastSyncedHash.get(trackingKey);
|
|
806
|
+
let localHash;
|
|
807
|
+
try {
|
|
808
|
+
({ contentHash: localHash } = readLocalFilePayload(localFilePath));
|
|
809
|
+
}
|
|
810
|
+
catch {
|
|
811
|
+
localHash = null;
|
|
812
|
+
}
|
|
813
|
+
if (localHash === null) {
|
|
814
|
+
console.warn(` [${orgId}] [manifest reconcile] skip unreadable local file ${trackingKey}`);
|
|
815
|
+
}
|
|
816
|
+
else if (baselineHash && localHash === baselineHash) {
|
|
817
|
+
deleteLocalReconcile(localFilePath, null, trackingKey);
|
|
818
|
+
}
|
|
819
|
+
else {
|
|
820
|
+
await pushLocalFileAtPath(localFilePath);
|
|
821
|
+
}
|
|
777
822
|
}
|
|
778
|
-
if (
|
|
779
|
-
|
|
823
|
+
else if (entry && !onDisk && cloud) {
|
|
824
|
+
await deleteRemoteReconcile(cloud.data, trackingKey);
|
|
780
825
|
}
|
|
781
|
-
else if (
|
|
782
|
-
|
|
826
|
+
else if ((entry || cloud) && onDisk && cloud) {
|
|
827
|
+
manifestStore.upsertEntry(trackingKey, {
|
|
828
|
+
fileId: cloud.fileId,
|
|
829
|
+
contentHash: cloud.data['contentHash'],
|
|
830
|
+
lastSyncedAt: cloud.data['updatedAt'] || cloud.data['createdAt'] || new Date().toISOString(),
|
|
831
|
+
});
|
|
783
832
|
}
|
|
784
|
-
else {
|
|
785
|
-
|
|
833
|
+
else if (!entry && onDisk && cloud) {
|
|
834
|
+
manifestStore.upsertEntry(trackingKey, {
|
|
835
|
+
fileId: cloud.fileId,
|
|
836
|
+
contentHash: cloud.data['contentHash'],
|
|
837
|
+
lastSyncedAt: cloud.data['updatedAt'] || cloud.data['createdAt'] || new Date().toISOString(),
|
|
838
|
+
});
|
|
786
839
|
}
|
|
787
840
|
}
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
manifestStore.upsertEntry(trackingKey, {
|
|
793
|
-
fileId: cloud.fileId,
|
|
794
|
-
contentHash: cloud.data['contentHash'],
|
|
795
|
-
lastSyncedAt: cloud.data['updatedAt'] || cloud.data['createdAt'] || new Date().toISOString(),
|
|
796
|
-
});
|
|
797
|
-
}
|
|
798
|
-
else if (!entry && onDisk && cloud) {
|
|
799
|
-
manifestStore.upsertEntry(trackingKey, {
|
|
800
|
-
fileId: cloud.fileId,
|
|
801
|
-
contentHash: cloud.data['contentHash'],
|
|
802
|
-
lastSyncedAt: cloud.data['updatedAt'] || cloud.data['createdAt'] || new Date().toISOString(),
|
|
803
|
-
});
|
|
841
|
+
catch (err) {
|
|
842
|
+
const code = err?.code;
|
|
843
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
844
|
+
console.warn(` [${orgId}] [manifest reconcile] skip ${trackingKey}: ${code ?? 'error'}: ${message}`);
|
|
804
845
|
}
|
|
805
846
|
}
|
|
806
847
|
manifestStore.flush();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@doubling/compound-sync",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.13.0",
|
|
4
4
|
"description": "Bidirectional sync between Compound and local markdown files",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"typecheck": "tsc --noEmit",
|
|
52
52
|
"prepack": "npm run build",
|
|
53
53
|
"test:unit": "node --import tsx --test config.test.ts paths.test.ts storage-helpers.test.ts folder-chain.test.ts team-registry.test.ts auth-persistence.test.ts pre-mint-app-check.test.ts setup-auth-with-pre-mint.test.ts sync-state.test.ts manifest.test.ts push-outcome.test.ts yjs-file-binding.test.ts yjs-binding-state.test.ts files-helpers.test.ts",
|
|
54
|
-
"test:integration": "node --import tsx --test --test-timeout=180000 files-push-create-private.test.ts files-push-create-team.test.ts files-push-update-private.test.ts files-push-binary.test.ts files-push-staleness.test.ts files-push-storage-fail.test.ts files-push-no-content.test.ts files-push-stamps.test.ts files-push-folder.test.ts files-delete.test.ts files-delete-folder.test.ts files-read-blob.test.ts org-sync-local-edit.test.ts org-sync-file-ops.test.ts org-sync-mtime.test.ts org-sync-rename.test.ts org-sync-yjs-pull.test.ts org-sync-yjs-push.test.ts org-sync-cold-start.test.ts org-sync-startup-ordering.test.ts org-sync-manifest-reconcile.test.ts org-sync-special-chars.test.ts org-sync-copy.test.ts two-user-happy-paths.test.ts two-user-yjs-convergence.test.ts files-concurrency-folder-create.test.ts files-concurrency-file-create.test.ts files-concurrency-file-create-sandbox.test.ts files-concurrency-file-delete.test.ts files-concurrency-folder-delete.test.ts",
|
|
54
|
+
"test:integration": "node --import tsx --test --test-timeout=180000 files-push-create-private.test.ts files-push-create-team.test.ts files-push-update-private.test.ts files-push-binary.test.ts files-push-staleness.test.ts files-push-storage-fail.test.ts files-push-no-content.test.ts files-push-stamps.test.ts files-push-folder.test.ts files-delete.test.ts files-delete-folder.test.ts files-read-blob.test.ts org-sync-local-edit.test.ts org-sync-file-ops.test.ts org-sync-mtime.test.ts org-sync-rename.test.ts org-sync-yjs-pull.test.ts org-sync-yjs-push.test.ts org-sync-cold-start.test.ts org-sync-startup-ordering.test.ts org-sync-manifest-reconcile.test.ts org-sync-team-membership-filter.test.ts org-sync-special-chars.test.ts org-sync-copy.test.ts two-user-happy-paths.test.ts two-user-yjs-convergence.test.ts files-concurrency-folder-create.test.ts files-concurrency-file-create.test.ts files-concurrency-file-create-sandbox.test.ts files-concurrency-file-delete.test.ts files-concurrency-folder-delete.test.ts",
|
|
55
55
|
"test:integration:emulator": "USE_EMULATOR=true firebase emulators:exec --only firestore,storage,auth 'npm run test:integration'",
|
|
56
56
|
"test": "npm run test:unit && npm run test:integration"
|
|
57
57
|
},
|