@doubling/compound-sync 1.5.0 → 1.6.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 CHANGED
@@ -39,6 +39,7 @@ import {
39
39
  computeContentHash,
40
40
  toBytes,
41
41
  } from './storage-helpers.js';
42
+ import { planFolderChain, splitPath } from './folder-chain.js';
42
43
 
43
44
  // Re-export pure helpers so callers (and existing tests) can import
44
45
  // either from here or directly from storage-helpers.js.
@@ -92,11 +93,168 @@ export async function readBlobAsText({ storage, orgId, fileId, ext }) {
92
93
  return buf.toString('utf-8');
93
94
  }
94
95
 
96
+ // ---- Folder-doc operations ----
97
+
98
+ // Load all folder docs in this org for the given scope. Used by
99
+ // ensureFolderChainInCloud to find or create folder docs along a
100
+ // file's parent path. The result is small (folder count << file count)
101
+ // so this query is cheap.
102
+ async function loadScopedFolders({ filesRef, scope, teamId, userId }) {
103
+ let q;
104
+ if (scope === 'private') {
105
+ q = query(filesRef,
106
+ where('type', '==', 'folder'),
107
+ where('scope', '==', 'private'),
108
+ where('ownerId', '==', userId));
109
+ } else if (scope === 'team' && teamId) {
110
+ q = query(filesRef,
111
+ where('type', '==', 'folder'),
112
+ where('scope', '==', 'team'),
113
+ where('teamId', '==', teamId));
114
+ } else {
115
+ return [];
116
+ }
117
+ const snap = await getDocs(q);
118
+ const folders = [];
119
+ snap.forEach(d => folders.push({ id: d.id, ...d.data() }));
120
+ return folders;
121
+ }
122
+
123
+ // Apply a planFolderChain result against Firestore. Creates the
124
+ // planned folder docs (rewriting tempId references to real ids) and
125
+ // applies backfills. Returns the resolved leaf folder id (or null if
126
+ // the input plan had no segments).
127
+ async function applyFolderChainPlan({ filesRef, plan, now }) {
128
+ const tempToReal = new Map();
129
+ for (const create of plan.creates) {
130
+ const newRef = doc(filesRef);
131
+ const docData = {
132
+ ...create.doc,
133
+ parentId: tempToReal.get(create.doc.parentId) || create.doc.parentId,
134
+ createdAt: now,
135
+ updatedAt: now,
136
+ };
137
+ await setDoc(newRef, docData);
138
+ tempToReal.set(create.tempId, newRef.id);
139
+ }
140
+ for (const bf of plan.backfills) {
141
+ await updateDoc(doc(filesRef, bf.id), { path: bf.path, updatedAt: now });
142
+ }
143
+ if (plan.leafId == null) return null;
144
+ return tempToReal.get(plan.leafId) || plan.leafId;
145
+ }
146
+
147
+ /**
148
+ * Ensure folder docs exist for every segment of `docPath` and return
149
+ * the id of the leaf folder. Idempotent: existing folder docs are
150
+ * reused; missing ones are created; folder docs missing the `path`
151
+ * field are backfilled.
152
+ *
153
+ * `docPath` is the FULL slash-joined path of the folder itself (NOT a
154
+ * file path). Pass `'A/B'` to ensure both `A` and `A/B` exist; the
155
+ * returned id is for `A/B`.
156
+ *
157
+ * Returns null if `docPath` is empty (root).
158
+ */
159
+ export async function ensureFolderChainInCloud({
160
+ filesRef,
161
+ scope,
162
+ teamId,
163
+ userId,
164
+ docPath,
165
+ now = new Date().toISOString(),
166
+ }) {
167
+ if (scope === 'shared-with-me' || scope === 'shared-by-me') return null;
168
+ const segments = splitPath(docPath);
169
+ if (segments.length === 0) return null;
170
+
171
+ const existingFolders = await loadScopedFolders({ filesRef, scope, teamId, userId });
172
+ const plan = planFolderChain({
173
+ existingFolders,
174
+ segments,
175
+ scope,
176
+ teamId,
177
+ ownerId: scope === 'private' ? userId : null,
178
+ });
179
+ return applyFolderChainPlan({ filesRef, plan, now });
180
+ }
181
+
182
+ /**
183
+ * Find or create the folder doc at `docPath`. Convenience wrapper
184
+ * around ensureFolderChainInCloud for callers that want to upsert a
185
+ * folder explicitly (e.g., chokidar `addDir` events).
186
+ *
187
+ * Returns:
188
+ * { action: 'skipped', reason: 'shared-scope' }
189
+ * { action: 'ensured', folderId }
190
+ */
191
+ export async function pushFolderToCloud({
192
+ filesRef,
193
+ scope,
194
+ teamId,
195
+ userId,
196
+ docPath,
197
+ now = new Date().toISOString(),
198
+ }) {
199
+ if (scope === 'shared-with-me' || scope === 'shared-by-me') {
200
+ return { action: 'skipped', reason: 'shared-scope' };
201
+ }
202
+ const folderId = await ensureFolderChainInCloud({
203
+ filesRef, scope, teamId, userId, docPath, now,
204
+ });
205
+ return { action: 'ensured', folderId };
206
+ }
207
+
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).
213
+ *
214
+ * Returns:
215
+ * { action: 'skipped', reason: 'shared-scope' }
216
+ * { action: 'not-found' }
217
+ * { action: 'deleted', folderId }
218
+ */
219
+ export async function deleteFolderFromCloud({
220
+ filesRef,
221
+ scope,
222
+ teamId,
223
+ userId,
224
+ docPath,
225
+ }) {
226
+ if (scope === 'shared-with-me' || scope === 'shared-by-me') {
227
+ return { action: 'skipped', reason: 'shared-scope' };
228
+ }
229
+ let q;
230
+ if (scope === 'private') {
231
+ q = query(filesRef,
232
+ where('type', '==', 'folder'),
233
+ where('scope', '==', 'private'),
234
+ where('ownerId', '==', userId),
235
+ where('path', '==', docPath));
236
+ } else if (scope === 'team' && teamId) {
237
+ q = query(filesRef,
238
+ where('type', '==', 'folder'),
239
+ where('scope', '==', 'team'),
240
+ where('teamId', '==', teamId),
241
+ where('path', '==', docPath));
242
+ } else {
243
+ return { action: 'not-found' };
244
+ }
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 };
250
+ }
251
+
95
252
  // ---- High-level coordinator ----
