@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 +187 -3
- package/folder-chain.js +97 -0
- package/package.json +4 -2
- package/storage-helpers.js +10 -0
- package/sync.js +186 -32
- package/team-registry.js +94 -0
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:
|
|
99
|
-
* doc
|
|
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,
|
package/folder-chain.js
ADDED
|
@@ -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.
|
|
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
|
},
|
package/storage-helpers.js
CHANGED
|
@@ -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
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
696
|
-
|
|
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
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
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
|
-
}
|
|
708
|
-
|
|
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
|
}
|
package/team-registry.js
ADDED
|
@@ -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
|
+
}
|