@doubling/compound-sync 1.12.4 → 1.12.5

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.
@@ -0,0 +1,44 @@
1
+ export interface FolderDoc {
2
+ id: string;
3
+ type: 'folder' | string;
4
+ name: string;
5
+ parentId?: string | null;
6
+ path?: string;
7
+ scope: string;
8
+ teamId?: string | null;
9
+ ownerId?: string | null;
10
+ }
11
+ export interface ScopeKey {
12
+ scope: string;
13
+ teamId?: string | null | undefined;
14
+ ownerId?: string | null | undefined;
15
+ }
16
+ export interface FolderChainCreate {
17
+ tempId: string;
18
+ doc: {
19
+ type: 'folder';
20
+ name: string;
21
+ parentId: string | null;
22
+ path: string;
23
+ scope: string;
24
+ teamId: string | null;
25
+ ownerId: string | null;
26
+ sharedWith: string[];
27
+ };
28
+ }
29
+ export interface FolderChainBackfill {
30
+ id: string;
31
+ path: string;
32
+ }
33
+ export interface FolderChainPlan {
34
+ creates: FolderChainCreate[];
35
+ backfills: FolderChainBackfill[];
36
+ leafId: string | null;
37
+ }
38
+ export interface PlanFolderChainInput extends ScopeKey {
39
+ existingFolders: FolderDoc[] | null | undefined;
40
+ segments: string[] | null | undefined;
41
+ }
42
+ export declare function splitPath(p: string | null | undefined): string[];
43
+ export declare function planFolderChain({ existingFolders, segments, scope, teamId, ownerId, }: PlanFolderChainInput): FolderChainPlan;
44
+ //# sourceMappingURL=folder-chain.d.ts.map
package/folder-chain.js CHANGED
@@ -12,23 +12,24 @@
12
12
  // planner with `segments` set to the dir's path and apply.
13
13
  //
14
14
  // Pure: no I/O. Tests exercise it with synthetic inputs.
15
-
16
15
  export function splitPath(p) {
17
- if (!p) return [];
18
- return p.split('/').filter(Boolean);
16
+ if (!p)
17
+ return [];
18
+ return p.split('/').filter(Boolean);
19
19
  }
20
-
21
20
  function matchesScope(folder, { scope, teamId, ownerId }) {
22
- if (folder.scope !== scope) return false;
23
- const folderTeam = folder.teamId == null ? null : folder.teamId;
24
- const wantTeam = teamId == null ? null : teamId;
25
- if (folderTeam !== wantTeam) return false;
26
- const folderOwner = folder.ownerId == null ? null : folder.ownerId;
27
- const wantOwner = ownerId == null ? null : ownerId;
28
- if (folderOwner !== wantOwner) return false;
29
- return true;
21
+ if (folder.scope !== scope)
22
+ return false;
23
+ const folderTeam = folder.teamId == null ? null : folder.teamId;
24
+ const wantTeam = teamId == null ? null : teamId;
25
+ if (folderTeam !== wantTeam)
26
+ return false;
27
+ const folderOwner = folder.ownerId == null ? null : folder.ownerId;
28
+ const wantOwner = ownerId == null ? null : ownerId;
29
+ if (folderOwner !== wantOwner)
30
+ return false;
31
+ return true;
30
32
  }
31
-
32
33
  // Plan the folder chain. Inputs:
33
34
  // - existingFolders: array of folder docs from the org's files
34
35
  // collection. Each has { id, type:'folder', name, parentId, path?,
