@doubling/compound-sync 1.12.1 → 1.12.2
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/files.js +77 -17
- package/org-sync.js +117 -32
- package/package.json +2 -2
- package/storage-helpers.js +24 -0
package/files.js
CHANGED
|
@@ -37,6 +37,8 @@ import {
|
|
|
37
37
|
extFromPath,
|
|
38
38
|
contentByteSize,
|
|
39
39
|
computeContentHash,
|
|
40
|
+
folderDocId,
|
|
41
|
+
fileDocId,
|
|
40
42
|
toBytes,
|
|
41
43
|
} from './storage-helpers.js';
|
|
42
44
|
import { planFolderChain, splitPath } from './folder-chain.js';
|
|
@@ -127,15 +129,28 @@ async function loadScopedFolders({ filesRef, scope, teamId, userId }) {
|
|
|
127
129
|
async function applyFolderChainPlan({ filesRef, plan, now }) {
|
|
128
130
|
const tempToReal = new Map();
|
|
129
131
|
for (const create of plan.creates) {
|
|
130
|
-
|
|
132
|
+
// Deterministic id by {scope, teamId, ownerId, path}; concurrent
|
|
133
|
+
// sibling creates for the same path collapse onto the same doc
|
|
134
|
+
// instead of producing duplicates (DOU-240).
|
|
135
|
+
const realId = folderDocId({
|
|
136
|
+
scope: create.doc.scope,
|
|
137
|
+
teamId: create.doc.teamId,
|
|
138
|
+
ownerId: create.doc.ownerId,
|
|
139
|
+
path: create.doc.path,
|
|
140
|
+
});
|
|
141
|
+
const newRef = doc(filesRef, realId);
|
|
131
142
|
const docData = {
|
|
132
143
|
...create.doc,
|
|
133
144
|
parentId: tempToReal.get(create.doc.parentId) || create.doc.parentId,
|
|
134
145
|
createdAt: now,
|
|
135
146
|
updatedAt: now,
|
|
136
147
|
};
|
|
148
|
+
// setDoc with merge:false is idempotent on identical input; the
|
|
149
|
+
// last writer wins on differing fields. For folder docs the only
|
|
150
|
+
// fields that vary between concurrent callers are timestamps, so
|
|
151
|
+
// any tie-break is acceptable.
|
|
137
152
|
await setDoc(newRef, docData);
|
|
138
|
-
tempToReal.set(create.tempId,
|
|
153
|
+
tempToReal.set(create.tempId, realId);
|
|
139
154
|
}
|
|
140
155
|
for (const bf of plan.backfills) {
|
|
141
156
|
await updateDoc(doc(filesRef, bf.id), { path: bf.path, updatedAt: now });
|
|
@@ -280,11 +295,25 @@ export async function deleteFolderFromCloud({
|
|
|
280
295
|
for (const { id, ref, data } of descendants) {
|
|
281
296
|
if (data.type !== 'folder') {
|
|
282
297
|
const ext = extFromPath(data.path);
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
298
|
+
// Re-check the doc still exists right before calling the
|
|
299
|
+
// Storage SDK. If a concurrent caller (chokidar's per-file
|
|
300
|
+
// unlink fan-out, or another cascade) already deleted the
|
|
301
|
+
// metadata doc, the Storage rule's cross-service
|
|
302
|
+
// firestore.get(...).data.scope reads null and throws "Null
|
|
303
|
+
// value error", and the Storage SDK retries for several
|
|
304
|
+
// seconds before surfacing storage/unauthorized. The retry
|
|
305
|
+
// latency is what blew the test budget on DOU-242 + DOU-251.
|
|
306
|
+
// Skipping the SDK call avoids both the rule throw and the
|
|
307
|
+
// retry; the orphan blob (if any) is cleaned up by the
|
|
308
|
+
// data-integrity audit (DOU-244).
|
|
309
|
+
const recheck = await getDoc(ref);
|
|
310
|
+
if (recheck.exists()) {
|
|
311
|
+
try {
|
|
312
|
+
await deleteBlob({ storage, orgId, fileId: id, ext });
|
|
313
|
+
} catch (err) {
|
|
314
|
+
// Orphan blob is a soft failure; the doc delete is the
|
|
315
|
+
// load-bearing state transition for the user's view.
|
|
316
|
+
}
|
|
288
317
|
}
|
|
289
318
|
}
|
|
290
319
|
await deleteDoc(ref);
|
|
@@ -387,8 +416,16 @@ export async function pushFileToCloud({
|
|
|
387
416
|
: null;
|
|
388
417
|
|
|
389
418
|
if (!snapshot || snapshot.empty) {
|
|
390
|
-
|
|
391
|
-
|
|
419
|
+
// Deterministic id by {scope, teamId, ownerId, path}; concurrent
|
|
420
|
+
// sibling creates for the same path collapse onto the same doc
|
|
421
|
+
// instead of producing duplicates. Mirrors the DOU-240 folder fix.
|
|
422
|
+
const fileId = fileDocId({
|
|
423
|
+
scope,
|
|
424
|
+
teamId: scope === 'team' ? (teamId || null) : null,
|
|
425
|
+
ownerId: scope === 'private' ? userId : null,
|
|
426
|
+
path: docPath,
|
|
427
|
+
});
|
|
428
|
+
const newDocRef = doc(filesRef, fileId);
|
|
392
429
|
const resolvedMime = mimeType || 'text/markdown';
|
|
393
430
|
const fileData = {
|
|
394
431
|
type: 'file',
|
|
@@ -502,21 +539,45 @@ export async function deleteFileFromCloud({
|
|
|
502
539
|
// Delete the Storage blob FIRST while the metadata doc still exists,
|
|
503
540
|
// because the Storage rule's authorization check looks up the doc.
|
|
504
541
|
//
|
|
505
|
-
// Race tolerance (DOU-
|
|
542
|
+
// Race tolerance (DOU-242 / DOU-252): a concurrent delete can remove
|
|
506
543
|
// the metadata doc between our snapshot read and our blob-delete
|
|
507
544
|
// (most commonly the cascade in deleteFolderFromCloud firing first
|
|
508
545
|
// for a path chokidar's per-file `unlink` event then re-tries via
|
|
509
|
-
// this function).
|
|
510
|
-
//
|
|
511
|
-
//
|
|
512
|
-
//
|
|
513
|
-
//
|
|
514
|
-
|
|
546
|
+
// this function). Pre-check the doc still exists right before the
|
|
547
|
+
// Storage SDK call: if it's gone, skip the blob delete entirely.
|
|
548
|
+
// This avoids the Storage rule's cross-service firestore.get(...)
|
|
549
|
+
// reading null and throwing "Null value error", and avoids the
|
|
550
|
+
// Storage SDK's multi-second retry chain that breaks test budgets.
|
|
551
|
+
const preRecheck = await getDoc(docSnap.ref);
|
|
552
|
+
if (!preRecheck.exists()) {
|
|
553
|
+
return { action: 'not-found', fileId };
|
|
554
|
+
}
|
|
515
555
|
try {
|
|
516
556
|
await deleteBlob({ storage, orgId, fileId, ext });
|
|
517
557
|
} catch (err) {
|
|
518
558
|
const code = err && err.code;
|
|
519
559
|
if (code === 'storage/unauthorized') {
|
|
560
|
+
// Last-resort safety net for the narrow window between
|
|
561
|
+
// preRecheck above and the SDK actually firing. Treat as
|
|
562
|
+
// already-deleted if the doc is now gone.
|
|
563
|
+
const recheck = await getDoc(docSnap.ref);
|
|
564
|
+
if (!recheck.exists()) {
|
|
565
|
+
return { action: 'not-found', fileId };
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
throw err;
|
|
569
|
+
}
|
|
570
|
+
// Concurrent-delete tolerance (DOU-242): the Firestore delete rule
|
|
571
|
+
// evaluates canAccessFile() which reads resource.data; if another
|
|
572
|
+
// caller already deleted this doc, resource is null and the rule
|
|
573
|
+
// throws "Null value error" → SDK surfaces permission-denied. The
|
|
574
|
+
// desired end state (no doc) is already achieved, so treat as
|
|
575
|
+
// already-deleted instead of propagating.
|
|
576
|
+
try {
|
|
577
|
+
await deleteDoc(docSnap.ref);
|
|
578
|
+
} catch (err) {
|
|
579
|
+
const code = err && err.code;
|
|
580
|
+
if (code === 'permission-denied') {
|
|
520
581
|
const recheck = await getDoc(docSnap.ref);
|
|
521
582
|
if (!recheck.exists()) {
|
|
522
583
|
return { action: 'not-found', fileId };
|
|
@@ -524,6 +585,5 @@ export async function deleteFileFromCloud({
|
|
|
524
585
|
}
|
|
525
586
|
throw err;
|
|
526
587
|
}
|
|
527
|
-
await deleteDoc(docSnap.ref);
|
|
528
588
|
return { action: 'deleted', fileId };
|
|
529
589
|
}
|
package/org-sync.js
CHANGED
|
@@ -135,13 +135,49 @@ export async function startOrgSync({
|
|
|
135
135
|
// Track which fileIds already have a binding (or one being set
|
|
136
136
|
// up) so we never double-create. `stoppedRef` is read by the
|
|
137
137
|
// background retry loops so they can abandon work after sync.stop.
|
|
138
|
-
// `pendingPromises` lets stop() await any in-flight setup
|
|
139
|
-
//
|
|
140
|
-
//
|
|
138
|
+
// `pendingPromises` lets stop() await any in-flight setup OR any
|
|
139
|
+
// fire-and-forget snapshot/chokidar handler work before returning.
|
|
140
|
+
// Without this, the next test's clearFirestore + terminate can
|
|
141
|
+
// race a handler that's still flushing storage from this test,
|
|
142
|
+
// surfacing as "Firebase app was deleted" errors in the daemon log
|
|
143
|
+
// and (suspected) accumulating gRPC stream state that the next
|
|
144
|
+
// listener attach receives as a single oversize Listen message.
|
|
141
145
|
const pendingBindings = new Set();
|
|
142
146
|
const pendingPromises = new Set();
|
|
143
147
|
const stoppedRef = { value: false };
|
|
144
148
|
|
|
149
|
+
// Helper: register a promise with pendingPromises so stop() awaits
|
|
150
|
+
// it, and auto-remove on settle. Returns the same promise so call
|
|
151
|
+
// sites can keep their chaining shape.
|
|
152
|
+
function track(p) {
|
|
153
|
+
pendingPromises.add(p);
|
|
154
|
+
p.finally(() => pendingPromises.delete(p));
|
|
155
|
+
return p;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// DOU-254: per-local-path operation queue. chokidar can fire `add`
|
|
159
|
+
// then `unlink` for the same path within a few hundred ms (a test
|
|
160
|
+
// writes a file, waits only for the Firestore doc to appear, then
|
|
161
|
+
// deletes locally). Without serialization, the push (setDoc +
|
|
162
|
+
// uploadBlob) and the delete (preRecheck + deleteBlob + deleteDoc)
|
|
163
|
+
// race against each other on the same fileId, and uploadBlob can
|
|
164
|
+
// land AFTER deleteBlob, leaving an orphaned blob after the doc is
|
|
165
|
+
// gone. Chain ops keyed by local path so the next op starts only
|
|
166
|
+
// after the previous one settles.
|
|
167
|
+
const localOpQueues = new Map();
|
|
168
|
+
function enqueueLocalOp(filePath, op) {
|
|
169
|
+
const prev = localOpQueues.get(filePath) || Promise.resolve();
|
|
170
|
+
// Swallow the previous op's rejection so a failed push doesn't
|
|
171
|
+
// skip the subsequent delete (or vice versa); each op handles
|
|
172
|
+
// its own errors via the .catch() at the call site.
|
|
173
|
+
const next = prev.catch(() => {}).then(op);
|
|
174
|
+
localOpQueues.set(filePath, next);
|
|
175
|
+
next.finally(() => {
|
|
176
|
+
if (localOpQueues.get(filePath) === next) localOpQueues.delete(filePath);
|
|
177
|
+
});
|
|
178
|
+
return next;
|
|
179
|
+
}
|
|
180
|
+
|
|
145
181
|
// Fire-and-forget binding setup. Returns nothing because the
|
|
146
182
|
// common shape "ensureBinding then read its ytext" runs straight
|
|
147
183
|
// into a race: the daemon's onSnapshot fires on Alice's own
|
|
@@ -512,9 +548,9 @@ export async function startOrgSync({
|
|
|
512
548
|
const q = query(filesRef, where('scope', '==', 'team'), where('teamId', '==', teamId));
|
|
513
549
|
const unsubscribe = onSnapshot(q, snapshot => {
|
|
514
550
|
snapshot.docChanges().forEach(change => {
|
|
515
|
-
handleFirestoreChange(change, data =>
|
|
551
|
+
track(handleFirestoreChange(change, data =>
|
|
516
552
|
path.join(LOCAL_PATH, teamFolder, data.path),
|
|
517
|
-
).catch(err => console.error(` [${orgId}] Firestore change handler error:`, err && err.message));
|
|
553
|
+
).catch(err => console.error(` [${orgId}] Firestore change handler error:`, err && err.message)));
|
|
518
554
|
});
|
|
519
555
|
}, err => console.error(` [${orgId}] Team "${teamFolder}" listener error:`, err));
|
|
520
556
|
|
|
@@ -563,8 +599,8 @@ export async function startOrgSync({
|
|
|
563
599
|
const data = change.doc.data();
|
|
564
600
|
const realPath = path.join(LOCAL_PATH, PRIVATE_FOLDER, data.path);
|
|
565
601
|
|
|
566
|
-
handleFirestoreChange(change, () => realPath)
|
|
567
|
-
.catch(err => console.error(` [${orgId}] Firestore change handler error:`, err && err.message));
|
|
602
|
+
track(handleFirestoreChange(change, () => realPath)
|
|
603
|
+
.catch(err => console.error(` [${orgId}] Firestore change handler error:`, err && err.message)));
|
|
568
604
|
|
|
569
605
|
if (change.type !== 'removed') {
|
|
570
606
|
updateSharedByMeSymlink(data, realPath);
|
|
@@ -590,9 +626,9 @@ export async function startOrgSync({
|
|
|
590
626
|
snapshot.docChanges().forEach(change => {
|
|
591
627
|
const data = change.doc.data();
|
|
592
628
|
if (data.ownerId === userId) return;
|
|
593
|
-
handleFirestoreChange(change, () =>
|
|
629
|
+
track(handleFirestoreChange(change, () =>
|
|
594
630
|
path.join(LOCAL_PATH, SHARED_WITH_ME_FOLDER, data.path),
|
|
595
|
-
).catch(err => console.error(` [${orgId}] Firestore change handler error:`, err && err.message));
|
|
631
|
+
).catch(err => console.error(` [${orgId}] Firestore change handler error:`, err && err.message)));
|
|
596
632
|
});
|
|
597
633
|
}, err => console.error(` [${orgId}] Shared with me listener error:`, err));
|
|
598
634
|
unsubscribers.push(sharedUnsub);
|
|
@@ -827,27 +863,29 @@ export async function startOrgSync({
|
|
|
827
863
|
if (binding) binding.applyDiskText(data);
|
|
828
864
|
}
|
|
829
865
|
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
866
|
+
track(enqueueLocalOp(filePath, () =>
|
|
867
|
+
pushToFirestore(filePath, data, mimeType, localModifiedAt).catch(err =>
|
|
868
|
+
console.error(` [${orgId}] Push error for ${rel}: ${err.message}`)
|
|
869
|
+
)
|
|
870
|
+
));
|
|
833
871
|
}
|
|
834
872
|
|
|
835
873
|
function handleLocalDirAdded(dirPath) {
|
|
836
874
|
if (suppressLocal.has(dirPath)) return;
|
|
837
875
|
const rel = path.relative(LOCAL_PATH, dirPath);
|
|
838
876
|
if (!isSyncableLocalDir(rel)) return;
|
|
839
|
-
pushDirToFirestore(dirPath).catch(err =>
|
|
877
|
+
track(pushDirToFirestore(dirPath).catch(err =>
|
|
840
878
|
console.error(` [${orgId}] Folder push error for ${rel}: ${err.message}`)
|
|
841
|
-
);
|
|
879
|
+
));
|
|
842
880
|
}
|
|
843
881
|
|
|
844
882
|
function handleLocalDirRemoved(dirPath) {
|
|
845
883
|
if (suppressLocal.has(dirPath)) return;
|
|
846
884
|
const rel = path.relative(LOCAL_PATH, dirPath);
|
|
847
885
|
if (!isSyncableLocalDir(rel)) return;
|
|
848
|
-
deleteDirFromFirestore(dirPath).catch(err =>
|
|
886
|
+
track(deleteDirFromFirestore(dirPath).catch(err =>
|
|
849
887
|
console.error(` [${orgId}] Folder delete error for ${rel}: ${err.message}`)
|
|
850
|
-
);
|
|
888
|
+
));
|
|
851
889
|
}
|
|
852
890
|
|
|
853
891
|
function startLocalWatcher() {
|
|
@@ -871,15 +909,44 @@ export async function startOrgSync({
|
|
|
871
909
|
},
|
|
872
910
|
});
|
|
873
911
|
|
|
874
|
-
|
|
875
|
-
|
|
912
|
+
// Reusable per-event trace logging, gated by COMPOUND_CHOKIDAR_DEBUG.
|
|
913
|
+
// Writes to both stdout AND a file (default /tmp/chokidar-debug.log)
|
|
914
|
+
// so traces survive a process kill or buffered stdout. Used by the
|
|
915
|
+
// Test-on-demand workflow's `Dump diagnostic logs` step for
|
|
916
|
+
// chokidar-related diagnostic runs. Zero cost when the env var is
|
|
917
|
+
// unset.
|
|
918
|
+
const chokidarDebug = process.env.COMPOUND_CHOKIDAR_DEBUG === '1';
|
|
919
|
+
const chokidarLogPath = process.env.COMPOUND_CHOKIDAR_LOG || '/tmp/chokidar-debug.log';
|
|
920
|
+
function logEvent(event, p) {
|
|
921
|
+
if (chokidarDebug) {
|
|
922
|
+
const rel = path.relative(LOCAL_PATH, p);
|
|
923
|
+
const line = ` [${orgId}] [chokidar ${event}] ${rel} @ ${new Date().toISOString()}\n`;
|
|
924
|
+
try { fs.appendFileSync(chokidarLogPath, line); } catch (_) {}
|
|
925
|
+
console.log(line.trimEnd());
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
watcher.on('add', filePath => { logEvent('add', filePath); handleLocalChange(filePath); });
|
|
930
|
+
watcher.on('change', filePath => { logEvent('change', filePath); handleLocalChange(filePath); });
|
|
876
931
|
watcher.on('unlink', filePath => {
|
|
932
|
+
logEvent('unlink', filePath);
|
|
877
933
|
if (suppressLocal.has(filePath)) return;
|
|
878
934
|
const rel = path.relative(LOCAL_PATH, filePath);
|
|
879
|
-
|
|
935
|
+
track(enqueueLocalOp(filePath, () =>
|
|
936
|
+
deleteFromFirestore(filePath).catch(err =>
|
|
937
|
+
console.error(` [${orgId}] Delete error for ${rel}: ${err.message}`)
|
|
938
|
+
)
|
|
939
|
+
));
|
|
880
940
|
});
|
|
881
|
-
watcher.on('addDir', dirPath => handleLocalDirAdded(dirPath));
|
|
882
|
-
watcher.on('unlinkDir', dirPath => handleLocalDirRemoved(dirPath));
|
|
941
|
+
watcher.on('addDir', dirPath => { logEvent('addDir', dirPath); handleLocalDirAdded(dirPath); });
|
|
942
|
+
watcher.on('unlinkDir', dirPath => { logEvent('unlinkDir', dirPath); handleLocalDirRemoved(dirPath); });
|
|
943
|
+
if (chokidarDebug) {
|
|
944
|
+
watcher.on('raw', (event, p, details) => {
|
|
945
|
+
const line = ` [${orgId}] [chokidar raw ${event}] ${p} @ ${new Date().toISOString()} ${JSON.stringify(details)}\n`;
|
|
946
|
+
try { fs.appendFileSync(chokidarLogPath, line); } catch (_) {}
|
|
947
|
+
console.log(line.trimEnd());
|
|
948
|
+
});
|
|
949
|
+
}
|
|
883
950
|
|
|
884
951
|
// Resolve only once chokidar has finished its initial scan and
|
|
885
952
|
// registered inotify (or FSEvents) watches on every existing
|
|
@@ -899,9 +966,10 @@ export async function startOrgSync({
|
|
|
899
966
|
await startLocalWatcher();
|
|
900
967
|
|
|
901
968
|
return {
|
|
902
|
-
// Tear down all listeners and the watcher.
|
|
903
|
-
//
|
|
904
|
-
|
|
969
|
+
// Tear down all listeners and the watcher. Awaits every tracked
|
|
970
|
+
// in-flight handler so callers/tests get a strong "no daemon work
|
|
971
|
+
// is still running" guarantee on the resolved promise.
|
|
972
|
+
async stop() {
|
|
905
973
|
stoppedRef.value = true;
|
|
906
974
|
for (const unsub of unsubscribers) {
|
|
907
975
|
try { unsub(); } catch (e) { /* ignore */ }
|
|
@@ -910,15 +978,32 @@ export async function startOrgSync({
|
|
|
910
978
|
try { unsub(); } catch (e) { /* ignore */ }
|
|
911
979
|
}
|
|
912
980
|
teamUnsubscribers.clear();
|
|
913
|
-
|
|
914
|
-
|
|
981
|
+
|
|
982
|
+
// Drain tracked promises in a loop, bounded by a 5s ceiling.
|
|
983
|
+
// A handler currently in flight (e.g., a snapshot callback
|
|
984
|
+
// already dispatched before unsub()) can chain follow-up work
|
|
985
|
+
// that gets added back to pendingPromises. Loop until the set
|
|
986
|
+
// quiesces so no daemon task outlives stop() in the common
|
|
987
|
+
// case. The cap protects against tests where a handler is
|
|
988
|
+
// genuinely stuck (e.g., emulator pause), so stop() never
|
|
989
|
+
// hangs indefinitely; anything still pending past 5s is
|
|
990
|
+
// abandoned and the next test's clearFirestore will sweep it.
|
|
991
|
+
const drainDeadline = Date.now() + 5000;
|
|
992
|
+
while (pendingPromises.size > 0 && Date.now() < drainDeadline) {
|
|
993
|
+
const remaining = Math.max(0, drainDeadline - Date.now());
|
|
994
|
+
await Promise.race([
|
|
995
|
+
Promise.all([...pendingPromises]),
|
|
996
|
+
new Promise((r) => setTimeout(r, remaining)),
|
|
997
|
+
]);
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
await Promise.all(
|
|
915
1001
|
[...bindings.values()].map((b) => b.stop().catch(() => {})),
|
|
916
|
-
)
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
return Promise.all([bindingDrain, watcherClose]).then(() => undefined);
|
|
1002
|
+
);
|
|
1003
|
+
bindings.clear();
|
|
1004
|
+
bindingsByPath.clear();
|
|
1005
|
+
|
|
1006
|
+
if (watcher) await watcher.close();
|
|
922
1007
|
},
|
|
923
1008
|
};
|
|
924
1009
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@doubling/compound-sync",
|
|
3
|
-
"version": "1.12.
|
|
3
|
+
"version": "1.12.2",
|
|
4
4
|
"description": "Bidirectional sync between Compound and local markdown files",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"typecheck": "tsc --noEmit",
|
|
33
33
|
"prepack": "npm run build",
|
|
34
34
|
"test:unit": "node --import tsx --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 yjs-file-binding.test.js yjs-binding-state.test.js",
|
|
35
|
-
"test:integration": "firebase emulators:exec --only firestore,storage 'node --import tsx --test --test-concurrency=1 files-helpers.test.js files-push-create-private.test.js files-push-create-team.test.js files-push-update-private.test.js files-push-binary.test.js files-push-staleness.test.js files-push-storage-fail.test.js files-push-no-content.test.js files-push-stamps.test.js files-push-folder.test.js files-delete.test.js files-delete-folder.test.js files-read-blob.test.js org-sync-local-edit.test.js org-sync-file-ops.test.js org-sync-mtime.test.js org-sync-rename.test.js org-sync-yjs-pull.test.js org-sync-yjs-push.test.js org-sync-cold-start.test.js two-user-
|
|
35
|
+
"test:integration": "firebase emulators:exec --only firestore,storage 'node --import tsx --test --test-concurrency=1 --test-timeout=120000 files-helpers.test.js files-push-create-private.test.js files-push-create-team.test.js files-push-update-private.test.js files-push-binary.test.js files-push-staleness.test.js files-push-storage-fail.test.js files-push-no-content.test.js files-push-stamps.test.js files-push-folder.test.js files-delete.test.js files-delete-folder.test.js files-read-blob.test.js org-sync-local-edit.test.js org-sync-file-ops.test.js org-sync-mtime.test.js org-sync-rename.test.js org-sync-yjs-pull.test.js org-sync-yjs-push.test.js org-sync-cold-start.test.js org-sync-special-chars.test.js org-sync-copy.test.js two-user-happy-paths.test.js two-user-yjs-convergence.test.js files-concurrency-folder-create.test.js files-concurrency-file-create.test.js files-concurrency-file-delete.test.js files-concurrency-folder-delete.test.js'",
|
|
36
36
|
"test": "npm run test:unit && npm run test:integration"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
package/storage-helpers.js
CHANGED
|
@@ -36,6 +36,30 @@ export function computeContentHash(data) {
|
|
|
36
36
|
return crypto.createHash('sha256').update(buf).digest('hex');
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
// Deterministic Firestore document id for a folder doc identified by
|
|
40
|
+
// scope + ownership + path. Two concurrent creates collapse onto the
|
|
41
|
+
// same id, so a setDoc by all of them is idempotent (no duplicates).
|
|
42
|
+
// See DOU-240 for the race this prevents.
|
|
43
|
+
export function folderDocId({ scope, teamId, ownerId, path }) {
|
|
44
|
+
const key = `folder|${scope}|${teamId ?? ''}|${ownerId ?? ''}|${path}`;
|
|
45
|
+
return 'f-' + crypto.createHash('sha256').update(key).digest('hex').slice(0, 20);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Deterministic Firestore document id for a file doc identified by
|
|
49
|
+
// scope + ownership + path. Same purpose as folderDocId: concurrent
|
|
50
|
+
// creates at the same path collapse onto one doc.
|
|
51
|
+
//
|
|
52
|
+
// Caveat: rename happens as unlink(old) + add(new) (DOU-205 / chokidar
|
|
53
|
+
// event sequence), so each rename produces a fresh doc with a new
|
|
54
|
+
// path-derived id. Two distinct files at the same path AT THE SAME
|
|
55
|
+
// TIME are the impossible state we want to prevent; sequential
|
|
56
|
+
// reuse of a path (e.g., delete then recreate) is fine because the
|
|
57
|
+
// old doc was already removed by unlink before the new add fires.
|
|
58
|
+
export function fileDocId({ scope, teamId, ownerId, path }) {
|
|
59
|
+
const key = `file|${scope}|${teamId ?? ''}|${ownerId ?? ''}|${path}`;
|
|
60
|
+
return 'x-' + crypto.createHash('sha256').update(key).digest('hex').slice(0, 20);
|
|
61
|
+
}
|
|
62
|
+
|
|
39
63
|
export function toBytes(data) {
|
|
40
64
|
if (data == null) return Buffer.alloc(0);
|
|
41
65
|
if (Buffer.isBuffer(data)) return data;
|