@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 +438 -0
- package/folder-chain.js +97 -0
- package/package.json +7 -4
- package/storage-helpers.js +88 -0
- package/sync.js +243 -46
- package/team-registry.js +94 -0
- package/dual-write.js +0 -226
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
|
+
}
|
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.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
|
-
"
|
|
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
|
|
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 {
|
|
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
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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
|
-
|
|
673
|
-
|
|
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
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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
|
-
}
|
|
685
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
780
|
-
|
|
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
|
}
|
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
|
+
}
|
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
|
-
}
|