@doubling/compound-sync 1.12.3 → 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/README.md +17 -0
- 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 +125 -0
- package/org-sync.d.ts +16 -0
- package/org-sync.js +1196 -972
- package/package.json +26 -3
- 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 +11 -0
- 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 +91 -70
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@doubling/compound-sync",
|
|
3
|
-
"version": "1.12.
|
|
3
|
+
"version": "1.12.5",
|
|
4
4
|
"description": "Bidirectional sync between Compound and local markdown files",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -8,22 +8,41 @@
|
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"sync.js",
|
|
11
|
+
"sync.d.ts",
|
|
11
12
|
"org-sync.js",
|
|
13
|
+
"org-sync.d.ts",
|
|
12
14
|
"sync-state.js",
|
|
15
|
+
"sync-state.d.ts",
|
|
16
|
+
"manifest.js",
|
|
17
|
+
"manifest.d.ts",
|
|
18
|
+
"push-outcome.js",
|
|
19
|
+
"push-outcome.d.ts",
|
|
13
20
|
"config.js",
|
|
21
|
+
"config.d.ts",
|
|
14
22
|
"paths.js",
|
|
15
23
|
"paths.d.ts",
|
|
16
24
|
"files.js",
|
|
25
|
+
"files.d.ts",
|
|
17
26
|
"folder-chain.js",
|
|
27
|
+
"folder-chain.d.ts",
|
|
18
28
|
"storage-helpers.js",
|
|
29
|
+
"storage-helpers.d.ts",
|
|
19
30
|
"team-registry.js",
|
|
31
|
+
"team-registry.d.ts",
|
|
20
32
|
"auth-persistence.js",
|
|
33
|
+
"auth-persistence.d.ts",
|
|
21
34
|
"pre-mint-app-check.js",
|
|
35
|
+
"pre-mint-app-check.d.ts",
|
|
22
36
|
"setup-auth-with-pre-mint.js",
|
|
37
|
+
"setup-auth-with-pre-mint.d.ts",
|
|
23
38
|
"yjs-firestore-update-store.js",
|
|
39
|
+
"yjs-firestore-update-store.d.ts",
|
|
24
40
|
"yjs-provider.js",
|
|
41
|
+
"yjs-provider.d.ts",
|
|
25
42
|
"yjs-file-binding.js",
|
|
43
|
+
"yjs-file-binding.d.ts",
|
|
26
44
|
"yjs-binding-state.js",
|
|
45
|
+
"yjs-binding-state.d.ts",
|
|
27
46
|
"README.md"
|
|
28
47
|
],
|
|
29
48
|
"scripts": {
|
|
@@ -31,12 +50,13 @@
|
|
|
31
50
|
"build": "tsc",
|
|
32
51
|
"typecheck": "tsc --noEmit",
|
|
33
52
|
"prepack": "npm run build",
|
|
34
|
-
"test:unit": "node --import tsx --test config.test.js paths.test.js storage-helpers.test.
|
|
35
|
-
"test:integration": "node --import tsx --test --test-timeout=180000 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",
|
|
53
|
+
"test:unit": "node --import tsx --test config.test.js paths.test.js storage-helpers.test.ts folder-chain.test.ts team-registry.test.ts auth-persistence.test.js pre-mint-app-check.test.js setup-auth-with-pre-mint.test.js sync-state.test.ts manifest.test.ts push-outcome.test.ts yjs-file-binding.test.ts yjs-binding-state.test.ts files-helpers.test.js",
|
|
54
|
+
"test:integration": "node --import tsx --test --test-timeout=180000 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-startup-ordering.test.js org-sync-manifest-reconcile.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
55
|
"test:integration:emulator": "USE_EMULATOR=true firebase emulators:exec --only firestore,storage,auth 'npm run test:integration'",
|
|
37
56
|
"test": "npm run test:unit && npm run test:integration"
|
|
38
57
|
},
|
|
39
58
|
"dependencies": {
|
|
59
|
+
"@doubling/types": "*",
|
|
40
60
|
"chokidar": "^5.0.0",
|
|
41
61
|
"fast-diff": "^1.3.0",
|
|
42
62
|
"firebase": "^12.14.0",
|
|
@@ -51,5 +71,8 @@
|
|
|
51
71
|
},
|
|
52
72
|
"publishConfig": {
|
|
53
73
|
"access": "public"
|
|
74
|
+
},
|
|
75
|
+
"engines": {
|
|
76
|
+
"node": ">=22"
|
|
54
77
|
}
|
|
55
78
|
}
|
package/paths.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ export declare const SHARED_BY_ME_FOLDER = "Shared by Me";
|
|
|
4
4
|
export type Scope = 'team' | 'private' | 'shared-with-me' | 'shared-by-me';
|
|
5
5
|
export interface ScopedDocPath {
|
|
6
6
|
scope: Scope;
|
|
7
|
-
teamId?: string;
|
|
7
|
+
teamId?: string | undefined;
|
|
8
8
|
docPath: string;
|
|
9
9
|
}
|
|
10
10
|
export declare function teamFolderName(teamName: string): string;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export interface ExchangeDeps {
|
|
2
|
+
fetch?: typeof globalThis.fetch;
|
|
3
|
+
}
|
|
4
|
+
export declare function readPersistedRefreshToken(filePath: string): string | null;
|
|
5
|
+
export declare function exchangeRefreshTokenForIdToken(refreshToken: string, apiKey: string, deps?: ExchangeDeps): Promise<string>;
|
|
6
|
+
//# sourceMappingURL=pre-mint-app-check.d.ts.map
|
package/pre-mint-app-check.js
CHANGED
|
@@ -18,9 +18,7 @@
|
|
|
18
18
|
// an App Check token from our mintappchecktoken cloud function, and
|
|
19
19
|
// initializeAppCheck. Now the SDK's :lookup carries the App Check
|
|
20
20
|
// header and silent restore succeeds.
|
|
21
|
-
|
|
22
21
|
import fs from 'node:fs';
|
|
23
|
-
|
|
24
22
|
// Read the refresh token stored by Firebase Auth's file persistence.
|
|
25
23
|
// The persistence stores a single key per app:
|
|
26
24
|
// 'firebase:authUser:<apiKey>:[DEFAULT]' → { uid, ..., stsTokenManager: { refreshToken, ... } }
|
|
@@ -29,28 +27,33 @@ import fs from 'node:fs';
|
|
|
29
27
|
// entry, and pull its refresh token. Returns null when there's nothing
|
|
30
28
|
// usable (file missing, corrupt, empty, or no refresh token field).
|
|
31
29
|
export function readPersistedRefreshToken(filePath) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
30
|
+
let raw;
|
|
31
|
+
try {
|
|
32
|
+
raw = fs.readFileSync(filePath, 'utf8');
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
let store;
|
|
38
|
+
try {
|
|
39
|
+
store = JSON.parse(raw);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
if (!store || typeof store !== 'object')
|
|
45
|
+
return null;
|
|
46
|
+
const s = store;
|
|
47
|
+
for (const key of Object.keys(s)) {
|
|
48
|
+
if (!key.startsWith('firebase:authUser:'))
|
|
49
|
+
continue;
|
|
50
|
+
const user = s[key];
|
|
51
|
+
const rt = user?.stsTokenManager?.refreshToken;
|
|
52
|
+
if (typeof rt === 'string' && rt.length > 0)
|
|
53
|
+
return rt;
|
|
54
|
+
}
|
|
36
55
|
return null;
|
|
37
|
-
}
|
|
38
|
-
let store;
|
|
39
|
-
try {
|
|
40
|
-
store = JSON.parse(raw);
|
|
41
|
-
} catch {
|
|
42
|
-
return null;
|
|
43
|
-
}
|
|
44
|
-
if (!store || typeof store !== 'object') return null;
|
|
45
|
-
for (const key of Object.keys(store)) {
|
|
46
|
-
if (!key.startsWith('firebase:authUser:')) continue;
|
|
47
|
-
const user = store[key];
|
|
48
|
-
const rt = user?.stsTokenManager?.refreshToken;
|
|
49
|
-
if (typeof rt === 'string' && rt.length > 0) return rt;
|
|
50
|
-
}
|
|
51
|
-
return null;
|
|
52
56
|
}
|
|
53
|
-
|
|
54
57
|
// Exchange a Firebase refresh token for a fresh ID token via Google's
|
|
55
58
|
// secure token endpoint. This endpoint lives at securetoken.googleapis.com
|
|
56
59
|
// and is intentionally NOT under App Check enforcement (App Check
|
|
@@ -64,21 +67,25 @@ export function readPersistedRefreshToken(filePath) {
|
|
|
64
67
|
//
|
|
65
68
|
// `deps.fetch` is injectable for tests; defaults to the global fetch.
|
|
66
69
|
export async function exchangeRefreshTokenForIdToken(refreshToken, apiKey, deps = {}) {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
70
|
+
const f = deps.fetch || fetch;
|
|
71
|
+
const url = `https://securetoken.googleapis.com/v1/token?key=${apiKey}`;
|
|
72
|
+
const body = new URLSearchParams({
|
|
73
|
+
grant_type: 'refresh_token',
|
|
74
|
+
refresh_token: refreshToken,
|
|
75
|
+
}).toString();
|
|
76
|
+
const resp = await f(url, {
|
|
77
|
+
method: 'POST',
|
|
78
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
79
|
+
body,
|
|
80
|
+
});
|
|
81
|
+
if (!resp.ok) {
|
|
82
|
+
const errBody = await resp.text().catch(() => '');
|
|
83
|
+
throw new Error(`secure-token exchange failed (${resp.status}): ${errBody}`);
|
|
84
|
+
}
|
|
85
|
+
const json = await resp.json();
|
|
86
|
+
if (typeof json.id_token !== 'string' || !json.id_token) {
|
|
87
|
+
throw new Error('secure-token exchange returned no id_token');
|
|
88
|
+
}
|
|
89
|
+
return json.id_token;
|
|
84
90
|
}
|
|
91
|
+
//# sourceMappingURL=pre-mint-app-check.js.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface PushFileOutcome {
|
|
2
|
+
action: string;
|
|
3
|
+
storageError?: unknown;
|
|
4
|
+
fileId?: string;
|
|
5
|
+
reason?: string;
|
|
6
|
+
fileData?: unknown;
|
|
7
|
+
}
|
|
8
|
+
export declare function shouldPersistLocalSyncState(result: PushFileOutcome): boolean;
|
|
9
|
+
//# sourceMappingURL=push-outcome.d.ts.map
|
package/push-outcome.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Whether a pushFileToCloud result should update local sync artifacts
|
|
2
|
+
// (content baseline + manifest). Partial uploads (Firestore OK, blob
|
|
3
|
+
// failed) must not be recorded as successfully synced.
|
|
4
|
+
export function shouldPersistLocalSyncState(result) {
|
|
5
|
+
if (result.storageError)
|
|
6
|
+
return false;
|
|
7
|
+
return result.action === 'created'
|
|
8
|
+
|| result.action === 'updated'
|
|
9
|
+
|| result.action === 'noop';
|
|
10
|
+
}
|
|
11
|
+
//# sourceMappingURL=push-outcome.js.map
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { FirebaseApp } from 'firebase/app';
|
|
2
|
+
import { type Auth, type Dependencies } from 'firebase/auth';
|
|
3
|
+
import type { FilePersistenceClass } from './auth-persistence.js';
|
|
4
|
+
export interface SetupAuthInput {
|
|
5
|
+
app: FirebaseApp;
|
|
6
|
+
authFilePath: string;
|
|
7
|
+
apiKey: string;
|
|
8
|
+
appCheckUrl?: string | null | undefined;
|
|
9
|
+
}
|
|
10
|
+
export type InitializeAuthFn = (app: FirebaseApp, deps?: Dependencies) => Auth;
|
|
11
|
+
export interface SetupAuthDeps {
|
|
12
|
+
readPersistedRefreshToken: (authFilePath: string) => string | null;
|
|
13
|
+
exchangeRefreshTokenForIdToken: (refreshToken: string, apiKey: string) => Promise<string>;
|
|
14
|
+
initAppCheckWithIdToken: (app: FirebaseApp, appCheckUrl: string, idToken: string) => Promise<unknown>;
|
|
15
|
+
makeFilePersistence: (filePath: string) => FilePersistenceClass;
|
|
16
|
+
initializeAuth: InitializeAuthFn;
|
|
17
|
+
}
|
|
18
|
+
export interface SetupAuthResult {
|
|
19
|
+
auth: Auth;
|
|
20
|
+
persistence: FilePersistenceClass;
|
|
21
|
+
appCheckPreMinted: boolean;
|
|
22
|
+
}
|
|
23
|
+
export declare function setupAuthWithPreMint({ app, authFilePath, apiKey, appCheckUrl }: SetupAuthInput, deps: SetupAuthDeps): Promise<SetupAuthResult>;
|
|
24
|
+
//# sourceMappingURL=setup-auth-with-pre-mint.d.ts.map
|
|
@@ -25,60 +25,53 @@
|
|
|
25
25
|
// real network calls. sync.js wires the real deps in two places (the
|
|
26
26
|
// discover path and the main daemon path), eliminating the duplicated
|
|
27
27
|
// inline pre-mint blocks that previously lived in sync.js.
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
exchangeRefreshTokenForIdToken,
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
// and the caller's browser-auth path will take over. The
|
|
61
|
-
// persistence freeze guard keeps the on-disk file intact.
|
|
62
|
-
console.log(`Pre-mint App Check failed (falling back to browser): ${err.message}`);
|
|
28
|
+
import { initializeAuth as _firebaseInitializeAuth } from 'firebase/auth';
|
|
29
|
+
// Compile-time check that `initializeAuth` from firebase/auth still
|
|
30
|
+
// matches the dep shape we declared. If Firebase changes the
|
|
31
|
+
// signature, this assignment fails and the diff makes it obvious.
|
|
32
|
+
const _initializeAuthShapeCheck = _firebaseInitializeAuth;
|
|
33
|
+
void _initializeAuthShapeCheck;
|
|
34
|
+
export async function setupAuthWithPreMint({ app, authFilePath, apiKey, appCheckUrl }, deps) {
|
|
35
|
+
const { readPersistedRefreshToken, exchangeRefreshTokenForIdToken, initAppCheckWithIdToken, makeFilePersistence, initializeAuth, } = deps;
|
|
36
|
+
// 1. Read the persisted refresh token directly, before involving the
|
|
37
|
+
// Firebase Auth SDK. If there's nothing persisted (first launch),
|
|
38
|
+
// this returns null and we skip pre-mint; the caller's
|
|
39
|
+
// browser-auth path handles first-run sign-in.
|
|
40
|
+
const refreshToken = readPersistedRefreshToken(authFilePath);
|
|
41
|
+
// 2. Pre-mint App Check on the app, if we have what we need. This
|
|
42
|
+
// MUST complete before step 3 (initializeAuth), or the SDK's
|
|
43
|
+
// startup :lookup races our pre-mint and goes out unauthed.
|
|
44
|
+
let appCheckPreMinted = false;
|
|
45
|
+
if (refreshToken && appCheckUrl) {
|
|
46
|
+
try {
|
|
47
|
+
const idToken = await exchangeRefreshTokenForIdToken(refreshToken, apiKey);
|
|
48
|
+
await initAppCheckWithIdToken(app, appCheckUrl, idToken);
|
|
49
|
+
appCheckPreMinted = true;
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
// Refresh token revoked, network blip, or App Check function
|
|
53
|
+
// error: don't block startup. The Auth SDK's :lookup will still
|
|
54
|
+
// fail under enforcement (and the SDK will null-out currentUser),
|
|
55
|
+
// and the caller's browser-auth path will take over. The
|
|
56
|
+
// persistence freeze guard keeps the on-disk file intact.
|
|
57
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
58
|
+
console.log(`Pre-mint App Check failed (falling back to browser): ${msg}`);
|
|
59
|
+
}
|
|
63
60
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
// 5. Unfreeze persistence so post-init _remove calls (e.g., an
|
|
80
|
-
// explicit sign-out by the user) actually clear the file.
|
|
81
|
-
PersistenceClass.unfreeze();
|
|
82
|
-
|
|
83
|
-
return { auth, persistence: PersistenceClass, appCheckPreMinted };
|
|
61
|
+
// 3. Now initializeAuth. App Check (if it was pre-minted) is already
|
|
62
|
+
// registered on the app, so the SDK's :lookup carries the header.
|
|
63
|
+
// makeFilePersistence returns a CLASS (Firebase's persistence
|
|
64
|
+
// contract is `new Persistence()`), and the unfreeze method is a
|
|
65
|
+
// static on that class — we call it as PersistenceClass.unfreeze().
|
|
66
|
+
const PersistenceClass = makeFilePersistence(authFilePath);
|
|
67
|
+
const auth = initializeAuth(app, { persistence: PersistenceClass });
|
|
68
|
+
// 4. Wait for the SDK to finish its startup validation. If we
|
|
69
|
+
// pre-minted successfully and the refresh token is still valid,
|
|
70
|
+
// auth.currentUser will be the restored user.
|
|
71
|
+
await auth.authStateReady();
|
|
72
|
+
// 5. Unfreeze persistence so post-init _remove calls (e.g., an
|
|
73
|
+
// explicit sign-out by the user) actually clear the file.
|
|
74
|
+
PersistenceClass.unfreeze();
|
|
75
|
+
return { auth, persistence: PersistenceClass, appCheckPreMinted };
|
|
84
76
|
}
|
|
77
|
+
//# sourceMappingURL=setup-auth-with-pre-mint.js.map
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export { EXT_TO_MIME, mimeTypeFromExt, isTextMimeType, extFromPath, storagePathFor, } from '@doubling/types';
|
|
2
|
+
export type StorageData = string | Buffer | Uint8Array | ArrayBuffer | null | undefined;
|
|
3
|
+
export declare function contentByteSize(data: StorageData): number;
|
|
4
|
+
export declare function toBytes(data: StorageData): Buffer;
|
|
5
|
+
export declare function computeContentHash(data: StorageData): string;
|
|
6
|
+
export interface DocIdInput {
|
|
7
|
+
scope: string;
|
|
8
|
+
teamId?: string | null;
|
|
9
|
+
ownerId?: string | null;
|
|
10
|
+
path: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function folderDocId({ scope, teamId, ownerId, path }: DocIdInput): string;
|
|
13
|
+
export declare function fileDocId({ scope, teamId, ownerId, path }: DocIdInput): string;
|
|
14
|
+
//# sourceMappingURL=storage-helpers.d.ts.map
|
package/storage-helpers.js
CHANGED
|
@@ -1,50 +1,55 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Node-side storage helpers (sync daemon environment).
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
3
|
+
// The pure, environment-agnostic parts (`EXT_TO_MIME`,
|
|
4
|
+
// `mimeTypeFromExt`, `isTextMimeType`, `extFromPath`,
|
|
5
|
+
// `storagePathFor`) live in `@doubling/types` and are re-exported
|
|
6
|
+
// here so existing imports continue to work. Adding a new extension
|
|
7
|
+
// is now a single edit in `@doubling/types`'s `mime.ts` (DOU-182).
|
|
8
8
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
if (idx <= 0 || idx === name.length - 1) return 'bin';
|
|
18
|
-
return name.slice(idx + 1).toLowerCase();
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export function storagePathFor(orgId, fileId, ext) {
|
|
22
|
-
return `orgs/${orgId}/files/${fileId}/current.${ext || 'bin'}`;
|
|
23
|
-
}
|
|
24
|
-
|
|
9
|
+
// The node-specific bytes / hash helpers (`toBytes`,
|
|
10
|
+
// `contentByteSize`, `computeContentHash`) and the deterministic
|
|
11
|
+
// doc-id helpers (`folderDocId`, `fileDocId`, DOU-240) stay here
|
|
12
|
+
// because they rely on `Buffer` and `crypto.createHash`, which the
|
|
13
|
+
// web client implements differently. A future Phase 5 cleanup could
|
|
14
|
+
// move to an isomorphic Web Crypto implementation.
|
|
15
|
+
import crypto from 'node:crypto';
|
|
16
|
+
export { EXT_TO_MIME, mimeTypeFromExt, isTextMimeType, extFromPath, storagePathFor, } from '@doubling/types';
|
|
25
17
|
export function contentByteSize(data) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
18
|
+
if (data == null)
|
|
19
|
+
return 0;
|
|
20
|
+
if (typeof data === 'string')
|
|
21
|
+
return Buffer.byteLength(data, 'utf-8');
|
|
22
|
+
if (Buffer.isBuffer(data))
|
|
23
|
+
return data.length;
|
|
24
|
+
if (data instanceof Uint8Array)
|
|
25
|
+
return data.byteLength;
|
|
26
|
+
if (data instanceof ArrayBuffer)
|
|
27
|
+
return data.byteLength;
|
|
28
|
+
return Buffer.byteLength(String(data), 'utf-8');
|
|
29
|
+
}
|
|
30
|
+
export function toBytes(data) {
|
|
31
|
+
if (data == null)
|
|
32
|
+
return Buffer.alloc(0);
|
|
33
|
+
if (Buffer.isBuffer(data))
|
|
34
|
+
return data;
|
|
35
|
+
if (data instanceof Uint8Array)
|
|
36
|
+
return Buffer.from(data);
|
|
37
|
+
if (data instanceof ArrayBuffer)
|
|
38
|
+
return Buffer.from(data);
|
|
39
|
+
return Buffer.from(String(data), 'utf-8');
|
|
32
40
|
}
|
|
33
|
-
|
|
34
41
|
export function computeContentHash(data) {
|
|
35
|
-
|
|
36
|
-
|
|
42
|
+
const buf = toBytes(data);
|
|
43
|
+
return crypto.createHash('sha256').update(buf).digest('hex');
|
|
37
44
|
}
|
|
38
|
-
|
|
39
45
|
// Deterministic Firestore document id for a folder doc identified by
|
|
40
46
|
// scope + ownership + path. Two concurrent creates collapse onto the
|
|
41
47
|
// same id, so a setDoc by all of them is idempotent (no duplicates).
|
|
42
48
|
// See DOU-240 for the race this prevents.
|
|
43
49
|
export function folderDocId({ scope, teamId, ownerId, path }) {
|
|
44
|
-
|
|
45
|
-
|
|
50
|
+
const key = `folder|${scope}|${teamId ?? ''}|${ownerId ?? ''}|${path}`;
|
|
51
|
+
return 'f-' + crypto.createHash('sha256').update(key).digest('hex').slice(0, 20);
|
|
46
52
|
}
|
|
47
|
-
|
|
48
53
|
// Deterministic Firestore document id for a file doc identified by
|
|
49
54
|
// scope + ownership + path. Same purpose as folderDocId: concurrent
|
|
50
55
|
// creates at the same path collapse onto one doc.
|
|
@@ -56,76 +61,7 @@ export function folderDocId({ scope, teamId, ownerId, path }) {
|
|
|
56
61
|
// reuse of a path (e.g., delete then recreate) is fine because the
|
|
57
62
|
// old doc was already removed by unlink before the new add fires.
|
|
58
63
|
export function fileDocId({ scope, teamId, ownerId, path }) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export function toBytes(data) {
|
|
64
|
-
if (data == null) return Buffer.alloc(0);
|
|
65
|
-
if (Buffer.isBuffer(data)) return data;
|
|
66
|
-
if (data instanceof Uint8Array) return Buffer.from(data);
|
|
67
|
-
if (data instanceof ArrayBuffer) return Buffer.from(data);
|
|
68
|
-
return Buffer.from(String(data), 'utf-8');
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// Files with these mime types are treated as inline-readable text by
|
|
72
|
-
// the sync daemon: read with utf-8 encoding from disk, written with
|
|
73
|
-
// utf-8 encoding to disk. Everything else is treated as binary and
|
|
74
|
-
// transferred as raw bytes.
|
|
75
|
-
//
|
|
76
|
-
// Missing mime type is treated as text for backward compatibility
|
|
77
|
-
// with markdown files written before mimeType was tracked.
|
|
78
|
-
export function isTextMimeType(mimeType) {
|
|
79
|
-
if (mimeType == null || mimeType === '') return true;
|
|
80
|
-
return typeof mimeType === 'string' && mimeType.startsWith('text/');
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const EXT_TO_MIME = {
|
|
84
|
-
md: 'text/markdown',
|
|
85
|
-
markdown: 'text/markdown',
|
|
86
|
-
txt: 'text/plain',
|
|
87
|
-
csv: 'text/csv',
|
|
88
|
-
html: 'text/html',
|
|
89
|
-
htm: 'text/html',
|
|
90
|
-
css: 'text/css',
|
|
91
|
-
js: 'text/javascript',
|
|
92
|
-
// Obsidian Bases (.base) files are YAML. Mapping to text/yaml here
|
|
93
|
-
// (in addition to the web copy) ensures the daemon uploads .base as
|
|
94
|
-
// a UTF-8 text blob with the right Content-Type so the web client's
|
|
95
|
-
// eager-load path picks up entity.content for the Bases renderer.
|
|
96
|
-
base: 'text/yaml',
|
|
97
|
-
yaml: 'text/yaml',
|
|
98
|
-
yml: 'text/yaml',
|
|
99
|
-
json: 'application/json',
|
|
100
|
-
pdf: 'application/pdf',
|
|
101
|
-
png: 'image/png',
|
|
102
|
-
jpg: 'image/jpeg',
|
|
103
|
-
jpeg: 'image/jpeg',
|
|
104
|
-
gif: 'image/gif',
|
|
105
|
-
webp: 'image/webp',
|
|
106
|
-
svg: 'image/svg+xml',
|
|
107
|
-
bmp: 'image/bmp',
|
|
108
|
-
heic: 'image/heic',
|
|
109
|
-
heif: 'image/heif',
|
|
110
|
-
mp3: 'audio/mpeg',
|
|
111
|
-
wav: 'audio/wav',
|
|
112
|
-
mp4: 'video/mp4',
|
|
113
|
-
webm: 'video/webm',
|
|
114
|
-
mov: 'video/quicktime',
|
|
115
|
-
zip: 'application/zip',
|
|
116
|
-
// Office Open XML (modern Office). The full type strings are
|
|
117
|
-
// mandatory: tools that dispatch by mime (Excel, third-party readers)
|
|
118
|
-
// ignore alternative spellings.
|
|
119
|
-
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
120
|
-
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
121
|
-
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
122
|
-
// Legacy Office formats (pre-2007 binary containers).
|
|
123
|
-
xls: 'application/vnd.ms-excel',
|
|
124
|
-
doc: 'application/msword',
|
|
125
|
-
ppt: 'application/vnd.ms-powerpoint',
|
|
126
|
-
};
|
|
127
|
-
|
|
128
|
-
export function mimeTypeFromExt(ext) {
|
|
129
|
-
if (ext == null) return 'application/octet-stream';
|
|
130
|
-
return EXT_TO_MIME[String(ext).toLowerCase()] || 'application/octet-stream';
|
|
64
|
+
const key = `file|${scope}|${teamId ?? ''}|${ownerId ?? ''}|${path}`;
|
|
65
|
+
return 'x-' + crypto.createHash('sha256').update(key).digest('hex').slice(0, 20);
|
|
131
66
|
}
|
|
67
|
+
//# sourceMappingURL=storage-helpers.js.map
|
package/sync-state.d.ts
ADDED
package/sync-state.js
CHANGED
|
@@ -6,27 +6,41 @@
|
|
|
6
6
|
// in-memory baseline alone is empty on a cold start, which forced an
|
|
7
7
|
// unreliable mtime fallback). Small JSON file; best-effort writes (a
|
|
8
8
|
// failed write must never take down sync).
|
|
9
|
-
|
|
10
9
|
import fs from 'node:fs';
|
|
11
10
|
import path from 'node:path';
|
|
12
|
-
|
|
13
11
|
// Load the baseline map ({ trackingKey: contentHash }). Missing or
|
|
14
12
|
// corrupt file reads as {} so a first run / damaged state can't crash.
|
|
13
|
+
// Validates that every entry is a string; non-string entries (from a
|
|
14
|
+
// partial write or hand-edit) are dropped silently rather than leaking
|
|
15
|
+
// invalid shapes into runtime.
|
|
15
16
|
export function loadSyncState(filePath) {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
17
|
+
try {
|
|
18
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
19
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
20
|
+
return {};
|
|
21
|
+
const result = {};
|
|
22
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
23
|
+
if (typeof value === 'string') {
|
|
24
|
+
result[key] = value;
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
console.error(`[sync-state] dropped invalid entry for ${key}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return {};
|
|
34
|
+
}
|
|
22
35
|
}
|
|
23
|
-
|
|
24
36
|
export function saveSyncState(filePath, state) {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
37
|
+
try {
|
|
38
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
39
|
+
fs.writeFileSync(filePath, JSON.stringify(state));
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// Best-effort: losing the baseline only costs us a cold-start
|
|
43
|
+
// reconciliation next launch, never data.
|
|
44
|
+
}
|
|
32
45
|
}
|
|
46
|
+
//# sourceMappingURL=sync-state.js.map
|
package/sync.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Bidirectional sync between Firestore files and local markdown files.
|
|
4
|
+
* Uses Firebase client SDK with browser-based auth — no gcloud needed.
|
|
5
|
+
*
|
|
6
|
+
* Local folder structure:
|
|
7
|
+
* {SyncFolder}/
|
|
8
|
+
* {TeamName} Teamspace/ ← team files (bidirectional)
|
|
9
|
+
* Private/ ← user's private files (bidirectional)
|
|
10
|
+
* Shared by Me/ ← symlinks to files you've shared
|
|
11
|
+
* Shared with Me/ ← files shared with you (read-only)
|
|
12
|
+
*
|
|
13
|
+
* Usage: node sync.js [--config <file>] [--setup] [--env <name>]
|
|
14
|
+
* node sync.js --version
|
|
15
|
+
* node sync.js --help
|
|
16
|
+
*/
|
|
17
|
+
export {};
|
|
18
|
+
//# sourceMappingURL=sync.d.ts.map
|