@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.
- package/auth-persistence.d.ts +22 -0
- package/auth-persistence.js +79 -83
- package/config.d.ts +13 -0
- package/config.js +55 -54
- package/files.d.ts +221 -0
- package/files.js +373 -438
- package/folder-chain.d.ts +44 -0
- package/folder-chain.js +52 -59
- package/manifest.d.ts +38 -0
- package/manifest.js +87 -89
- package/org-sync.d.ts +16 -0
- package/org-sync.js +1156 -1203
- package/package.json +23 -2
- package/paths.d.ts +1 -1
- package/pre-mint-app-check.d.ts +6 -0
- package/pre-mint-app-check.js +46 -39
- package/push-outcome.d.ts +9 -0
- package/push-outcome.js +6 -5
- package/setup-auth-with-pre-mint.d.ts +24 -0
- package/setup-auth-with-pre-mint.js +48 -55
- package/storage-helpers.d.ts +14 -0
- package/storage-helpers.js +43 -107
- package/sync-state.d.ts +4 -0
- package/sync-state.js +30 -16
- package/sync.d.ts +18 -0
- package/sync.js +464 -515
- package/team-registry.d.ts +41 -0
- package/team-registry.js +90 -81
- package/yjs-binding-state.d.ts +4 -0
- package/yjs-binding-state.js +25 -22
- package/yjs-file-binding.d.ts +32 -0
- package/yjs-file-binding.js +217 -204
- package/yjs-firestore-update-store.d.ts +13 -0
- package/yjs-firestore-update-store.js +99 -103
- package/yjs-provider.d.ts +43 -0
- package/yjs-provider.js +79 -75
|
@@ -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
|
-
|
|
18
|
-
|
|
16
|
+
if (!p)
|
|
17
|
+
return [];
|
|
18
|
+
return p.split('/').filter(Boolean);
|
|
19
19
|
}
|
|
20
|
-
|
|
21
20
|
function matchesScope(folder, { scope, teamId, ownerId }) {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
12
|
+
return path.join(localPath, MANIFEST_DIR, MANIFEST_FILE);
|
|
16
13
|
}
|
|
17
|
-
|
|
18
14
|
function normalizeEntry(raw) {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
contentHash
|
|
25
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
console.error(
|
|
85
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|