@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.
@@ -31,6 +31,18 @@ export function resolveAuthFilePath({ authFileEnv, homeDir, projectId, accountKe
31
31
  }
32
32
 
33
33
  export function makeFilePersistence(filePath) {
34
+ // Freeze guard: starts true. During daemon startup the Firebase Auth
35
+ // SDK validates the cached refresh token against the backend. With
36
+ // App Check enforcement on Authentication enabled, that validation
37
+ // request lacks an App Check header (App Check is initialized after
38
+ // authStateReady today), the backend rejects, and the SDK calls
39
+ // _remove(userKey) on our persistence, wiping credentials. While
40
+ // frozen, _remove is a no-op. The daemon calls FilePersistence.unfreeze()
41
+ // after authStateReady resolves, restoring normal sign-out behavior.
42
+ // Per factory call (per account): unfreezing one account's persistence
43
+ // must not unfreeze another's.
44
+ let frozen = true;
45
+
34
46
  function readStore() {
35
47
  try {
36
48
  return JSON.parse(fs.readFileSync(filePath, 'utf8'));
@@ -41,8 +53,17 @@ export function makeFilePersistence(filePath) {
41
53
  }
42
54
 
43
55
  function writeStore(store) {
44
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
45
- fs.writeFileSync(filePath, JSON.stringify(store), { mode: 0o600 });
56
+ // Atomic write: temp file + rename. fs.writeFileSync is not atomic,
57
+ // so a crash or kill mid-write would truncate the credentials file
58
+ // and the next launch would read it as an empty store (parse fail
59
+ // = {}), forcing a browser popup. rename(2) on the same filesystem
60
+ // is atomic, so on-disk state is always either the prior valid
61
+ // JSON or the new valid JSON.
62
+ const dir = path.dirname(filePath);
63
+ fs.mkdirSync(dir, { recursive: true });
64
+ const tmp = path.join(dir, `.${path.basename(filePath)}.${process.pid}.tmp`);
65
+ fs.writeFileSync(tmp, JSON.stringify(store), { mode: 0o600 });
66
+ fs.renameSync(tmp, filePath);
46
67
  }
47
68
 
48
69
  return class FilePersistence {
@@ -51,6 +72,13 @@ export function makeFilePersistence(filePath) {
51
72
  static type = 'LOCAL';
52
73
  type = 'LOCAL';
53
74
 
75
+ // Called by the daemon once auth restore is past the validation
76
+ // window (after authStateReady). Sign-out _remove calls after this
77
+ // point are honored normally.
78
+ static unfreeze() {
79
+ frozen = false;
80
+ }
81
+
54
82
  async _isAvailable() {
55
83
  return true;
56
84
  }
@@ -67,6 +95,10 @@ export function makeFilePersistence(filePath) {
67
95
  }
68
96
 
69
97
  async _remove(key) {
98
+ // See the freeze comment above the closure. During init we keep
99
+ // the cached credential rather than letting the SDK's startup
100
+ // validation wipe it.
101
+ if (frozen) return;
70
102
  const store = readStore();
71
103
  delete store[key];
72
104
  writeStore(store);
package/files.js CHANGED
@@ -264,11 +264,20 @@ export async function deleteFolderFromCloud({
264
264
  * and for new files defaults to 'text/markdown' to keep
265
265
  * legacy callers working.
266
266
  *
267
+ * - localModifiedAt: ISO timestamp of the local file's last
268
+ * modification (mtime). When provided, a push that would
269
+ * overwrite a server copy whose `updatedAt` is strictly
270
+ * newer is skipped instead, so an older local file cannot
271
+ * clobber a newer remote edit (last-write-wins, symmetric
272
+ * with the firestore -> local pull guard). Omit it to
273
+ * force the push regardless of timestamps (legacy callers).
274
+ *
267
275
  * Returns one of:
268
- * { action: 'skipped', reason: 'shared-scope' }
269
- * { action: 'noop', fileId }
270
- * { action: 'created', fileId, fileData, storageError? }
271
- * { action: 'updated', fileId, storageError? }
276
+ * { action: 'skipped', reason: 'shared-scope' }
277
+ * { action: 'noop', fileId }
278
+ * { action: 'skipped-stale', fileId } // server copy is newer
279
+ * { action: 'created', fileId, fileData, storageError? }
280
+ * { action: 'updated', fileId, storageError? }
272
281
  */
273
282
  export async function pushFileToCloud({
274
283
  filesRef,
@@ -281,6 +290,7 @@ export async function pushFileToCloud({
281
290
  data,
282
291
  mimeType,
283
292
  now = new Date().toISOString(),
293
+ localModifiedAt = null,
284
294
  }) {
285
295
  if (scope === 'shared-with-me' || scope === 'shared-by-me') {
286
296
  return { action: 'skipped', reason: 'shared-scope' };
@@ -362,6 +372,13 @@ export async function pushFileToCloud({
362
372
  && (existing.parentId == null ? null : existing.parentId) === parentId) {
363
373
  return { action: 'noop', fileId: docSnap.id };
364
374
  }
375
+ // Last-write-wins: never let an older local file overwrite a newer
376
+ // server edit. The caller (sync daemon) passes the local file's mtime;
377
+ // if the server doc was updated more recently, leave it for the
378
+ // firestore -> local pull to bring down instead.
379
+ if (localModifiedAt && existing.updatedAt && existing.updatedAt > localModifiedAt) {
380
+ return { action: 'skipped-stale', fileId: docSnap.id };
381
+ }
365
382
  const resolvedMime = mimeType || existing.mimeType || 'text/markdown';
366
383
  await updateDoc(docSnap.ref, {
367
384
  type: 'file',