@doubling/compound-sync 1.2.0 → 1.3.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/dual-write.js +226 -0
- package/package.json +15 -3
- package/sync.js +50 -117
package/dual-write.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,16 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@doubling/compound-sync",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Bidirectional sync between Compound and local markdown files",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"bin":
|
|
6
|
+
"bin": {
|
|
7
|
+
"compound-sync": "./sync.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"sync.js",
|
|
11
|
+
"dual-write.js",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
7
14
|
"scripts": {
|
|
8
|
-
"sync": "node sync.js"
|
|
15
|
+
"sync": "node sync.js",
|
|
16
|
+
"test": "firebase emulators:exec --only firestore,storage 'node --test dual-write.test.js'"
|
|
9
17
|
},
|
|
10
18
|
"dependencies": {
|
|
11
19
|
"firebase": "^11.7.0",
|
|
12
20
|
"chokidar": "^4.0.0"
|
|
13
21
|
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@firebase/rules-unit-testing": "^4.0.0",
|
|
24
|
+
"firebase-tools": "^15.0.0"
|
|
25
|
+
},
|
|
14
26
|
"publishConfig": {
|
|
15
27
|
"access": "public"
|
|
16
28
|
}
|
package/sync.js
CHANGED
|
@@ -20,10 +20,9 @@ import {
|
|
|
20
20
|
getFirestore, collection, doc, getDocs, getDoc, setDoc, addDoc, updateDoc, deleteDoc,
|
|
21
21
|
query, where, onSnapshot
|
|
22
22
|
} from 'firebase/firestore';
|
|
23
|
-
import { getStorage
|
|
23
|
+
import { getStorage } from 'firebase/storage';
|
|
24
24
|
import { initializeAppCheck, CustomProvider } from 'firebase/app-check';
|
|
25
25
|
import chokidar from 'chokidar';
|
|
26
|
-
import crypto from 'crypto';
|
|
27
26
|
import fs from 'fs';
|
|
28
27
|
import path from 'path';
|
|
29
28
|
import readline from 'readline';
|
|
@@ -31,6 +30,7 @@ import os from 'os';
|
|
|
31
30
|
import http from 'http';
|
|
32
31
|
import { exec } from 'child_process';
|
|
33
32
|
import { fileURLToPath } from 'url';
|
|
33
|
+
import { pushFileToCloud, deleteFileFromCloud, readFileContent } from './dual-write.js';
|
|
34
34
|
|
|
35
35
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
36
36
|
|
|
@@ -498,43 +498,15 @@ const LOCAL_PATH = expandHome(config.localPath || path.join(os.homedir(), 'Sync'
|
|
|
498
498
|
|
|
499
499
|
const filesRef = collection(db, `orgs/${ORG_ID}/files`);
|
|
500
500
|
|
|
501
|
-
// --- Cloud Storage
|
|
501
|
+
// --- Cloud Storage handle ---
|
|
502
502
|
//
|
|
503
|
-
//
|
|
504
|
-
//
|
|
505
|
-
//
|
|
506
|
-
//
|
|
507
|
-
// (1) write Firestore metadata, (2) upload to Storage, (3) update
|
|
508
|
-
// Firestore with the hash/path/size fields.
|
|
503
|
+
// Dual-write file content to Cloud Storage in addition to the Firestore
|
|
504
|
+
// `content` field. Implementation lives in ./dual-write.js so it can be
|
|
505
|
+
// imported by tests and other callers without triggering the daemon's
|
|
506
|
+
// browser-auth side effects.
|
|
509
507
|
|
|
510
508
|
const storage = getStorage(app);
|
|
511
509
|
|
|
512
|
-
function computeContentHash(content) {
|
|
513
|
-
return crypto.createHash('sha256').update(content || '', 'utf-8').digest('hex');
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
function storagePathFor(fileId) {
|
|
517
|
-
return `orgs/${ORG_ID}/files/${fileId}/current.md`;
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
async function uploadBlob(fileId, content) {
|
|
521
|
-
const blobPath = storagePathFor(fileId);
|
|
522
|
-
await uploadString(storageRef(storage, blobPath), content || '', 'raw', {
|
|
523
|
-
contentType: 'text/markdown',
|
|
524
|
-
});
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
async function deleteBlob(fileId) {
|
|
528
|
-
const blobPath = storagePathFor(fileId);
|
|
529
|
-
try {
|
|
530
|
-
await deleteObject(storageRef(storage, blobPath));
|
|
531
|
-
} catch (err) {
|
|
532
|
-
if (err && err.code !== 'storage/object-not-found') {
|
|
533
|
-
console.error(` [storage] delete failed for ${blobPath}: ${err.message}`);
|
|
534
|
-
}
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
|
|
538
510
|
// --- Load teams ---
|
|
539
511
|
|
|
540
512
|
const teams = new Map();
|
|
@@ -643,7 +615,7 @@ ensureFolderStructure();
|
|
|
643
615
|
|
|
644
616
|
// --- Firestore → Local (multiple listeners) ---
|
|
645
617
|
|
|
646
|
-
function handleFirestoreChange(change, getLocalPath) {
|
|
618
|
+
async function handleFirestoreChange(change, getLocalPath) {
|
|
647
619
|
const data = change.doc.data();
|
|
648
620
|
const docPath = data.path;
|
|
649
621
|
if (!docPath) return;
|
|
@@ -676,7 +648,16 @@ function handleFirestoreChange(change, getLocalPath) {
|
|
|
676
648
|
console.log(` [firestore → local] ${action} ${docPath} (${data.scope || 'team'})`);
|
|
677
649
|
ensureDir(localPath);
|
|
678
650
|
suppressLocal.add(localPath);
|
|
679
|
-
|
|
651
|
+
|
|
652
|
+
// Read content from Cloud Storage. As of PR 6 there is no
|
|
653
|
+
// fallback; any read failure propagates to the caller's catch
|
|
654
|
+
// handler (handleFirestoreChange's promise rejection logger).
|
|
655
|
+
const content = await readFileContent({
|
|
656
|
+
storage,
|
|
657
|
+
orgId: ORG_ID,
|
|
658
|
+
fileId: change.doc.id,
|
|
659
|
+
});
|
|
660
|
+
fs.writeFileSync(localPath, content || '', 'utf-8');
|
|
680
661
|
lastKnownUpdate.set(trackingKey, remoteUpdated);
|
|
681
662
|
setTimeout(() => suppressLocal.delete(localPath), 1000);
|
|
682
663
|
}
|
|
@@ -691,7 +672,7 @@ function startFirestoreListeners() {
|
|
|
691
672
|
snapshot.docChanges().forEach(change => {
|
|
692
673
|
handleFirestoreChange(change, (data) => {
|
|
693
674
|
return path.join(LOCAL_PATH, teamFolder, data.path);
|
|
694
|
-
});
|
|
675
|
+
}).catch(err => console.error('Firestore change handler error:', err && err.message));
|
|
695
676
|
});
|
|
696
677
|
}, err => console.error(`Team "${teamFolder}" listener error:`, err));
|
|
697
678
|
console.log(` Listening: ${teamFolder}`);
|
|
@@ -704,7 +685,8 @@ function startFirestoreListeners() {
|
|
|
704
685
|
const data = change.doc.data();
|
|
705
686
|
const realPath = path.join(LOCAL_PATH, PRIVATE_FOLDER, data.path);
|
|
706
687
|
|
|
707
|
-
handleFirestoreChange(change, () => realPath)
|
|
688
|
+
handleFirestoreChange(change, () => realPath)
|
|
689
|
+
.catch(err => console.error('Firestore change handler error:', err && err.message));
|
|
708
690
|
|
|
709
691
|
if (change.type !== 'removed') {
|
|
710
692
|
updateSharedByMeSymlink(data, realPath);
|
|
@@ -723,7 +705,7 @@ function startFirestoreListeners() {
|
|
|
723
705
|
if (data.ownerId === USER_ID) return;
|
|
724
706
|
handleFirestoreChange(change, () => {
|
|
725
707
|
return path.join(LOCAL_PATH, SHARED_WITH_ME_FOLDER, data.path);
|
|
726
|
-
});
|
|
708
|
+
}).catch(err => console.error('Firestore change handler error:', err && err.message));
|
|
727
709
|
});
|
|
728
710
|
}, err => console.error('Shared with me listener error:', err));
|
|
729
711
|
console.log(' Listening: Shared with Me');
|
|
@@ -738,72 +720,25 @@ async function pushToFirestore(filePath, content) {
|
|
|
738
720
|
const now = new Date().toISOString();
|
|
739
721
|
lastKnownUpdate.set(trackingKey, now);
|
|
740
722
|
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
where('scope', '==', 'team'),
|
|
753
|
-
where('teamId', '==', teamId),
|
|
754
|
-
where('path', '==', docPath)
|
|
755
|
-
));
|
|
756
|
-
} else {
|
|
757
|
-
snapshot = await getDocs(query(filesRef, where('path', '==', docPath)));
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
const contentHash = computeContentHash(content);
|
|
761
|
-
const size = Buffer.byteLength(content || '', 'utf-8');
|
|
723
|
+
const result = await pushFileToCloud({
|
|
724
|
+
filesRef,
|
|
725
|
+
storage,
|
|
726
|
+
orgId: ORG_ID,
|
|
727
|
+
userId: USER_ID,
|
|
728
|
+
scope,
|
|
729
|
+
teamId,
|
|
730
|
+
docPath,
|
|
731
|
+
content,
|
|
732
|
+
now,
|
|
733
|
+
});
|
|
762
734
|
|
|
763
|
-
if (
|
|
735
|
+
if (result.action === 'created') {
|
|
764
736
|
console.log(` [local → firestore] CREATE ${docPath} (${scope})`);
|
|
765
|
-
|
|
766
|
-
// storagePath before the first Firestore write.
|
|
767
|
-
const newDocRef = doc(filesRef);
|
|
768
|
-
const fileId = newDocRef.id;
|
|
769
|
-
const fileData = {
|
|
770
|
-
path: docPath,
|
|
771
|
-
content,
|
|
772
|
-
createdAt: now,
|
|
773
|
-
updatedAt: now,
|
|
774
|
-
scope,
|
|
775
|
-
teamId: scope === 'team' ? (teamId || null) : null,
|
|
776
|
-
ownerId: scope === 'private' ? USER_ID : null,
|
|
777
|
-
sharedWith: [],
|
|
778
|
-
storagePath: storagePathFor(fileId),
|
|
779
|
-
contentHash,
|
|
780
|
-
size,
|
|
781
|
-
};
|
|
782
|
-
await setDoc(newDocRef, fileData);
|
|
783
|
-
// Upload to Storage after Firestore so the Storage rule (which
|
|
784
|
-
// reads the file metadata to authorize) sees an existing doc.
|
|
785
|
-
try {
|
|
786
|
-
await uploadBlob(fileId, content);
|
|
787
|
-
} catch (err) {
|
|
788
|
-
console.error(` [storage] upload failed for ${docPath}: ${err && err.message}`);
|
|
789
|
-
}
|
|
790
|
-
} else {
|
|
791
|
-
const docSnap = snapshot.docs[0];
|
|
792
|
-
const existing = docSnap.data();
|
|
793
|
-
if (existing.content === content && existing.contentHash === contentHash) return;
|
|
737
|
+
} else if (result.action === 'updated') {
|
|
794
738
|
console.log(` [local → firestore] UPDATE ${docPath} (${scope})`);
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
storagePath: storagePathFor(docSnap.id),
|
|
799
|
-
contentHash,
|
|
800
|
-
size,
|
|
801
|
-
});
|
|
802
|
-
try {
|
|
803
|
-
await uploadBlob(docSnap.id, content);
|
|
804
|
-
} catch (err) {
|
|
805
|
-
console.error(` [storage] upload failed for ${docPath}: ${err && err.message}`);
|
|
806
|
-
}
|
|
739
|
+
}
|
|
740
|
+
if (result.storageError) {
|
|
741
|
+
console.error(` [storage] upload failed for ${docPath}: ${result.storageError.message}`);
|
|
807
742
|
}
|
|
808
743
|
}
|
|
809
744
|
|
|
@@ -812,20 +747,18 @@ async function deleteFromFirestore(filePath) {
|
|
|
812
747
|
const trackingKey = `${scope}:${docPath}`;
|
|
813
748
|
lastKnownUpdate.delete(trackingKey);
|
|
814
749
|
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
await deleteBlob(fileId);
|
|
828
|
-
}
|
|
750
|
+
const result = await deleteFileFromCloud({
|
|
751
|
+
filesRef,
|
|
752
|
+
storage,
|
|
753
|
+
orgId: ORG_ID,
|
|
754
|
+
userId: USER_ID,
|
|
755
|
+
scope,
|
|
756
|
+
teamId,
|
|
757
|
+
docPath,
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
if (result.action === 'deleted') {
|
|
761
|
+
console.log(` [local → firestore] DELETE ${docPath} (${scope})`);
|
|
829
762
|
}
|
|
830
763
|
}
|
|
831
764
|
|