@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.
@@ -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,14 @@ export async function pushFileToCloud({
281
290
  data,
282
291
  mimeType,
283
292
  now = new Date().toISOString(),
293
+ localModifiedAt = null,
294
+ // The contentHash this caller saw as "current" when it last pulled.
295
+ // If the existing remote doc has a different contentHash, another
296
+ // writer landed an edit in the meantime; we refuse to overwrite and
297
+ // return action='conflict' so the caller can write a conflict copy
298
+ // and pull the new remote state. Undefined disables the check (used
299
+ // for create-from-nothing where no baseline exists).
300
+ expectedRemoteHash = undefined,
284
301
  }) {
285
302
  if (scope === 'shared-with-me' || scope === 'shared-by-me') {
286
303
  return { action: 'skipped', reason: 'shared-scope' };
@@ -362,6 +379,30 @@ export async function pushFileToCloud({
362
379
  && (existing.parentId == null ? null : existing.parentId) === parentId) {
363
380
  return { action: 'noop', fileId: docSnap.id };
364
381
  }
382
+ // Compare-and-swap (DOU-167): if the caller passed a baseline of
383
+ // what they last saw as current, refuse the push when the remote
384
+ // has moved on. The daemon will write the proposed local content
385
+ // to a sibling conflict file and pull the new remote state into
386
+ // the live path. Without this guard, a stale push silently
387
+ // overwrites another user's freshly-saved edits (data loss).
388
+ if (
389
+ expectedRemoteHash !== undefined &&
390
+ existing.contentHash !== expectedRemoteHash
391
+ ) {
392
+ return {
393
+ action: 'conflict',
394
+ fileId: docSnap.id,
395
+ remoteHash: existing.contentHash,
396
+ remoteData: { id: docSnap.id, ...existing },
397
+ };
398
+ }
399
+ // Last-write-wins: never let an older local file overwrite a newer
400
+ // server edit. The caller (sync daemon) passes the local file's mtime;
401
+ // if the server doc was updated more recently, leave it for the
402
+ // firestore -> local pull to bring down instead.
403
+ if (localModifiedAt && existing.updatedAt && existing.updatedAt > localModifiedAt) {
404
+ return { action: 'skipped-stale', fileId: docSnap.id };
405
+ }
365
406
  const resolvedMime = mimeType || existing.mimeType || 'text/markdown';
366
407
  await updateDoc(docSnap.ref, {
367
408
  type: 'file',