@doubling/compound-sync 1.12.3 → 1.12.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/files.js CHANGED
@@ -24,141 +24,114 @@
24
24
  // 1. Delete the Storage blob (while metadata still exists so the
25
25
  // Storage rule can authorize).
26
26
  // 2. Delete the Firestore metadata document.
27
-
28
- import {
29
- doc, getDoc, 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
- folderDocId,
41
- fileDocId,
42
- toBytes,
43
- } from './storage-helpers.js';
44
- import { planFolderChain, splitPath } from './folder-chain.js';
45
-
27
+ import { doc, getDoc, getDocs, setDoc, updateDoc, deleteDoc, query, where, } from 'firebase/firestore';
28
+ import { ref as storageRef, uploadBytes, deleteObject, getBytes, } from 'firebase/storage';
29
+ import { storagePathFor, extFromPath, contentByteSize, computeContentHash, folderDocId, fileDocId, toBytes, } from './storage-helpers.js';
30
+ import { planFolderChain, splitPath, } from './folder-chain.js';
46
31
  // Re-export pure helpers so callers (and existing tests) can import
47
32
  // either from here or directly from storage-helpers.js.
48
- export {
49
- storagePathFor,
50
- extFromPath,
51
- contentByteSize,
52
- computeContentHash,
53
- isTextMimeType,
54
- mimeTypeFromExt,
55
- } from './storage-helpers.js';
56
-
33
+ export { storagePathFor, extFromPath, contentByteSize, computeContentHash, isTextMimeType, mimeTypeFromExt, } from './storage-helpers.js';
57
34
  // ---- Storage primitives ----
58
-
59
35
  export async function uploadBlob({ storage, orgId, fileId, ext, data, mimeType }) {
60
- const blobPath = storagePathFor(orgId, fileId, ext);
61
- const payload = toBytes(data);
62
- await uploadBytes(storageRef(storage, blobPath), payload, {
63
- contentType: mimeType || 'application/octet-stream',
64
- });
36
+ const blobPath = storagePathFor(orgId, fileId, ext);
37
+ const payload = toBytes(data);
38
+ await uploadBytes(storageRef(storage, blobPath), payload, {
39
+ contentType: mimeType || 'application/octet-stream',
40
+ });
65
41
  }
66
-
67
42
  export async function deleteBlob({ storage, orgId, fileId, ext }) {
68
- const blobPath = storagePathFor(orgId, fileId, ext);
69
- try {
70
- await deleteObject(storageRef(storage, blobPath));
71
- } catch (err) {
72
- if (err && err.code === 'storage/object-not-found') {
73
- return; // tolerate missing blob
74
- }
75
- throw err;
76
- }
43
+ const blobPath = storagePathFor(orgId, fileId, ext);
44
+ try {
45
+ await deleteObject(storageRef(storage, blobPath));
46
+ }
47
+ catch (err) {
48
+ if (err?.code === 'storage/object-not-found') {
49
+ return; // tolerate missing blob
50
+ }
51
+ throw err;
52
+ }
77
53
  }
78
-
79
54
  /**
80
55
  * Read a blob from Cloud Storage as raw bytes. Throws on any failure
81
56
  * (missing blob, permission error, network).
82
57
  */
83
58
  export async function readBlob({ storage, orgId, fileId, ext }) {
84
- const blobPath = storagePathFor(orgId, fileId, ext);
85
- const buf = await getBytes(storageRef(storage, blobPath));
86
- return Buffer.from(buf);
59
+ const blobPath = storagePathFor(orgId, fileId, ext);
60
+ const buf = await getBytes(storageRef(storage, blobPath));
61
+ return Buffer.from(buf);
87
62
  }
88
-
89
63
  /**
90
64
  * Read a blob and decode as UTF-8 text. Convenience for text/markdown
91
65
  * callers.
92
66
  */
