@doubling/compound-sync 1.12.6 → 1.14.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.
Files changed (2) hide show
  1. package/org-sync.js +96 -55
  2. 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
- const teamsSnap = await getDocs(collection(db, `orgs/${orgId}/teams`));
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
- teamRegistry.bootstrap(teamDoc.id, teamDoc.data());
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
- const entry = manifestStore.getEntry(trackingKey);
745
- let localFilePath = localByKey.get(trackingKey) ?? null;
746
- const cloud = cloudByKey.get(trackingKey) ?? null;
747
- if (!localFilePath && cloud) {
748
- localFilePath = localPathForCloudData(cloud.data);
749
- }
750
- const onDisk = !!(localFilePath && fs.existsSync(localFilePath));
751
- if (entry && onDisk && !cloud) {
752
- let localHash;
753
- try {
754
- ({ contentHash: localHash } = readLocalFilePayload(localFilePath));
755
- }
756
- catch {
757
- localHash = null;
758
- }
759
- if (localHash === null) {
760
- console.warn(` [${orgId}] [manifest reconcile] skip unreadable local file ${trackingKey}`);
761
- }
762
- else if (entry.contentHash && localHash !== entry.contentHash) {
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
- else if (!entry && onDisk && !cloud) {
770
- const baselineHash = lastSyncedHash.get(trackingKey);
771
- let localHash;
772
- try {
773
- ({ contentHash: localHash } = readLocalFilePayload(localFilePath));
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
- catch {
776
- localHash = null;
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 (localHash === null) {
779
- console.warn(` [${orgId}] [manifest reconcile] skip unreadable local file ${trackingKey}`);
823
+ else if (entry && !onDisk && cloud) {
824
+ await deleteRemoteReconcile(cloud.data, trackingKey);
780
825
  }
781
- else if (baselineHash && localHash === baselineHash) {
782
- deleteLocalReconcile(localFilePath, null, trackingKey);
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
- await pushLocalFileAtPath(localFilePath);
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
- else if (entry && !onDisk && cloud) {
789
- await deleteRemoteReconcile(cloud.data, trackingKey);
790
- }
791
- else if ((entry || cloud) && onDisk && cloud) {
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.12.6",
3
+ "version": "1.14.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
  },