@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/auth-persistence.js
CHANGED
|
@@ -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.
|
|
45
|
-
|
|
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',
|
|
269
|
-
* { action: 'noop',
|
|
270
|
-
* { action: '
|
|
271
|
-
* { action: '
|
|
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',
|