@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 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` if it exists. Does NOT cascade
210
- * to children (the sync daemon's `unlinkDir` event fires after its
211
- * children's `unlink` events have already cleaned up child docs, so
212
- * by the time we reach the folder it should be empty).
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
- let q;
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
- q = query(filesRef,
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
- q = query(filesRef,
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(q);
246
- if (snap.empty) return { action: 'not-found' };
247
- const docSnap = snap.docs[0];
248
- await deleteDoc(docSnap.ref);
249
- return { action: 'deleted', folderId: docSnap.id };
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
- await deleteBlob({ storage, orgId, fileId, ext });
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
  }