@@ -47,51 +48,43 @@ function matchesScope(folder, { scope, teamId, ownerId }) {
47
48
  // - leafId: id of the leaf folder (the deepest segment), either a
48
49
  // real id (if it existed) or a tempId (if planned for creation).
49
50
  // null if `segments` is empty.
50
- export function planFolderChain({ existingFolders, segments, scope, teamId, ownerId }) {
51
- const creates = [];
52
- const backfills = [];
53
-
54
- if (!segments || segments.length === 0) {
55
- return { creates, backfills, leafId: null };
56
- }
57
-
58
- let parentId = null;
59
- let pathSoFar = '';
60
-
61
- for (const name of segments) {
62
- pathSoFar = pathSoFar ? `${pathSoFar}/${name}` : name;
63
-
64
- const existing = (existingFolders || []).find(f =>
65
- f && f.type === 'folder'
66
- && f.name === name
67
- && (f.parentId == null ? null : f.parentId) === parentId
68
- && matchesScope(f, { scope, teamId, ownerId })
69
- );
70
-
71
- if (existing) {
72
- if (existing.path !== pathSoFar) {
73
- backfills.push({ id: existing.id, path: pathSoFar });
74
- }
75
- parentId = existing.id;
76
- continue;
51
+ export function planFolderChain({ existingFolders, segments, scope, teamId, ownerId, }) {
52
+ const creates = [];
53
+ const backfills = [];
54
+ if (!segments || segments.length === 0) {
55
+ return { creates, backfills, leafId: null };
77
56
  }
78
-
79
- const tempId = `__plan_folder_${creates.length}`;
80
- creates.push({
81
- tempId,
82
- doc: {
83
- type: 'folder',
84
- name,
85
- parentId,
86
- path: pathSoFar,
87
- scope,
88
- teamId: scope === 'team' ? (teamId == null ? null : teamId) : null,
89
- ownerId: scope === 'private' ? (ownerId == null ? null : ownerId) : null,
90
- sharedWith: [],
91
- },
92
- });
93
- parentId = tempId;
94
- }
95
-
96
- return { creates, backfills, leafId: parentId };
57
+ let parentId = null;
58
+ let pathSoFar = '';
59
+ for (const name of segments) {
60
+ pathSoFar = pathSoFar ? `${pathSoFar}/${name}` : name;
61
+ const existing = (existingFolders || []).find((f) => f && f.type === 'folder'
62
+ && f.name === name
63
+ && (f.parentId == null ? null : f.parentId) === parentId
64
+ && matchesScope(f, { scope, teamId, ownerId }));
65
+ if (existing) {
66
+ if (existing.path !== pathSoFar) {
67
+ backfills.push({ id: existing.id, path: pathSoFar });
68
+ }
69
+ parentId = existing.id;
70
+ continue;
71
+ }
72
+ const tempId = `__plan_folder_${creates.length}`;
73
+ creates.push({
74
+ tempId,
75
+ doc: {
76
+ type: 'folder',
77
+ name,
78
+ parentId,
79
+ path: pathSoFar,
80
+ scope,
81
+ teamId: scope === 'team' ? (teamId == null ? null : teamId) : null,
82
+ ownerId: scope === 'private' ? (ownerId == null ? null : ownerId) : null,
83
+ sharedWith: [],
84
+ },
85
+ });
86
+ parentId = tempId;
87
+ }
88
+ return { creates, backfills, leafId: parentId };
97
89
  }
90
+ //# sourceMappingURL=folder-chain.js.map
package/manifest.d.ts ADDED
@@ -0,0 +1,38 @@
1
+ export interface ManifestEntry {
2
+ fileId: string;
3
+ contentHash: string;
4
+ lastSyncedAt: string;
5
+ }
6
+ export interface ManifestState {
7
+ version: number;
8
+ entries: Record<string, ManifestEntry>;
9
+ }
10
+ export interface ManifestStore {
11
+ upsertEntry(trackingKey: string, entry: ManifestEntry): void;
12
+ removeEntry(trackingKey: string): void;
13
+ getEntry(trackingKey: string): ManifestEntry | null;
14
+ getState(): ManifestState;
15
+ flush(): void;
16
+ }
17
+ export interface CreateManifestStoreOptions {
18
+ debounceMs?: number;
19
+ }
20
+ export declare function manifestPath(localPath: string): string;
21
+ export declare function loadManifest(localPath: string): ManifestState;
22
+ /**
23
+ * Persists the in-memory manifest state to disk.
24
+ *
25
+ * PERFORMANCE & SAFETY NOTES:
26
+ * 1. Atomic Writes: We write to `.compound-sync/manifest.json.tmp` first,
27
+ * then use `fs.renameSync` to overwrite the actual manifest. This ensures
28
+ * that if the daemon crashes mid-write, we do not corrupt the JSON.
29
+ * 2. Unsorted Keys: We do not sort the object keys before calling
30
+ * `JSON.stringify()`. Sorting requires significant CPU overhead for large
31
+ * workspaces. The in-memory object map is the source of truth; disk
32
+ * order does not matter.
33
+ * 3. Synchronous I/O: All disk operations use `*Sync` APIs. A flush blocks
34
+ * the Node event loop briefly; see docs/features/sync/sync-manifest-design.md.
35
+ */
36
+ export declare function saveManifest(localPath: string, manifestState: ManifestState): void;
37
+ export declare function createManifestStore(localPath: string, { debounceMs }?: CreateManifestStoreOptions): ManifestStore;
38
+ //# sourceMappingURL=manifest.d.ts.map
package/manifest.js CHANGED
@@ -3,50 +3,53 @@
3
3
  // Maps `scope:docPath` → Firestore fileId so the daemon can reconcile
4
4
  // local disk vs cloud on startup (e.g. web deletes while offline must
5
5
  // remove local orphans instead of re-uploading them).
6
-
7
6
  import fs from 'node:fs';
8
7
  import path from 'node:path';
9
-
10
8
  const MANIFEST_DIR = '.compound-sync';
11
9
  const MANIFEST_FILE = 'manifest.json';
12
10
  const FLUSH_DEBOUNCE_MS = 500;
13
-
14
11
  export function manifestPath(localPath) {
15
- return path.join(localPath, MANIFEST_DIR, MANIFEST_FILE);
12
+ return path.join(localPath, MANIFEST_DIR, MANIFEST_FILE);
16
13
  }
17
-
18
14
  function normalizeEntry(raw) {
19
- if (!raw || typeof raw !== 'object') return null;
20
- if (typeof raw.fileId !== 'string' || !raw.fileId) return null;
21
- if (typeof raw.contentHash !== 'string' || !raw.contentHash) return null;
22
- return {
23
- fileId: raw.fileId,
24
- contentHash: raw.contentHash,
25
- lastSyncedAt: typeof raw.lastSyncedAt === 'string' ? raw.lastSyncedAt : new Date().toISOString(),
26
- };
15
+ if (!raw || typeof raw !== 'object')
16
+ return null;
17
+ const r = raw;
18
+ if (typeof r['fileId'] !== 'string' || !r['fileId'])
19
+ return null;
20
+ if (typeof r['contentHash'] !== 'string' || !r['contentHash'])
21
+ return null;
22
+ return {
23
+ fileId: r['fileId'],
24
+ contentHash: r['contentHash'],
25
+ lastSyncedAt: typeof r['lastSyncedAt'] === 'string' ? r['lastSyncedAt'] : new Date().toISOString(),
26
+ };
27
27
  }
28
-
29
28
  export function loadManifest(localPath) {
30
- try {
31
- const parsed = JSON.parse(fs.readFileSync(manifestPath(localPath), 'utf8'));
32
- if (parsed && typeof parsed === 'object' && parsed.entries && typeof parsed.entries === 'object') {
33
- const entries = {};
34
- for (const [key, raw] of Object.entries(parsed.entries)) {
35
- const entry = normalizeEntry(raw);
36
- if (entry) {
37
- entries[key] = entry;
38
- } else {
39
- console.error(`[sync-manifest] dropped invalid entry for ${key}`);
29
+ try {
30
+ const parsed = JSON.parse(fs.readFileSync(manifestPath(localPath), 'utf8'));
31
+ if (parsed && typeof parsed === 'object') {
32
+ const p = parsed;
33
+ if (p.entries && typeof p.entries === 'object') {
34
+ const entries = {};
35
+ for (const [key, raw] of Object.entries(p.entries)) {
36
+ const entry = normalizeEntry(raw);
37
+ if (entry) {
38
+ entries[key] = entry;
39
+ }
40
+ else {
41
+ console.error(`[sync-manifest] dropped invalid entry for ${key}`);
42
+ }
43
+ }
44
+ return { version: typeof p.version === 'number' ? p.version : 1, entries };
45
+ }
40
46
  }
41
- }
42
- return { version: parsed.version ?? 1, entries };
43
47
  }
44
- } catch {
45
- // Missing or corrupt file reads as empty so first run can't crash sync.
46
- }
47
- return { version: 1, entries: {} };
48
+ catch {
49
+ // Missing or corrupt file reads as empty so first run can't crash sync.
50
+ }
51
+ return { version: 1, entries: {} };
48
52
  }
49
-
50
53
  /**
51
54
  * Persists the in-memory manifest state to disk.
52
55
  *
@@ -60,68 +63,63 @@ export function loadManifest(localPath) {
60
63
  * order does not matter.
61
64
  * 3. Synchronous I/O: All disk operations use `*Sync` APIs. A flush blocks
62
65
  * the Node event loop briefly; see docs/features/sync/sync-manifest-design.md.
63
- *
64
- * @param {string} localPath - Sync folder root
65
- * @param {Object} manifestState - The current in-memory manifest dictionary
66
66
  */
67
67
  export function saveManifest(localPath, manifestState) {
68
- const finalPath = manifestPath(localPath);
69
- const tmpPath = path.join(path.dirname(finalPath), 'manifest.json.tmp');
70
- try {
71
- fs.mkdirSync(path.dirname(finalPath), { recursive: true });
72
- fs.writeFileSync(tmpPath, JSON.stringify(manifestState));
73
- fs.renameSync(tmpPath, finalPath);
74
- } catch (err) {
75
- console.error(
76
- `[sync-manifest] failed to persist manifest at ${finalPath}: ${err && err.message}`,
77
- );
68
+ const finalPath = manifestPath(localPath);
69
+ const tmpPath = path.join(path.dirname(finalPath), 'manifest.json.tmp');
78
70
  try {
79
- fs.unlinkSync(tmpPath);
80
- } catch (cleanupErr) {
81
- // A leftover .tmp is ignored on load (only the canonical path is read).
82
- // ENOENT means the tmp never landed or rename already consumed it.
83
- if (cleanupErr?.code !== 'ENOENT') {
84
- console.error(
85
- `[sync-manifest] failed to remove temp manifest ${tmpPath}: ${cleanupErr && cleanupErr.message}`,
86
- );
87
- }
71
+ fs.mkdirSync(path.dirname(finalPath), { recursive: true });
72
+ fs.writeFileSync(tmpPath, JSON.stringify(manifestState));
73
+ fs.renameSync(tmpPath, finalPath);
74
+ }
75
+ catch (err) {
76
+ console.error(`[sync-manifest] failed to persist manifest at ${finalPath}: ${err?.message}`);
77
+ try {
78
+ fs.unlinkSync(tmpPath);
79
+ }
80
+ catch (cleanupErr) {
81
+ // A leftover .tmp is ignored on load (only the canonical path is read).
82
+ // ENOENT means the tmp never landed or rename already consumed it.
83
+ const code = cleanupErr?.code;
84
+ if (code !== 'ENOENT') {
85
+ console.error(`[sync-manifest] failed to remove temp manifest ${tmpPath}: ${cleanupErr?.message}`);
86
+ }
87
+ }
88
88
  }
89
- }
90
89
  }
91
-
92
90
  export function createManifestStore(localPath, { debounceMs = FLUSH_DEBOUNCE_MS } = {}) {
93
- const state = loadManifest(localPath);
94
- let timer = null;
95
-
96
- function scheduleFlush() {
97
- if (timer) clearTimeout(timer);
98
- timer = setTimeout(() => {
99
- timer = null;
100
- saveManifest(localPath, state);
101
- }, debounceMs);
102
- }
103
-
104
- return {
105
- upsertEntry(trackingKey, entry) {
106
- state.entries[trackingKey] = entry;
107
- scheduleFlush();
108
- },
109
- removeEntry(trackingKey) {
110
- delete state.entries[trackingKey];
111
- scheduleFlush();
112
- },
113
- getEntry(trackingKey) {
114
- return state.entries[trackingKey] ?? null;
115
- },
116
- getState() {
117
- return state;
118
- },
119
- flush() {
120
- if (timer) {
121
- clearTimeout(timer);
122
- timer = null;
123
- }
124
- saveManifest(localPath, state);
125
- },
126
- };
91
+ const state = loadManifest(localPath);
92
+ let timer = null;
93
+ function scheduleFlush() {
94
+ if (timer)
95
+ clearTimeout(timer);
96
+ timer = setTimeout(() => {
97
+ timer = null;
98
+ saveManifest(localPath, state);
99
+ }, debounceMs);
100
+ }
101
+ return {
102
+ upsertEntry(trackingKey, entry) {
103
+ state.entries[trackingKey] = entry;
104
+ scheduleFlush();
105
+ },
106
+ removeEntry(trackingKey) {
107
+ delete state.entries[trackingKey];
108
+ scheduleFlush();
109
+ },
110
+ getEntry(trackingKey) {
111
+ return state.entries[trackingKey] ?? null;
112
+ },
113
+ getState() {
114
+ return state;
115
+ },
116
+ flush() {
117
+ if (timer) {
118
+ clearTimeout(timer);
119
+ timer = null;
120
+ }
121
+ saveManifest(localPath, state);
122
+ },
123
+ };
127
124
  }
125
+ //# sourceMappingURL=manifest.js.map
package/org-sync.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ import { type Firestore } from 'firebase/firestore';
2
+ import type { FirebaseStorage } from 'firebase/storage';
3
+ export interface StartOrgSyncInput {
4
+ db: Firestore;
5
+ storage: FirebaseStorage;
6
+ userId: string;
7
+ orgId: string;
8
+ localPath: string;
9
+ maxUploadBytes?: number;
10
+ statePath?: string;
11
+ }
12
+ export interface OrgSyncHandle {
13
+ stop(): Promise<void>;
14
+ }
15
+ export declare function startOrgSync({ db, storage, userId, orgId, localPath, maxUploadBytes, statePath, }: StartOrgSyncInput): Promise<OrgSyncHandle>;
16
+ //# sourceMappingURL=org-sync.d.ts.map