@doubling/compound-sync 1.7.1 → 1.10.1
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.js +34 -2
- package/files.js +45 -4
- package/org-sync.js +710 -0
- package/package.json +7 -3
- package/pre-mint-app-check.js +84 -0
- package/setup-auth-with-pre-mint.js +84 -0
- package/storage-helpers.js +7 -0
- package/sync-state.js +32 -0
- package/sync.js +134 -463
package/org-sync.js
ADDED
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
// Per-org sync loop, extracted from sync.js so it can be driven in
|
|
2
|
+
// tests against the Firebase emulator (org-sync.test.js, DOU-156)
|
|
3
|
+
// instead of only by launching the daemon. `startOrgSync` wires the
|
|
4
|
+
// Firestore listeners (pull) and the chokidar watcher (push) for one
|
|
5
|
+
// org given injected Firebase handles, and returns a `stop()` that
|
|
6
|
+
// tears down every listener and the watcher.
|
|
7
|
+
//
|
|
8
|
+
// This is a faithful extraction of the former in-sync.js `setupOrgSync`:
|
|
9
|
+
// module globals it used (db, storage, USER_ID, MAX_UPLOAD_BYTES) are
|
|
10
|
+
// now parameters, and the localPath is expected pre-resolved by the
|
|
11
|
+
// caller. Behavior is otherwise unchanged (including the known
|
|
12
|
+
// mtime-based decision quirks DOU-154 will rework, those stay so the
|
|
13
|
+
// scenario tests can pin them red first).
|
|
14
|
+
|
|
15
|
+
import chokidar from 'chokidar';
|
|
16
|
+
import fs from 'fs';
|
|
17
|
+
import path from 'path';
|
|
18
|
+
import { collection, query, where, onSnapshot } from 'firebase/firestore';
|
|
19
|
+
import {
|
|
20
|
+
pushFileToCloud,
|
|
21
|
+
deleteFileFromCloud,
|
|
22
|
+
pushFolderToCloud,
|
|
23
|
+
deleteFolderFromCloud,
|
|
24
|
+
readBlob,
|
|
25
|
+
readBlobAsText,
|
|
26
|
+
extFromPath,
|
|
27
|
+
isTextMimeType,
|
|
28
|
+
mimeTypeFromExt,
|
|
29
|
+
computeContentHash,
|
|
30
|
+
} from './files.js';
|
|
31
|
+
import {
|
|
32
|
+
scopeFromLocalPath,
|
|
33
|
+
teamFolderName,
|
|
34
|
+
PRIVATE_FOLDER,
|
|
35
|
+
SHARED_WITH_ME_FOLDER,
|
|
36
|
+
SHARED_BY_ME_FOLDER,
|
|
37
|
+
} from './paths.js';
|
|
38
|
+
import { TeamRegistry } from './team-registry.js';
|
|
39
|
+
import { loadSyncState, saveSyncState } from './sync-state.js';
|
|
40
|
+
|
|
41
|
+
const DEFAULT_MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
|
|
42
|
+
|
|
43
|
+
// Retry a blob download until either the bytes appear at all (covers
|
|
44
|
+
// the create race: doc written before blob upload) AND the content
|
|
45
|
+
// hash matches the metadata's `contentHash` (covers the update race:
|
|
46
|
+
// the blob still has the old bytes when the new doc is already
|
|
47
|
+
// visible). Backs off 100/200/400/800 ms (1.5s total) across both
|
|
48
|
+
// failure modes. Other errors throw immediately.
|
|
49
|
+
async function readBlobWithRetry(read, { expectedHash = null, maxAttempts = 5 } = {}) {
|
|
50
|
+
let delay = 100;
|
|
51
|
+
let lastErr;
|
|
52
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
53
|
+
try {
|
|
54
|
+
const content = await read();
|
|
55
|
+
if (!expectedHash || computeContentHash(content) === expectedHash) {
|
|
56
|
+
return content;
|
|
57
|
+
}
|
|
58
|
+
lastErr = new Error(
|
|
59
|
+
`blob hash does not match metadata contentHash (attempt ${attempt}/${maxAttempts})`,
|
|
60
|
+
);
|
|
61
|
+
} catch (err) {
|
|
62
|
+
if (err?.code !== 'storage/object-not-found') throw err;
|
|
63
|
+
lastErr = err;
|
|
64
|
+
}
|
|
65
|
+
if (attempt < maxAttempts) {
|
|
66
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
67
|
+
delay *= 2;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
throw lastErr;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Start the per-org sync loop. Returns { stop } to unsubscribe all
|
|
74
|
+
// listeners and close the watcher (used by tests and by graceful
|
|
75
|
+
// shutdown). `localPath` must already be absolute/expanded.
|
|
76
|
+
export async function startOrgSync({
|
|
77
|
+
db,
|
|
78
|
+
storage,
|
|
79
|
+
userId,
|
|
80
|
+
orgId,
|
|
81
|
+
localPath,
|
|
82
|
+
maxUploadBytes = DEFAULT_MAX_UPLOAD_BYTES,
|
|
83
|
+
statePath,
|
|
84
|
+
}) {
|
|
85
|
+
const LOCAL_PATH = localPath;
|
|
86
|
+
const filesRef = collection(db, `orgs/${orgId}/files`);
|
|
87
|
+
// Where the per-folder content baseline is persisted. Default: a
|
|
88
|
+
// dotfile in the synced folder (ignored by the watcher), so it travels
|
|
89
|
+
// with the folder and needs no extra wiring from the caller.
|
|
90
|
+
const STATE_PATH = statePath ?? path.join(LOCAL_PATH, '.compound-sync-state.json');
|
|
91
|
+
|
|
92
|
+
// Teardown handles: every onSnapshot unsub plus the chokidar watcher.
|
|
93
|
+
const unsubscribers = [];
|
|
94
|
+
let watcher = null;
|
|
95
|
+
|
|
96
|
+
const lastKnownUpdate = new Map();
|
|
97
|
+
// Content hash of each file as of the last successful sync (push or
|
|
98
|
+
// pull), keyed by trackingKey. This "baseline" decides pull/push
|
|
99
|
+
// direction by CONTENT rather than filesystem mtime (which an editor
|
|
100
|
+
// like Obsidian bumps without a content change). Loaded from
|
|
101
|
+
// STATE_PATH so it survives restarts; persisted on every change.
|
|
102
|
+
const lastSyncedHash = new Map(Object.entries(loadSyncState(STATE_PATH)));
|
|
103
|
+
function persistBaseline() {
|
|
104
|
+
saveSyncState(STATE_PATH, Object.fromEntries(lastSyncedHash));
|
|
105
|
+
}
|
|
106
|
+
function setBaseline(key, hash) { lastSyncedHash.set(key, hash); persistBaseline(); }
|
|
107
|
+
function deleteBaseline(key) { lastSyncedHash.delete(key); persistBaseline(); }
|
|
108
|
+
const suppressLocal = new Set();
|
|
109
|
+
// Per-team Firestore-listener unsubscribe handles, keyed by teamId.
|
|
110
|
+
const teamUnsubscribers = new Map();
|
|
111
|
+
// Hoisted forward-decl: setupTeam closes over handleFirestoreChange,
|
|
112
|
+
// which is defined later in this function. Wired below.
|
|
113
|
+
let setupTeam = () => {};
|
|
114
|
+
function teardownTeam(teamId, teamData) {
|
|
115
|
+
const unsub = teamUnsubscribers.get(teamId);
|
|
116
|
+
if (unsub) {
|
|
117
|
+
try { unsub(); } catch (e) { /* ignore */ }
|
|
118
|
+
teamUnsubscribers.delete(teamId);
|
|
119
|
+
const folder = teamData && teamData.name ? teamFolderName(teamData.name) : teamId;
|
|
120
|
+
console.log(` [${orgId}] Stopped listening: ${folder}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const teamRegistry = new TeamRegistry({
|
|
124
|
+
onSetup: (teamId, teamData) => setupTeam(teamId, teamData),
|
|
125
|
+
onTeardown: teardownTeam,
|
|
126
|
+
});
|
|
127
|
+
// scopeFromLocalPath expects a Map<teamName, teamId>; the registry
|
|
128
|
+
// exposes one matching that shape.
|
|
129
|
+
const teamIdsByName = teamRegistry.teamIdsByName;
|
|
130
|
+
|
|
131
|
+
console.log('');
|
|
132
|
+
console.log(` [${orgId}] Local: ${LOCAL_PATH}`);
|
|
133
|
+
|
|
134
|
+
function ensureDir(filePath) {
|
|
135
|
+
const dir = path.dirname(filePath);
|
|
136
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function updateSharedByMeSymlink(fileDoc, realPath) {
|
|
140
|
+
const sharedWith = fileDoc.sharedWith || [];
|
|
141
|
+
const symlinkPath = path.join(LOCAL_PATH, SHARED_BY_ME_FOLDER, fileDoc.path);
|
|
142
|
+
|
|
143
|
+
if (sharedWith.length > 0) {
|
|
144
|
+
ensureDir(symlinkPath);
|
|
145
|
+
const relativeTo = path.relative(path.dirname(symlinkPath), realPath);
|
|
146
|
+
try {
|
|
147
|
+
if (fs.existsSync(symlinkPath) || fs.lstatSync(symlinkPath).isSymbolicLink()) {
|
|
148
|
+
fs.unlinkSync(symlinkPath);
|
|
149
|
+
}
|
|
150
|
+
} catch (e) { /* doesn't exist */ }
|
|
151
|
+
try {
|
|
152
|
+
fs.symlinkSync(relativeTo, symlinkPath);
|
|
153
|
+
console.log(` [${orgId}] [symlink] ${fileDoc.path} → Shared by Me/`);
|
|
154
|
+
} catch (e) {
|
|
155
|
+
if (e.code !== 'EEXIST') console.error(` [${orgId}] Symlink error: ${e.message}`);
|
|
156
|
+
}
|
|
157
|
+
} else {
|
|
158
|
+
try {
|
|
159
|
+
if (fs.lstatSync(symlinkPath).isSymbolicLink()) {
|
|
160
|
+
fs.unlinkSync(symlinkPath);
|
|
161
|
+
console.log(` [${orgId}] [symlink] removed Shared by Me/${fileDoc.path}`);
|
|
162
|
+
}
|
|
163
|
+
} catch (e) { /* doesn't exist */ }
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Static (user-level) folders. Team folders are created per-team by
|
|
168
|
+
// setupTeam below as soon as the registry sees each teamspace.
|
|
169
|
+
function ensureFolderStructure() {
|
|
170
|
+
const privatePath = path.join(LOCAL_PATH, PRIVATE_FOLDER);
|
|
171
|
+
if (!fs.existsSync(privatePath)) fs.mkdirSync(privatePath, { recursive: true });
|
|
172
|
+
|
|
173
|
+
const sharedWithMePath = path.join(LOCAL_PATH, SHARED_WITH_ME_FOLDER);
|
|
174
|
+
if (!fs.existsSync(sharedWithMePath)) fs.mkdirSync(sharedWithMePath, { recursive: true });
|
|
175
|
+
|
|
176
|
+
const sharedByMePath = path.join(LOCAL_PATH, SHARED_BY_ME_FOLDER);
|
|
177
|
+
if (!fs.existsSync(sharedByMePath)) fs.mkdirSync(sharedByMePath, { recursive: true });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
ensureFolderStructure();
|
|
181
|
+
|
|
182
|
+
async function handleFirestoreChange(change, getLocalPath) {
|
|
183
|
+
const data = change.doc.data();
|
|
184
|
+
const docPath = data.path;
|
|
185
|
+
if (!docPath) return;
|
|
186
|
+
|
|
187
|
+
const localFilePath = getLocalPath(data);
|
|
188
|
+
const trackingKey = `${data.scope || 'team'}:${docPath}`;
|
|
189
|
+
const isFolder = data.type === 'folder';
|
|
190
|
+
|
|
191
|
+
if (change.type === 'removed') {
|
|
192
|
+
if (isFolder) {
|
|
193
|
+
// Best-effort directory removal. The daemon's own file-delete
|
|
194
|
+
// events should have cleared all children by the time we get
|
|
195
|
+
// here; if not, rmdirSync throws ENOTEMPTY and we keep the
|
|
196
|
+
// directory rather than risk losing data the user hasn't seen.
|
|
197
|
+
try {
|
|
198
|
+
suppressLocal.add(localFilePath);
|
|
199
|
+
fs.rmdirSync(localFilePath);
|
|
200
|
+
console.log(` [${orgId}] [firestore → local] RMDIR ${docPath}`);
|
|
201
|
+
setTimeout(() => suppressLocal.delete(localFilePath), 1000);
|
|
202
|
+
} catch (err) {
|
|
203
|
+
suppressLocal.delete(localFilePath);
|
|
204
|
+
if (err.code !== 'ENOENT' && err.code !== 'ENOTEMPTY') {
|
|
205
|
+
console.error(` [${orgId}] [firestore → local] RMDIR ${docPath} failed: ${err.message}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
} else if (fs.existsSync(localFilePath)) {
|
|
209
|
+
console.log(` [${orgId}] [firestore → local] DELETE ${docPath}`);
|
|
210
|
+
suppressLocal.add(localFilePath);
|
|
211
|
+
fs.unlinkSync(localFilePath);
|
|
212
|
+
setTimeout(() => suppressLocal.delete(localFilePath), 1000);
|
|
213
|
+
}
|
|
214
|
+
lastKnownUpdate.delete(trackingKey);
|
|
215
|
+
deleteBaseline(trackingKey);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const remoteUpdated = data.updatedAt || data.createdAt || '';
|
|
220
|
+
const lastKnown = lastKnownUpdate.get(trackingKey);
|
|
221
|
+
if (lastKnown && lastKnown >= remoteUpdated) return;
|
|
222
|
+
|
|
223
|
+
if (isFolder) {
|
|
224
|
+
// Folder docs mirror to a local directory. No blob, no content
|
|
225
|
+
// comparison; if the directory doesn't exist yet, create it.
|
|
226
|
+
if (!fs.existsSync(localFilePath)) {
|
|
227
|
+
suppressLocal.add(localFilePath);
|
|
228
|
+
fs.mkdirSync(localFilePath, { recursive: true });
|
|
229
|
+
console.log(` [${orgId}] [firestore → local] MKDIR ${docPath}`);
|
|
230
|
+
setTimeout(() => suppressLocal.delete(localFilePath), 1000);
|
|
231
|
+
}
|
|
232
|
+
lastKnownUpdate.set(trackingKey, remoteUpdated);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (fs.existsSync(localFilePath)) {
|
|
237
|
+
const baseline = lastSyncedHash.get(trackingKey);
|
|
238
|
+
if (baseline !== undefined) {
|
|
239
|
+
// We have a record of what we last synced: decide by content,
|
|
240
|
+
// not mtime. (Obsidian keeps the mtime fresh, which made the old
|
|
241
|
+
// mtime guard wrongly skip web edits.)
|
|
242
|
+
const localHash = computeContentHash(fs.readFileSync(localFilePath));
|
|
243
|
+
if (localHash === data.contentHash) {
|
|
244
|
+
// Local already matches the incoming server content; nothing
|
|
245
|
+
// to write.
|
|
246
|
+
lastKnownUpdate.set(trackingKey, remoteUpdated);
|
|
247
|
+
setBaseline(trackingKey, data.contentHash);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (localHash !== baseline) {
|
|
251
|
+
// Local changed since our last sync AND the server changed:
|
|
252
|
+
// a genuine conflict. Keep the local copy rather than clobber
|
|
253
|
+
// it. (Writing a "conflicted copy" is the next increment.)
|
|
254
|
+
console.log(` [${orgId}] [conflict] ${docPath}: local and server both changed; keeping local`);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
// localHash === baseline: local is untouched since last sync, so
|
|
258
|
+
// the server is authoritative -> fall through and pull.
|
|
259
|
+
} else {
|
|
260
|
+
// No baseline at all: a local file this daemon has never synced
|
|
261
|
+
// (e.g. created locally while offline, or a folder adopted from
|
|
262
|
+
// elsewhere). We can't tell by content who is newer, so fall
|
|
263
|
+
// back to the mtime heuristic. Conflict copies (next increment)
|
|
264
|
+
// will replace this last-resort with a no-loss outcome.
|
|
265
|
+
const localMtime = fs.statSync(localFilePath).mtime.toISOString();
|
|
266
|
+
if (localMtime > remoteUpdated) return;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const action = change.type === 'added' ? 'CREATE' : 'UPDATE';
|
|
271
|
+
console.log(` [${orgId}] [firestore → local] ${action} ${docPath} (${data.scope || 'team'})`);
|
|
272
|
+
ensureDir(localFilePath);
|
|
273
|
+
suppressLocal.add(localFilePath);
|
|
274
|
+
|
|
275
|
+
const ext = extFromPath(docPath);
|
|
276
|
+
// Extension is used as a fallback authority when the stored
|
|
277
|
+
// mimeType claims non-text but the extension maps to a text mime
|
|
278
|
+
// in EXT_TO_MIME. This heals files uploaded before their extension
|
|
279
|
+
// was registered as text (e.g. .base files uploaded prior to the
|
|
280
|
+
// text/yaml mapping landing): on the next pull the daemon writes
|
|
281
|
+
// them as UTF-8 so text-aware features (line diffing, conflict
|
|
282
|
+
// markers) can apply.
|
|
283
|
+
const extDerivedMime = mimeTypeFromExt(ext);
|
|
284
|
+
const isText = isTextMimeType(data.mimeType) || isTextMimeType(extDerivedMime);
|
|
285
|
+
// Storage rules require the Firestore doc to exist before they
|
|
286
|
+
// authorize a blob upload, so writers must create the doc first
|
|
287
|
+
// and upload second. That leaves a window where the metadata is
|
|
288
|
+
// visible (this listener fires) but the blob is not yet uploaded.
|
|
289
|
+
// Retry on object-not-found with exponential backoff covers that
|
|
290
|
+
// race; on a persistent miss we fall through to the original
|
|
291
|
+
// catch upstream.
|
|
292
|
+
const blobReadArgs = { storage, orgId, fileId: change.doc.id, ext };
|
|
293
|
+
const retryOpts = { expectedHash: data.contentHash };
|
|
294
|
+
if (isText) {
|
|
295
|
+
const content = await readBlobWithRetry(() => readBlobAsText(blobReadArgs), retryOpts);
|
|
296
|
+
fs.writeFileSync(localFilePath, content || '', 'utf-8');
|
|
297
|
+
} else {
|
|
298
|
+
const bytes = await readBlobWithRetry(() => readBlob(blobReadArgs), retryOpts);
|
|
299
|
+
fs.writeFileSync(localFilePath, bytes);
|
|
300
|
+
}
|
|
301
|
+
lastKnownUpdate.set(trackingKey, remoteUpdated);
|
|
302
|
+
setBaseline(trackingKey, data.contentHash);
|
|
303
|
+
setTimeout(() => suppressLocal.delete(localFilePath), 1000);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Set up per-team resources: mkdir the local folder, register the
|
|
307
|
+
// Firestore listener for that team's files. Wired into the
|
|
308
|
+
// TeamRegistry; called once per teamspace as it appears.
|
|
309
|
+
setupTeam = (teamId, teamData) => {
|
|
310
|
+
const teamFolder = teamFolderName(teamData.name);
|
|
311
|
+
const teamPath = path.join(LOCAL_PATH, teamFolder);
|
|
312
|
+
if (!fs.existsSync(teamPath)) fs.mkdirSync(teamPath, { recursive: true });
|
|
313
|
+
|
|
314
|
+
const q = query(filesRef, where('scope', '==', 'team'), where('teamId', '==', teamId));
|
|
315
|
+
const unsubscribe = onSnapshot(q, snapshot => {
|
|
316
|
+
snapshot.docChanges().forEach(change => {
|
|
317
|
+
handleFirestoreChange(change, data =>
|
|
318
|
+
path.join(LOCAL_PATH, teamFolder, data.path),
|
|
319
|
+
).catch(err => console.error(` [${orgId}] Firestore change handler error:`, err && err.message));
|
|
320
|
+
});
|
|
321
|
+
}, err => console.error(` [${orgId}] Team "${teamFolder}" listener error:`, err));
|
|
322
|
+
|
|
323
|
+
teamUnsubscribers.set(teamId, unsubscribe);
|
|
324
|
+
console.log(` [${orgId}] Listening: ${teamFolder}`);
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
// Live listener on the teams collection. Initial snapshot bootstraps
|
|
328
|
+
// the known teamspaces; subsequent changes pick up new/renamed/
|
|
329
|
+
// removed teamspaces without restarting the daemon. We await the
|
|
330
|
+
// first snapshot so the local watcher sees a populated teamIdsByName
|
|
331
|
+
// before it tries to route any addDir/add events.
|
|
332
|
+
let teamsUnsub = null;
|
|
333
|
+
await new Promise((resolve, reject) => {
|
|
334
|
+
let firstSnapshotResolved = false;
|
|
335
|
+
teamsUnsub = onSnapshot(collection(db, `orgs/${orgId}/teams`), (snapshot) => {
|
|
336
|
+
snapshot.docChanges().forEach((change) => {
|
|
337
|
+
teamRegistry.applyChange({
|
|
338
|
+
type: change.type,
|
|
339
|
+
id: change.doc.id,
|
|
340
|
+
data: change.doc.data(),
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
if (!firstSnapshotResolved) {
|
|
344
|
+
firstSnapshotResolved = true;
|
|
345
|
+
console.log(` [${orgId}] Teams: ${teamRegistry.names().join(', ') || 'none'}`);
|
|
346
|
+
resolve();
|
|
347
|
+
}
|
|
348
|
+
}, (err) => {
|
|
349
|
+
console.error(` [${orgId}] Teams listener error:`, err);
|
|
350
|
+
if (!firstSnapshotResolved) {
|
|
351
|
+
firstSnapshotResolved = true;
|
|
352
|
+
reject(err);
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
unsubscribers.push(teamsUnsub);
|
|
357
|
+
|
|
358
|
+
function startFirestoreListeners() {
|
|
359
|
+
console.log(` [${orgId}] Starting Firestore listeners...`);
|
|
360
|
+
|
|
361
|
+
if (userId) {
|
|
362
|
+
const privateQ = query(filesRef, where('scope', '==', 'private'), where('ownerId', '==', userId));
|
|
363
|
+
const privateUnsub = onSnapshot(privateQ, snapshot => {
|
|
364
|
+
snapshot.docChanges().forEach(change => {
|
|
365
|
+
const data = change.doc.data();
|
|
366
|
+
const realPath = path.join(LOCAL_PATH, PRIVATE_FOLDER, data.path);
|
|
367
|
+
|
|
368
|
+
handleFirestoreChange(change, () => realPath)
|
|
369
|
+
.catch(err => console.error(` [${orgId}] Firestore change handler error:`, err && err.message));
|
|
370
|
+
|
|
371
|
+
if (change.type !== 'removed') {
|
|
372
|
+
updateSharedByMeSymlink(data, realPath);
|
|
373
|
+
} else {
|
|
374
|
+
const symlinkPath = path.join(LOCAL_PATH, SHARED_BY_ME_FOLDER, data.path);
|
|
375
|
+
try { fs.lstatSync(symlinkPath); fs.unlinkSync(symlinkPath); } catch (e) { /* ok */ }
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
}, err => console.error(` [${orgId}] Private files listener error:`, err));
|
|
379
|
+
unsubscribers.push(privateUnsub);
|
|
380
|
+
console.log(` [${orgId}] Listening: Private files + Shared by Me symlinks`);
|
|
381
|
+
|
|
382
|
+
// scope=='private' is required: Firestore rules only grant
|
|
383
|
+
// sharedWith-based reads on private files, so the query must
|
|
384
|
+
// constrain scope or the whole listen is denied (rules are not
|
|
385
|
+
// filters). See tests/rules.test.js "shared-with-me query".
|
|
386
|
+
const sharedQ = query(
|
|
387
|
+
filesRef,
|
|
388
|
+
where('scope', '==', 'private'),
|
|
389
|
+
where('sharedWith', 'array-contains', userId),
|
|
390
|
+
);
|
|
391
|
+
const sharedUnsub = onSnapshot(sharedQ, snapshot => {
|
|
392
|
+
snapshot.docChanges().forEach(change => {
|
|
393
|
+
const data = change.doc.data();
|
|
394
|
+
if (data.ownerId === userId) return;
|
|
395
|
+
handleFirestoreChange(change, () =>
|
|
396
|
+
path.join(LOCAL_PATH, SHARED_WITH_ME_FOLDER, data.path),
|
|
397
|
+
).catch(err => console.error(` [${orgId}] Firestore change handler error:`, err && err.message));
|
|
398
|
+
});
|
|
399
|
+
}, err => console.error(` [${orgId}] Shared with me listener error:`, err));
|
|
400
|
+
unsubscribers.push(sharedUnsub);
|
|
401
|
+
console.log(` [${orgId}] Listening: Shared with Me`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async function pushToFirestore(filePath, data, mimeType, localModifiedAt) {
|
|
406
|
+
const { scope, teamId, docPath } = scopeFromLocalPath(filePath, LOCAL_PATH, teamIdsByName);
|
|
407
|
+
const trackingKey = `${scope}:${docPath}`;
|
|
408
|
+
|
|
409
|
+
// Skip if the local content is unchanged since the last sync. This
|
|
410
|
+
// is the push-side counterpart of the content baseline: an editor
|
|
411
|
+
// re-saving or touching a file (or the watcher re-emitting it on
|
|
412
|
+
// startup) must not push stale bytes over a newer server edit. Only
|
|
413
|
+
// a genuine content change proceeds.
|
|
414
|
+
const contentHash = computeContentHash(data);
|
|
415
|
+
if (lastSyncedHash.get(trackingKey) === contentHash) return;
|
|
416
|
+
|
|
417
|
+
const now = new Date().toISOString();
|
|
418
|
+
|
|
419
|
+
// Set the echo-suppression marker BEFORE writing. Our own write
|
|
420
|
+
// round-trips through the Firestore listener; without a marker
|
|
421
|
+
// already in place, that echo is treated as an incoming change and
|
|
422
|
+
// triggers a spurious self-pull, which (a) reads the blob before it
|
|
423
|
+
// is uploaded (object-not-found) and (b) adds the path to
|
|
424
|
+
// suppressLocal, swallowing a subsequent rename's unlink (leaving a
|
|
425
|
+
// duplicate doc). Pre-setting it suppresses the echo.
|
|
426
|
+
lastKnownUpdate.set(trackingKey, now);
|
|
427
|
+
|
|
428
|
+
// Baseline = what we last saw as the remote contentHash for this
|
|
429
|
+
// path. pushFileToCloud uses it as a compare-and-swap precondition
|
|
430
|
+
// (DOU-167): if the remote has moved past this baseline, refuse
|
|
431
|
+
// the push and surface the conflict to the caller.
|
|
432
|
+
const baseline = lastSyncedHash.get(trackingKey);
|
|
433
|
+
|
|
434
|
+
const result = await pushFileToCloud({
|
|
435
|
+
filesRef,
|
|
436
|
+
storage,
|
|
437
|
+
orgId,
|
|
438
|
+
userId,
|
|
439
|
+
scope,
|
|
440
|
+
teamId,
|
|
441
|
+
docPath,
|
|
442
|
+
data,
|
|
443
|
+
mimeType,
|
|
444
|
+
now,
|
|
445
|
+
localModifiedAt,
|
|
446
|
+
expectedRemoteHash: baseline,
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
// Record the synced content baseline whenever local matches the
|
|
450
|
+
// server (created/updated, or already-equal noop), so the pull side
|
|
451
|
+
// can tell "local unchanged" from "local edited" by content.
|
|
452
|
+
if (result.action === 'created' || result.action === 'updated' || result.action === 'noop') {
|
|
453
|
+
setBaseline(trackingKey, contentHash);
|
|
454
|
+
} else if (result.action === 'skipped-stale') {
|
|
455
|
+
// The server copy is genuinely newer; clear the pre-set marker so
|
|
456
|
+
// its firestore -> local snapshot is allowed through to update the
|
|
457
|
+
// local file.
|
|
458
|
+
lastKnownUpdate.delete(trackingKey);
|
|
459
|
+
} else if (result.action === 'conflict') {
|
|
460
|
+
// Another writer landed an edit in the gap between our last sync
|
|
461
|
+
// and this push. Preserve the user's would-be push as a sibling
|
|
462
|
+
// conflict copy, pull the new remote state into the live file,
|
|
463
|
+
// and advance the baseline so the next push (or further edits)
|
|
464
|
+
// can proceed against the new ground truth.
|
|
465
|
+
const conflictPath = writeConflictCopy(filePath, data);
|
|
466
|
+
await pullRemoteIntoLocal(result.remoteData, filePath);
|
|
467
|
+
setBaseline(trackingKey, result.remoteHash);
|
|
468
|
+
lastKnownUpdate.delete(trackingKey);
|
|
469
|
+
console.log(
|
|
470
|
+
` [${orgId}] [local → firestore] CONFLICT ${docPath}: ` +
|
|
471
|
+
`remote changed; local saved to ${path.basename(conflictPath)}`,
|
|
472
|
+
);
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (result.action === 'created') {
|
|
477
|
+
console.log(` [${orgId}] [local → firestore] CREATE ${docPath} (${scope}, ${mimeType})`);
|
|
478
|
+
} else if (result.action === 'updated') {
|
|
479
|
+
console.log(` [${orgId}] [local → firestore] UPDATE ${docPath} (${scope})`);
|
|
480
|
+
} else if (result.action === 'skipped-stale') {
|
|
481
|
+
console.log(` [${orgId}] [local → firestore] SKIP ${docPath} (server copy is newer)`);
|
|
482
|
+
}
|
|
483
|
+
if (result.storageError) {
|
|
484
|
+
console.error(` [${orgId}] [storage] upload failed for ${docPath}: ${result.storageError.message}`);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** Write the proposed (rejected) push to a sibling conflict file.
|
|
489
|
+
* Returns the new path. Suppresses chokidar so the conflict file's
|
|
490
|
+
* creation doesn't trigger its own push. */
|
|
491
|
+
function writeConflictCopy(filePath, data) {
|
|
492
|
+
const parsed = path.parse(filePath);
|
|
493
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
494
|
+
const conflictPath = path.join(parsed.dir, `${parsed.name}.conflict-${ts}${parsed.ext}`);
|
|
495
|
+
suppressLocal.add(conflictPath);
|
|
496
|
+
fs.writeFileSync(conflictPath, data);
|
|
497
|
+
setTimeout(() => suppressLocal.delete(conflictPath), 1000);
|
|
498
|
+
return conflictPath;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** Pull the remote blob into the live local path after a conflict.
|
|
502
|
+
* Uses suppressLocal so chokidar doesn't re-emit the write. */
|
|
503
|
+
async function pullRemoteIntoLocal(remoteData, localFilePath) {
|
|
504
|
+
const ext = extFromPath(remoteData.path);
|
|
505
|
+
const extDerivedMime = mimeTypeFromExt(ext);
|
|
506
|
+
const isText = isTextMimeType(remoteData.mimeType) || isTextMimeType(extDerivedMime);
|
|
507
|
+
suppressLocal.add(localFilePath);
|
|
508
|
+
const blobReadArgs = { storage, orgId, fileId: remoteData.id, ext };
|
|
509
|
+
const retryOpts = { expectedHash: remoteData.contentHash };
|
|
510
|
+
if (isText) {
|
|
511
|
+
const content = await readBlobWithRetry(() => readBlobAsText(blobReadArgs), retryOpts);
|
|
512
|
+
fs.writeFileSync(localFilePath, content || '', 'utf-8');
|
|
513
|
+
} else {
|
|
514
|
+
const bytes = await readBlobWithRetry(() => readBlob(blobReadArgs), retryOpts);
|
|
515
|
+
fs.writeFileSync(localFilePath, bytes);
|
|
516
|
+
}
|
|
517
|
+
setTimeout(() => suppressLocal.delete(localFilePath), 1000);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
async function deleteFromFirestore(filePath) {
|
|
521
|
+
const { scope, teamId, docPath } = scopeFromLocalPath(filePath, LOCAL_PATH, teamIdsByName);
|
|
522
|
+
const trackingKey = `${scope}:${docPath}`;
|
|
523
|
+
lastKnownUpdate.delete(trackingKey);
|
|
524
|
+
|
|
525
|
+
const result = await deleteFileFromCloud({
|
|
526
|
+
filesRef,
|
|
527
|
+
storage,
|
|
528
|
+
orgId,
|
|
529
|
+
userId,
|
|
530
|
+
scope,
|
|
531
|
+
teamId,
|
|
532
|
+
docPath,
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
if (result.action === 'deleted') {
|
|
536
|
+
console.log(` [${orgId}] [local → firestore] DELETE ${docPath} (${scope})`);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Determine whether a local path corresponds to a syncable directory.
|
|
541
|
+
// Excludes the scope-root directories themselves (Private, Shared
|
|
542
|
+
// with Me, Shared by Me, and the per-team roots) since those are
|
|
543
|
+
// managed by ensureFolderStructure, not the user.
|
|
544
|
+
function isSyncableLocalDir(rel) {
|
|
545
|
+
if (!rel) return false;
|
|
546
|
+
if (rel === PRIVATE_FOLDER || rel === SHARED_WITH_ME_FOLDER || rel === SHARED_BY_ME_FOLDER) {
|
|
547
|
+
return false;
|
|
548
|
+
}
|
|
549
|
+
if (rel.startsWith(SHARED_WITH_ME_FOLDER + path.sep)) return false;
|
|
550
|
+
if (rel.startsWith(SHARED_BY_ME_FOLDER + path.sep)) return false;
|
|
551
|
+
const parts = rel.split(path.sep);
|
|
552
|
+
// A bare team root (e.g. "Engineering Teamspace") corresponds to
|
|
553
|
+
// the scope root, not a subdirectory. Skip these.
|
|
554
|
+
if (parts.length === 1 && parts[0].endsWith(' Teamspace')) return false;
|
|
555
|
+
return true;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async function pushDirToFirestore(dirPath) {
|
|
559
|
+
const { scope, teamId, docPath } = scopeFromLocalPath(dirPath, LOCAL_PATH, teamIdsByName);
|
|
560
|
+
if (!docPath) return; // scope root
|
|
561
|
+
const trackingKey = `${scope}:${docPath}`;
|
|
562
|
+
const now = new Date().toISOString();
|
|
563
|
+
lastKnownUpdate.set(trackingKey, now);
|
|
564
|
+
|
|
565
|
+
const result = await pushFolderToCloud({
|
|
566
|
+
filesRef,
|
|
567
|
+
scope,
|
|
568
|
+
teamId,
|
|
569
|
+
userId,
|
|
570
|
+
docPath,
|
|
571
|
+
now,
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
if (result.action === 'ensured' && result.folderId) {
|
|
575
|
+
console.log(` [${orgId}] [local → firestore] FOLDER ${docPath} (${scope})`);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
async function deleteDirFromFirestore(dirPath) {
|
|
580
|
+
const { scope, teamId, docPath } = scopeFromLocalPath(dirPath, LOCAL_PATH, teamIdsByName);
|
|
581
|
+
if (!docPath) return;
|
|
582
|
+
const trackingKey = `${scope}:${docPath}`;
|
|
583
|
+
lastKnownUpdate.delete(trackingKey);
|
|
584
|
+
|
|
585
|
+
const result = await deleteFolderFromCloud({
|
|
586
|
+
filesRef,
|
|
587
|
+
scope,
|
|
588
|
+
teamId,
|
|
589
|
+
userId,
|
|
590
|
+
docPath,
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
if (result.action === 'deleted') {
|
|
594
|
+
console.log(` [${orgId}] [local → firestore] RMFOLDER ${docPath} (${scope})`);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function handleLocalChange(filePath) {
|
|
599
|
+
if (suppressLocal.has(filePath)) return;
|
|
600
|
+
|
|
601
|
+
const rel = path.relative(LOCAL_PATH, filePath);
|
|
602
|
+
if (rel.startsWith(SHARED_WITH_ME_FOLDER + path.sep)) return;
|
|
603
|
+
if (rel.startsWith(SHARED_BY_ME_FOLDER + path.sep)) return;
|
|
604
|
+
|
|
605
|
+
let stat;
|
|
606
|
+
try {
|
|
607
|
+
stat = fs.statSync(filePath);
|
|
608
|
+
} catch (err) {
|
|
609
|
+
return; // disappeared between event and read
|
|
610
|
+
}
|
|
611
|
+
if (!stat.isFile()) return;
|
|
612
|
+
if (stat.size > maxUploadBytes) {
|
|
613
|
+
console.error(` [${orgId}] [skip] ${rel} exceeds ${maxUploadBytes} byte cap (${stat.size} bytes)`);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const ext = extFromPath(rel);
|
|
618
|
+
const mimeType = mimeTypeFromExt(ext);
|
|
619
|
+
// Text files: read as a UTF-8 string so future text-aware features
|
|
620
|
+
// (line-level diffing, conflict markers) can work. Binary files:
|
|
621
|
+
// read as a Buffer so bytes round-trip without corruption.
|
|
622
|
+
const data = isTextMimeType(mimeType)
|
|
623
|
+
? fs.readFileSync(filePath, 'utf-8')
|
|
624
|
+
: fs.readFileSync(filePath);
|
|
625
|
+
const localModifiedAt = stat.mtime.toISOString();
|
|
626
|
+
pushToFirestore(filePath, data, mimeType, localModifiedAt).catch(err =>
|
|
627
|
+
console.error(` [${orgId}] Push error: ${err.message}`)
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function handleLocalDirAdded(dirPath) {
|
|
632
|
+
if (suppressLocal.has(dirPath)) return;
|
|
633
|
+
const rel = path.relative(LOCAL_PATH, dirPath);
|
|
634
|
+
if (!isSyncableLocalDir(rel)) return;
|
|
635
|
+
pushDirToFirestore(dirPath).catch(err =>
|
|
636
|
+
console.error(` [${orgId}] Folder push error for ${rel}: ${err.message}`)
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function handleLocalDirRemoved(dirPath) {
|
|
641
|
+
if (suppressLocal.has(dirPath)) return;
|
|
642
|
+
const rel = path.relative(LOCAL_PATH, dirPath);
|
|
643
|
+
if (!isSyncableLocalDir(rel)) return;
|
|
644
|
+
deleteDirFromFirestore(dirPath).catch(err =>
|
|
645
|
+
console.error(` [${orgId}] Folder delete error for ${rel}: ${err.message}`)
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function startLocalWatcher() {
|
|
650
|
+
console.log(` [${orgId}] Watching local files...`);
|
|
651
|
+
|
|
652
|
+
watcher = chokidar.watch(LOCAL_PATH, {
|
|
653
|
+
ignored: [
|
|
654
|
+
// Dotfiles (the sync engine's own .compound-sync-state.json lives
|
|
655
|
+
// here, plus things like .DS_Store, .git, etc. that the user
|
|
656
|
+
// doesn't want syncing).
|
|
657
|
+
/(^|[\/\\])\./,
|
|
658
|
+
// node_modules: large dependency dirs, never appropriate to sync.
|
|
659
|
+
/node_modules/,
|
|
660
|
+
],
|
|
661
|
+
persistent: true,
|
|
662
|
+
ignoreInitial: false,
|
|
663
|
+
followSymlinks: false,
|
|
664
|
+
awaitWriteFinish: {
|
|
665
|
+
stabilityThreshold: 500,
|
|
666
|
+
pollInterval: 100,
|
|
667
|
+
},
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
watcher.on('add', filePath => handleLocalChange(filePath));
|
|
671
|
+
watcher.on('change', filePath => handleLocalChange(filePath));
|
|
672
|
+
watcher.on('unlink', filePath => {
|
|
673
|
+
if (suppressLocal.has(filePath)) return;
|
|
674
|
+
deleteFromFirestore(filePath).catch(err => console.error(` [${orgId}] Delete error: ${err.message}`));
|
|
675
|
+
});
|
|
676
|
+
watcher.on('addDir', dirPath => handleLocalDirAdded(dirPath));
|
|
677
|
+
watcher.on('unlinkDir', dirPath => handleLocalDirRemoved(dirPath));
|
|
678
|
+
|
|
679
|
+
// Resolve only once chokidar has finished its initial scan and
|
|
680
|
+
// registered inotify (or FSEvents) watches on every existing
|
|
681
|
+
// subdirectory. Until 'ready' fires, files written to the watched
|
|
682
|
+
// tree can be silently missed because the OS-level watch hasn't
|
|
683
|
+
// attached yet. Callers awaiting startOrgSync get a guarantee
|
|
684
|
+
// that any local writes after the await are seen by the daemon.
|
|
685
|
+
return new Promise((resolve) => {
|
|
686
|
+
watcher.on('ready', () => {
|
|
687
|
+
console.log(` [${orgId}] Local watcher ready.`);
|
|
688
|
+
resolve();
|
|
689
|
+
});
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
startFirestoreListeners();
|
|
694
|
+
await startLocalWatcher();
|
|
695
|
+
|
|
696
|
+
return {
|
|
697
|
+
// Tear down all listeners and the watcher. Returns the watcher's
|
|
698
|
+
// close() promise so callers/tests can await full shutdown.
|
|
699
|
+
stop() {
|
|
700
|
+
for (const unsub of unsubscribers) {
|
|
701
|
+
try { unsub(); } catch (e) { /* ignore */ }
|
|
702
|
+
}
|
|
703
|
+
for (const unsub of teamUnsubscribers.values()) {
|
|
704
|
+
try { unsub(); } catch (e) { /* ignore */ }
|
|
705
|
+
}
|
|
706
|
+
teamUnsubscribers.clear();
|
|
707
|
+
return watcher ? watcher.close() : Promise.resolve();
|
|
708
|
+
},
|
|
709
|
+
};
|
|
710
|
+
}
|