96
253
 
97
254
  /**
98
- * Push a file's content to the cloud: upsert the Firestore metadata
99
- * doc and upload the blob.
255
+ * Push a file's content to the cloud: ensure the parent folder chain
256
+ * exists, upsert the Firestore metadata doc (with canonical fields
257
+ * including `type: 'file'`, `name`, `parentId`), and upload the blob.
100
258
  *
101
259
  * Args:
102
260
  * - data: the file's content. String, Buffer, or Uint8Array.
@@ -148,12 +306,32 @@ export async function pushFileToCloud({
148
306
  const ext = extFromPath(docPath);
149
307
  const contentHash = computeContentHash(data);
150
308
  const size = contentByteSize(data);
309
+ const parts = splitPath(docPath);
310
+ const fileName = parts[parts.length - 1] || docPath;
311
+ // Parent folder docs must exist before we write the file doc so the
312
+ // file's `parentId` is a real folder id. ensureFolderChainInCloud
313
+ // is idempotent (existing folder docs are reused; missing ones are
314
+ // created with the canonical shape including `path`).
315
+ const parentFolderPath = parts.slice(0, -1).join('/');
316
+ const parentId = parentFolderPath
317
+ ? await ensureFolderChainInCloud({
318
+ filesRef,
319
+ scope,
320
+ teamId,
321
+ userId,
322
+ docPath: parentFolderPath,
323
+ now,
324
+ })
325
+ : null;
151
326
 
152
327
  if (!snapshot || snapshot.empty) {
153
328
  const newDocRef = doc(filesRef);
154
329
  const fileId = newDocRef.id;
155
330
  const resolvedMime = mimeType || 'text/markdown';
156
331
  const fileData = {
332
+ type: 'file',
333
+ name: fileName,
334
+ parentId,
157
335
  path: docPath,
158
336
  createdAt: now,
159
337
  updatedAt: now,
@@ -178,11 +356,17 @@ export async function pushFileToCloud({
178
356
 
179
357
  const docSnap = snapshot.docs[0];
180
358
  const existing = docSnap.data();
181
- if (existing.contentHash === contentHash) {
359
+ if (existing.contentHash === contentHash
360
+ && existing.type === 'file'
361
+ && existing.name === fileName
362
+ && (existing.parentId == null ? null : existing.parentId) === parentId) {
182
363
  return { action: 'noop', fileId: docSnap.id };
183
364
  }
184
365
  const resolvedMime = mimeType || existing.mimeType || 'text/markdown';
185
366
  await updateDoc(docSnap.ref, {
367
+ type: 'file',
368
+ name: fileName,
369
+ parentId,
186
370
  updatedAt: now,
187
371
  storagePath: storagePathFor(orgId, docSnap.id, ext),
188
372
  contentHash,
@@ -0,0 +1,97 @@
1
+ // Pure planner for folder-doc chains. Given a target path and the set
2
+ // of folder docs that already exist in an org+scope, computes which
3
+ // folder docs would need to be created so that every segment along
4
+ // the path corresponds to a real folder doc with the canonical shape.
5
+ //
6
+ // Used by:
7
+ // - sync/files.js (server-side): before writing a file doc, walk
8
+ // its parent path and ensure folder docs exist. Returns the leaf
9
+ // folder's id (or null for root) so the caller can set
10
+ // `parentId` on the file doc.
11
+ // - sync/sync.js: when a directory appears locally, run the
12
+ // planner with `segments` set to the dir's path and apply.
13
+ //
14
+ // Pure: no I/O. Tests exercise it with synthetic inputs.
15
+
16
+ export function splitPath(p) {
17
+ if (!p) return [];
18
+ return p.split('/').filter(Boolean);
19
+ }
20
+
21
+ function matchesScope(folder, { scope, teamId, ownerId }) {
22
+ if (folder.scope !== scope) return false;
23
+ const folderTeam = folder.teamId == null ? null : folder.teamId;
24
+ const wantTeam = teamId == null ? null : teamId;
25
+ if (folderTeam !== wantTeam) return false;
26
+ const folderOwner = folder.ownerId == null ? null : folder.ownerId;
27
+ const wantOwner = ownerId == null ? null : ownerId;
28
+ if (folderOwner !== wantOwner) return false;
29
+ return true;
30
+ }
31
+
32
+ // Plan the folder chain. Inputs:
33
+ // - existingFolders: array of folder docs from the org's files
34
+ // collection. Each has { id, type:'folder', name, parentId, path?,
35
+ // scope, teamId, ownerId, ... }.
36
+ // - segments: ordered path parts (no filename). For "A/B/file.md"
37
+ // the caller passes ['A', 'B']. For a folder doc itself at path
38
+ // "A/B", the caller passes ['A', 'B'].
39
+ // - scope, teamId, ownerId: the scope shape the folders must match.
40
+ //
41
+ // Returns:
42
+ // - creates: array of { tempId, doc } describing folder docs that
43
+ // would need to be created. Order is root-first; later items may
44
+ // reference earlier items' tempIds in their parentId field.
45
+ // - backfills: existing folder docs missing a `path` field, with
46
+ // the path they should be set to.
47
+ // - leafId: id of the leaf folder (the deepest segment), either a
48
+ // real id (if it existed) or a tempId (if planned for creation).
49
+ // null if `segments` is empty.
50
+ export function planFolderChain({ existingFolders, segments, scope, teamId, ownerId }) {
51
+ const creates = [];
52
+ const backfills = [];
53
+
54
+ if (!segments || segments.length === 0) {
55
+ return { creates, backfills, leafId: null };
56
+ }
57
+
58
+ let parentId = null;
59
+ let pathSoFar = '';
60
+
61
+ for (const name of segments) {
62
+ pathSoFar = pathSoFar ? `${pathSoFar}/${name}` : name;
63
+
64
+ const existing = (existingFolders || []).find(f =>
65
+ f && f.type === 'folder'
66
+ && f.name === name
67
+ && (f.parentId == null ? null : f.parentId) === parentId
68
+ && matchesScope(f, { scope, teamId, ownerId })
69
+ );
70
+
71
+ if (existing) {
72
+ if (existing.path !== pathSoFar) {
73
+ backfills.push({ id: existing.id, path: pathSoFar });
74
+ }
75
+ parentId = existing.id;
76
+ continue;
77
+ }
78
+
79
+ const tempId = `__plan_folder_${creates.length}`;
80
+ creates.push({
81
+ tempId,
82
+ doc: {
83
+ type: 'folder',
84
+ name,
85
+ parentId,
86
+ path: pathSoFar,
87
+ scope,
88
+ teamId: scope === 'team' ? (teamId == null ? null : teamId) : null,
89
+ ownerId: scope === 'private' ? (ownerId == null ? null : ownerId) : null,
90
+ sharedWith: [],
91
+ },
92
+ });
93
+ parentId = tempId;
94
+ }
95
+
96
+ return { creates, backfills, leafId: parentId };
97
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doubling/compound-sync",
3
- "version": "1.5.0",
3
+ "version": "1.6.1",
4
4
  "description": "Bidirectional sync between Compound and local markdown files",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,12 +11,14 @@
11
11
  "config.js",
12
12
  "paths.js",
13
13
  "files.js",
14
+ "folder-chain.js",
14
15
  "storage-helpers.js",
16
+ "team-registry.js",
15
17
  "README.md"
16
18
  ],
17
19
  "scripts": {
18
20
  "sync": "node sync.js",
19
- "test:unit": "node --test config.test.js paths.test.js storage-helpers.test.js",
21
+ "test:unit": "node --test config.test.js paths.test.js storage-helpers.test.js folder-chain.test.js team-registry.test.js",
20
22
  "test:integration": "firebase emulators:exec --only firestore,storage 'node --test files.test.js'",
21
23
  "test": "npm run test:unit && npm run test:integration"
22
24
  },
@@ -80,6 +80,16 @@ const EXT_TO_MIME = {
80
80
  webm: 'video/webm',
81
81
  mov: 'video/quicktime',
82
82
  zip: 'application/zip',
83
+ // Office Open XML (modern Office). The full type strings are
84
+ // mandatory: tools that dispatch by mime (Excel, third-party readers)
85
+ // ignore alternative spellings.
86
+ xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
87
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
88
+ pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
89
+ // Legacy Office formats (pre-2007 binary containers).
90
+ xls: 'application/vnd.ms-excel',
91
+ doc: 'application/msword',
92
+ ppt: 'application/vnd.ms-powerpoint',
83
93
  };
84
94
 
85
95
  export function mimeTypeFromExt(ext) {
package/sync.js CHANGED
@@ -35,6 +35,8 @@ import { fileURLToPath } from 'url';
35
35
  import {
36
36
  pushFileToCloud,
37
37
  deleteFileFromCloud,
38
+ pushFolderToCloud,
39
+ deleteFolderFromCloud,
38
40
  readBlob,
39
41
  readBlobAsText,
40
42
  extFromPath,
@@ -52,6 +54,7 @@ import {
52
54
  SHARED_WITH_ME_FOLDER,
53
55
  SHARED_BY_ME_FOLDER,
54
56
  } from './paths.js';
57
+ import { TeamRegistry } from './team-registry.js';
55
58
 
56
59
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
57
60
 
@@ -566,25 +569,34 @@ async function setupOrgSync({ orgId, localPath: rawLocalPath }) {
566
569
  const LOCAL_PATH = expandHome(rawLocalPath);
567
570
  const filesRef = collection(db, `orgs/${orgId}/files`);
568
571
 
569
- const teams = new Map();
570
- const teamIdsByName = new Map();
571
572
  const lastKnownUpdate = new Map();
572
573
  const suppressLocal = new Set();
573
-
574
- async function loadTeams() {
575
- const snapshot = await getDocs(collection(db, `orgs/${orgId}/teams`));
576
- snapshot.forEach(d => {
577
- const data = d.data();
578
- teams.set(d.id, data);
579
- teamIdsByName.set(data.name, d.id);
580
- });
574
+ // Per-team Firestore-listener unsubscribe handles, keyed by teamId.
575
+ // The TeamRegistry's onSetup populates this; onTeardown calls and
576
+ // drops the entry so a removed teamspace stops pulling files.
577
+ const teamUnsubscribers = new Map();
578
+ // Hoisted forward-decl: setupTeam closes over handleFirestoreChange,
579
+ // which is defined later in this function. Wired below.
580
+ let setupTeam = () => {};
581
+ function teardownTeam(teamId, teamData) {
582
+ const unsub = teamUnsubscribers.get(teamId);
583
+ if (unsub) {
584
+ try { unsub(); } catch (e) { /* ignore */ }
585
+ teamUnsubscribers.delete(teamId);
586
+ const folder = teamData && teamData.name ? teamFolderName(teamData.name) : teamId;
587
+ console.log(` [${orgId}] Stopped listening: ${folder}`);
588
+ }
581
589
  }
