Skip to content

Commit 0c4e3e4

Browse files
Juan Pablo Díaz S.claude
andcommitted
feat: add named Firestore database support and migrate CI to pnpm
- Add optional Database ID field in the Service Account connection dialog, enabling connections to named Firestore databases instead of only (default) - Propagate databaseId through the full chain: UI → Redux → preload → backend - Persist databaseId in localStorage for automatic reconnection - Expose FieldValue, Filter, Timestamp, and GeoPoint in JS Query sandbox - Migrate GitHub Actions workflows (CI + Build & Release) from npm to pnpm - Add pnpm-lock.yaml and package-lock.json to .gitignore - Add unit tests for controllers and Redux slices with vitest Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 65df4aa commit 0c4e3e4

27 files changed

Lines changed: 766 additions & 12186 deletions

.github/workflows/build.yml

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,16 @@ jobs:
1313
- name: Checkout
1414
uses: actions/checkout@v4
1515

16+
- name: Setup pnpm
17+
uses: pnpm/action-setup@v4
18+
1619
- name: Setup Node.js
1720
uses: actions/setup-node@v4
1821
with:
1922
node-version: '22'
20-
cache: 'npm'
2123

2224
- name: Install dependencies
23-
run: npm ci
25+
run: pnpm install
2426

2527
- name: Create .env file
2628
run: |
@@ -29,11 +31,11 @@ jobs:
2931
3032
- name: Build Windows (unsigned)
3133
if: vars.ENABLE_CODE_SIGNING != 'true'
32-
run: npm run build:win
34+
run: pnpm run build:win
3335

3436
- name: Build Windows (signed)
3537
if: vars.ENABLE_CODE_SIGNING == 'true'
36-
run: npm run build:win
38+
run: pnpm run build:win
3739
env:
3840
CSC_LINK: ${{ secrets.WINDOWS_CERTIFICATE }}
3941
CSC_KEY_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
@@ -53,14 +55,16 @@ jobs:
5355
- name: Checkout
5456
uses: actions/checkout@v4
5557

58+
- name: Setup pnpm
59+
uses: pnpm/action-setup@v4
60+
5661
- name: Setup Node.js
5762
uses: actions/setup-node@v4
5863
with:
5964
node-version: '22'
60-
cache: 'npm'
6165

6266
- name: Install dependencies
63-
run: npm ci
67+
run: pnpm install
6468

6569
- name: Create .env file
6670
run: |
@@ -69,11 +73,11 @@ jobs:
6973
7074
- name: Build macOS (unsigned)
7175
if: vars.ENABLE_CODE_SIGNING != 'true'
72-
run: npm run build:mac
76+
run: pnpm run build:mac
7377

7478
- name: Build macOS (signed)
7579
if: vars.ENABLE_CODE_SIGNING == 'true'
76-
run: npm run build:mac
80+
run: pnpm run build:mac
7781
env:
7882
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }}
7983
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
@@ -96,22 +100,24 @@ jobs:
96100
- name: Checkout
97101
uses: actions/checkout@v4
98102

103+
- name: Setup pnpm
104+
uses: pnpm/action-setup@v4
105+
99106
- name: Setup Node.js
100107
uses: actions/setup-node@v4
101108
with:
102109
node-version: '22'
103-
cache: 'npm'
104110

105111
- name: Install dependencies
106-
run: npm ci
112+
run: pnpm install
107113

108114
- name: Create .env file
109115
run: |
110116
echo "GOOGLE_CLIENT_ID=${{ secrets.GOOGLE_CLIENT_ID }}" > .env
111117
echo "GOOGLE_CLIENT_SECRET=${{ secrets.GOOGLE_CLIENT_SECRET }}" >> .env
112118
113119
- name: Build Linux
114-
run: npm run build:linux
120+
run: pnpm run build:linux
115121

116122
- name: Upload Linux artifacts
117123
uses: actions/upload-artifact@v4

.github/workflows/ci.yml

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,22 @@ jobs:
1212
- name: Checkout
1313
uses: actions/checkout@v4
1414

