@doubling/compound-sync 1.7.0 → 1.9.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/auth-persistence.js +34 -2
- package/files.js +21 -4
- package/org-sync.js +607 -0
- package/package.json +7 -3
- package/pre-mint-app-check.js +84 -0
- package/setup-auth-with-pre-mint.js +84 -0
- package/sync-state.js +32 -0
- package/sync.js +134 -455
package/org-sync.js
ADDED
|
@@ -0,0 +1,607 @@
|
|
|
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
|
+
// Start the per-org sync loop. Returns { stop } to unsubscribe all
|
|
44
|
+
// listeners and close the watcher (used by tests and by graceful
|
|
45
|
+
// shutdown). `localPath` must already be absolute/expanded.
|
|
46
|
+
export async function startOrgSync({
|
|
47
|
+
db,
|
|
48
|
+
storage,
|
|
49
|
+
userId,
|
|
50
|
+
orgId,
|
|
51
|
+
localPath,
|
|
52
|
+
maxUploadBytes = DEFAULT_MAX_UPLOAD_BYTES,
|
|
53
|
+
statePath,
|
|
54
|
+
}) {
|
|
55
|
+
const LOCAL_PATH = localPath;
|
|
56
|
+
const filesRef = collection(db, `orgs/${orgId}/files`);
|
|
57
|
+
// Where the per-folder content baseline is persisted. Default: a
|
|
58
|
+
// dotfile in the synced folder (ignored by the watcher), so it travels
|
|
59
|
+
// with the folder and needs no extra wiring from the caller.
|
|
60
|
+
const STATE_PATH = statePath ?? path.join(LOCAL_PATH, '.compound-sync-state.json');
|
|
61
|
+
|
|
62
|
+
// Teardown handles: every onSnapshot unsub plus the chokidar watcher.
|
|
63
|
+
const unsubscribers = [];
|
|
64
|
+
let watcher = null;
|
|
65
|
+
|
|
66
|
+
const lastKnownUpdate = new Map();
|
|
67
|
+
// Content hash of each file as of the last successful sync (push or
|
|
68
|
+
// pull), keyed by trackingKey. This "baseline" decides pull/push
|
|
69
|
+
// direction by CONTENT rather than filesystem mtime (which an editor
|
|
70
|
+
// like Obsidian bumps without a content change). Loaded from
|
|
71
|
+
// STATE_PATH so it survives restarts; persisted on every change.
|
|
72
|
+
const lastSyncedHash = new Map(Object.entries(loadSyncState(STATE_PATH)));
|
|
73
|
+
function persistBaseline() {
|
|
74
|
+
saveSyncState(STATE_PATH, Object.fromEntries(lastSyncedHash));
|
|
75
|
+
}
|
|
76
|
+
function setBaseline(key, hash) { lastSyncedHash.set(key, hash); persistBaseline(); }
|
|
77
|
+
function deleteBaseline(key) { lastSyncedHash.delete(key); persistBaseline(); }
|
|
78
|
+
const suppressLocal = new Set();
|
|
79
|
+
// Per-team Firestore-listener unsubscribe handles, keyed by teamId.
|
|
80
|
+
const teamUnsubscribers = new Map();
|
|
81
|
+
// Hoisted forward-decl: setupTeam closes over handleFirestoreChange,
|
|
82
|
+
// which is defined later in this function. Wired below.
|
|
83
|
+
let setupTeam = () => {};
|
|
84
|
+
function teardownTeam(teamId, teamData) {
|
|
85
|
+
const unsub = teamUnsubscribers.get(teamId);
|
|
86
|
+
if (unsub) {
|
|
87
|
+
try { unsub(); } catch (e) { /* ignore */ }
|
|
88
|
+
teamUnsubscribers.delete(teamId);
|
|
89
|
+
const folder = teamData && teamData.name ? teamFolderName(teamData.name) : teamId;
|
|
90
|
+
console.log(` [${orgId}] Stopped listening: ${folder}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const teamRegistry = new TeamRegistry({
|
|
94
|
+
onSetup: (teamId, teamData) => setupTeam(teamId, teamData),
|
|
95
|
+
onTeardown: teardownTeam,
|
|
96
|
+
});
|
|
97
|
+
// scopeFromLocalPath expects a Map<teamName, teamId>; the registry
|
|
98
|
+
// exposes one matching that shape.
|
|
99
|
+
const teamIdsByName = teamRegistry.teamIdsByName;
|
|
100
|
+
|
|
101
|
+
console.log('');
|
|
102
|
+
console.log(` [${orgId}] Local: ${LOCAL_PATH}`);
|
|
103
|
+
|
|
104
|
+
function ensureDir(filePath) {
|
|
105
|
+
const dir = path.dirname(filePath);
|
|
106
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function updateSharedByMeSymlink(fileDoc, realPath) {
|
|
110
|
+
const sharedWith = fileDoc.sharedWith || [];
|
|
111
|
+
const symlinkPath = path.join(LOCAL_PATH, SHARED_BY_ME_FOLDER, fileDoc.path);
|
|
112
|
+
|
|
113
|
+
if (sharedWith.length > 0) {
|
|
114
|
+
ensureDir(symlinkPath);
|
|
115
|
+
const relativeTo = path.relative(path.dirname(symlinkPath), realPath);
|
|
116
|
+
try {
|
|
117
|
+
if (fs.existsSync(symlinkPath) || fs.lstatSync(symlinkPath).isSymbolicLink()) {
|
|
118
|
+
fs.unlinkSync(symlinkPath);
|
|
119
|
+
}
|
|
120
|
+
} catch (e) { /* doesn't exist */ }
|
|
121
|
+
try {
|
|
122
|
+
fs.symlinkSync(relativeTo, symlinkPath);
|
|
123
|
+
console.log(` [${orgId}] [symlink] ${fileDoc.path} → Shared by Me/`);
|
|
124
|
+
} catch (e) {
|
|
125
|
+
if (e.code !== 'EEXIST') console.error(` [${orgId}] Symlink error: ${e.message}`);
|
|
126
|
+
}
|
|
127
|
+
} else {
|
|
128
|
+
try {
|
|
129
|
+
if (fs.lstatSync(symlinkPath).isSymbolicLink()) {
|
|
130
|
+
fs.unlinkSync(symlinkPath);
|
|
131
|
+
console.log(` [${orgId}] [symlink] removed Shared by Me/${fileDoc.path}`);
|
|
132
|
+
}
|
|
133
|
+
} catch (e) { /* doesn't exist */ }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Static (user-level) folders. Team folders are created per-team by
|
|
138
|
+
// setupTeam below as soon as the registry sees each teamspace.
|
|
139
|
+
function ensureFolderStructure() {
|
|
140
|
+
const privatePath = path.join(LOCAL_PATH, PRIVATE_FOLDER);
|
|
141
|
+
if (!fs.existsSync(privatePath)) fs.mkdirSync(privatePath, { recursive: true });
|
|
142
|
+
|
|
143
|
+
const sharedWithMePath = path.join(LOCAL_PATH, SHARED_WITH_ME_FOLDER);
|
|
144
|
+
if (!fs.existsSync(sharedWithMePath)) fs.mkdirSync(sharedWithMePath, { recursive: true });
|
|
145
|
+
|
|
146
|
+
const sharedByMePath = path.join(LOCAL_PATH, SHARED_BY_ME_FOLDER);
|
|
147
|
+
if (!fs.existsSync(sharedByMePath)) fs.mkdirSync(sharedByMePath, { recursive: true });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
ensureFolderStructure();
|
|
151
|
+
|
|
152
|
+
async function handleFirestoreChange(change, getLocalPath) {
|
|
153
|
+
const data = change.doc.data();
|
|
154
|
+
const docPath = data.path;
|
|
155
|
+
if (!docPath) return;
|
|
156
|
+
|
|
157
|
+
const localFilePath = getLocalPath(data);
|
|
158
|
+
const trackingKey = `${data.scope || 'team'}:${docPath}`;
|
|
159
|
+
const isFolder = data.type === 'folder';
|
|
160
|
+
|
|
161
|
+
if (change.type === 'removed') {
|
|
162
|
+
if (isFolder) {
|
|
163
|
+
// Best-effort directory removal. The daemon's own file-delete
|
|
164
|
+
// events should have cleared all children by the time we get
|
|
165
|
+
// here; if not, rmdirSync throws ENOTEMPTY and we keep the
|
|
166
|
+
// directory rather than risk losing data the user hasn't seen.
|
|
167
|
+
try {
|
|
168
|
+
suppressLocal.add(localFilePath);
|
|
169
|
+
fs.rmdirSync(localFilePath);
|
|
170
|
+
console.log(` [${orgId}] [firestore → local] RMDIR ${docPath}`);
|
|
171
|
+
setTimeout(() => suppressLocal.delete(localFilePath), 1000);
|
|
172
|
+
} catch (err) {
|
|
173
|
+
suppressLocal.delete(localFilePath);
|
|
174
|
+
if (err.code !== 'ENOENT' && err.code !== 'ENOTEMPTY') {
|
|
175
|
+
console.error(` [${orgId}] [firestore → local] RMDIR ${docPath} failed: ${err.message}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
} else if (fs.existsSync(localFilePath)) {
|
|
179
|
+
console.log(` [${orgId}] [firestore → local] DELETE ${docPath}`);
|
|
180
|
+
suppressLocal.add(localFilePath);
|
|
181
|
+
fs.unlinkSync(localFilePath);
|
|
182
|
+
setTimeout(() => suppressLocal.delete(localFilePath), 1000);
|
|
183
|
+
}
|
|
184
|
+
lastKnownUpdate.delete(trackingKey);
|
|
185
|
+
deleteBaseline(trackingKey);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const remoteUpdated = data.updatedAt || data.createdAt || '';
|
|
190
|
+
const lastKnown = lastKnownUpdate.get(trackingKey);
|
|
191
|
+
if (lastKnown && lastKnown >= remoteUpdated) return;
|
|
192
|
+
|
|
193
|
+
if (isFolder) {
|
|
194
|
+
// Folder docs mirror to a local directory. No blob, no content
|
|
195
|
+
// comparison; if the directory doesn't exist yet, create it.
|
|
196
|
+
if (!fs.existsSync(localFilePath)) {
|
|
197
|
+
suppressLocal.add(localFilePath);
|
|
198
|
+
fs.mkdirSync(localFilePath, { recursive: true });
|
|
199
|
+
console.log(` [${orgId}] [firestore → local] MKDIR ${docPath}`);
|
|
200
|
+
setTimeout(() => suppressLocal.delete(localFilePath), 1000);
|
|
201
|
+
}
|
|
202
|
+
lastKnownUpdate.set(trackingKey, remoteUpdated);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (fs.existsSync(localFilePath)) {
|
|
207
|
+
const baseline = lastSyncedHash.get(trackingKey);
|
|
208
|
+
if (baseline !== undefined) {
|
|
209
|
+
// We have a record of what we last synced: decide by content,
|
|
210
|
+
// not mtime. (Obsidian keeps the mtime fresh, which made the old
|
|
211
|
+
// mtime guard wrongly skip web edits.)
|
|
212
|
+
const localHash = computeContentHash(fs.readFileSync(localFilePath));
|
|
213
|
+
if (localHash === data.contentHash) {
|
|
214
|
+
// Local already matches the incoming server content; nothing
|
|
215
|
+
// to write.
|
|
216
|
+
lastKnownUpdate.set(trackingKey, remoteUpdated);
|
|
217
|
+
setBaseline(trackingKey, data.contentHash);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (localHash !== baseline) {
|
|
221
|
+
// Local changed since our last sync AND the server changed:
|
|
222
|
+
// a genuine conflict. Keep the local copy rather than clobber
|
|
223
|
+
// it. (Writing a "conflicted copy" is the next increment.)
|
|
224
|
+
console.log(` [${orgId}] [conflict] ${docPath}: local and server both changed; keeping local`);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
// localHash === baseline: local is untouched since last sync, so
|
|
228
|
+
// the server is authoritative -> fall through and pull.
|
|
229
|
+
} else {
|
|
230
|
+
// No baseline at all: a local file this daemon has never synced
|
|
231
|
+
// (e.g. created locally while offline, or a folder adopted from
|
|
232
|
+
// elsewhere). We can't tell by content who is newer, so fall
|
|
233
|
+
// back to the mtime heuristic. Conflict copies (next increment)
|
|
234
|
+
// will replace this last-resort with a no-loss outcome.
|
|
235
|
+
const localMtime = fs.statSync(localFilePath).mtime.toISOString();
|
|
236
|
+
if (localMtime > remoteUpdated) return;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const action = change.type === 'added' ? 'CREATE' : 'UPDATE';
|
|
241
|
+
console.log(` [${orgId}] [firestore → local] ${action} ${docPath} (${data.scope || 'team'})`);
|
|
242
|
+
ensureDir(localFilePath);
|
|
243
|
+
suppressLocal.add(localFilePath);
|
|
244
|
+
|
|
245
|
+
const ext = extFromPath(docPath);
|
|
246
|
+
if (isTextMimeType(data.mimeType)) {
|
|
247
|
+
const content = await readBlobAsText({
|
|
248
|
+
storage,
|
|
249
|
+
orgId,
|
|
250
|
+
fileId: change.doc.id,
|
|
251
|
+
ext,
|
|
252
|
+
});
|
|
253
|
+
fs.writeFileSync(localFilePath, content || '', 'utf-8');
|
|
254
|
+
} else {
|
|
255
|
+
const bytes = await readBlob({
|
|
256
|
+
storage,
|
|
257
|
+
orgId,
|
|
258
|
+
fileId: change.doc.id,
|
|
259
|
+
ext,
|
|
260
|
+
});
|
|
261
|
+
fs.writeFileSync(localFilePath, bytes);
|
|
262
|
+
}
|
|
263
|
+
lastKnownUpdate.set(trackingKey, remoteUpdated);
|
|
264
|
+
setBaseline(trackingKey, data.contentHash);
|
|
265
|
+
setTimeout(() => suppressLocal.delete(localFilePath), 1000);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Set up per-team resources: mkdir the local folder, register the
|
|
269
|
+
// Firestore listener for that team's files. Wired into the
|
|
270
|
+
// TeamRegistry; called once per teamspace as it appears.
|
|
271
|
+
setupTeam = (teamId, teamData) => {
|
|
272
|
+
const teamFolder = teamFolderName(teamData.name);
|
|
273
|
+
const teamPath = path.join(LOCAL_PATH, teamFolder);
|
|
274
|
+
if (!fs.existsSync(teamPath)) fs.mkdirSync(teamPath, { recursive: true });
|
|
275
|
+
|
|
276
|
+
const q = query(filesRef, where('scope', '==', 'team'), where('teamId', '==', teamId));
|
|
277
|
+
const unsubscribe = onSnapshot(q, snapshot => {
|
|
278
|
+
snapshot.docChanges().forEach(change => {
|
|
279
|
+
handleFirestoreChange(change, data =>
|
|
280
|
+
path.join(LOCAL_PATH, teamFolder, data.path),
|
|
281
|
+
).catch(err => console.error(` [${orgId}] Firestore change handler error:`, err && err.message));
|
|
282
|
+
});
|
|
283
|
+
}, err => console.error(` [${orgId}] Team "${teamFolder}" listener error:`, err));
|
|
284
|
+
|
|
285
|
+
teamUnsubscribers.set(teamId, unsubscribe);
|
|
286
|
+
console.log(` [${orgId}] Listening: ${teamFolder}`);
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
// Live listener on the teams collection. Initial snapshot bootstraps
|
|
290
|
+
// the known teamspaces; subsequent changes pick up new/renamed/
|
|
291
|
+
// removed teamspaces without restarting the daemon. We await the
|
|
292
|
+
// first snapshot so the local watcher sees a populated teamIdsByName
|
|
293
|
+
// before it tries to route any addDir/add events.
|
|
294
|
+
let teamsUnsub = null;
|
|
295
|
+
await new Promise((resolve, reject) => {
|
|
296
|
+
let firstSnapshotResolved = false;
|
|
297
|
+
teamsUnsub = onSnapshot(collection(db, `orgs/${orgId}/teams`), (snapshot) => {
|
|
298
|
+
snapshot.docChanges().forEach((change) => {
|
|
299
|
+
teamRegistry.applyChange({
|
|
300
|
+
type: change.type,
|
|
301
|
+
id: change.doc.id,
|
|
302
|
+
data: change.doc.data(),
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
if (!firstSnapshotResolved) {
|
|
306
|
+
firstSnapshotResolved = true;
|
|
307
|
+
console.log(` [${orgId}] Teams: ${teamRegistry.names().join(', ') || 'none'}`);
|
|
308
|
+
resolve();
|
|
309
|
+
}
|
|
310
|
+
}, (err) => {
|
|
311
|
+
console.error(` [${orgId}] Teams listener error:`, err);
|
|
312
|
+
if (!firstSnapshotResolved) {
|
|
313
|
+
firstSnapshotResolved = true;
|
|
314
|
+
reject(err);
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
unsubscribers.push(teamsUnsub);
|
|
319
|
+
|
|
320
|
+
function startFirestoreListeners() {
|
|
321
|
+
console.log(` [${orgId}] Starting Firestore listeners...`);
|
|
322
|
+
|
|
323
|
+
if (userId) {
|
|
324
|
+
const privateQ = query(filesRef, where('scope', '==', 'private'), where('ownerId', '==', userId));
|
|
325
|
+
const privateUnsub = onSnapshot(privateQ, snapshot => {
|
|
326
|
+
snapshot.docChanges().forEach(change => {
|
|
327
|
+
const data = change.doc.data();
|
|
328
|
+
const realPath = path.join(LOCAL_PATH, PRIVATE_FOLDER, data.path);
|
|
329
|
+
|
|
330
|
+
handleFirestoreChange(change, () => realPath)
|
|
331
|
+
.catch(err => console.error(` [${orgId}] Firestore change handler error:`, err && err.message));
|
|
332
|
+
|
|
333
|
+
if (change.type !== 'removed') {
|
|
334
|
+
updateSharedByMeSymlink(data, realPath);
|
|
335
|
+
} else {
|
|
336
|
+
const symlinkPath = path.join(LOCAL_PATH, SHARED_BY_ME_FOLDER, data.path);
|
|
337
|
+
try { fs.lstatSync(symlinkPath); fs.unlinkSync(symlinkPath); } catch (e) { /* ok */ }
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
}, err => console.error(` [${orgId}] Private files listener error:`, err));
|
|
341
|
+
unsubscribers.push(privateUnsub);
|
|
342
|
+
console.log(` [${orgId}] Listening: Private files + Shared by Me symlinks`);
|
|
343
|
+
|
|
344
|
+
// scope=='private' is required: Firestore rules only grant
|
|
345
|
+
// sharedWith-based reads on private files, so the query must
|
|
346
|
+
// constrain scope or the whole listen is denied (rules are not
|
|
347
|
+
// filters). See tests/rules.test.js "shared-with-me query".
|
|
348
|
+
const sharedQ = query(
|
|
349
|
+
filesRef,
|
|
350
|
+
where('scope', '==', 'private'),
|
|
351
|
+
where('sharedWith', 'array-contains', userId),
|
|
352
|
+
);
|
|
353
|
+
const sharedUnsub = onSnapshot(sharedQ, snapshot => {
|
|
354
|
+
snapshot.docChanges().forEach(change => {
|
|
355
|
+
const data = change.doc.data();
|
|
356
|
+
if (data.ownerId === userId) return;
|
|
357
|
+
handleFirestoreChange(change, () =>
|
|
358
|
+
path.join(LOCAL_PATH, SHARED_WITH_ME_FOLDER, data.path),
|
|
359
|
+
).catch(err => console.error(` [${orgId}] Firestore change handler error:`, err && err.message));
|
|
360
|
+
});
|
|
361
|
+
}, err => console.error(` [${orgId}] Shared with me listener error:`, err));
|
|
362
|
+
unsubscribers.push(sharedUnsub);
|
|
363
|
+
console.log(` [${orgId}] Listening: Shared with Me`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function pushToFirestore(filePath, data, mimeType, localModifiedAt) {
|
|
368
|
+
const { scope, teamId, docPath } = scopeFromLocalPath(filePath, LOCAL_PATH, teamIdsByName);
|
|
369
|
+
const trackingKey = `${scope}:${docPath}`;
|
|
370
|
+
|
|
371
|
+
// Skip if the local content is unchanged since the last sync. This
|
|
372
|
+
// is the push-side counterpart of the content baseline: an editor
|
|
373
|
+
// re-saving or touching a file (or the watcher re-emitting it on
|
|
374
|
+
// startup) must not push stale bytes over a newer server edit. Only
|
|
375
|
+
// a genuine content change proceeds.
|
|
376
|
+
const contentHash = computeContentHash(data);
|
|
377
|
+
if (lastSyncedHash.get(trackingKey) === contentHash) return;
|
|
378
|
+
|
|
379
|
+
const now = new Date().toISOString();
|
|
380
|
+
|
|
381
|
+
// Set the echo-suppression marker BEFORE writing. Our own write
|
|
382
|
+
// round-trips through the Firestore listener; without a marker
|
|
383
|
+
// already in place, that echo is treated as an incoming change and
|
|
384
|
+
// triggers a spurious self-pull, which (a) reads the blob before it
|
|
385
|
+
// is uploaded (object-not-found) and (b) adds the path to
|
|
386
|
+
// suppressLocal, swallowing a subsequent rename's unlink (leaving a
|
|
387
|
+
// duplicate doc). Pre-setting it suppresses the echo.
|
|
388
|
+
lastKnownUpdate.set(trackingKey, now);
|
|
389
|
+
|
|
390
|
+
const result = await pushFileToCloud({
|
|
391
|
+
filesRef,
|
|
392
|
+
storage,
|
|
393
|
+
orgId,
|
|
394
|
+
userId,
|
|
395
|
+
scope,
|
|
396
|
+
teamId,
|
|
397
|
+
docPath,
|
|
398
|
+
data,
|
|
399
|
+
mimeType,
|
|
400
|
+
now,
|
|
401
|
+
localModifiedAt,
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
// Record the synced content baseline whenever local matches the
|
|
405
|
+
// server (created/updated, or already-equal noop), so the pull side
|
|
406
|
+
// can tell "local unchanged" from "local edited" by content.
|
|
407
|
+
if (result.action === 'created' || result.action === 'updated' || result.action === 'noop') {
|
|
408
|
+
setBaseline(trackingKey, contentHash);
|
|
409
|
+
} else if (result.action === 'skipped-stale') {
|
|
410
|
+
// The server copy is genuinely newer; clear the pre-set marker so
|
|
411
|
+
// its firestore -> local snapshot is allowed through to update the
|
|
412
|
+
// local file.
|
|
413
|
+
lastKnownUpdate.delete(trackingKey);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (result.action === 'created') {
|
|
417
|
+
console.log(` [${orgId}] [local → firestore] CREATE ${docPath} (${scope}, ${mimeType})`);
|
|
418
|
+
} else if (result.action === 'updated') {
|
|
419
|
+
console.log(` [${orgId}] [local → firestore] UPDATE ${docPath} (${scope})`);
|
|
420
|
+
} else if (result.action === 'skipped-stale') {
|
|
421
|
+
console.log(` [${orgId}] [local → firestore] SKIP ${docPath} (server copy is newer)`);
|
|
422
|
+
}
|
|
423
|
+
if (result.storageError) {
|
|
424
|
+
console.error(` [${orgId}] [storage] upload failed for ${docPath}: ${result.storageError.message}`);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async function deleteFromFirestore(filePath) {
|
|
429
|
+
const { scope, teamId, docPath } = scopeFromLocalPath(filePath, LOCAL_PATH, teamIdsByName);
|
|
430
|
+
const trackingKey = `${scope}:${docPath}`;
|
|
431
|
+
lastKnownUpdate.delete(trackingKey);
|
|
432
|
+
|
|
433
|
+
const result = await deleteFileFromCloud({
|
|
434
|
+
filesRef,
|
|
435
|
+
storage,
|
|
436
|
+
orgId,
|
|
437
|
+
userId,
|
|
438
|
+
scope,
|
|
439
|
+
teamId,
|
|
440
|
+
docPath,
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
if (result.action === 'deleted') {
|
|
444
|
+
console.log(` [${orgId}] [local → firestore] DELETE ${docPath} (${scope})`);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Determine whether a local path corresponds to a syncable directory.
|
|
449
|
+
// Excludes the scope-root directories themselves (Private, Shared
|
|
450
|
+
// with Me, Shared by Me, and the per-team roots) since those are
|
|
451
|
+
// managed by ensureFolderStructure, not the user.
|
|
452
|
+
function isSyncableLocalDir(rel) {
|
|
453
|
+
if (!rel) return false;
|
|
454
|
+
if (rel === PRIVATE_FOLDER || rel === SHARED_WITH_ME_FOLDER || rel === SHARED_BY_ME_FOLDER) {
|
|
455
|
+
return false;
|
|
456
|
+
}
|
|
457
|
+
if (rel.startsWith(SHARED_WITH_ME_FOLDER + path.sep)) return false;
|
|
458
|
+
if (rel.startsWith(SHARED_BY_ME_FOLDER + path.sep)) return false;
|
|
459
|
+
const parts = rel.split(path.sep);
|
|
460
|
+
// A bare team root (e.g. "Engineering Teamspace") corresponds to
|
|
461
|
+
// the scope root, not a subdirectory. Skip these.
|
|
462
|
+
if (parts.length === 1 && parts[0].endsWith(' Teamspace')) return false;
|
|
463
|
+
return true;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
async function pushDirToFirestore(dirPath) {
|
|
467
|
+
const { scope, teamId, docPath } = scopeFromLocalPath(dirPath, LOCAL_PATH, teamIdsByName);
|
|
468
|
+
if (!docPath) return; // scope root
|
|
469
|
+
const trackingKey = `${scope}:${docPath}`;
|
|
470
|
+
const now = new Date().toISOString();
|
|
471
|
+
lastKnownUpdate.set(trackingKey, now);
|
|
472
|
+
|
|
473
|
+
const result = await pushFolderToCloud({
|
|
474
|
+
filesRef,
|
|
475
|
+
scope,
|
|
476
|
+
teamId,
|
|
477
|
+
userId,
|
|
478
|
+
docPath,
|
|
479
|
+
now,
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
if (result.action === 'ensured' && result.folderId) {
|
|
483
|
+
console.log(` [${orgId}] [local → firestore] FOLDER ${docPath} (${scope})`);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async function deleteDirFromFirestore(dirPath) {
|
|
488
|
+
const { scope, teamId, docPath } = scopeFromLocalPath(dirPath, LOCAL_PATH, teamIdsByName);
|
|
489
|
+
if (!docPath) return;
|
|
490
|
+
const trackingKey = `${scope}:${docPath}`;
|
|
491
|
+
lastKnownUpdate.delete(trackingKey);
|
|
492
|
+
|
|
493
|
+
const result = await deleteFolderFromCloud({
|
|
494
|
+
filesRef,
|
|
495
|
+
scope,
|
|
496
|
+
teamId,
|
|
497
|
+
userId,
|
|
498
|
+
docPath,
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
if (result.action === 'deleted') {
|
|
502
|
+
console.log(` [${orgId}] [local → firestore] RMFOLDER ${docPath} (${scope})`);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function handleLocalChange(filePath) {
|
|
507
|
+
if (suppressLocal.has(filePath)) return;
|
|
508
|
+
|
|
509
|
+
const rel = path.relative(LOCAL_PATH, filePath);
|
|
510
|
+
if (rel.startsWith(SHARED_WITH_ME_FOLDER + path.sep)) return;
|
|
511
|
+
if (rel.startsWith(SHARED_BY_ME_FOLDER + path.sep)) return;
|
|
512
|
+
|
|
513
|
+
let stat;
|
|
514
|
+
try {
|
|
515
|
+
stat = fs.statSync(filePath);
|
|
516
|
+
} catch (err) {
|
|
517
|
+
return; // disappeared between event and read
|
|
518
|
+
}
|
|
519
|
+
if (!stat.isFile()) return;
|
|
520
|
+
if (stat.size > maxUploadBytes) {
|
|
521
|
+
console.error(` [${orgId}] [skip] ${rel} exceeds ${maxUploadBytes} byte cap (${stat.size} bytes)`);
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const ext = extFromPath(rel);
|
|
526
|
+
const mimeType = mimeTypeFromExt(ext);
|
|
527
|
+
// Text files: read as a UTF-8 string so future text-aware features
|
|
528
|
+
// (line-level diffing, conflict markers) can work. Binary files:
|
|
529
|
+
// read as a Buffer so bytes round-trip without corruption.
|
|
530
|
+
const data = isTextMimeType(mimeType)
|
|
531
|
+
? fs.readFileSync(filePath, 'utf-8')
|
|
532
|
+
: fs.readFileSync(filePath);
|
|
533
|
+
const localModifiedAt = stat.mtime.toISOString();
|
|
534
|
+
pushToFirestore(filePath, data, mimeType, localModifiedAt).catch(err =>
|
|
535
|
+
console.error(` [${orgId}] Push error: ${err.message}`)
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function handleLocalDirAdded(dirPath) {
|
|
540
|
+
if (suppressLocal.has(dirPath)) return;
|
|
541
|
+
const rel = path.relative(LOCAL_PATH, dirPath);
|
|
542
|
+
if (!isSyncableLocalDir(rel)) return;
|
|
543
|
+
pushDirToFirestore(dirPath).catch(err =>
|
|
544
|
+
console.error(` [${orgId}] Folder push error for ${rel}: ${err.message}`)
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function handleLocalDirRemoved(dirPath) {
|
|
549
|
+
if (suppressLocal.has(dirPath)) return;
|
|
550
|
+
const rel = path.relative(LOCAL_PATH, dirPath);
|
|
551
|
+
if (!isSyncableLocalDir(rel)) return;
|
|
552
|
+
deleteDirFromFirestore(dirPath).catch(err =>
|
|
553
|
+
console.error(` [${orgId}] Folder delete error for ${rel}: ${err.message}`)
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function startLocalWatcher() {
|
|
558
|
+
console.log(` [${orgId}] Watching local files...`);
|
|
559
|
+
|
|
560
|
+
watcher = chokidar.watch(LOCAL_PATH, {
|
|
561
|
+
ignored: [
|
|
562
|
+
// Dotfiles (the sync engine's own .compound-sync-state.json lives
|
|
563
|
+
// here, plus things like .DS_Store, .git, etc. that the user
|
|
564
|
+
// doesn't want syncing).
|
|
565
|
+
/(^|[\/\\])\./,
|
|
566
|
+
// node_modules: large dependency dirs, never appropriate to sync.
|
|
567
|
+
/node_modules/,
|
|
568
|
+
],
|
|
569
|
+
persistent: true,
|
|
570
|
+
ignoreInitial: false,
|
|
571
|
+
followSymlinks: false,
|
|
572
|
+
awaitWriteFinish: {
|
|
573
|
+
stabilityThreshold: 500,
|
|
574
|
+
pollInterval: 100,
|
|
575
|
+
},
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
watcher.on('add', filePath => handleLocalChange(filePath));
|
|
579
|
+
watcher.on('change', filePath => handleLocalChange(filePath));
|
|
580
|
+
watcher.on('unlink', filePath => {
|
|
581
|
+
if (suppressLocal.has(filePath)) return;
|
|
582
|
+
deleteFromFirestore(filePath).catch(err => console.error(` [${orgId}] Delete error: ${err.message}`));
|
|
583
|
+
});
|
|
584
|
+
watcher.on('addDir', dirPath => handleLocalDirAdded(dirPath));
|
|
585
|
+
watcher.on('unlinkDir', dirPath => handleLocalDirRemoved(dirPath));
|
|
586
|
+
|
|
587
|
+
watcher.on('ready', () => console.log(` [${orgId}] Local watcher ready.`));
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
startFirestoreListeners();
|
|
591
|
+
startLocalWatcher();
|
|
592
|
+
|
|
593
|
+
return {
|
|
594
|
+
// Tear down all listeners and the watcher. Returns the watcher's
|
|
595
|
+
// close() promise so callers/tests can await full shutdown.
|
|
596
|
+
stop() {
|
|
597
|
+
for (const unsub of unsubscribers) {
|
|
598
|
+
try { unsub(); } catch (e) { /* ignore */ }
|
|
599
|
+
}
|
|
600
|
+
for (const unsub of teamUnsubscribers.values()) {
|
|
601
|
+
try { unsub(); } catch (e) { /* ignore */ }
|
|
602
|
+
}
|
|
603
|
+
teamUnsubscribers.clear();
|
|
604
|
+
return watcher ? watcher.close() : Promise.resolve();
|
|
605
|
+
},
|
|
606
|
+
};
|
|
607
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@doubling/compound-sync",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.5",
|
|
4
4
|
"description": "Bidirectional sync between Compound and local markdown files",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"sync.js",
|
|
11
|
+
"org-sync.js",
|
|
12
|
+
"sync-state.js",
|
|
11
13
|
"config.js",
|
|
12
14
|
"paths.js",
|
|
13
15
|
"files.js",
|
|
@@ -15,12 +17,14 @@
|
|
|
15
17
|
"storage-helpers.js",
|
|
16
18
|
"team-registry.js",
|
|
17
19
|
"auth-persistence.js",
|
|
20
|
+
"pre-mint-app-check.js",
|
|
21
|
+
"setup-auth-with-pre-mint.js",
|
|
18
22
|
"README.md"
|
|
19
23
|
],
|
|
20
24
|
"scripts": {
|
|
21
25
|
"sync": "node sync.js",
|
|
22
|
-
"test:unit": "node --test config.test.js paths.test.js storage-helpers.test.js folder-chain.test.js team-registry.test.js auth-persistence.test.js",
|
|
23
|
-
"test:integration": "firebase emulators:exec --only firestore,storage 'node --test files.test.js'",
|
|
26
|
+
"test:unit": "node --test config.test.js paths.test.js storage-helpers.test.js folder-chain.test.js team-registry.test.js auth-persistence.test.js pre-mint-app-check.test.js setup-auth-with-pre-mint.test.js sync-state.test.js",
|
|
27
|
+
"test:integration": "firebase emulators:exec --only firestore,storage 'node --test --test-concurrency=1 files.test.js org-sync.test.js'",
|
|
24
28
|
"test": "npm run test:unit && npm run test:integration"
|
|
25
29
|
},
|
|
26
30
|
"dependencies": {
|