582
-
583
- await loadTeams();
590
+ const teamRegistry = new TeamRegistry({
591
+ onSetup: (teamId, teamData) => setupTeam(teamId, teamData),
592
+ onTeardown: teardownTeam,
593
+ });
594
+ // scopeFromLocalPath expects a Map<teamName, teamId>; the registry
595
+ // exposes one matching that shape.
596
+ const teamIdsByName = teamRegistry.teamIdsByName;
584
597
 
585
598
  console.log('');
586
599
  console.log(` [${orgId}] Local: ${LOCAL_PATH}`);
587
- console.log(` [${orgId}] Teams: ${[...teams.values()].map(t => t.name).join(', ') || 'none'}`);
588
600
 
589
601
  function ensureDir(filePath) {
590
602
  const dir = path.dirname(filePath);
@@ -619,6 +631,8 @@ async function setupOrgSync({ orgId, localPath: rawLocalPath }) {
619
631
  }
620
632
  }
621
633
 
634
+ // Static (user-level) folders. Team folders are created per-team by
635
+ // setupTeam below as soon as the registry sees each teamspace.
622
636
  function ensureFolderStructure() {
623
637
  const privatePath = path.join(LOCAL_PATH, PRIVATE_FOLDER);
624
638
  if (!fs.existsSync(privatePath)) fs.mkdirSync(privatePath, { recursive: true });
@@ -628,11 +642,6 @@ async function setupOrgSync({ orgId, localPath: rawLocalPath }) {
628
642
 
629
643
  const sharedByMePath = path.join(LOCAL_PATH, SHARED_BY_ME_FOLDER);
630
644
  if (!fs.existsSync(sharedByMePath)) fs.mkdirSync(sharedByMePath, { recursive: true });
631
-
632
- for (const [, teamData] of teams) {
633
- const teamPath = path.join(LOCAL_PATH, teamFolderName(teamData.name));
634
- if (!fs.existsSync(teamPath)) fs.mkdirSync(teamPath, { recursive: true });
635
- }
636
645
  }
637
646
 
638
647
  ensureFolderStructure();
@@ -644,9 +653,26 @@ async function setupOrgSync({ orgId, localPath: rawLocalPath }) {
644
653
 
645
654
  const localFilePath = getLocalPath(data);
646
655
  const trackingKey = `${data.scope || 'team'}:${docPath}`;
656
+ const isFolder = data.type === 'folder';
647
657
 
648
658
  if (change.type === 'removed') {
649
- if (fs.existsSync(localFilePath)) {
659
+ if (isFolder) {
660
+ // Best-effort directory removal. The daemon's own file-delete
661
+ // events should have cleared all children by the time we get
662
+ // here; if not, rmdirSync throws ENOTEMPTY and we keep the
663
+ // directory rather than risk losing data the user hasn't seen.
664
+ try {
665
+ suppressLocal.add(localFilePath);
666
+ fs.rmdirSync(localFilePath);
667
+ console.log(` [${orgId}] [firestore → local] RMDIR ${docPath}`);
668
+ setTimeout(() => suppressLocal.delete(localFilePath), 1000);
669
+ } catch (err) {
670
+ suppressLocal.delete(localFilePath);
671
+ if (err.code !== 'ENOENT' && err.code !== 'ENOTEMPTY') {
672
+ console.error(` [${orgId}] [firestore → local] RMDIR ${docPath} failed: ${err.message}`);
673
+ }
674
+ }
675
+ } else if (fs.existsSync(localFilePath)) {
650
676
  console.log(` [${orgId}] [firestore → local] DELETE ${docPath}`);
651
677
  suppressLocal.add(localFilePath);
652
678
  fs.unlinkSync(localFilePath);
@@ -660,6 +686,19 @@ async function setupOrgSync({ orgId, localPath: rawLocalPath }) {
660
686
  const lastKnown = lastKnownUpdate.get(trackingKey);
661
687
  if (lastKnown && lastKnown >= remoteUpdated) return;
662
688
 
689
+ if (isFolder) {
690
+ // Folder docs mirror to a local directory. No blob, no content
691
+ // comparison; if the directory doesn't exist yet, create it.
692
+ if (!fs.existsSync(localFilePath)) {
693
+ suppressLocal.add(localFilePath);
694
+ fs.mkdirSync(localFilePath, { recursive: true });
695
+ console.log(` [${orgId}] [firestore → local] MKDIR ${docPath}`);
696
+ setTimeout(() => suppressLocal.delete(localFilePath), 1000);
697
+ }
698
+ lastKnownUpdate.set(trackingKey, remoteUpdated);
699
+ return;
700
+ }
701
+
663
702
  if (fs.existsSync(localFilePath)) {
664
703
  const localMtime = fs.statSync(localFilePath).mtime.toISOString();
665
704
  if (localMtime > remoteUpdated) return;
@@ -692,21 +731,58 @@ async function setupOrgSync({ orgId, localPath: rawLocalPath }) {
692
731
  setTimeout(() => suppressLocal.delete(localFilePath), 1000);
693
732
  }
694
733
 
695
- function startFirestoreListeners() {
696
- console.log(` [${orgId}] Starting Firestore listeners...`);
734
+ // Set up per-team resources: mkdir the local folder, register the
735
+ // Firestore listener for that team's files. Wired into the
736
+ // TeamRegistry; called once per teamspace as it appears.
737
+ setupTeam = (teamId, teamData) => {
738
+ const teamFolder = teamFolderName(teamData.name);
739
+ const teamPath = path.join(LOCAL_PATH, teamFolder);
740
+ if (!fs.existsSync(teamPath)) fs.mkdirSync(teamPath, { recursive: true });
741
+
742
+ const q = query(filesRef, where('scope', '==', 'team'), where('teamId', '==', teamId));
743
+ const unsubscribe = onSnapshot(q, snapshot => {
744
+ snapshot.docChanges().forEach(change => {
745
+ handleFirestoreChange(change, data =>
746
+ path.join(LOCAL_PATH, teamFolder, data.path),
747
+ ).catch(err => console.error(` [${orgId}] Firestore change handler error:`, err && err.message));
748
+ });
749
+ }, err => console.error(` [${orgId}] Team "${teamFolder}" listener error:`, err));
697
750
 
698
- for (const [teamId, teamData] of teams) {
699
- const teamFolder = teamFolderName(teamData.name);
700
- const q = query(filesRef, where('scope', '==', 'team'), where('teamId', '==', teamId));
701
- onSnapshot(q, snapshot => {
702
- snapshot.docChanges().forEach(change => {
703
- handleFirestoreChange(change, data =>
704
- path.join(LOCAL_PATH, teamFolder, data.path),
705
- ).catch(err => console.error(` [${orgId}] Firestore change handler error:`, err && err.message));
751
+ teamUnsubscribers.set(teamId, unsubscribe);
752
+ console.log(` [${orgId}] Listening: ${teamFolder}`);
753
+ };
754
+
755
+ // Live listener on the teams collection. Initial snapshot bootstraps
756
+ // the known teamspaces; subsequent changes pick up new/renamed/
757
+ // removed teamspaces without restarting the daemon. We await the
758
+ // first snapshot so the local watcher sees a populated teamIdsByName
759
+ // before it tries to route any addDir/add events.
760
+ await new Promise((resolve, reject) => {
761
+ let firstSnapshotResolved = false;
762
+ onSnapshot(collection(db, `orgs/${orgId}/teams`), (snapshot) => {
763
+ snapshot.docChanges().forEach((change) => {
764
+ teamRegistry.applyChange({
765
+ type: change.type,
766
+ id: change.doc.id,
767
+ data: change.doc.data(),
706
768
  });
707
- }, err => console.error(` [${orgId}] Team "${teamFolder}" listener error:`, err));
708
- console.log(` [${orgId}] Listening: ${teamFolder}`);
709
- }
769
+ });
770
+ if (!firstSnapshotResolved) {
771
+ firstSnapshotResolved = true;
772
+ console.log(` [${orgId}] Teams: ${teamRegistry.names().join(', ') || 'none'}`);
773
+ resolve();
774
+ }
775
+ }, (err) => {
776
+ console.error(` [${orgId}] Teams listener error:`, err);
777
+ if (!firstSnapshotResolved) {
778
+ firstSnapshotResolved = true;
779
+ reject(err);
780
+ }
781
+ });
782
+ });
783
+
784
+ function startFirestoreListeners() {
785
+ console.log(` [${orgId}] Starting Firestore listeners...`);
710
786
 
711
787
  if (USER_ID) {
712
788
  const privateQ = query(filesRef, where('scope', '==', 'private'), where('ownerId', '==', USER_ID));
@@ -791,6 +867,64 @@ async function setupOrgSync({ orgId, localPath: rawLocalPath }) {
791
867
  }
792
868
  }
793
869
 
870
+ // Determine whether a local path corresponds to a syncable directory.
871
+ // Excludes the scope-root directories themselves (Private, Shared
872
+ // with Me, Shared by Me, and the per-team roots) since those are
873
+ // managed by ensureFolderStructure, not the user.
874
+ function isSyncableLocalDir(rel) {
875
+ if (!rel) return false;
876
+ if (rel === PRIVATE_FOLDER || rel === SHARED_WITH_ME_FOLDER || rel === SHARED_BY_ME_FOLDER) {
877
+ return false;
878
+ }
879
+ if (rel.startsWith(SHARED_WITH_ME_FOLDER + path.sep)) return false;
880
+ if (rel.startsWith(SHARED_BY_ME_FOLDER + path.sep)) return false;
881
+ const parts = rel.split(path.sep);
882
+ // A bare team root (e.g. "Engineering Teamspace") corresponds to
883
+ // the scope root, not a subdirectory. Skip these.
884
+ if (parts.length === 1 && parts[0].endsWith(' Teamspace')) return false;
885
+ return true;
886
+ }
887
+
888
+ async function pushDirToFirestore(dirPath) {
889
+ const { scope, teamId, docPath } = scopeFromLocalPath(dirPath, LOCAL_PATH, teamIdsByName);
890
+ if (!docPath) return; // scope root
891
+ const trackingKey = `${scope}:${docPath}`;
892
+ const now = new Date().toISOString();
893
+ lastKnownUpdate.set(trackingKey, now);
894
+
895
+ const result = await pushFolderToCloud({
896
+ filesRef,
897
+ scope,
898
+ teamId,
899
+ userId: USER_ID,
900
+ docPath,
901
+ now,
902
+ });
903
+
904
+ if (result.action === 'ensured' && result.folderId) {
905
+ console.log(` [${orgId}] [local → firestore] FOLDER ${docPath} (${scope})`);
906
+ }
907
+ }
908
+
909
+ async function deleteDirFromFirestore(dirPath) {
910
+ const { scope, teamId, docPath } = scopeFromLocalPath(dirPath, LOCAL_PATH, teamIdsByName);
911
+ if (!docPath) return;
912
+ const trackingKey = `${scope}:${docPath}`;
913
+ lastKnownUpdate.delete(trackingKey);
914
+
915
+ const result = await deleteFolderFromCloud({
916
+ filesRef,
917
+ scope,
918
+ teamId,
919
+ userId: USER_ID,
920
+ docPath,
921
+ });
922
+
923
+ if (result.action === 'deleted') {
924
+ console.log(` [${orgId}] [local → firestore] RMFOLDER ${docPath} (${scope})`);
925
+ }
926
+ }
927
+
794
928
  function handleLocalChange(filePath) {
795
929
  if (suppressLocal.has(filePath)) return;
796
930
 
@@ -823,6 +957,24 @@ async function setupOrgSync({ orgId, localPath: rawLocalPath }) {
823
957
  );
824
958
  }
825
959
 
960
+ function handleLocalDirAdded(dirPath) {
961
+ if (suppressLocal.has(dirPath)) return;
962
+ const rel = path.relative(LOCAL_PATH, dirPath);
963
+ if (!isSyncableLocalDir(rel)) return;
964
+ pushDirToFirestore(dirPath).catch(err =>
965
+ console.error(` [${orgId}] Folder push error for ${rel}: ${err.message}`)
966
+ );
967
+ }
968
+
969
+ function handleLocalDirRemoved(dirPath) {
970
+ if (suppressLocal.has(dirPath)) return;
971
+ const rel = path.relative(LOCAL_PATH, dirPath);
972
+ if (!isSyncableLocalDir(rel)) return;
973
+ deleteDirFromFirestore(dirPath).catch(err =>
974
+ console.error(` [${orgId}] Folder delete error for ${rel}: ${err.message}`)
975
+ );
976
+ }
977
+
826
978
  function startLocalWatcher() {
827
979
  console.log(` [${orgId}] Watching local files...`);
828
980
 
@@ -849,6 +1001,8 @@ async function setupOrgSync({ orgId, localPath: rawLocalPath }) {
849
1001
  if (suppressLocal.has(filePath)) return;
850
1002
  deleteFromFirestore(filePath).catch(err => console.error(` [${orgId}] Delete error: ${err.message}`));
851
1003
  });
1004
+ watcher.on('addDir', dirPath => handleLocalDirAdded(dirPath));
1005
+ watcher.on('unlinkDir', dirPath => handleLocalDirRemoved(dirPath));
852
1006
 
853
1007
  watcher.on('ready', () => console.log(` [${orgId}] Local watcher ready.`));
854
1008
  }
@@ -0,0 +1,94 @@
1
+ // Pure state machine for tracking which teamspaces the sync daemon
2
+ // is currently watching. The daemon used to enumerate teams once at
3
+ // startup and statically register a Firestore listener for each; this
4
+ // missed any teamspaces created later (e.g. via the web's "+ New
5
+ // Teamspace" button) until the daemon was restarted.
6
+ //
7
+ // TeamRegistry owns the in-memory map of teamId -> teamData and
8
+ // dispatches setup/teardown callbacks when the desired team set
9
+ // changes. The actual side effects (mkdir, onSnapshot, unsubscribe)
10
+ // live in the caller and are passed in as `onSetup` / `onTeardown`.
11
+ // This keeps the dispatch logic testable without mocking Firestore.
12
+ //
13
+ // Usage:
14
+ // const registry = new TeamRegistry({
15
+ // onSetup: (teamId, teamData) => { ... },
16
+ // onTeardown: (teamId, teamData) => { ... },
17
+ // });
18
+ // // For each docChange from onSnapshot on the teams collection:
19
+ // registry.applyChange({ type: 'added', id, data });
20
+
21
+ export class TeamRegistry {
22
+ constructor({ onSetup, onTeardown }) {
23
+ this.teams = new Map(); // teamId -> teamData
24
+ this.teamIdsByName = new Map(); // teamName -> teamId
25
+ this.onSetup = onSetup;
26
+ this.onTeardown = onTeardown;
27
+ }
28
+
29
+ // Idempotently register a team. Safe to call with the same team
30
+ // multiple times; only the first call triggers onSetup.
31
+ add(teamId, teamData) {
32
+ if (this.teams.has(teamId)) return false;
33
+ this.teams.set(teamId, teamData);
34
+ if (teamData && teamData.name) {
35
+ this.teamIdsByName.set(teamData.name, teamId);
36
+ }
37
+ this.onSetup(teamId, teamData);
38
+ return true;
39
+ }
40
+
41
+ // Update a team's metadata. If the team's name changed, treat as a
42
+ // rename: teardown the old listener, register a new one (which
43
+ // creates a new local folder under the new name). The old folder
44
+ // is intentionally NOT renamed or removed; the user can clean it
45
+ // up manually. This is conservative; a rename-aware implementation
46
+ // is a follow-up.
47
+ modify(teamId, teamData) {
48
+ const prev = this.teams.get(teamId);
49
+ if (!prev) {
50
+ // Treat modify-of-unknown as an add. Firestore emits both
51
+ // 'added' and 'modified' for the same doc under certain
52
+ // listener-restart scenarios, so we want this path resilient.
53
+ return this.add(teamId, teamData);
54
+ }
55
+ this.teams.set(teamId, teamData);
56
+ const renamed = prev.name !== (teamData && teamData.name);
57
+ if (renamed) {
58
+ if (prev.name) this.teamIdsByName.delete(prev.name);
59
+ if (teamData && teamData.name) {
60
+ this.teamIdsByName.set(teamData.name, teamId);
61
+ }
62
+ this.onTeardown(teamId, prev);
63
+ this.onSetup(teamId, teamData);
64
+ }
65
+ return renamed;
66
+ }
67
+
68
+ // Remove a team (e.g., user lost membership). Tears down the
69
+ // listener; leaves any local folder alone.
70
+ remove(teamId) {
71
+ const prev = this.teams.get(teamId);
72
+ if (!prev) return false;
73
+ this.teams.delete(teamId);
74
+ if (prev.name) this.teamIdsByName.delete(prev.name);
75
+ this.onTeardown(teamId, prev);
76
+ return true;
77
+ }
78
+
79
+ // Convenience: dispatch a Firestore docChange into add/modify/remove.
80
+ applyChange({ type, id, data }) {
81
+ if (type === 'added') return this.add(id, data);
82
+ if (type === 'modified') return this.modify(id, data);
83
+ if (type === 'removed') return this.remove(id);
84
+ return false;
85
+ }
86
+
87
+ // Read-only accessors for the daemon's existing call sites.
88
+ has(teamId) { return this.teams.has(teamId); }
89
+ get(teamId) { return this.teams.get(teamId); }
90
+ getIdByName(teamName) { return this.teamIdsByName.get(teamName); }
91
+ size() { return this.teams.size; }
92
+ entries() { return this.teams.entries(); }
93
+ names() { return [...this.teams.values()].map(t => t.name).filter(Boolean); }
94
+ }