@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doubling/compound-sync",
3
- "version": "1.12.4",
3
+ "version": "1.12.5",
4
4
  "description": "Bidirectional sync between Compound and local markdown files",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,24 +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",
13
16
  "manifest.js",
17
+ "manifest.d.ts",
14
18
  "push-outcome.js",
19
+ "push-outcome.d.ts",
15
20
  "config.js",
21
+ "config.d.ts",
16
22
  "paths.js",
17
23
  "paths.d.ts",
18
24
  "files.js",
25
+ "files.d.ts",
19
26
  "folder-chain.js",
27
+ "folder-chain.d.ts",
20
28
  "storage-helpers.js",
29
+ "storage-helpers.d.ts",
21
30
  "team-registry.js",
31
+ "team-registry.d.ts",
22
32
  "auth-persistence.js",
33
+ "auth-persistence.d.ts",
23
34
  "pre-mint-app-check.js",
35
+ "pre-mint-app-check.d.ts",
24
36
  "setup-auth-with-pre-mint.js",
37
+ "setup-auth-with-pre-mint.d.ts",
25
38
  "yjs-firestore-update-store.js",
39
+ "yjs-firestore-update-store.d.ts",
26
40
  "yjs-provider.js",
41
+ "yjs-provider.d.ts",
27
42
  "yjs-file-binding.js",
43
+ "yjs-file-binding.d.ts",
28
44
  "yjs-binding-state.js",
45
+ "yjs-binding-state.d.ts",
29
46
  "README.md"
30
47
  ],
31
48
  "scripts": {
@@ -33,12 +50,13 @@
33
50
  "build": "tsc",
34
51
  "typecheck": "tsc --noEmit",
35
52
  "prepack": "npm run build",
36
- "test:unit": "node --import tsx --test config.test.js paths.test.js storage-helpers.test.js folder-chain.test.js team-registry.test.js auth-persistence.test.js pre-mint-app-check.test.js setup-auth-with-pre-mint.test.js sync-state.test.js manifest.test.js push-outcome.test.js yjs-file-binding.test.js yjs-binding-state.test.js files-helpers.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",
37
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",
38
55
  "test:integration:emulator": "USE_EMULATOR=true firebase emulators:exec --only firestore,storage,auth 'npm run test:integration'",
39
56
  "test": "npm run test:unit && npm run test:integration"
40
57
  },
41
58
  "dependencies": {
59
+ "@doubling/types": "*",
42
60
  "chokidar": "^5.0.0",
43
61
  "fast-diff": "^1.3.0",
44
62
  "firebase": "^12.14.0",
@@ -53,5 +71,8 @@
53
71
  },
54
72
  "publishConfig": {
55
73
  "access": "public"
74
+ },
75
+ "engines": {
76
+ "node": ">=22"
56
77
  }
