@doubling/compound-sync 1.4.1 → 1.6.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/files.js ADDED
@@ -0,0 +1,438 @@
1
+ // Cloud-side file persistence for the sync daemon and integration tests.
2
+ //
3
+ // Each function takes its dependencies (filesRef, storage, orgId, etc.)
4
+ // as parameters so the logic is importable into a test harness that
5
+ // provides emulator-backed Firebase handles. The sync daemon (sync.js)
6
+ // is the production caller and supplies real Firebase handles.
7
+ //
8
+ // A "file" is a Firestore metadata document at
9
+ // orgs/{orgId}/files/{fileId}
10
+ // plus a Storage blob at
11
+ // orgs/{orgId}/files/{fileId}/current.{ext}
12
+ // where {ext} is derived from the file's path so Storage can serve the
13
+ // correct Content-Type header automatically.
14
+ //
15
+ // Ordering on writes:
16
+ // 1. Write/update the Firestore metadata document.
17
+ // (Storage rules require the metadata doc to exist before they
18
+ // authorize the blob upload.)
19
+ // 2. Upload the blob to Cloud Storage.
20
+ // Storage failures are returned in the result, not thrown, so the
21
+ // caller can decide whether to surface as a soft warning and retry.
22
+ //
23
+ // Ordering on deletes:
24
+ // 1. Delete the Storage blob (while metadata still exists so the
25
+ // Storage rule can authorize).
26
+ // 2. Delete the Firestore metadata document.
27
+
28
+ import {
29
+ doc, getDocs, setDoc, updateDoc, deleteDoc, query, where,
30
+ } from 'firebase/firestore';
31
+ import {
32
+ ref as storageRef, uploadBytes, deleteObject, getBytes,
33
+ } from 'firebase/storage';
34
+
35
+ import {
36
+ storagePathFor,
37
+ extFromPath,
38
+ contentByteSize,
39
+ computeContentHash,
40
+ toBytes,
41
+ } from './storage-helpers.js';
42
+ import { planFolderChain, splitPath } from './folder-chain.js';
43
+
44
+ // Re-export pure helpers so callers (and existing tests) can import
45
+ // either from here or directly from storage-helpers.js.
46
+ export {
47
+ storagePathFor,
48
+ extFromPath,
49
+ contentByteSize,
50
+ computeContentHash,
51
+ isTextMimeType,
52
+ mimeTypeFromExt,
53
+ } from './storage-helpers.js';
54
+
55
+ // ---- Storage primitives ----
56
+
57
+ export async function uploadBlob({ storage, orgId, fileId, ext, data, mimeType }) {
58
+ const blobPath = storagePathFor(orgId, fileId, ext);
59
+ const payload = toBytes(data);
60
+ await uploadBytes(storageRef(storage, blobPath), payload, {
61
+ contentType: mimeType || 'application/octet-stream',
62
+ });
63
+ }
64
+
65
+ export async function deleteBlob({ storage, orgId, fileId, ext }) {
66
+ const blobPath = storagePathFor(orgId, fileId, ext);
67
+ try {
68
+ await deleteObject(storageRef(storage, blobPath));
69
+ } catch (err) {
70
+ if (err && err.code === 'storage/object-not-found') {
71
+ return; // tolerate missing blob
72
+ }
73
+ throw err;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Read a blob from Cloud Storage as raw bytes. Throws on any failure
79
+ * (missing blob, permission error, network).
80
+ */
81
+ export async function readBlob({ storage, orgId, fileId, ext }) {
82
+ const blobPath = storagePathFor(orgId, fileId, ext);
83
+ const buf = await getBytes(storageRef(storage, blobPath));
84
+ return Buffer.from(buf);
85
+ }
86
+
87
+ /**
88
+ * Read a blob and decode as UTF-8 text. Convenience for text/markdown
89
+ * callers.
90
+ */
91
+ export async function readBlobAsText({ storage, orgId, fileId, ext }) {
92
+ const buf = await readBlob({ storage, orgId, fileId, ext });
93
+ return buf.toString('utf-8');
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
+
252
+ // ---- High-level coordinator ----
253
+
254
+ /**
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.
258
+ *
259
+ * Args:
260
+ * - data: the file's content. String, Buffer, or Uint8Array.
261
+ * - mimeType: the file's mime type. Stored on the metadata doc and
262
+ * used as the blob's Content-Type. Optional; if absent
263
+ * for an existing file the existing mime is preserved,
264
+ * and for new files defaults to 'text/markdown' to keep
265
+ * legacy callers working.
266
+ *
267
+ * Returns one of:
268
+ * { action: 'skipped', reason: 'shared-scope' }
269
+ * { action: 'noop', fileId }
270
+ * { action: 'created', fileId, fileData, storageError? }
271
+ * { action: 'updated', fileId, storageError? }
272
+ */
273
+ export async function pushFileToCloud({
274
+ filesRef,
275
+ storage,
276
+ orgId,
277
+ userId,
278
+ scope,
279
+ teamId,
280
+ docPath,
281
+ data,
282
+ mimeType,
283
+ now = new Date().toISOString(),
284
+ }) {
285
+ if (scope === 'shared-with-me' || scope === 'shared-by-me') {
286
+ return { action: 'skipped', reason: 'shared-scope' };
287
+ }
288
+
289
+ let snapshot;
290
+ if (scope === 'private') {
291
+ snapshot = await getDocs(query(filesRef,
292
+ where('scope', '==', 'private'),
293
+ where('ownerId', '==', userId),
294
+ where('path', '==', docPath)
295
+ ));
296
+ } else if (scope === 'team' && teamId) {
297
+ snapshot = await getDocs(query(filesRef,
298
+ where('scope', '==', 'team'),
299
+ where('teamId', '==', teamId),
300
+ where('path', '==', docPath)
301
+ ));
302
+ } else {
303
+ snapshot = await getDocs(query(filesRef, where('path', '==', docPath)));
304
+ }
305
+
306
+ const ext = extFromPath(docPath);
307
+ const contentHash = computeContentHash(data);
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;
326
+
327
+ if (!snapshot || snapshot.empty) {
328
+ const newDocRef = doc(filesRef);
329
+ const fileId = newDocRef.id;
330
+ const resolvedMime = mimeType || 'text/markdown';
331
+ const fileData = {
332
+ type: 'file',
333
+ name: fileName,
334
+ parentId,
335
+ path: docPath,
336
+ createdAt: now,
337
+ updatedAt: now,
338
+ scope,
339
+ teamId: scope === 'team' ? (teamId || null) : null,
340
+ ownerId: scope === 'private' ? userId : null,
341
+ sharedWith: [],
342
+ storagePath: storagePathFor(orgId, fileId, ext),
343
+ contentHash,
344
+ size,
345
+ mimeType: resolvedMime,
346
+ };
347
+ await setDoc(newDocRef, fileData);
348
+ let storageError = null;
349
+ try {
350
+ await uploadBlob({ storage, orgId, fileId, ext, data, mimeType: resolvedMime });
351
+ } catch (err) {
352
+ storageError = err;
353
+ }
354
+ return { action: 'created', fileId, fileData, storageError };
355
+ }
356
+
357
+ const docSnap = snapshot.docs[0];
358
+ const existing = docSnap.data();
359
+ if (existing.contentHash === contentHash
360
+ && existing.type === 'file'
361
+ && existing.name === fileName
362
+ && (existing.parentId == null ? null : existing.parentId) === parentId) {
363
+ return { action: 'noop', fileId: docSnap.id };
364
+ }
365
+ const resolvedMime = mimeType || existing.mimeType || 'text/markdown';
366
+ await updateDoc(docSnap.ref, {
367
+ type: 'file',
368
+ name: fileName,
369
+ parentId,
370
+ updatedAt: now,
371
+ storagePath: storagePathFor(orgId, docSnap.id, ext),
372
+ contentHash,
373
+ size,
374
+ mimeType: resolvedMime,
375
+ });
376
+ let storageError = null;
377
+ try {
378
+ await uploadBlob({ storage, orgId, fileId: docSnap.id, ext, data, mimeType: resolvedMime });
379
+ } catch (err) {
380
+ storageError = err;
381
+ }
382
+ return { action: 'updated', fileId: docSnap.id, storageError };
383
+ }
384
+
385
+ /**
386
+ * Delete a file from Firestore and the corresponding Storage blob.
387
+ *
388
+ * Returns one of:
389
+ * { action: 'skipped', reason: 'shared-scope' }
390
+ * { action: 'not-found' }
391
+ * { action: 'deleted', fileId }
392
+ *
393
+ * Blob deletion tolerates `storage/object-not-found`.
394
+ */
395
+ export async function deleteFileFromCloud({
396
+ filesRef,
397
+ storage,
398
+ orgId,
399
+ userId,
400
+ scope,
401
+ teamId,
402
+ docPath,
403
+ }) {
404
+ if (scope === 'shared-with-me' || scope === 'shared-by-me') {
405
+ return { action: 'skipped', reason: 'shared-scope' };
406
+ }
407
+
408
+ let snapshot;
409
+ if (scope === 'private') {
410
+ snapshot = await getDocs(query(
411
+ filesRef,
412
+ where('scope', '==', 'private'),
413
+ where('ownerId', '==', userId),
414
+ where('path', '==', docPath)
415
+ ));
416
+ } else if (scope === 'team' && teamId) {
417
+ snapshot = await getDocs(query(
418
+ filesRef,
419
+ where('scope', '==', 'team'),
420
+ where('teamId', '==', teamId),
421
+ where('path', '==', docPath)
422
+ ));
423
+ } else {
424
+ return { action: 'not-found' };
425
+ }
426
+
427
+ if (snapshot.empty) {
428
+ return { action: 'not-found' };
429
+ }
430
+ const docSnap = snapshot.docs[0];
431
+ const fileId = docSnap.id;
432
+ const ext = extFromPath(docPath);
433
+ // Delete the Storage blob FIRST while the metadata doc still exists,
434
+ // because the Storage rule's authorization check looks up the doc.
435
+ await deleteBlob({ storage, orgId, fileId, ext });
436
+ await deleteDoc(docSnap.ref);
437
+ return { action: 'deleted', fileId };
438
+ }
@@ -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.4.1",
3
+ "version": "1.6.0",
4
4
  "description": "Bidirectional sync between Compound and local markdown files",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,13 +10,16 @@
10
10
  "sync.js",
11
11
  "config.js",
12
12
  "paths.js",
13
- "dual-write.js",
13
+ "files.js",
14
+ "folder-chain.js",
15
+ "storage-helpers.js",
16
+ "team-registry.js",
14
17
  "README.md"
15
18
  ],
16
19
  "scripts": {
17
20
  "sync": "node sync.js",
18
- "test:unit": "node --test config.test.js paths.test.js",
19
- "test:integration": "firebase emulators:exec --only firestore,storage 'node --test dual-write.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",
22
+ "test:integration": "firebase emulators:exec --only firestore,storage 'node --test files.test.js'",
20
23
  "test": "npm run test:unit && npm run test:integration"
21
24
  },
22
25
  "dependencies": {
@@ -0,0 +1,88 @@
1
+ // Pure Node-side storage helpers. No Firebase imports.
2
+ //
3
+ // Mirrors public/js/storage-helpers.js but uses Node-native APIs
4
+ // (Buffer, crypto.createHash) instead of the browser equivalents
5
+ // (Uint8Array, crypto.subtle). Function names and return types
6
+ // match so that callers on either side can be reasoned about
7
+ // uniformly.
8
+ //
9
+ // Storage layout convention:
10
+ // orgs/{orgId}/files/{fileId}/current.{ext}
11
+
12
+ import crypto from 'crypto';
13
+
14
+ export function extFromPath(path) {
15
+ const name = (((path == null ? '' : path) + '').split('/').pop()) || '';
16
+ const idx = name.lastIndexOf('.');
17
+ if (idx <= 0 || idx === name.length - 1) return 'bin';
18
+ return name.slice(idx + 1).toLowerCase();
19
+ }
20
+
21
+ export function storagePathFor(orgId, fileId, ext) {
22
+ return `orgs/${orgId}/files/${fileId}/current.${ext || 'bin'}`;
23
+ }
24
+
25
+ export function contentByteSize(data) {
26
+ if (data == null) return 0;
27
+ if (typeof data === 'string') return Buffer.byteLength(data, 'utf-8');
28
+ if (Buffer.isBuffer(data)) return data.length;
29
+ if (data instanceof Uint8Array) return data.byteLength;
30
+ if (data instanceof ArrayBuffer) return data.byteLength;
31
+ return Buffer.byteLength(String(data), 'utf-8');
32
+ }
33
+
34
+ export function computeContentHash(data) {
35
+ const buf = toBytes(data);
36
+ return crypto.createHash('sha256').update(buf).digest('hex');
37
+ }
38
+
39
+ export function toBytes(data) {
40
+ if (data == null) return Buffer.alloc(0);
41
+ if (Buffer.isBuffer(data)) return data;
42
+ if (data instanceof Uint8Array) return Buffer.from(data);
43
+ if (data instanceof ArrayBuffer) return Buffer.from(data);
44
+ return Buffer.from(String(data), 'utf-8');
45
+ }
46
+
47
+ // Files with these mime types are treated as inline-readable text by
48
+ // the sync daemon: read with utf-8 encoding from disk, written with
49
+ // utf-8 encoding to disk. Everything else is treated as binary and
50
+ // transferred as raw bytes.
51
+ //
52
+ // Missing mime type is treated as text for backward compatibility
53
+ // with markdown files written before mimeType was tracked.
54
+ export function isTextMimeType(mimeType) {
55
+ if (mimeType == null || mimeType === '') return true;
56
+ return typeof mimeType === 'string' && mimeType.startsWith('text/');
57
+ }
58
+
59
+ const EXT_TO_MIME = {
60
+ md: 'text/markdown',
61
+ markdown: 'text/markdown',
62
+ txt: 'text/plain',
63
+ csv: 'text/csv',
64
+ html: 'text/html',
65
+ htm: 'text/html',
66
+ css: 'text/css',
67
+ js: 'text/javascript',
68
+ json: 'application/json',
69
+ pdf: 'application/pdf',
70
+ png: 'image/png',
71
+ jpg: 'image/jpeg',
72
+ jpeg: 'image/jpeg',
73
+ gif: 'image/gif',
74
+ webp: 'image/webp',
75
+ svg: 'image/svg+xml',
76
+ bmp: 'image/bmp',
77
+ mp3: 'audio/mpeg',
78
+ wav: 'audio/wav',
79
+ mp4: 'video/mp4',
80
+ webm: 'video/webm',
81
+ mov: 'video/quicktime',
82
+ zip: 'application/zip',
83
+ };
84
+
85
+ export function mimeTypeFromExt(ext) {
86
+ if (ext == null) return 'application/octet-stream';
87
+ return EXT_TO_MIME[String(ext).toLowerCase()] || 'application/octet-stream';
88
+ }
package/sync.js CHANGED
@@ -32,7 +32,20 @@ import os from 'os';
32
32
  import http from 'http';
33
33
  import { exec } from 'child_process';
34
34
  import { fileURLToPath } from 'url';
35
- import { pushFileToCloud, deleteFileFromCloud, readFileContent } from './dual-write.js';
35
+ import {
36
+ pushFileToCloud,
37
+ deleteFileFromCloud,
38
+ pushFolderToCloud,
39
+ deleteFolderFromCloud,
40
+ readBlob,
41
+ readBlobAsText,
42
+ extFromPath,
43
+ isTextMimeType,
44
+ mimeTypeFromExt,
45
+ } from './files.js';
46
+
47
+ // Max file size enforced before upload. Mirrored in storage.rules.
48
+ const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
36
49
  import { resolveConfigPath, loadConfig, saveConfig } from './config.js';
37
50
  import {
38
51
  scopeFromLocalPath,
@@ -41,6 +54,7 @@ import {
41
54
  SHARED_WITH_ME_FOLDER,
42
55
  SHARED_BY_ME_FOLDER,
43
56
  } from './paths.js';
57
+ import { TeamRegistry } from './team-registry.js';
44
58
 
45
59
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
46
60
 
@@ -555,25 +569,34 @@ async function setupOrgSync({ orgId, localPath: rawLocalPath }) {
555
569
  const LOCAL_PATH = expandHome(rawLocalPath);
556
570
  const filesRef = collection(db, `orgs/${orgId}/files`);
557
571
 
558
- const teams = new Map();
559
- const teamIdsByName = new Map();
560
572
  const lastKnownUpdate = new Map();
561
573
  const suppressLocal = new Set();
562
-
563
- async function loadTeams() {
564
- const snapshot = await getDocs(collection(db, `orgs/${orgId}/teams`));
565
- snapshot.forEach(d => {
566
- const data = d.data();
567
- teams.set(d.id, data);
568
- teamIdsByName.set(data.name, d.id);
569
- });
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
+ }
570
589
  }
571
-
572
- 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;
573
597
 
574
598
  console.log('');
575
599
  console.log(` [${orgId}] Local: ${LOCAL_PATH}`);
576
- console.log(` [${orgId}] Teams: ${[...teams.values()].map(t => t.name).join(', ') || 'none'}`);
577
600
 
578
601
  function ensureDir(filePath) {
579
602
  const dir = path.dirname(filePath);
@@ -608,6 +631,8 @@ async function setupOrgSync({ orgId, localPath: rawLocalPath }) {
608
631
  }
609
632
  }
610
633
 
634
+ // Static (user-level) folders. Team folders are created per-team by
635
+ // setupTeam below as soon as the registry sees each teamspace.
611
636
  function ensureFolderStructure() {
612
637
  const privatePath = path.join(LOCAL_PATH, PRIVATE_FOLDER);
613
638
  if (!fs.existsSync(privatePath)) fs.mkdirSync(privatePath, { recursive: true });
@@ -617,11 +642,6 @@ async function setupOrgSync({ orgId, localPath: rawLocalPath }) {
617
642
 
618
643
  const sharedByMePath = path.join(LOCAL_PATH, SHARED_BY_ME_FOLDER);
619
644
  if (!fs.existsSync(sharedByMePath)) fs.mkdirSync(sharedByMePath, { recursive: true });
620
-
621
- for (const [, teamData] of teams) {
622
- const teamPath = path.join(LOCAL_PATH, teamFolderName(teamData.name));
623
- if (!fs.existsSync(teamPath)) fs.mkdirSync(teamPath, { recursive: true });
624
- }
625
645
  }
626
646
 
627
647
  ensureFolderStructure();
@@ -633,9 +653,26 @@ async function setupOrgSync({ orgId, localPath: rawLocalPath }) {
633
653
 
634
654
  const localFilePath = getLocalPath(data);
635
655
  const trackingKey = `${data.scope || 'team'}:${docPath}`;
656
+ const isFolder = data.type === 'folder';
636
657
 
637
658
  if (change.type === 'removed') {
638
- 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)) {
639
676
  console.log(` [${orgId}] [firestore → local] DELETE ${docPath}`);
640
677
  suppressLocal.add(localFilePath);
641
678
  fs.unlinkSync(localFilePath);
@@ -649,6 +686,19 @@ async function setupOrgSync({ orgId, localPath: rawLocalPath }) {
649
686
  const lastKnown = lastKnownUpdate.get(trackingKey);
650
687
  if (lastKnown && lastKnown >= remoteUpdated) return;
651
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
+
652
702
  if (fs.existsSync(localFilePath)) {
653
703
  const localMtime = fs.statSync(localFilePath).mtime.toISOString();
654
704
  if (localMtime > remoteUpdated) return;
@@ -659,31 +709,80 @@ async function setupOrgSync({ orgId, localPath: rawLocalPath }) {
659
709
  ensureDir(localFilePath);
660
710
  suppressLocal.add(localFilePath);
661
711
 
662
- const content = await readFileContent({
663
- storage,
664
- orgId,
665
- fileId: change.doc.id,
666
- });
667
- fs.writeFileSync(localFilePath, content || '', 'utf-8');
712
+ const ext = extFromPath(docPath);
713
+ if (isTextMimeType(data.mimeType)) {
714
+ const content = await readBlobAsText({
715
+ storage,
716
+ orgId,
717
+ fileId: change.doc.id,
718
+ ext,
719
+ });
720
+ fs.writeFileSync(localFilePath, content || '', 'utf-8');
721
+ } else {
722
+ const bytes = await readBlob({
723
+ storage,
724
+ orgId,
725
+ fileId: change.doc.id,
726
+ ext,
727
+ });
728
+ fs.writeFileSync(localFilePath, bytes);
729
+ }
668
730
  lastKnownUpdate.set(trackingKey, remoteUpdated);
669
731
  setTimeout(() => suppressLocal.delete(localFilePath), 1000);
670
732
  }
671
733
 
672
- function startFirestoreListeners() {
673
- 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));
750
+
751
+ teamUnsubscribers.set(teamId, unsubscribe);
752
+ console.log(` [${orgId}] Listening: ${teamFolder}`);
753
+ };
674
754
 
675
- for (const [teamId, teamData] of teams) {
676
- const teamFolder = teamFolderName(teamData.name);
677
- const q = query(filesRef, where('scope', '==', 'team'), where('teamId', '==', teamId));
678
- onSnapshot(q, snapshot => {
679
- snapshot.docChanges().forEach(change => {
680
- handleFirestoreChange(change, data =>
681
- path.join(LOCAL_PATH, teamFolder, data.path),
682
- ).catch(err => console.error(` [${orgId}] Firestore change handler error:`, err && err.message));
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(),
683
768
  });
684
- }, err => console.error(` [${orgId}] Team "${teamFolder}" listener error:`, err));
685
- console.log(` [${orgId}] Listening: ${teamFolder}`);
686
- }
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...`);
687
786
 
688
787
  if (USER_ID) {
689
788
  const privateQ = query(filesRef, where('scope', '==', 'private'), where('ownerId', '==', USER_ID));
@@ -719,7 +818,7 @@ async function setupOrgSync({ orgId, localPath: rawLocalPath }) {
719
818
  }
720
819
  }
721
820
 
722
- async function pushToFirestore(filePath, content) {
821
+ async function pushToFirestore(filePath, data, mimeType) {
723
822
  const { scope, teamId, docPath } = scopeFromLocalPath(filePath, LOCAL_PATH, teamIdsByName);
724
823
  const trackingKey = `${scope}:${docPath}`;
725
824
  const now = new Date().toISOString();
@@ -733,12 +832,13 @@ async function setupOrgSync({ orgId, localPath: rawLocalPath }) {
733
832
  scope,
734
833
  teamId,
735
834
  docPath,
736
- content,
835
+ data,
836
+ mimeType,
737
837
  now,
738
838
  });
739
839
 
740
840
  if (result.action === 'created') {
741
- console.log(` [${orgId}] [local → firestore] CREATE ${docPath} (${scope})`);
841
+ console.log(` [${orgId}] [local → firestore] CREATE ${docPath} (${scope}, ${mimeType})`);
742
842
  } else if (result.action === 'updated') {
743
843
  console.log(` [${orgId}] [local → firestore] UPDATE ${docPath} (${scope})`);
744
844
  }
@@ -767,17 +867,112 @@ async function setupOrgSync({ orgId, localPath: rawLocalPath }) {
767
867
  }
768
868
  }
769
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
+
770
928
  function handleLocalChange(filePath) {
771
929
  if (suppressLocal.has(filePath)) return;
772
930
 
773
931
  const rel = path.relative(LOCAL_PATH, filePath);
774
- if (!rel.endsWith('.md') && !rel.endsWith('.txt')) return;
775
-
776
932
  if (rel.startsWith(SHARED_WITH_ME_FOLDER + path.sep)) return;
777
933
  if (rel.startsWith(SHARED_BY_ME_FOLDER + path.sep)) return;
778
934
 
779
- const content = fs.readFileSync(filePath, 'utf-8');
780
- pushToFirestore(filePath, content).catch(err => console.error(` [${orgId}] Push error: ${err.message}`));
935
+ let stat;
936
+ try {
937
+ stat = fs.statSync(filePath);
938
+ } catch (err) {
939
+ return; // disappeared between event and read
940
+ }
941
+ if (!stat.isFile()) return;
942
+ if (stat.size > MAX_UPLOAD_BYTES) {
943
+ console.error(` [${orgId}] [skip] ${rel} exceeds ${MAX_UPLOAD_BYTES} byte cap (${stat.size} bytes)`);
944
+ return;
945
+ }
946
+
947
+ const ext = extFromPath(rel);
948
+ const mimeType = mimeTypeFromExt(ext);
949
+ // Text files: read as a UTF-8 string so future text-aware features
950
+ // (line-level diffing, conflict markers) can work. Binary files:
951
+ // read as a Buffer so bytes round-trip without corruption.
952
+ const data = isTextMimeType(mimeType)
953
+ ? fs.readFileSync(filePath, 'utf-8')
954
+ : fs.readFileSync(filePath);
955
+ pushToFirestore(filePath, data, mimeType).catch(err =>
956
+ console.error(` [${orgId}] Push error: ${err.message}`)
957
+ );
958
+ }
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
+ );
781
976
  }
782
977
 
783
978
  function startLocalWatcher() {
@@ -806,6 +1001,8 @@ async function setupOrgSync({ orgId, localPath: rawLocalPath }) {
806
1001
  if (suppressLocal.has(filePath)) return;
807
1002
  deleteFromFirestore(filePath).catch(err => console.error(` [${orgId}] Delete error: ${err.message}`));
808
1003
  });
1004
+ watcher.on('addDir', dirPath => handleLocalDirAdded(dirPath));
1005
+ watcher.on('unlinkDir', dirPath => handleLocalDirRemoved(dirPath));
809
1006
 
810
1007
  watcher.on('ready', () => console.log(` [${orgId}] Local watcher ready.`));
811
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
+ }
package/dual-write.js DELETED
@@ -1,226 +0,0 @@
1
- // Dual-write helpers for the sync daemon and integration tests.
2
- //
3
- // Each function takes its dependencies (filesRef, storage, orgId, etc.)
4
- // as parameters so the logic is importable into a test harness that
5
- // provides emulator-backed Firebase handles. The sync daemon (sync.js)
6
- // is the production caller and supplies real-Firebase handles.
7
- //
8
- // Side effect ordering on writes:
9
- // 1. Write the Firestore metadata document (Storage rules require
10
- // the metadata doc to exist before the upload is authorized).
11
- // 2. Upload the blob to Cloud Storage at the deterministic path
12
- // derived from orgId + fileId.
13
- // 3. Storage failures are returned in the result, not thrown, so the
14
- // caller can decide whether to surface as a soft warning while the
15
- // Firestore content field still holds the up-to-date value.
16
-
17
- import crypto from 'crypto';
18
- import {
19
- doc, getDocs, setDoc, updateDoc, deleteDoc, query, where,
20
- } from 'firebase/firestore';
21
- import {
22
- ref as storageRef, uploadString, deleteObject, getBytes,
23
- } from 'firebase/storage';
24
-
25
- // ---- Pure helpers ----
26
-
27
- export function computeContentHash(content) {
28
- return crypto.createHash('sha256').update(content || '', 'utf-8').digest('hex');
29
- }
30
-
31
- export function storagePathFor(orgId, fileId) {
32
- return `orgs/${orgId}/files/${fileId}/current.md`;
33
- }
34
-
35
- // ---- Storage primitives ----
36
-
37
- export async function uploadBlob({ storage, orgId, fileId, content }) {
38
- const blobPath = storagePathFor(orgId, fileId);
39
- await uploadString(storageRef(storage, blobPath), content || '', 'raw', {
40
- contentType: 'text/markdown',
41
- });
42
- }
43
-
44
- export async function deleteBlob({ storage, orgId, fileId }) {
45
- const blobPath = storagePathFor(orgId, fileId);
46
- try {
47
- await deleteObject(storageRef(storage, blobPath));
48
- } catch (err) {
49
- if (err && err.code === 'storage/object-not-found') {
50
- return; // tolerate missing blob (older files written before PR 2)
51
- }
52
- throw err;
53
- }
54
- }
55
-
56
- /**
57
- * Read file content from Cloud Storage. Throws on any failure
58
- * (missing blob, permission error, network). The Firestore content
59
- * field is no longer written or read as of PR 6, so the read path
60
- * has no fallback. Backfill (PR 4) is a prerequisite.
61
- */
62
- export async function readFileContent({ storage, orgId, fileId }) {
63
- const blobPath = storagePathFor(orgId, fileId);
64
- const bytes = await getBytes(storageRef(storage, blobPath));
65
- return new TextDecoder('utf-8').decode(new Uint8Array(bytes));
66
- }
67
-
68
- // ---- Core dual-write ----
69
-
70
- /**
71
- * Write a file's content to Cloud Storage at
72
- * orgs/{orgId}/files/{fileId}/current.md and update the Firestore
73
- * metadata doc with storagePath/contentHash/size. As of PR 6 the
74
- * Firestore `content` field is no longer written; the blob is the
75
- * sole source of file content.
76
- *
77
- * Returns one of:
78
- * { action: 'skipped', reason: 'shared-scope' }
79
- * { action: 'noop', fileId }
80
- * { action: 'created', fileId, fileData, storageError? }
81
- * { action: 'updated', fileId, storageError? }
82
- *
83
- * `storageError` is populated when the Firestore write succeeded but
84
- * the Storage upload failed. Callers should log and retry; the next
85
- * save attempts the upload again.
86
- */
87
- export async function pushFileToCloud({
88
- filesRef,
89
- storage,
90
- orgId,
91
- userId,
92
- scope,
93
- teamId,
94
- docPath,
95
- content,
96
- now = new Date().toISOString(),
97
- }) {
98
- if (scope === 'shared-with-me' || scope === 'shared-by-me') {
99
- return { action: 'skipped', reason: 'shared-scope' };
100
- }
101
-
102
- let snapshot;
103
- if (scope === 'private') {
104
- snapshot = await getDocs(query(filesRef,
105
- where('scope', '==', 'private'),
106
- where('ownerId', '==', userId),
107
- where('path', '==', docPath)
108
- ));
109
- } else if (scope === 'team' && teamId) {
110
- snapshot = await getDocs(query(filesRef,
111
- where('scope', '==', 'team'),
112
- where('teamId', '==', teamId),
113
- where('path', '==', docPath)
114
- ));
115
- } else {
116
- snapshot = await getDocs(query(filesRef, where('path', '==', docPath)));
117
- }
118
-
119
- const contentHash = computeContentHash(content);
120
- const size = Buffer.byteLength(content || '', 'utf-8');
121
-
122
- if (!snapshot || snapshot.empty) {
123
- const newDocRef = doc(filesRef);
124
- const fileId = newDocRef.id;
125
- const fileData = {
126
- path: docPath,
127
- createdAt: now,
128
- updatedAt: now,
129
- scope,
130
- teamId: scope === 'team' ? (teamId || null) : null,
131
- ownerId: scope === 'private' ? userId : null,
132
- sharedWith: [],
133
- storagePath: storagePathFor(orgId, fileId),
134
- contentHash,
135
- size,
136
- };
137
- await setDoc(newDocRef, fileData);
138
- let storageError = null;
139
- try {
140
- await uploadBlob({ storage, orgId, fileId, content });
141
- } catch (err) {
142
- storageError = err;
143
- }
144
- return { action: 'created', fileId, fileData, storageError };
145
- }
146
-
147
- const docSnap = snapshot.docs[0];
148
- const existing = docSnap.data();
149
- if (existing.contentHash === contentHash) {
150
- return { action: 'noop', fileId: docSnap.id };
151
- }
152
- await updateDoc(docSnap.ref, {
153
- updatedAt: now,
154
- storagePath: storagePathFor(orgId, docSnap.id),
155
- contentHash,
156
- size,
157
- });
158
- let storageError = null;
159
- try {
160
- await uploadBlob({ storage, orgId, fileId: docSnap.id, content });
161
- } catch (err) {
162
- storageError = err;
163
- }
164
- return { action: 'updated', fileId: docSnap.id, storageError };
165
- }
166
-
167
- /**
168
- * Delete a file from Firestore and the corresponding Storage blob.
169
- *
170
- * Returns one of:
171
- * { action: 'skipped', reason: 'shared-scope' }
172
- * { action: 'not-found' }
173
- * { action: 'scope-mismatch' }
174
- * { action: 'deleted', fileId }
175
- *
176
- * Blob deletion tolerates `storage/object-not-found`.
177
- */
178
- export async function deleteFileFromCloud({
179
- filesRef,
180
- storage,
181
- orgId,
182
- userId,
183
- scope,
184
- teamId,
185
- docPath,
186
- }) {
187
- if (scope === 'shared-with-me' || scope === 'shared-by-me') {
188
- return { action: 'skipped', reason: 'shared-scope' };
189
- }
190
-
191
- // The list query must mirror the Firestore rule's per-branch
192
- // constraints (scope + ownerId for private, scope + teamId for team)
193
- // so the rules engine can statically authorize it.
194
- let snapshot;
195
- if (scope === 'private') {
196
- snapshot = await getDocs(query(
197
- filesRef,
198
- where('scope', '==', 'private'),
199
- where('ownerId', '==', userId),
200
- where('path', '==', docPath)
201
- ));
202
- } else if (scope === 'team' && teamId) {
203
- snapshot = await getDocs(query(
204
- filesRef,
205
- where('scope', '==', 'team'),
206
- where('teamId', '==', teamId),
207
- where('path', '==', docPath)
208
- ));
209
- } else {
210
- return { action: 'not-found' };
211
- }
212
-
213
- if (snapshot.empty) {
214
- return { action: 'not-found' };
215
- }
216
- const docSnap = snapshot.docs[0];
217
- const fileId = docSnap.id;
218
- // Delete the Storage blob FIRST while the metadata doc still exists,
219
- // because the Storage rule's authorization check looks up the doc.
220
- // After Firestore deletion the rule would deny (firestore.get fails).
221
- // deleteBlob tolerates storage/object-not-found for files that were
222
- // written before PR 2 and so have no blob.
223
- await deleteBlob({ storage, orgId, fileId });
224
- await deleteDoc(docSnap.ref);
225
- return { action: 'deleted', fileId };
226
- }