@doubling/compound-sync 1.12.4 → 1.12.6
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/auth-persistence.d.ts +22 -0
- package/auth-persistence.js +79 -83
- package/config.d.ts +13 -0
- package/config.js +55 -54
- package/files.d.ts +221 -0
- package/files.js +373 -438
- package/folder-chain.d.ts +44 -0
- package/folder-chain.js +52 -59
- package/manifest.d.ts +38 -0
- package/manifest.js +87 -89
- package/org-sync.d.ts +16 -0
- package/org-sync.js +1156 -1203
- package/package.json +24 -3
- package/paths.d.ts +1 -1
- package/pre-mint-app-check.d.ts +6 -0
- package/pre-mint-app-check.js +46 -39
- package/push-outcome.d.ts +9 -0
- package/push-outcome.js +6 -5
- package/setup-auth-with-pre-mint.d.ts +24 -0
- package/setup-auth-with-pre-mint.js +48 -55
- package/storage-helpers.d.ts +14 -0
- package/storage-helpers.js +43 -107
- package/sync-state.d.ts +4 -0
- package/sync-state.js +30 -16
- package/sync.d.ts +18 -0
- package/sync.js +464 -515
- package/team-registry.d.ts +41 -0
- package/team-registry.js +90 -81
- package/yjs-binding-state.d.ts +4 -0
- package/yjs-binding-state.js +25 -22
- package/yjs-file-binding.d.ts +32 -0
- package/yjs-file-binding.js +217 -204
- package/yjs-firestore-update-store.d.ts +13 -0
- package/yjs-firestore-update-store.js +99 -103
- package/yjs-provider.d.ts +43 -0
- package/yjs-provider.js +79 -75
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
|
-
|
|
30
|
-
} from '
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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(
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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
|