15+
- name: Setup pnpm
16+
uses: pnpm/action-setup@v4
17+
1518
- name: Setup Node.js
1619
uses: actions/setup-node@v4
1720
with:
1821
node-version: '22'
19-
cache: 'npm'
2022

2123
- name: Install dependencies
22-
run: npm ci
24+
run: pnpm install
2325

2426
- name: Lint
25-
run: npm run lint
27+
run: pnpm run lint
2628

2729
- name: Format check
28-
run: npm run format:check
30+
run: pnpm run format:check
2931

3032
- name: Typecheck
31-
run: npm run typecheck
33+
run: pnpm run typecheck

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# Dependencies
22
node_modules/
3+
package-lock.json
4+
pnpm-lock.yaml
35

46
# Build outputs
57
dist/

.npmrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
node-linker=hoisted

electron/controllers/firebaseController.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,12 @@ function setConnectionChangeCallback(callback) {
2929
*/
3030
function registerHandlers() {
3131
// Connect to Firebase with service account
32-
ipcMain.handle('firebase:connect', async (event, serviceAccountPath) => {
32+
ipcMain.handle('firebase:connect', async (event, params) => {
3333
try {
34+
// Support both object params and legacy string path
35+
const serviceAccountPath = typeof params === 'string' ? params : params.serviceAccountPath;
36+
const databaseId = typeof params === 'string' ? undefined : params.databaseId;
37+
3438
if (admin) {
3539
await admin.app().delete();
3640
}
@@ -44,12 +48,16 @@ function registerHandlers() {
4448

4549
db = admin.firestore();
4650

51+
if (databaseId) {
52+
db.settings({ databaseId });
53+
}
54+
4755
// Notify other controllers about the connection change
4856
if (onConnectionChange) {
4957
onConnectionChange(admin, db);
5058
}
5159

52-
return { success: true, projectId: serviceAccount.project_id };
60+
return { success: true, projectId: serviceAccount.project_id, databaseId };
5361
} catch (error) {
5462
return { success: false, error: error.message };
5563
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// @vitest-environment node
2+
import { describe, it, expect, vi, beforeEach } from 'vitest';
3+
import { createRequire } from 'module';
4+
5+
// ─── Setup CJS mocks via require cache ───────────────────────────────────────
6+
const require_ = createRequire(import.meta.url);
7+
const readFileSyncMock = vi.fn();
8+
const handleMock = vi.fn();
9+
const mockAppDelete = vi.fn().mockResolvedValue(undefined);
10+
const mockSettings = vi.fn();
11+
12+
// Inject electron mock into require cache
13+
require_.cache[require_.resolve('electron')] = {
14+
id: 'electron',
15+
filename: require_.resolve('electron'),
16+
loaded: true,
17+
exports: {
18+
ipcMain: { handle: handleMock },
19+
dialog: { showOpenDialog: vi.fn() },
20+
},
21+
};
22+
23+
// Inject fs mock
24+
require_.cache[require_.resolve('fs')] = {
25+
id: 'fs',
26+
filename: require_.resolve('fs'),
27+
loaded: true,
28+
exports: {
29+
readFileSync: readFileSyncMock,
30+
},
31+
};
32+
33+
// Inject firebase-admin mock
34+
const firebaseAdminPath = require_.resolve('firebase-admin');
35+
require_.cache[firebaseAdminPath] = {
36+
id: 'firebase-admin',
37+
filename: firebaseAdminPath,
38+
loaded: true,
39+
exports: {
40+
initializeApp: vi.fn(),
41+
credential: { cert: vi.fn().mockReturnValue('mock-credential') },
42+
firestore: vi.fn().mockReturnValue({ settings: mockSettings }),
43+
app: vi.fn().mockReturnValue({ delete: mockAppDelete }),
44+
},
45+
};
46+
47+
// Now load the controller — it will pick up our cached mocks
48+
// We must delete any cached version first
49+
const controllerPath = require_.resolve('./firebaseController');
50+
delete require_.cache[controllerPath];
51+
const { registerHandlers } = require_(controllerPath);
52+
registerHandlers();
53+
54+
// Capture the handler functions
55+
const handlers = {};
56+
for (const [channel, handler] of handleMock.mock.calls) {
57+
handlers[channel] = handler;
58+
}
59+
60+
describe('firebaseController', () => {
61+
beforeEach(() => {
62+
readFileSyncMock.mockReset();
63+
mockAppDelete.mockClear();
64+
});
65+
66+
it('connects with a valid service account path', async () => {
67+
const serviceAccount = { project_id: 'test-project' };
68+
readFileSyncMock.mockReturnValue(JSON.stringify(serviceAccount));
69+
70+
const result = await handlers['firebase:connect'](null, {
71+
serviceAccountPath: '/path/to/sa.json',
72+
});
73+
74+
expect(result).toEqual({ success: true, projectId: 'test-project', databaseId: undefined });
75+
expect(readFileSyncMock).toHaveBeenCalledWith('/path/to/sa.json', 'utf8');
76+
});
77+
78+
it('connects with databaseId', async () => {
79+
const serviceAccount = { project_id: 'test-project' };
80+
readFileSyncMock.mockReturnValue(JSON.stringify(serviceAccount));
81+
82+
const result = await handlers['firebase:connect'](null, {
83+
serviceAccountPath: '/path/to/sa.json',
84+
databaseId: 'my-database',
85+
});
86+
87+
expect(result).toEqual({ success: true, projectId: 'test-project', databaseId: 'my-database' });
88+
});
89+
90+
it('supports backward compat with string param', async () => {
91+
const serviceAccount = { project_id: 'legacy-project' };
92+
readFileSyncMock.mockReturnValue(JSON.stringify(serviceAccount));
93+
94+
const result = await handlers['firebase:connect'](null, '/legacy/path.json');
95+
96+
expect(result.success).toBe(true);
97+
expect(result.projectId).toBe('legacy-project');
98+
expect(result.databaseId).toBeUndefined();
99+
});
100+
101+
it('returns error for invalid file', async () => {
102+
readFileSyncMock.mockImplementation(() => {
103+
throw new Error('ENOENT: no such file');
104+
});
105+
106+
const result = await handlers['firebase:connect'](null, {
107+
serviceAccountPath: '/bad/path.json',
108+
});
109+
110+
expect(result.success).toBe(false);
111+
expect(result.error).toContain('ENOENT');
112+
});
113+
114+
it('disconnects when connected', async () => {
115+
const serviceAccount = { project_id: 'test-project' };
116+
readFileSyncMock.mockReturnValue(JSON.stringify(serviceAccount));
117+
118+
await handlers['firebase:connect'](null, { serviceAccountPath: '/path/to/sa.json' });
119+
const result = await handlers['firebase:disconnect']();
120+
121+
expect(result).toEqual({ success: true });
122+
});
123+
124+
it('disconnects gracefully when not connected', async () => {
125+
const result = await handlers['firebase:disconnect']();
126+
127+
expect(result).toEqual({ success: true });
128+
});
129+
});

electron/controllers/firestoreController.js

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const { ipcMain, dialog } = require('electron');
77
const fs = require('fs');
88
const vm = require('vm');
99

10+
let adminRef = null;
1011
let dbRef = null;
1112

1213
function normalizeFirestoreError(error) {
@@ -24,7 +25,7 @@ function normalizeFirestoreError(error) {
2425
* Sets references to admin SDK and database
2526
*/
2627
function setRefs(admin, db) {
27-
void admin;
28+
adminRef = admin;
2829
dbRef = db;
2930
}
3031

@@ -232,8 +233,16 @@ function registerHandlers() {
232233
if (!dbRef) throw new Error('Not connected to Firebase');
233234

234235
const wrappedCode = `(async () => { ${jsQuery} return await run(); })()`;
236+
const FieldValue = adminRef ? adminRef.firestore.FieldValue : null;
237+
const Filter = adminRef ? adminRef.firestore.Filter : null;
238+
const Timestamp = adminRef ? adminRef.firestore.Timestamp : null;
239+
const GeoPoint = adminRef ? adminRef.firestore.GeoPoint : null;
235240
const context = vm.createContext({
236241
db: dbRef,
242+
FieldValue,
243+
Filter,
244+
Timestamp,
245+
GeoPoint,
237246
console,
238247
Date,
239248
JSON,

0 commit comments

Comments
 (0)