@doubling/compound-sync 1.10.5 → 1.12.0
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/README.md +23 -0
- package/files.js +95 -45
- package/org-sync.js +274 -60
- package/package.json +19 -6
- package/paths.d.ts +12 -0
- package/paths.js +29 -34
- package/storage-helpers.js +2 -0
- package/sync.js +37 -0
- package/yjs-binding-state.js +61 -0
- package/yjs-file-binding.js +236 -0
- package/yjs-firestore-update-store.js +117 -0
- package/yjs-provider.js +85 -0
package/README.md
CHANGED
|
@@ -14,6 +14,16 @@ That's it. A browser window will open for sign-in (Google or email/password), th
|
|
|
14
14
|
|
|
15
15
|
- [Node.js](https://nodejs.org/) (v18 or later)
|
|
16
16
|
|
|
17
|
+
### TLS certificates
|
|
18
|
+
|
|
19
|
+
On Node 22+ the daemon auto-detects the OS system certificate store (`/etc/ssl/cert.pem` on macOS, `/etc/ssl/certs/ca-certificates.crt` or `/etc/pki/tls/certs/ca-bundle.crt` on Linux) and re-execs itself with `NODE_EXTRA_CA_CERTS` set; otherwise `fetch()` to Google Identity / Firebase Auth endpoints fails with `auth/network-request-failed` because Node's bundled CA store doesn't match what those services use.
|
|
20
|
+
|
|
21
|
+
If you're behind a corporate proxy with custom roots, set `NODE_EXTRA_CA_CERTS` explicitly to your bundle and the daemon will use that instead:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
NODE_EXTRA_CA_CERTS=/path/to/corporate-bundle.pem npx @doubling/compound-sync
|
|
25
|
+
```
|
|
26
|
+
|
|
17
27
|
## What it does
|
|
18
28
|
|
|
19
29
|
Compound Sync watches a local folder and your Compound workspace simultaneously. Changes in either direction are synced automatically:
|
|
@@ -95,6 +105,19 @@ npm run test:integration
|
|
|
95
105
|
npm test
|
|
96
106
|
```
|
|
97
107
|
|
|
108
|
+
### TypeScript conventions (DOU-181)
|
|
109
|
+
|
|
110
|
+
`sync/` is being migrated to TypeScript file-by-file. The conventions:
|
|
111
|
+
|
|
112
|
+
- **Source layout: in-place.** Each `.ts` file emits `.js`, `.d.ts`, and source maps as siblings via `tsc` with `outDir: "."`. The emitted `.js` is a gitignored build artifact, never edited by hand; the `.ts` is the source.
|
|
113
|
+
- **Imports always use the `.js` extension** (NodeNext convention). At runtime: in dev/test the `tsx` loader maps `./paths.js` to `./paths.ts` so source runs without a build step; in production the compiled `paths.js` exists alongside and resolves directly.
|
|
114
|
+
- **Dev (no build needed):** `npm run sync` runs through `node --import tsx`, so `.ts` files load on demand.
|
|
115
|
+
- **CI / publish:** `npm run build` compiles every `.ts` source to its `.js` sibling. `prepack` runs build automatically before `npm publish` so the npm tarball ships compiled JS. The desktop staging script also runs `npm run -w sync build` before copying.
|
|
116
|
+
- **Typecheck:** `npm run typecheck` (alias for `tsc --noEmit`) at the workspace level, or at repo root `npm run typecheck` which runs web + sync together.
|
|
117
|
+
- **Strictness:** `strict: true`, `noUncheckedIndexedAccess: true`, `noImplicitOverride: true`. No `any`. No `// @ts-ignore`. If a third-party module lacks types, declare its shape in a `.d.ts` or PR types upstream.
|
|
118
|
+
|
|
119
|
+
Phase 1 migrated `paths.js` to `paths.ts` as proof of pattern. Phase 3 ([[DOU-183]]) migrates the remaining source files; everything in `sync/` will be `.ts` by the end.
|
|
120
|
+
|
|
98
121
|
### Publishing to npm
|
|
99
122
|
|
|
100
123
|
After making changes to the sync daemon, publish a new version:
|
package/files.js
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
// 2. Delete the Firestore metadata document.
|
|
27
27
|
|
|
28
28
|
import {
|
|
29
|
-
doc, getDocs, setDoc, updateDoc, deleteDoc, query, where,
|
|
29
|
+
doc, getDoc, getDocs, setDoc, updateDoc, deleteDoc, query, where,
|
|
30
30
|
} from 'firebase/firestore';
|
|
31
31
|
import {
|
|
32
32
|
ref as storageRef, uploadBytes, deleteObject, getBytes,
|
|
@@ -206,18 +206,32 @@ export async function pushFolderToCloud({
|
|
|
206
206
|
}
|
|
207
207
|
|
|
208
208
|
/**
|
|
209
|
-
* Delete the folder doc at `docPath`
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
209
|
+
* Delete the folder doc at `docPath` AND every descendant (files and
|
|
210
|
+
* sub-folders) in the same scope. Cascade is authoritative: it does
|
|
211
|
+
* not rely on per-child `unlink` events to clean up children first.
|
|
212
|
+
*
|
|
213
|
+
* Pre-DOU-223 this function deleted only the folder doc, on the
|
|
214
|
+
* (wrong) assumption that chokidar would emit `unlink` for every
|
|
215
|
+
* child before the `unlinkDir` for the folder. macOS FSEvents
|
|
216
|
+
* coalescing and burst-event ordering broke that assumption,
|
|
217
|
+
* leaving orphan file docs that reconstituted the folder on the web
|
|
218
|
+
* via `getFoldersAndFiles` (which derives folder names from paths).
|
|
219
|
+
* The cascade closes the gap; if children's `unlink` events also
|
|
220
|
+
* fire in time, the cascade is a no-op for them.
|
|
221
|
+
*
|
|
222
|
+
* Storage blobs for descendant files are deleted before their
|
|
223
|
+
* Firestore docs so the Storage rule (which reads the doc to
|
|
224
|
+
* authorize) still passes.
|
|
213
225
|
*
|
|
214
226
|
* Returns:
|
|
215
227
|
* { action: 'skipped', reason: 'shared-scope' }
|
|
216
228
|
* { action: 'not-found' }
|
|
217
|
-
* { action: 'deleted', folderId }
|
|
229
|
+
* { action: 'deleted', folderId, childCount }
|
|
218
230
|
*/
|
|
219
231
|
export async function deleteFolderFromCloud({
|
|
220
232
|
filesRef,
|
|
233
|
+
storage,
|
|
234
|
+
orgId,
|
|
221
235
|
scope,
|
|
222
236
|
teamId,
|
|
223
237
|
userId,
|
|
@@ -226,27 +240,65 @@ export async function deleteFolderFromCloud({
|
|
|
226
240
|
if (scope === 'shared-with-me' || scope === 'shared-by-me') {
|
|
227
241
|
return { action: 'skipped', reason: 'shared-scope' };
|
|
228
242
|
}
|
|
229
|
-
|
|
243
|
+
// Pull every doc in this scope; cheap because vaults are small
|
|
244
|
+
// (hundreds of docs at most). Filtering in memory avoids the
|
|
245
|
+
// Firestore composite-index requirement for a path-prefix range
|
|
246
|
+
// query and keeps the cascade contract dead simple to reason about.
|
|
247
|
+
let scopeQuery;
|
|
230
248
|
if (scope === 'private') {
|
|
231
|
-
|
|
232
|
-
where('type', '==', 'folder'),
|
|
249
|
+
scopeQuery = query(filesRef,
|
|
233
250
|
where('scope', '==', 'private'),
|
|
234
|
-
where('ownerId', '==', userId)
|
|
235
|
-
where('path', '==', docPath));
|
|
251
|
+
where('ownerId', '==', userId));
|
|
236
252
|
} else if (scope === 'team' && teamId) {
|
|
237
|
-
|
|
238
|
-
where('type', '==', 'folder'),
|
|
253
|
+
scopeQuery = query(filesRef,
|
|
239
254
|
where('scope', '==', 'team'),
|
|
240
|
-
where('teamId', '==', teamId)
|
|
241
|
-
where('path', '==', docPath));
|
|
255
|
+
where('teamId', '==', teamId));
|
|
242
256
|
} else {
|
|
243
257
|
return { action: 'not-found' };
|
|
244
258
|
}
|
|
245
|
-
const snap = await getDocs(
|
|
246
|
-
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
259
|
+
const snap = await getDocs(scopeQuery);
|
|
260
|
+
|
|
261
|
+
const prefix = docPath + '/';
|
|
262
|
+
let folderDocSnap = null;
|
|
263
|
+
const descendants = [];
|
|
264
|
+
snap.forEach((d) => {
|
|
265
|
+
const data = d.data();
|
|
266
|
+
const p = data.path;
|
|
267
|
+
if (!p) return;
|
|
268
|
+
if (data.type === 'folder' && p === docPath) {
|
|
269
|
+
folderDocSnap = d;
|
|
270
|
+
} else if (p.startsWith(prefix)) {
|
|
271
|
+
descendants.push({ id: d.id, ref: d.ref, data });
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
if (!folderDocSnap && descendants.length === 0) {
|
|
276
|
+
return { action: 'not-found' };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Delete each descendant's blob (files only) then its doc.
|
|
280
|
+
for (const { id, ref, data } of descendants) {
|
|
281
|
+
if (data.type !== 'folder') {
|
|
282
|
+
const ext = extFromPath(data.path);
|
|
283
|
+
try {
|
|
284
|
+
await deleteBlob({ storage, orgId, fileId: id, ext });
|
|
285
|
+
} catch (err) {
|
|
286
|
+
// Orphan blob is a soft failure; the doc delete is the
|
|
287
|
+
// load-bearing state transition for the user's view.
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
await deleteDoc(ref);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (folderDocSnap) {
|
|
294
|
+
await deleteDoc(folderDocSnap.ref);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
action: 'deleted',
|
|
299
|
+
folderId: folderDocSnap ? folderDocSnap.id : null,
|
|
300
|
+
childCount: descendants.length,
|
|
301
|
+
};
|
|
250
302
|
}
|
|
251
303
|
|
|
252
304
|
// ---- High-level coordinator ----
|
|
@@ -291,13 +343,6 @@ export async function pushFileToCloud({
|
|
|
291
343
|
mimeType,
|
|
292
344
|
now = new Date().toISOString(),
|
|
293
345
|
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,
|
|
301
346
|
}) {
|
|
302
347
|
if (scope === 'shared-with-me' || scope === 'shared-by-me') {
|
|
303
348
|
return { action: 'skipped', reason: 'shared-scope' };
|
|
@@ -379,23 +424,6 @@ export async function pushFileToCloud({
|
|
|
379
424
|
&& (existing.parentId == null ? null : existing.parentId) === parentId) {
|
|
380
425
|
return { action: 'noop', fileId: docSnap.id };
|
|
381
426
|
}
|
|
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
427
|
// Last-write-wins: never let an older local file overwrite a newer
|
|
400
428
|
// server edit. The caller (sync daemon) passes the local file's mtime;
|
|
401
429
|
// if the server doc was updated more recently, leave it for the
|
|
@@ -473,7 +501,29 @@ export async function deleteFileFromCloud({
|
|
|
473
501
|
const ext = extFromPath(docPath);
|
|
474
502
|
// Delete the Storage blob FIRST while the metadata doc still exists,
|
|
475
503
|
// because the Storage rule's authorization check looks up the doc.
|
|
476
|
-
|
|
504
|
+
//
|
|
505
|
+
// Race tolerance (DOU-231 follow-up): a concurrent delete can remove
|
|
506
|
+
// the metadata doc between our snapshot read and our blob-delete
|
|
507
|
+
// (most commonly the cascade in deleteFolderFromCloud firing first
|
|
508
|
+
// for a path chokidar's per-file `unlink` event then re-tries via
|
|
509
|
+
// this function). After that delete, the Storage rule's
|
|
510
|
+
// `firestore.get(...).data.scope` reads null, the rule throws
|
|
511
|
+
// "Null value error", and the Storage SDK surfaces it as
|
|
512
|
+
// `storage/unauthorized`. In that specific case the desired end
|
|
513
|
+
// state (no doc, no blob) is already achieved, so treat as
|
|
514
|
+
// already-deleted instead of propagating a noisy auth error.
|
|
515
|
+
try {
|
|
516
|
+
await deleteBlob({ storage, orgId, fileId, ext });
|
|
517
|
+
} catch (err) {
|
|
518
|
+
const code = err && err.code;
|
|
519
|
+
if (code === 'storage/unauthorized') {
|
|
520
|
+
const recheck = await getDoc(docSnap.ref);
|
|
521
|
+
if (!recheck.exists()) {
|
|
522
|
+
return { action: 'not-found', fileId };
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
throw err;
|
|
526
|
+
}
|
|
477
527
|
await deleteDoc(docSnap.ref);
|
|
478
528
|
return { action: 'deleted', fileId };
|
|
479
529
|
}
|