@syncular/dialect-electron-sqlite 0.0.1
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/dist/index.d.ts +142 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +380 -0
- package/dist/index.js.map +1 -0
- package/package.json +59 -0
- package/src/index.test.ts +424 -0
- package/src/index.ts +626 -0
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'bun:test';
|
|
2
|
+
import type {
|
|
3
|
+
DatabaseConnection,
|
|
4
|
+
Dialect,
|
|
5
|
+
Driver,
|
|
6
|
+
Kysely,
|
|
7
|
+
QueryResult,
|
|
8
|
+
TransactionSettings,
|
|
9
|
+
} from 'kysely';
|
|
10
|
+
import {
|
|
11
|
+
CompiledQuery,
|
|
12
|
+
SqliteAdapter,
|
|
13
|
+
SqliteIntrospector,
|
|
14
|
+
SqliteQueryCompiler,
|
|
15
|
+
} from 'kysely';
|
|
16
|
+
import type {
|
|
17
|
+
ElectronAppLike,
|
|
18
|
+
ElectronIpcMainEventLike,
|
|
19
|
+
ElectronIpcMainLike,
|
|
20
|
+
ElectronIpcRendererLike,
|
|
21
|
+
ElectronSqliteBridge,
|
|
22
|
+
ElectronSqliteExecuteRequest,
|
|
23
|
+
ElectronSqliteExecuteResponse,
|
|
24
|
+
ElectronSqliteWindowLike,
|
|
25
|
+
} from './index';
|
|
26
|
+
import {
|
|
27
|
+
createElectronSqliteBridgeFromDialect,
|
|
28
|
+
createElectronSqliteBridgeFromIpc,
|
|
29
|
+
createElectronSqliteDb,
|
|
30
|
+
createElectronSqliteDbFromWindow,
|
|
31
|
+
registerElectronSqliteIpc,
|
|
32
|
+
} from './index';
|
|
33
|
+
|
|
34
|
+
interface TestDb {
|
|
35
|
+
tasks: {
|
|
36
|
+
id: string;
|
|
37
|
+
title: string;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
class FakeIpcRenderer implements ElectronIpcRendererLike {
|
|
42
|
+
readonly calls: Array<{ channel: string; args: readonly unknown[] }> = [];
|
|
43
|
+
|
|
44
|
+
async invoke<TResult>(
|
|
45
|
+
channel: string,
|
|
46
|
+
...args: readonly unknown[]
|
|
47
|
+
): Promise<TResult> {
|
|
48
|
+
this.calls.push({ channel, args });
|
|
49
|
+
|
|
50
|
+
if (channel === 'sqlite:open') {
|
|
51
|
+
return { open: true } as TResult;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (channel === 'sqlite:execute') {
|
|
55
|
+
return {
|
|
56
|
+
rows: [{ id: 'task-ipc', title: 'from-ipc' }],
|
|
57
|
+
numAffectedRows: 1,
|
|
58
|
+
} as TResult;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (channel === 'sqlite:close') {
|
|
62
|
+
return true as TResult;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
throw new Error(`Unsupported channel "${channel}"`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type FakeIpcHandler = (
|
|
70
|
+
event: ElectronIpcMainEventLike,
|
|
71
|
+
...args: readonly unknown[]
|
|
72
|
+
) => Promise<unknown> | unknown;
|
|
73
|
+
|
|
74
|
+
class FakeIpcMain implements ElectronIpcMainLike {
|
|
75
|
+
readonly #handlers = new Map<string, FakeIpcHandler>();
|
|
76
|
+
|
|
77
|
+
handle<TArgs extends readonly unknown[], TResult>(
|
|
78
|
+
channel: string,
|
|
79
|
+
listener: (
|
|
80
|
+
event: ElectronIpcMainEventLike,
|
|
81
|
+
...args: TArgs
|
|
82
|
+
) => TResult | Promise<TResult>
|
|
83
|
+
): void {
|
|
84
|
+
this.#handlers.set(
|
|
85
|
+
channel,
|
|
86
|
+
(event: ElectronIpcMainEventLike, ...args: readonly unknown[]) =>
|
|
87
|
+
listener(event, ...(args as TArgs))
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async invoke<TResult>(
|
|
92
|
+
channel: string,
|
|
93
|
+
...args: readonly unknown[]
|
|
94
|
+
): Promise<TResult> {
|
|
95
|
+
const handler = this.#handlers.get(channel);
|
|
96
|
+
if (!handler) {
|
|
97
|
+
throw new Error(`Missing IPC handler for channel "${channel}"`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const event: ElectronIpcMainEventLike = {};
|
|
101
|
+
return (await handler(event, ...args)) as TResult;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
class FakeApp implements ElectronAppLike {
|
|
106
|
+
readonly #willQuitListeners: Array<() => void> = [];
|
|
107
|
+
|
|
108
|
+
on(event: 'will-quit', listener: () => void): void {
|
|
109
|
+
if (event === 'will-quit') {
|
|
110
|
+
this.#willQuitListeners.push(listener);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
emitWillQuit(): void {
|
|
115
|
+
for (const listener of this.#willQuitListeners) {
|
|
116
|
+
listener();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
interface FakeDialectState {
|
|
122
|
+
createDriverCalls: number;
|
|
123
|
+
initCalls: number;
|
|
124
|
+
acquireCalls: number;
|
|
125
|
+
releaseCalls: number;
|
|
126
|
+
destroyCalls: number;
|
|
127
|
+
executedSql: string[];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
class FakeDialectConnection implements DatabaseConnection {
|
|
131
|
+
readonly #state: FakeDialectState;
|
|
132
|
+
|
|
133
|
+
constructor(state: FakeDialectState) {
|
|
134
|
+
this.#state = state;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async executeQuery<R>(compiledQuery: CompiledQuery): Promise<QueryResult<R>> {
|
|
138
|
+
this.#state.executedSql.push(compiledQuery.sql);
|
|
139
|
+
|
|
140
|
+
if (/^\s*select\b/i.test(compiledQuery.sql)) {
|
|
141
|
+
return { rows: [{ id: 'task-main', title: 'from-main' }] as R[] };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
rows: [],
|
|
146
|
+
numAffectedRows: 1n,
|
|
147
|
+
insertId: 9007199254740993n,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async *streamQuery<R>(
|
|
152
|
+
compiledQuery: CompiledQuery,
|
|
153
|
+
_chunkSize?: number
|
|
154
|
+
): AsyncIterableIterator<QueryResult<R>> {
|
|
155
|
+
yield await this.executeQuery<R>(compiledQuery);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
class FakeDialectDriver implements Driver {
|
|
160
|
+
readonly #state: FakeDialectState;
|
|
161
|
+
readonly #connection: FakeDialectConnection;
|
|
162
|
+
|
|
163
|
+
constructor(state: FakeDialectState) {
|
|
164
|
+
this.#state = state;
|
|
165
|
+
this.#connection = new FakeDialectConnection(state);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async init(): Promise<void> {
|
|
169
|
+
this.#state.initCalls += 1;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async acquireConnection(): Promise<DatabaseConnection> {
|
|
173
|
+
this.#state.acquireCalls += 1;
|
|
174
|
+
return this.#connection;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async beginTransaction(
|
|
178
|
+
connection: DatabaseConnection,
|
|
179
|
+
_settings: TransactionSettings
|
|
180
|
+
): Promise<void> {
|
|
181
|
+
await connection.executeQuery(CompiledQuery.raw('begin'));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async commitTransaction(connection: DatabaseConnection): Promise<void> {
|
|
185
|
+
await connection.executeQuery(CompiledQuery.raw('commit'));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async rollbackTransaction(connection: DatabaseConnection): Promise<void> {
|
|
189
|
+
await connection.executeQuery(CompiledQuery.raw('rollback'));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async releaseConnection(_connection: DatabaseConnection): Promise<void> {
|
|
193
|
+
this.#state.releaseCalls += 1;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async destroy(): Promise<void> {
|
|
197
|
+
this.#state.destroyCalls += 1;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function createFakeDialect(state: FakeDialectState): Dialect {
|
|
202
|
+
return {
|
|
203
|
+
createDriver: () => {
|
|
204
|
+
state.createDriverCalls += 1;
|
|
205
|
+
return new FakeDialectDriver(state);
|
|
206
|
+
},
|
|
207
|
+
createAdapter: () => new SqliteAdapter(),
|
|
208
|
+
createQueryCompiler: () => new SqliteQueryCompiler(),
|
|
209
|
+
createIntrospector: (db: Kysely<unknown>) => new SqliteIntrospector(db),
|
|
210
|
+
} satisfies Dialect;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
describe('electron sqlite dialect', () => {
|
|
214
|
+
let db: Kysely<TestDb> | undefined;
|
|
215
|
+
|
|
216
|
+
afterEach(async () => {
|
|
217
|
+
if (!db) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
await db.destroy();
|
|
221
|
+
db = undefined;
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('executes UPDATE ... RETURNING through bridge and opens once', async () => {
|
|
225
|
+
let openCalls = 0;
|
|
226
|
+
let closeCalls = 0;
|
|
227
|
+
const sqlLog: string[] = [];
|
|
228
|
+
|
|
229
|
+
const bridge: ElectronSqliteBridge = {
|
|
230
|
+
open: async () => {
|
|
231
|
+
openCalls += 1;
|
|
232
|
+
return { open: true };
|
|
233
|
+
},
|
|
234
|
+
execute: async <Row>(request: ElectronSqliteExecuteRequest) => {
|
|
235
|
+
sqlLog.push(request.sql);
|
|
236
|
+
if (/\breturning\b/i.test(request.sql)) {
|
|
237
|
+
const rows = [{ id: 'task-1', title: 'after' }];
|
|
238
|
+
return {
|
|
239
|
+
rows: rows as Row[],
|
|
240
|
+
numAffectedRows: rows.length,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
return { rows: [], numAffectedRows: 1 };
|
|
244
|
+
},
|
|
245
|
+
close: async () => {
|
|
246
|
+
closeCalls += 1;
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
db = createElectronSqliteDb<TestDb>(bridge);
|
|
251
|
+
|
|
252
|
+
const updated = await db
|
|
253
|
+
.updateTable('tasks')
|
|
254
|
+
.set({ title: 'after' })
|
|
255
|
+
.where('id', '=', 'task-1')
|
|
256
|
+
.returning(['id', 'title'])
|
|
257
|
+
.executeTakeFirstOrThrow();
|
|
258
|
+
|
|
259
|
+
expect(updated).toEqual({ id: 'task-1', title: 'after' });
|
|
260
|
+
expect(sqlLog).toHaveLength(1);
|
|
261
|
+
expect(openCalls).toBe(1);
|
|
262
|
+
|
|
263
|
+
await db.destroy();
|
|
264
|
+
db = undefined;
|
|
265
|
+
|
|
266
|
+
expect(closeCalls).toBe(1);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('resolves bridge from window.electronAPI using bridgeKey', async () => {
|
|
270
|
+
const bridge: ElectronSqliteBridge = {
|
|
271
|
+
execute: async <Row>(_request: ElectronSqliteExecuteRequest) => ({
|
|
272
|
+
rows: [{ id: 'task-window', title: 'from-window' }] as Row[],
|
|
273
|
+
}),
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const windowLike: ElectronSqliteWindowLike = {
|
|
277
|
+
electronAPI: { app: bridge },
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
db = createElectronSqliteDbFromWindow<TestDb>({
|
|
281
|
+
bridgeKey: 'app',
|
|
282
|
+
window: windowLike,
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const row = await db
|
|
286
|
+
.selectFrom('tasks')
|
|
287
|
+
.select(['id', 'title'])
|
|
288
|
+
.executeTakeFirstOrThrow();
|
|
289
|
+
|
|
290
|
+
expect(row).toEqual({ id: 'task-window', title: 'from-window' });
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
describe('electron sqlite bridge helpers', () => {
|
|
295
|
+
it('adapts an existing Kysely dialect for main-process execution', async () => {
|
|
296
|
+
const state: FakeDialectState = {
|
|
297
|
+
createDriverCalls: 0,
|
|
298
|
+
initCalls: 0,
|
|
299
|
+
acquireCalls: 0,
|
|
300
|
+
releaseCalls: 0,
|
|
301
|
+
destroyCalls: 0,
|
|
302
|
+
executedSql: [],
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const bridge = createElectronSqliteBridgeFromDialect({
|
|
306
|
+
dialect: createFakeDialect(state),
|
|
307
|
+
openResult: { open: true, path: '/tmp/app.sqlite' },
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const selectResult = await bridge.execute<{ id: string; title: string }>({
|
|
311
|
+
sql: 'select id, title from tasks',
|
|
312
|
+
parameters: [],
|
|
313
|
+
});
|
|
314
|
+
const writeResult = await bridge.execute({
|
|
315
|
+
sql: 'insert into tasks(id, title) values (?, ?)',
|
|
316
|
+
parameters: ['task-2', 'hello'],
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
expect(selectResult.rows).toEqual([
|
|
320
|
+
{ id: 'task-main', title: 'from-main' },
|
|
321
|
+
]);
|
|
322
|
+
expect(writeResult.numAffectedRows).toBe(1);
|
|
323
|
+
expect(writeResult.insertId).toBe('9007199254740993');
|
|
324
|
+
expect(state.initCalls).toBe(1);
|
|
325
|
+
expect(state.acquireCalls).toBe(1);
|
|
326
|
+
expect(state.executedSql).toEqual([
|
|
327
|
+
'select id, title from tasks',
|
|
328
|
+
'insert into tasks(id, title) values (?, ?)',
|
|
329
|
+
]);
|
|
330
|
+
|
|
331
|
+
const openResult = await bridge.open?.();
|
|
332
|
+
expect(openResult).toEqual({ open: true, path: '/tmp/app.sqlite' });
|
|
333
|
+
expect(state.createDriverCalls).toBe(1);
|
|
334
|
+
|
|
335
|
+
await bridge.close?.();
|
|
336
|
+
expect(state.releaseCalls).toBe(1);
|
|
337
|
+
expect(state.destroyCalls).toBe(1);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('creates bridge from ipcRenderer.invoke channels', async () => {
|
|
341
|
+
const ipcRenderer = new FakeIpcRenderer();
|
|
342
|
+
const bridge = createElectronSqliteBridgeFromIpc({
|
|
343
|
+
ipcRenderer,
|
|
344
|
+
openChannel: 'sqlite:open',
|
|
345
|
+
executeChannel: 'sqlite:execute',
|
|
346
|
+
closeChannel: 'sqlite:close',
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
await bridge.open?.();
|
|
350
|
+
const result = await bridge.execute<{ id: string; title: string }>({
|
|
351
|
+
sql: 'select id, title from tasks',
|
|
352
|
+
parameters: [],
|
|
353
|
+
});
|
|
354
|
+
await bridge.close?.();
|
|
355
|
+
|
|
356
|
+
expect(result.rows).toEqual([{ id: 'task-ipc', title: 'from-ipc' }]);
|
|
357
|
+
expect(ipcRenderer.calls.map((call) => call.channel)).toEqual([
|
|
358
|
+
'sqlite:open',
|
|
359
|
+
'sqlite:execute',
|
|
360
|
+
'sqlite:close',
|
|
361
|
+
]);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('defaults executeChannel to sqlite:execute when omitted', async () => {
|
|
365
|
+
const ipcRenderer = new FakeIpcRenderer();
|
|
366
|
+
const bridge = createElectronSqliteBridgeFromIpc({
|
|
367
|
+
ipcRenderer,
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
const result = await bridge.execute<{ id: string; title: string }>({
|
|
371
|
+
sql: 'select id, title from tasks',
|
|
372
|
+
parameters: [],
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
expect(result.rows).toEqual([{ id: 'task-ipc', title: 'from-ipc' }]);
|
|
376
|
+
expect(ipcRenderer.calls.map((call) => call.channel)).toEqual([
|
|
377
|
+
'sqlite:execute',
|
|
378
|
+
]);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('registers main-process IPC handlers with auto-open', async () => {
|
|
382
|
+
const ipcMain = new FakeIpcMain();
|
|
383
|
+
const app = new FakeApp();
|
|
384
|
+
|
|
385
|
+
let openCalls = 0;
|
|
386
|
+
let executeCalls = 0;
|
|
387
|
+
let closeCalls = 0;
|
|
388
|
+
|
|
389
|
+
registerElectronSqliteIpc({
|
|
390
|
+
ipcMain,
|
|
391
|
+
app,
|
|
392
|
+
bridge: {
|
|
393
|
+
open: async () => {
|
|
394
|
+
openCalls += 1;
|
|
395
|
+
return { open: true };
|
|
396
|
+
},
|
|
397
|
+
execute: async <Row>(request: ElectronSqliteExecuteRequest) => {
|
|
398
|
+
executeCalls += 1;
|
|
399
|
+
return { rows: [{ sql: request.sql }] as Row[], numAffectedRows: 1 };
|
|
400
|
+
},
|
|
401
|
+
close: async () => {
|
|
402
|
+
closeCalls += 1;
|
|
403
|
+
},
|
|
404
|
+
},
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
const response = await ipcMain.invoke<
|
|
408
|
+
ElectronSqliteExecuteResponse<{ sql: string }>
|
|
409
|
+
>('sqlite:execute', {
|
|
410
|
+
sql: 'select 1',
|
|
411
|
+
parameters: [],
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
expect(response.rows).toEqual([{ sql: 'select 1' }]);
|
|
415
|
+
expect(openCalls).toBe(1);
|
|
416
|
+
expect(executeCalls).toBe(1);
|
|
417
|
+
|
|
418
|
+
await ipcMain.invoke<boolean>('sqlite:close');
|
|
419
|
+
expect(closeCalls).toBe(1);
|
|
420
|
+
|
|
421
|
+
app.emitWillQuit();
|
|
422
|
+
expect(closeCalls).toBe(2);
|
|
423
|
+
});
|
|
424
|
+
});
|