93
- export async function readBlobAsText({ storage, orgId, fileId, ext }) {
94
- const buf = await readBlob({ storage, orgId, fileId, ext });
95
- return buf.toString('utf-8');
67
+ export async function readBlobAsText(input) {
68
+ const buf = await readBlob(input);
69
+ return buf.toString('utf-8');
96
70
  }
97
-
98
71
  // ---- Folder-doc operations ----
99
-
100
72
  // Load all folder docs in this org for the given scope. Used by
101
73
  // ensureFolderChainInCloud to find or create folder docs along a
102
74
  // file's parent path. The result is small (folder count << file count)
103
75
  // so this query is cheap.
104
76
  async function loadScopedFolders({ filesRef, scope, teamId, userId }) {
105
- let q;
106
- if (scope === 'private') {
107
- q = query(filesRef,
108
- where('type', '==', 'folder'),
109
- where('scope', '==', 'private'),
110
- where('ownerId', '==', userId));
111
- } else if (scope === 'team' && teamId) {
112
- q = query(filesRef,
113
- where('type', '==', 'folder'),
114
- where('scope', '==', 'team'),
115
- where('teamId', '==', teamId));
116
- } else {
117
- return [];
118
- }
119
- const snap = await getDocs(q);
120
- const folders = [];
121
- snap.forEach(d => folders.push({ id: d.id, ...d.data() }));
122
- return folders;
77
+ let q;
78
+ if (scope === 'private') {
79
+ if (!userId) {
80
+ throw new Error('loadScopedFolders: userId is required for private scope');
81
+ }
82
+ q = query(filesRef, where('type', '==', 'folder'), where('scope', '==', 'private'), where('ownerId', '==', userId));
83
+ }
84
+ else if (scope === 'team' && teamId) {
85
+ q = query(filesRef, where('type', '==', 'folder'), where('scope', '==', 'team'), where('teamId', '==', teamId));
86
+ }
87
+ else {
88
+ return [];
89
+ }
90
+ const snap = await getDocs(q);
91
+ const folders = [];
92
+ snap.forEach((d) => folders.push({ id: d.id, ...d.data() }));
93
+ return folders;
123
94
  }
124
-
125
95
  // Apply a planFolderChain result against Firestore. Creates the
126
96
  // planned folder docs (rewriting tempId references to real ids) and
127
97
  // applies backfills. Returns the resolved leaf folder id (or null if
128
98
  // the input plan had no segments).
129
99
  async function applyFolderChainPlan({ filesRef, plan, now }) {
130
- const tempToReal = new Map();
131
- for (const create of plan.creates) {
132
- // Deterministic id by {scope, teamId, ownerId, path}; concurrent
133
- // sibling creates for the same path collapse onto the same doc
134
- // instead of producing duplicates (DOU-240).
135
- const realId = folderDocId({
136
- scope: create.doc.scope,
137
- teamId: create.doc.teamId,
138
- ownerId: create.doc.ownerId,
139
- path: create.doc.path,
140
- });
141
- const newRef = doc(filesRef, realId);
142
- const docData = {
143
- ...create.doc,
144
- parentId: tempToReal.get(create.doc.parentId) || create.doc.parentId,
145
- createdAt: now,
146
- updatedAt: now,
147
- };
148
- // setDoc with merge:false is idempotent on identical input; the
149
- // last writer wins on differing fields. For folder docs the only
150
- // fields that vary between concurrent callers are timestamps, so
151
- // any tie-break is acceptable.
152
- await setDoc(newRef, docData);
153
- tempToReal.set(create.tempId, realId);
154
- }
155
- for (const bf of plan.backfills) {
156
- await updateDoc(doc(filesRef, bf.id), { path: bf.path, updatedAt: now });
157
- }
158
- if (plan.leafId == null) return null;
159
- return tempToReal.get(plan.leafId) || plan.leafId;
100
+ const tempToReal = new Map();
101
+ for (const create of plan.creates) {
102
+ // Deterministic id by {scope, teamId, ownerId, path}; concurrent
103
+ // sibling creates for the same path collapse onto the same doc
104
+ // instead of producing duplicates (DOU-240).
105
+ const realId = folderDocId({
106
+ scope: create.doc.scope,
107
+ teamId: create.doc.teamId,
108
+ ownerId: create.doc.ownerId,
109
+ path: create.doc.path,
110
+ });
111
+ const newRef = doc(filesRef, realId);
112
+ const resolvedParentId = create.doc.parentId == null
113
+ ? null
114
+ : (tempToReal.get(create.doc.parentId) ?? create.doc.parentId);
115
+ const docData = {
116
+ ...create.doc,
117
+ parentId: resolvedParentId,
118
+ createdAt: now,
119
+ updatedAt: now,
120
+ };
121
+ // setDoc with merge:false is idempotent on identical input; the
122
+ // last writer wins on differing fields. For folder docs the only
123
+ // fields that vary between concurrent callers are timestamps, so
124
+ // any tie-break is acceptable.
125
+ await setDoc(newRef, docData);
126
+ tempToReal.set(create.tempId, realId);
127
+ }
128
+ for (const bf of plan.backfills) {
129
+ await updateDoc(doc(filesRef, bf.id), { path: bf.path, updatedAt: now });
130
+ }
131
+ if (plan.leafId == null)
132
+ return null;
133
+ return tempToReal.get(plan.leafId) ?? plan.leafId;
160
134
  }
161
-
162
135
  /**
163
136
  * Ensure folder docs exist for every segment of `docPath` and return
164
137
  * the id of the leaf folder. Idempotent: existing folder docs are
@@ -171,29 +144,22 @@ async function applyFolderChainPlan({ filesRef, plan, now }) {
171
144
  *
172
145
  * Returns null if `docPath` is empty (root).
173
146
  */
174
- export async function ensureFolderChainInCloud({
175
- filesRef,
176
- scope,
177
- teamId,
178
- userId,
179
- docPath,
180
- now = new Date().toISOString(),
181
- }) {
182
- if (scope === 'shared-with-me' || scope === 'shared-by-me') return null;
183
- const segments = splitPath(docPath);
184
- if (segments.length === 0) return null;
185
-
186
- const existingFolders = await loadScopedFolders({ filesRef, scope, teamId, userId });
187
- const plan = planFolderChain({
188
- existingFolders,
189
- segments,
190
- scope,
191
- teamId,
192
- ownerId: scope === 'private' ? userId : null,
193
- });
194
- return applyFolderChainPlan({ filesRef, plan, now });
147
+ export async function ensureFolderChainInCloud({ filesRef, scope, teamId, userId, docPath, now = new Date().toISOString(), }) {
148
+ if (scope === 'shared-with-me' || scope === 'shared-by-me')
149
+ return null;
150
+ const segments = splitPath(docPath);
151
+ if (segments.length === 0)
152
+ return null;
153
+ const existingFolders = await loadScopedFolders({ filesRef, scope, teamId, userId });
154
+ const plan = planFolderChain({
155
+ existingFolders,
156
+ segments,
157
+ scope,
158
+ teamId,
159
+ ownerId: scope === 'private' ? (userId ?? null) : null,
160
+ });
161
+ return applyFolderChainPlan({ filesRef, plan, now });
195
162
  }
196
-
197
163
  /**
198
164
  * Find or create the folder doc at `docPath`. Convenience wrapper
199
165
  * around ensureFolderChainInCloud for callers that want to upsert a
@@ -203,23 +169,15 @@ export async function ensureFolderChainInCloud({
203
169
  * { action: 'skipped', reason: 'shared-scope' }
204
170
  * { action: 'ensured', folderId }
205
171
  */
206
- export async function pushFolderToCloud({
207
- filesRef,
208
- scope,
209
- teamId,
210
- userId,
211
- docPath,
212
- now = new Date().toISOString(),
213
- }) {
214
- if (scope === 'shared-with-me' || scope === 'shared-by-me') {
215
- return { action: 'skipped', reason: 'shared-scope' };
216
- }
217
- const folderId = await ensureFolderChainInCloud({
218
- filesRef, scope, teamId, userId, docPath, now,
219
- });
220
- return { action: 'ensured', folderId };
172
+ export async function pushFolderToCloud({ filesRef, scope, teamId, userId, docPath, now = new Date().toISOString(), }) {
173
+ if (scope === 'shared-with-me' || scope === 'shared-by-me') {
174
+ return { action: 'skipped', reason: 'shared-scope' };
175
+ }
176
+ const folderId = await ensureFolderChainInCloud({
177
+ filesRef, scope, teamId, userId, docPath, now,
178
+ });
179
+ return { action: 'ensured', folderId };
221
180
  }
222
-
223
181
  /**
224
182
  * Delete the folder doc at `docPath` AND every descendant (files and
225
183
  * sub-folders) in the same scope. Cascade is authoritative: it does
@@ -243,95 +201,89 @@ export async function pushFolderToCloud({
243
201
  * { action: 'not-found' }
244
202
  * { action: 'deleted', folderId, childCount }
245
203
  */
246
- export async function deleteFolderFromCloud({
247
- filesRef,
248
- storage,
249
- orgId,
250
- scope,
251
- teamId,
252
- userId,
253
- docPath,
254
- }) {
255
- if (scope === 'shared-with-me' || scope === 'shared-by-me') {
256
- return { action: 'skipped', reason: 'shared-scope' };
257
- }
258
- // Pull every doc in this scope; cheap because vaults are small
259
- // (hundreds of docs at most). Filtering in memory avoids the
260
- // Firestore composite-index requirement for a path-prefix range
261
- // query and keeps the cascade contract dead simple to reason about.
262
- let scopeQuery;
263
- if (scope === 'private') {
264
- scopeQuery = query(filesRef,
265
- where('scope', '==', 'private'),
266
- where('ownerId', '==', userId));
267
- } else if (scope === 'team' && teamId) {
268
- scopeQuery = query(filesRef,
269
- where('scope', '==', 'team'),
270
- where('teamId', '==', teamId));
271
- } else {
272
- return { action: 'not-found' };
273
- }
274
- const snap = await getDocs(scopeQuery);
275
-
276
- const prefix = docPath + '/';
277
- let folderDocSnap = null;
278
- const descendants = [];
279
- snap.forEach((d) => {
280
- const data = d.data();
281
- const p = data.path;
282
- if (!p) return;
283
- if (data.type === 'folder' && p === docPath) {
284
- folderDocSnap = d;
285
- } else if (p.startsWith(prefix)) {
286
- descendants.push({ id: d.id, ref: d.ref, data });
287
- }
288
- });
289
-
290
- if (!folderDocSnap && descendants.length === 0) {
291
- return { action: 'not-found' };
292
- }
293
-
294
- // Delete each descendant's blob (files only) then its doc.
295
- for (const { id, ref, data } of descendants) {
296
- if (data.type !== 'folder') {
297
- const ext = extFromPath(data.path);
298
- // Re-check the doc still exists right before calling the
299
- // Storage SDK. If a concurrent caller (chokidar's per-file
300
- // unlink fan-out, or another cascade) already deleted the
301
- // metadata doc, the Storage rule's cross-service
302
- // firestore.get(...).data.scope reads null and throws "Null
303
- // value error", and the Storage SDK retries for several
304
- // seconds before surfacing storage/unauthorized. The retry
305
- // latency is what blew the test budget on DOU-242 + DOU-251.
306
- // Skipping the SDK call avoids both the rule throw and the
307
- // retry; the orphan blob (if any) is cleaned up by the
308
- // data-integrity audit (DOU-244).
309
- const recheck = await getDoc(ref);
310
- if (recheck.exists()) {
311
- try {
312
- await deleteBlob({ storage, orgId, fileId: id, ext });
313
- } catch (err) {
314
- // Orphan blob is a soft failure; the doc delete is the
315
- // load-bearing state transition for the user's view.
204
+ export async function deleteFolderFromCloud({ filesRef, storage, orgId, scope, teamId, userId, docPath, }) {
205
+ if (scope === 'shared-with-me' || scope === 'shared-by-me') {
206
+ return { action: 'skipped', reason: 'shared-scope' };
207
+ }
208
+ // Pull every doc in this scope; cheap because vaults are small
209
+ // (hundreds of docs at most). Filtering in memory avoids the
210
+ // Firestore composite-index requirement for a path-prefix range
211
+ // query and keeps the cascade contract dead simple to reason about.
212
+ let scopeQuery;
213
+ if (scope === 'private') {
214
+ if (!userId) {
215
+ throw new Error('deleteFolderFromCloud: userId is required for private scope');
216
+ }
217
+ scopeQuery = query(filesRef, where('scope', '==', 'private'), where('ownerId', '==', userId));
218
+ }
219
+ else if (scope === 'team' && teamId) {
220
+ scopeQuery = query(filesRef, where('scope', '==', 'team'), where('teamId', '==', teamId));
221
+ }
222
+ else {
223
+ return { action: 'not-found' };
224
+ }
225
+ const snap = await getDocs(scopeQuery);
226
+ const prefix = docPath + '/';
227
+ // Use an array as a single-slot holder rather than `let`. Inside the
228
+ // forEach callback TS can't see same-scope assignment to a `let`
229
+ // (the closure breaks control-flow analysis), so the variable type
230
+ // would stay `null` post-loop. The array sidesteps that.
231
+ const folderHolder = [];
232
+ const descendants = [];
233
+ snap.forEach((d) => {
234
+ const data = d.data();
235
+ const p = data['path'];
236
+ if (typeof p !== 'string' || !p)
237
+ return;
238
+ if (data['type'] === 'folder' && p === docPath) {
239
+ folderHolder[0] = { id: d.id, ref: d.ref, data };
316
240
  }
317
- }
318
- }
319
- await deleteDoc(ref);
320
- }
321
-
322
- if (folderDocSnap) {
323
- await deleteDoc(folderDocSnap.ref);
324
- }
325
-
326
- return {
327
- action: 'deleted',
328
- folderId: folderDocSnap ? folderDocSnap.id : null,
329
- childCount: descendants.length,
330
- };
241
+ else if (p.startsWith(prefix)) {
242
+ descendants.push({ id: d.id, ref: d.ref, data });
243
+ }
244
+ });
245
+ const folderDocSnap = folderHolder[0] ?? null;
246
+ if (!folderDocSnap && descendants.length === 0) {
247
+ return { action: 'not-found' };
248
+ }
249
+ // Delete each descendant's blob (files only) then its doc.
250
+ for (const { id, ref, data } of descendants) {
251
+ if (data['type'] !== 'folder') {
252
+ const ext = extFromPath(typeof data['path'] === 'string' ? data['path'] : '');
253
+ // Re-check the doc still exists right before calling the
254
+ // Storage SDK. If a concurrent caller (chokidar's per-file
255
+ // unlink fan-out, or another cascade) already deleted the
256
+ // metadata doc, the Storage rule's cross-service
257
+ // firestore.get(...).data.scope reads null and throws "Null
258
+ // value error", and the Storage SDK retries for several
259
+ // seconds before surfacing storage/unauthorized. The retry
260
+ // latency is what blew the test budget on DOU-242 + DOU-251.
261
+ // Skipping the SDK call avoids both the rule throw and the
262
+ // retry; the orphan blob (if any) is cleaned up by the
263
+ // data-integrity audit (DOU-244).
264
+ const recheck = await getDoc(ref);
265
+ if (recheck.exists()) {
266
+ try {
267
+ await deleteBlob({ storage, orgId, fileId: id, ext });
268
+ }
269
+ catch {
270
+ // Orphan blob is a soft failure; the doc delete is the
271
+ // load-bearing state transition for the user's view.
272
+ }
273
+ }
274
+ }
275
+ await deleteDoc(ref);
276
+ }
277
+ if (folderDocSnap) {
278
+ await deleteDoc(folderDocSnap.ref);
279
+ }
280
+ return {
281
+ action: 'deleted',
282
+ folderId: folderDocSnap ? folderDocSnap.id : null,
283
+ childCount: descendants.length,
284
+ };
331
285
  }
332
-
333
286
  // ---- High-level coordinator ----
334
-
335
287
  /**
336
288
  * Push a file's content to the cloud: ensure the parent folder chain
337
289
  * exists, upsert the Firestore metadata doc (with canonical fields
@@ -360,134 +312,126 @@ export async function deleteFolderFromCloud({
360
312
  * { action: 'created', fileId, fileData, storageError? }
361
313
  * { action: 'updated', fileId, storageError? }
362
314
  */
363
- export async function pushFileToCloud({
364
- filesRef,
365
- storage,
366
- orgId,
367
- userId,
368
- scope,
369
- teamId,
370
- docPath,
371
- data,
372
- mimeType,
373
- now = new Date().toISOString(),
374
- localModifiedAt = null,
375
- }) {
376
- if (scope === 'shared-with-me' || scope === 'shared-by-me') {
377
- return { action: 'skipped', reason: 'shared-scope' };
378
- }
379
-
380
- let snapshot;
381
- if (scope === 'private') {
382
- snapshot = await getDocs(query(filesRef,
383
- where('scope', '==', 'private'),
384
- where('ownerId', '==', userId),
385
- where('path', '==', docPath)
386
- ));
387
- } else if (scope === 'team' && teamId) {
388
- snapshot = await getDocs(query(filesRef,
389
- where('scope', '==', 'team'),
390
- where('teamId', '==', teamId),
391
- where('path', '==', docPath)
392
- ));
393
- } else {
394
- snapshot = await getDocs(query(filesRef, where('path', '==', docPath)));
395
- }
396
-
397
- const ext = extFromPath(docPath);
398
- const contentHash = computeContentHash(data);
399
- const size = contentByteSize(data);
400
- const parts = splitPath(docPath);
401
- const fileName = parts[parts.length - 1] || docPath;
402
- // Parent folder docs must exist before we write the file doc so the
403
- // file's `parentId` is a real folder id. ensureFolderChainInCloud
404
- // is idempotent (existing folder docs are reused; missing ones are
405
- // created with the canonical shape including `path`).
406
- const parentFolderPath = parts.slice(0, -1).join('/');
407
- const parentId = parentFolderPath
408
- ? await ensureFolderChainInCloud({
409
- filesRef,
410
- scope,
411
- teamId,
412
- userId,
413
- docPath: parentFolderPath,
414
- now,
415
- })
416
- : null;
417
-
418
- if (!snapshot || snapshot.empty) {
419
- // Deterministic id by {scope, teamId, ownerId, path}; concurrent
420
- // sibling creates for the same path collapse onto the same doc
421
- // instead of producing duplicates. Mirrors the DOU-240 folder fix.
422
- const fileId = fileDocId({
423
- scope,
424
- teamId: scope === 'team' ? (teamId || null) : null,
425
- ownerId: scope === 'private' ? userId : null,
426
- path: docPath,
315
+ export async function pushFileToCloud({ filesRef, storage, orgId, userId, scope, teamId, docPath, data, mimeType, now = new Date().toISOString(), localModifiedAt = null, }) {
316
+ if (scope === 'shared-with-me' || scope === 'shared-by-me') {
317
+ return { action: 'skipped', reason: 'shared-scope' };
318
+ }
319
+ let snapshot;
320
+ if (scope === 'private') {
321
+ if (!userId) {
322
+ throw new Error('pushFileToCloud: userId is required for private scope');
323
+ }
324
+ snapshot = await getDocs(query(filesRef, where('scope', '==', 'private'), where('ownerId', '==', userId), where('path', '==', docPath)));
325
+ }
326
+ else if (scope === 'team' && teamId) {
327
+ snapshot = await getDocs(query(filesRef, where('scope', '==', 'team'), where('teamId', '==', teamId), where('path', '==', docPath)));
328
+ }
329
+ else {
330
+ snapshot = await getDocs(query(filesRef, where('path', '==', docPath)));
331
+ }
332
+ const ext = extFromPath(docPath);
333
+ const contentHash = computeContentHash(data);
334
+ const size = contentByteSize(data);
335
+ const parts = splitPath(docPath);
336
+ const fileName = parts[parts.length - 1] || docPath;
337
+ // Parent folder docs must exist before we write the file doc so the
338
+ // file's `parentId` is a real folder id. ensureFolderChainInCloud
339
+ // is idempotent (existing folder docs are reused; missing ones are
340
+ // created with the canonical shape including `path`).
341
+ const parentFolderPath = parts.slice(0, -1).join('/');
342
+ const parentId = parentFolderPath
343
+ ? await ensureFolderChainInCloud({
344
+ filesRef,
345
+ scope,
346
+ teamId,
347
+ userId,
348
+ docPath: parentFolderPath,
349
+ now,
350
+ })
351
+ : null;
352
+ if (!snapshot || snapshot.empty) {
353
+ // Deterministic id by {scope, teamId, ownerId, path}; concurrent
354
+ // sibling creates for the same path collapse onto the same doc
355
+ // instead of producing duplicates. Mirrors the DOU-240 folder fix.
356
+ const fileId = fileDocId({
357
+ scope,
358
+ teamId: scope === 'team' ? (teamId || null) : null,
359
+ ownerId: scope === 'private' ? userId : null,
360
+ path: docPath,
361
+ });
362
+ const newDocRef = doc(filesRef, fileId);
363
+ const resolvedMime = mimeType || 'text/markdown';
364
+ const fileData = {
365
+ type: 'file',
366
+ name: fileName,
367
+ parentId,
368
+ path: docPath,
369
+ createdAt: now,
370
+ updatedAt: now,
371
+ scope,
372
+ teamId: scope === 'team' ? (teamId || null) : null,
373
+ ownerId: scope === 'private' ? userId : null,
374
+ sharedWith: [],
375
+ storagePath: storagePathFor(orgId, fileId, ext),
376
+ contentHash,
377
+ size,
378
+ mimeType: resolvedMime,
379
+ };
380
+ await setDoc(newDocRef, fileData);
381
+ let storageError = null;
382
+ try {
383
+ await uploadBlob({ storage, orgId, fileId, ext, data, mimeType: resolvedMime });
384
+ }
385
+ catch (err) {
386
+ storageError = err;
387
+ }
388
+ return { action: 'created', fileId, fileData, storageError };
389
+ }
390
+ const docSnap = snapshot.docs[0];
391
+ if (!docSnap) {
392
+ // Defensive: snapshot.empty was false but docs[0] is undefined.
393
+ // Shouldn't happen with Firestore, but keep the type narrowed.
394
+ throw new Error('pushFileToCloud: snapshot.docs[0] missing despite non-empty snapshot');
395
+ }
396
+ const existing = docSnap.data();
397
+ const existingParentId = existing['parentId'] == null ? null : existing['parentId'];
398
+ if (existing['contentHash'] === contentHash
399
+ && existing['type'] === 'file'
400
+ && existing['name'] === fileName
401
+ && existingParentId === parentId) {
402
+ return { action: 'noop', fileId: docSnap.id };
403
+ }
404
+ // Last-write-wins: never let an older local file overwrite a newer
405
+ // server edit. The caller (sync daemon) passes the local file's mtime;
406
+ // if the server doc was updated more recently, leave it for the
407
+ // firestore -> local pull to bring down instead.
408
+ const existingUpdatedAt = existing['updatedAt'];
409
+ if (localModifiedAt
410
+ && typeof existingUpdatedAt === 'string'
411
+ && existingUpdatedAt > localModifiedAt) {
412
+ return { action: 'skipped-stale', fileId: docSnap.id };
413
+ }
414
+ const existingMime = typeof existing['mimeType'] === 'string' ? existing['mimeType'] : undefined;
415
+ const resolvedMime = mimeType || existingMime || 'text/markdown';
416
+ await updateDoc(docSnap.ref, {
417
+ type: 'file',
418
+ name: fileName,
419
+ parentId,
420
+ updatedAt: now,
421
+ storagePath: storagePathFor(orgId, docSnap.id, ext),
422
+ contentHash,
423
+ size,
424
+ mimeType: resolvedMime,
427
425
  });
428
- const newDocRef = doc(filesRef, fileId);
429
- const resolvedMime = mimeType || 'text/markdown';
430
- const fileData = {
431
- type: 'file',
432
- name: fileName,
433
- parentId,
434
- path: docPath,
435
- createdAt: now,
436
- updatedAt: now,
437
- scope,
438
- teamId: scope === 'team' ? (teamId || null) : null,
439
- ownerId: scope === 'private' ? userId : null,
440
- sharedWith: [],
441
- storagePath: storagePathFor(orgId, fileId, ext),
442
- contentHash,
443
- size,
444
- mimeType: resolvedMime,
445
- };
446
- await setDoc(newDocRef, fileData);
447
426
  let storageError = null;
448
427
  try {
449
- await uploadBlob({ storage, orgId, fileId, ext, data, mimeType: resolvedMime });
450
- } catch (err) {
451
- storageError = err;
452
- }
453
- return { action: 'created', fileId, fileData, storageError };
454
- }
455
-
456
- const docSnap = snapshot.docs[0];
457
- const existing = docSnap.data();
458
- if (existing.contentHash === contentHash
459
- && existing.type === 'file'
460
- && existing.name === fileName
461
- && (existing.parentId == null ? null : existing.parentId) === parentId) {
462
- return { action: 'noop', fileId: docSnap.id };
463
- }
464
- // Last-write-wins: never let an older local file overwrite a newer
465
- // server edit. The caller (sync daemon) passes the local file's mtime;
466
- // if the server doc was updated more recently, leave it for the
467
- // firestore -> local pull to bring down instead.
468
- if (localModifiedAt && existing.updatedAt && existing.updatedAt > localModifiedAt) {
469
- return { action: 'skipped-stale', fileId: docSnap.id };
470
- }
471
- const resolvedMime = mimeType || existing.mimeType || 'text/markdown';
472
- await updateDoc(docSnap.ref, {
473
- type: 'file',
474
- name: fileName,
475
- parentId,
476
- updatedAt: now,
477
- storagePath: storagePathFor(orgId, docSnap.id, ext),
478
- contentHash,
479
- size,
480
- mimeType: resolvedMime,
481
- });
482
- let storageError = null;
483
- try {
484
- await uploadBlob({ storage, orgId, fileId: docSnap.id, ext, data, mimeType: resolvedMime });
485
- } catch (err) {
486
- storageError = err;
487
- }
488
- return { action: 'updated', fileId: docSnap.id, storageError };
428
+ await uploadBlob({ storage, orgId, fileId: docSnap.id, ext, data, mimeType: resolvedMime });
429
+ }
430
+ catch (err) {
431
+ storageError = err;
432
+ }
433
+ return { action: 'updated', fileId: docSnap.id, storageError };
489
434
  }
490
-
491
435
  /**
492
436
  * Delete a file from Firestore and the corresponding Storage blob.
493
437
  *
@@ -498,92 +442,83 @@ export async function pushFileToCloud({
498
442
  *
499
443
  * Blob deletion tolerates `storage/object-not-found`.
500
444
  */
501
- export async function deleteFileFromCloud({
502
- filesRef,
503
- storage,
504
- orgId,
505
- userId,
506
- scope,
507
- teamId,
508
- docPath,
509
- }) {
510
- if (scope === 'shared-with-me' || scope === 'shared-by-me') {
511
- return { action: 'skipped', reason: 'shared-scope' };
512
- }
513
-
514
- let snapshot;
515
- if (scope === 'private') {
516
- snapshot = await getDocs(query(
517
- filesRef,
518
- where('scope', '==', 'private'),
519
- where('ownerId', '==', userId),
520
- where('path', '==', docPath)
521
- ));
522
- } else if (scope === 'team' && teamId) {
523
- snapshot = await getDocs(query(
524
- filesRef,
525
- where('scope', '==', 'team'),
526
- where('teamId', '==', teamId),
527
- where('path', '==', docPath)
528
- ));
529
- } else {
530
- return { action: 'not-found' };
531
- }
532
-
533
- if (snapshot.empty) {
534
- return { action: 'not-found' };
535
- }
536
- const docSnap = snapshot.docs[0];
537
- const fileId = docSnap.id;
538
- const ext = extFromPath(docPath);
539
- // Delete the Storage blob FIRST while the metadata doc still exists,
540
- // because the Storage rule's authorization check looks up the doc.
541
- //
542
- // Race tolerance (DOU-242 / DOU-252): a concurrent delete can remove
543
- // the metadata doc between our snapshot read and our blob-delete
544
- // (most commonly the cascade in deleteFolderFromCloud firing first
545
- // for a path chokidar's per-file `unlink` event then re-tries via
546
- // this function). Pre-check the doc still exists right before the
547
- // Storage SDK call: if it's gone, skip the blob delete entirely.
548
- // This avoids the Storage rule's cross-service firestore.get(...)
549
- // reading null and throwing "Null value error", and avoids the
550
- // Storage SDK's multi-second retry chain that breaks test budgets.
551
- const preRecheck = await getDoc(docSnap.ref);
552
- if (!preRecheck.exists()) {
553
- return { action: 'not-found', fileId };
554
- }
555
- try {
556
- await deleteBlob({ storage, orgId, fileId, ext });
557
- } catch (err) {
558
- const code = err && err.code;
559
- if (code === 'storage/unauthorized') {
560
- // Last-resort safety net for the narrow window between
561
- // preRecheck above and the SDK actually firing. Treat as
562
- // already-deleted if the doc is now gone.
563
- const recheck = await getDoc(docSnap.ref);
564
- if (!recheck.exists()) {
565
- return { action: 'not-found', fileId };
566
- }
567
- }
568
- throw err;
569
- }
570
- // Concurrent-delete tolerance (DOU-242): the Firestore delete rule
571
- // evaluates canAccessFile() which reads resource.data; if another
572
- // caller already deleted this doc, resource is null and the rule
573
- // throws "Null value error" → SDK surfaces permission-denied. The
574
- // desired end state (no doc) is already achieved, so treat as
575
- // already-deleted instead of propagating.
576
- try {
577
- await deleteDoc(docSnap.ref);
578
- } catch (err) {
579
- const code = err && err.code;
580
- if (code === 'permission-denied') {
581
- const recheck = await getDoc(docSnap.ref);
582
- if (!recheck.exists()) {
445
+ export async function deleteFileFromCloud({ filesRef, storage, orgId, userId, scope, teamId, docPath, }) {
446
+ if (scope === 'shared-with-me' || scope === 'shared-by-me') {
447
+ return { action: 'skipped', reason: 'shared-scope' };
448
+ }
449
+ let snapshot;
450
+ if (scope === 'private') {
451
+ if (!userId) {
452
+ throw new Error('deleteFileFromCloud: userId is required for private scope');
453
+ }
454
+ snapshot = await getDocs(query(filesRef, where('scope', '==', 'private'), where('ownerId', '==', userId), where('path', '==', docPath)));
455
+ }
456
+ else if (scope === 'team' && teamId) {
457
+ snapshot = await getDocs(query(filesRef, where('scope', '==', 'team'), where('teamId', '==', teamId), where('path', '==', docPath)));
458
+ }
459
+ else {
460
+ return { action: 'not-found' };
461
+ }
462
+ if (snapshot.empty) {
463
+ return { action: 'not-found' };
464
+ }
465
+ const docSnap = snapshot.docs[0];
466
+ if (!docSnap) {
467
+ return { action: 'not-found' };
468
+ }
469
+ const fileId = docSnap.id;
470
+ const ext = extFromPath(docPath);
471
+ // Delete the Storage blob FIRST while the metadata doc still exists,
472
+ // because the Storage rule's authorization check looks up the doc.
473
+ //
474
+ // Race tolerance (DOU-242 / DOU-252): a concurrent delete can remove
475
+ // the metadata doc between our snapshot read and our blob-delete
476
+ // (most commonly the cascade in deleteFolderFromCloud firing first
477
+ // for a path chokidar's per-file `unlink` event then re-tries via
478
+ // this function). Pre-check the doc still exists right before the
479
+ // Storage SDK call: if it's gone, skip the blob delete entirely.
480
+ // This avoids the Storage rule's cross-service firestore.get(...)
481
+ // reading null and throwing "Null value error", and avoids the
482
+ // Storage SDK's multi-second retry chain that breaks test budgets.
483
+ const preRecheck = await getDoc(docSnap.ref);
484
+ if (!preRecheck.exists()) {
583
485
  return { action: 'not-found', fileId };
584
- }
585
486
  }
586
- throw err;
587
- }
588
- return { action: 'deleted', fileId };
487
+ try {
488
+ await deleteBlob({ storage, orgId, fileId, ext });
489
+ }
490
+ catch (err) {
491
+ const code = err?.code;
492
+ if (code === 'storage/unauthorized') {
493
+ // Last-resort safety net for the narrow window between
494
+ // preRecheck above and the SDK actually firing. Treat as
495
+ // already-deleted if the doc is now gone.
496
+ const recheck = await getDoc(docSnap.ref);
497
+ if (!recheck.exists()) {
498
+ return { action: 'not-found', fileId };
499
+ }
500
+ }
501
+ throw err;
502
+ }
503
+ // Concurrent-delete tolerance (DOU-242): the Firestore delete rule
504
+ // evaluates canAccessFile() which reads resource.data; if another
505
+ // caller already deleted this doc, resource is null and the rule
506
+ // throws "Null value error" → SDK surfaces permission-denied. The
507
+ // desired end state (no doc) is already achieved, so treat as
508
+ // already-deleted instead of propagating.
509
+ try {
510
+ await deleteDoc(docSnap.ref);
511
+ }
512
+ catch (err) {
513
+ const code = err?.code;
514
+ if (code === 'permission-denied') {
515
+ const recheck = await getDoc(docSnap.ref);
516
+ if (!recheck.exists()) {
517
+ return { action: 'not-found', fileId };
518
+ }
519
+ }
520
+ throw err;
521
+ }
522
+ return { action: 'deleted', fileId };
589
523
  }
524
+ //# sourceMappingURL=files.js.map