57
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
@@ -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
- let raw;
33
- try {
34
- raw = fs.readFileSync(filePath, 'utf8');
35
- } catch {
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
- const f = deps.fetch || fetch;
68
- const url = `https://securetoken.googleapis.com/v1/token?key=${apiKey}`;
69
- const body = new URLSearchParams({
70
- grant_type: 'refresh_token',
71
- refresh_token: refreshToken,
72
- }).toString();
73
- const resp = await f(url, {
74
- method: 'POST',
75
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
76
- body,
77
- });
78
- if (!resp.ok) {
79
- const errBody = await resp.text().catch(() => '');
80
- throw new Error(`secure-token exchange failed (${resp.status}): ${errBody}`);
81
- }
82
- const json = await resp.json();
83
- return json.id_token;
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 CHANGED
@@ -1,10 +1,11 @@
1
1
  // Whether a pushFileToCloud result should update local sync artifacts
2
2
  // (content baseline + manifest). Partial uploads (Firestore OK, blob
3
3
  // failed) must not be recorded as successfully synced.
4
-
5
4
  export function shouldPersistLocalSyncState(result) {
6
- if (result.storageError) return false;
7
- return result.action === 'created'
8
- || result.action === 'updated'
9
- || result.action === 'noop';
5
+ if (result.storageError)
6
+ return false;
7
+ return result.action === 'created'
8
+ || result.action === 'updated'
9
+ || result.action === 'noop';
10
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
- export async function setupAuthWithPreMint(
30
- { app, authFilePath, apiKey, appCheckUrl },
31
- deps,
32
- ) {
33
- const {
34
- readPersistedRefreshToken,
35
- exchangeRefreshTokenForIdToken,
36
- initAppCheckWithIdToken,
37
- makeFilePersistence,
38
- initializeAuth,
39
- } = deps;
40
-
41
- // 1. Read the persisted refresh token directly, before involving the
42
- // Firebase Auth SDK. If there's nothing persisted (first launch),
43
- // this returns null and we skip pre-mint; the caller's
44
- // browser-auth path handles first-run sign-in.
45
- const refreshToken = readPersistedRefreshToken(authFilePath);
46
-
47
- // 2. Pre-mint App Check on the app, if we have what we need. This
48
- // MUST complete before step 3 (initializeAuth), or the SDK's
49
- // startup :lookup races our pre-mint and goes out unauthed.
50
- let appCheckPreMinted = false;
51
- if (refreshToken && appCheckUrl) {
52
- try {
53
- const idToken = await exchangeRefreshTokenForIdToken(refreshToken, apiKey);
54
- await initAppCheckWithIdToken(app, appCheckUrl, idToken);
55
- appCheckPreMinted = true;
56
- } catch (err) {
57
- // Refresh token revoked, network blip, or App Check function
58
- // error: don't block startup. The Auth SDK's :lookup will still
59
- // fail under enforcement (and the SDK will null-out currentUser),
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
- // 3. Now initializeAuth. App Check (if it was pre-minted) is already
67
- // registered on the app, so the SDK's :lookup carries the header.
68
- // makeFilePersistence returns a CLASS (Firebase's persistence
69
- // contract is `new Persistence()`), and the unfreeze method is a
70
- // static on that class we call it as PersistenceClass.unfreeze().
71
- const PersistenceClass = makeFilePersistence(authFilePath);
72
- const auth = initializeAuth(app, { persistence: PersistenceClass });
73
-
74
- // 4. Wait for the SDK to finish its startup validation. If we
75
- // pre-minted successfully and the refresh token is still valid,
76
- // auth.currentUser will be the restored user.
77
- await auth.authStateReady();
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
@@ -1,50 +1,55 @@
1
- // Pure Node-side storage helpers. No Firebase imports.
1
+ // Node-side storage helpers (sync daemon environment).
2
2
  //
3
- // Mirrors public/js/storage-helpers.js but uses Node-native APIs
4
- // (Buffer, crypto.createHash) instead of the browser equivalents
5
- // (Uint8Array, crypto.subtle). Function names and return types
6
- // match so that callers on either side can be reasoned about
7
- // uniformly.
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
- // Storage layout convention:
10
- // orgs/{orgId}/files/{fileId}/current.{ext}
11
-
12
- import crypto from 'crypto';
13
-
14
- export function extFromPath(path) {
15
- const name = (((path == null ? '' : path) + '').split('/').pop()) || '';
16
- const idx = name.lastIndexOf('.');
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
- if (data == null) return 0;
27
- if (typeof data === 'string') return Buffer.byteLength(data, 'utf-8');
28
- if (Buffer.isBuffer(data)) return data.length;
29
- if (data instanceof Uint8Array) return data.byteLength;
30
- if (data instanceof ArrayBuffer) return data.byteLength;
31
- return Buffer.byteLength(String(data), 'utf-8');
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
- const buf = toBytes(data);
36
- return crypto.createHash('sha256').update(buf).digest('hex');
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
- const key = `folder|${scope}|${teamId ?? ''}|${ownerId ?? ''}|${path}`;
45
- return 'f-' + crypto.createHash('sha256').update(key).digest('hex').slice(0, 20);
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
- const key = `file|${scope}|${teamId ?? ''}|${ownerId ?? ''}|${path}`;
60
- return 'x-' + crypto.createHash('sha256').update(key).digest('hex').slice(0, 20);
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
@@ -0,0 +1,4 @@
1
+ export type SyncState = Record<string, string>;
2
+ export declare function loadSyncState(filePath: string): SyncState;
3
+ export declare function saveSyncState(filePath: string, state: SyncState): void;
4
+ //# sourceMappingURL=sync-state.d.ts.map
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
- try {
17
- const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
18
- return parsed && typeof parsed === 'object' ? parsed : {};
19
- } catch {
20
- return {};
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
- try {
26
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
27
- fs.writeFileSync(filePath, JSON.stringify(state));
28
- } catch {
29
- // Best-effort: losing the baseline only costs us a cold-start
30
- // reconciliation next launch, never data.
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