@syncular/dialect-electron-sqlite 0.0.1-110

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/src/index.ts ADDED
@@ -0,0 +1,626 @@
1
+ /**
2
+ * @syncular/dialect-electron-sqlite - Electron IPC SQLite dialect for sync
3
+ *
4
+ * Provides a Kysely SQLite dialect for Electron renderer processes that
5
+ * execute SQL through an IPC bridge exposed by preload.
6
+ *
7
+ * This keeps database access in the Electron main process while preserving a
8
+ * standard Kysely API in renderer code.
9
+ */
10
+
11
+ import { SerializePlugin } from '@syncular/core';
12
+ import type {
13
+ DatabaseConnection,
14
+ DatabaseIntrospector,
15
+ Dialect,
16
+ DialectAdapter,
17
+ Driver,
18
+ QueryCompiler,
19
+ QueryResult,
20
+ TransactionSettings,
21
+ } from 'kysely';
22
+ import {
23
+ CompiledQuery,
24
+ Kysely,
25
+ SqliteAdapter,
26
+ SqliteIntrospector,
27
+ SqliteQueryCompiler,
28
+ } from 'kysely';
29
+
30
+ export type ElectronSqliteInteger = bigint | number | string;
31
+
32
+ export interface ElectronSqliteExecuteRequest {
33
+ sql: string;
34
+ parameters: readonly unknown[];
35
+ }
36
+
37
+ export interface ElectronSqliteExecuteResponse<Row = Record<string, unknown>> {
38
+ rows: Row[];
39
+ numAffectedRows?: ElectronSqliteInteger;
40
+ insertId?: ElectronSqliteInteger | null;
41
+ }
42
+
43
+ export type ElectronSqliteOpenResult =
44
+ | undefined
45
+ | {
46
+ open?: boolean;
47
+ path?: string;
48
+ version?: string | null;
49
+ [key: string]: boolean | number | string | null | undefined;
50
+ };
51
+
52
+ export interface ElectronSqliteBridge {
53
+ open?(): Promise<ElectronSqliteOpenResult> | ElectronSqliteOpenResult;
54
+ execute<Row = Record<string, unknown>>(
55
+ request: ElectronSqliteExecuteRequest
56
+ ):
57
+ | Promise<ElectronSqliteExecuteResponse<Row>>
58
+ | ElectronSqliteExecuteResponse<Row>;
59
+ close?(): Promise<void> | void;
60
+ }
61
+
62
+ export type ElectronSqliteOptions = ElectronSqliteBridge;
63
+
64
+ export interface ElectronSqliteWindowLike {
65
+ electronAPI?: Record<string, ElectronSqliteBridge | undefined>;
66
+ }
67
+
68
+ export interface ElectronSqliteWindowOptions {
69
+ bridgeKey?: string;
70
+ window?: ElectronSqliteWindowLike;
71
+ }
72
+
73
+ export interface ElectronIpcRendererLike {
74
+ invoke<TResult>(
75
+ channel: string,
76
+ ...args: readonly unknown[]
77
+ ): Promise<TResult>;
78
+ }
79
+
80
+ export interface ElectronSqliteBridgeFromIpcOptions {
81
+ ipcRenderer: ElectronIpcRendererLike;
82
+ executeChannel?: string;
83
+ openChannel?: string;
84
+ closeChannel?: string;
85
+ }
86
+
87
+ export interface ElectronSqliteBridgeFromDialectOptions {
88
+ dialect: Dialect;
89
+ openResult?: Exclude<ElectronSqliteOpenResult, undefined>;
90
+ }
91
+
92
+ export type ElectronIpcMainEventLike = object;
93
+
94
+ export interface ElectronIpcMainLike {
95
+ handle<TArgs extends readonly unknown[], TResult>(
96
+ channel: string,
97
+ listener: (
98
+ event: ElectronIpcMainEventLike,
99
+ ...args: TArgs
100
+ ) => TResult | Promise<TResult>
101
+ ): void;
102
+ }
103
+
104
+ export interface ElectronAppLike {
105
+ on(event: 'will-quit', listener: () => void): void;
106
+ }
107
+
108
+ export interface ElectronSqliteIpcChannels {
109
+ open: string;
110
+ execute: string;
111
+ close: string;
112
+ }
113
+
114
+ export interface RegisterElectronSqliteIpcOptions {
115
+ ipcMain: ElectronIpcMainLike;
116
+ bridge: ElectronSqliteBridge;
117
+ channels?: Partial<ElectronSqliteIpcChannels>;
118
+ autoOpen?: boolean;
119
+ app?: ElectronAppLike;
120
+ }
121
+
122
+ const DEFAULT_WINDOW_BRIDGE_KEY = 'sqlite';
123
+
124
+ const DEFAULT_ELECTRON_SQLITE_CHANNELS: ElectronSqliteIpcChannels = {
125
+ open: 'sqlite:open',
126
+ execute: 'sqlite:execute',
127
+ close: 'sqlite:close',
128
+ };
129
+
130
+ const INTEGER_PATTERN = /^-?\d+$/;
131
+
132
+ /**
133
+ * Create a Kysely instance with Electron IPC SQLite dialect and SerializePlugin.
134
+ *
135
+ * @example
136
+ * const db = createElectronSqliteDb<MyDb>({
137
+ * open: () => window.electronAPI.sqlite.open(),
138
+ * execute: (request) => window.electronAPI.sqlite.execute(request),
139
+ * close: () => window.electronAPI.sqlite.close(),
140
+ * });
141
+ */
142
+ export function createElectronSqliteDb<T>(
143
+ options: ElectronSqliteOptions
144
+ ): Kysely<T> {
145
+ return new Kysely<T>({
146
+ dialect: createElectronSqliteDialect(options),
147
+ plugins: [new SerializePlugin()],
148
+ });
149
+ }
150
+
151
+ /**
152
+ * Create the Electron IPC SQLite dialect directly (without SerializePlugin).
153
+ */
154
+ export function createElectronSqliteDialect(
155
+ options: ElectronSqliteOptions
156
+ ): ElectronSqliteDialect {
157
+ return new ElectronSqliteDialect(options);
158
+ }
159
+
160
+ /**
161
+ * Create a Kysely instance using `window.electronAPI[bridgeKey]`.
162
+ *
163
+ * `bridgeKey` defaults to `sqlite`.
164
+ */
165
+ export function createElectronSqliteDbFromWindow<T>(
166
+ options: ElectronSqliteWindowOptions = {}
167
+ ): Kysely<T> {
168
+ return createElectronSqliteDb<T>(resolveWindowBridge(options));
169
+ }
170
+
171
+ /**
172
+ * Create the Electron dialect using `window.electronAPI[bridgeKey]`.
173
+ *
174
+ * `bridgeKey` defaults to `sqlite`.
175
+ */
176
+ export function createElectronSqliteDialectFromWindow(
177
+ options: ElectronSqliteWindowOptions = {}
178
+ ): ElectronSqliteDialect {
179
+ return createElectronSqliteDialect(resolveWindowBridge(options));
180
+ }
181
+
182
+ /**
183
+ * Build an Electron SQLite bridge from `ipcRenderer.invoke(...)`.
184
+ *
185
+ * `executeChannel` defaults to `sqlite:execute`.
186
+ */
187
+ export function createElectronSqliteBridgeFromIpc(
188
+ options: ElectronSqliteBridgeFromIpcOptions
189
+ ): ElectronSqliteBridge {
190
+ const {
191
+ ipcRenderer,
192
+ executeChannel = DEFAULT_ELECTRON_SQLITE_CHANNELS.execute,
193
+ openChannel,
194
+ closeChannel,
195
+ } = options;
196
+
197
+ const bridge: ElectronSqliteBridge = {
198
+ execute: <Row>(request: ElectronSqliteExecuteRequest) =>
199
+ ipcRenderer.invoke<ElectronSqliteExecuteResponse<Row>>(
200
+ executeChannel,
201
+ request
202
+ ),
203
+ };
204
+
205
+ if (openChannel) {
206
+ bridge.open = () =>
207
+ ipcRenderer.invoke<ElectronSqliteOpenResult>(openChannel);
208
+ }
209
+
210
+ if (closeChannel) {
211
+ bridge.close = async () => {
212
+ await ipcRenderer.invoke<boolean>(closeChannel);
213
+ };
214
+ }
215
+
216
+ return bridge;
217
+ }
218
+
219
+ /**
220
+ * Build an Electron SQLite bridge from a Kysely dialect in main process.
221
+ *
222
+ * This allows renderer IPC queries to reuse existing SQLite dialects such as:
223
+ * - `@syncular/dialect-better-sqlite3`
224
+ * - `@syncular/dialect-sqlite3`
225
+ * - `@syncular/dialect-libsql`
226
+ *
227
+ * The bridge keeps a single acquired connection for its lifetime so
228
+ * transaction statements (`BEGIN` / `COMMIT` / `ROLLBACK`) stay on the same
229
+ * connection.
230
+ */
231
+ export function createElectronSqliteBridgeFromDialect(
232
+ options: ElectronSqliteBridgeFromDialectOptions
233
+ ): ElectronSqliteBridge {
234
+ return new DialectBackedElectronSqliteBridge(options);
235
+ }
236
+
237
+ /**
238
+ * Register standard Electron IPC handlers for a SQLite bridge.
239
+ *
240
+ * This helper enables a pluggable main-process backend: any adapter that
241
+ * implements `open/execute/close` can be exposed to renderer code through one
242
+ * channel contract.
243
+ */
244
+ export function registerElectronSqliteIpc(
245
+ options: RegisterElectronSqliteIpcOptions
246
+ ): ElectronSqliteIpcChannels {
247
+ const channels: ElectronSqliteIpcChannels = {
248
+ ...DEFAULT_ELECTRON_SQLITE_CHANNELS,
249
+ ...options.channels,
250
+ };
251
+ const autoOpen = options.autoOpen ?? true;
252
+ let isOpen = false;
253
+
254
+ const open = async (): Promise<ElectronSqliteOpenResult> => {
255
+ if (isOpen) {
256
+ return { open: true };
257
+ }
258
+
259
+ if (!options.bridge.open) {
260
+ isOpen = true;
261
+ return { open: true };
262
+ }
263
+
264
+ const result = await options.bridge.open();
265
+ isOpen = true;
266
+ return result;
267
+ };
268
+
269
+ options.ipcMain.handle<[], ElectronSqliteOpenResult>(
270
+ channels.open,
271
+ async () => open()
272
+ );
273
+
274
+ options.ipcMain.handle<
275
+ [ElectronSqliteExecuteRequest],
276
+ ElectronSqliteExecuteResponse
277
+ >(channels.execute, async (_event, request) => {
278
+ if (autoOpen && !isOpen) {
279
+ await open();
280
+ }
281
+ return options.bridge.execute(request);
282
+ });
283
+
284
+ options.ipcMain.handle<[], boolean>(channels.close, async () => {
285
+ if (options.bridge.close) {
286
+ await options.bridge.close();
287
+ }
288
+ isOpen = false;
289
+ return true;
290
+ });
291
+
292
+ if (options.app) {
293
+ options.app.on('will-quit', () => {
294
+ if (!options.bridge.close) {
295
+ return;
296
+ }
297
+ Promise.resolve(options.bridge.close()).catch(() => undefined);
298
+ });
299
+ }
300
+
301
+ return channels;
302
+ }
303
+
304
+ export function createSerializePlugin(): SerializePlugin {
305
+ return new SerializePlugin();
306
+ }
307
+
308
+ class DialectBackedElectronSqliteBridge implements ElectronSqliteBridge {
309
+ readonly #dialect: Dialect;
310
+ readonly #openResult: Exclude<ElectronSqliteOpenResult, undefined>;
311
+ readonly #mutex = new ConnectionMutex();
312
+
313
+ #driver: Driver | undefined;
314
+ #connection: DatabaseConnection | undefined;
315
+ #opened = false;
316
+
317
+ constructor(options: ElectronSqliteBridgeFromDialectOptions) {
318
+ this.#dialect = options.dialect;
319
+ this.#openResult = options.openResult ?? { open: true };
320
+ }
321
+
322
+ async open(): Promise<ElectronSqliteOpenResult> {
323
+ await this.#mutex.lock();
324
+ try {
325
+ await this.#ensureOpenLocked();
326
+ return this.#openResult;
327
+ } finally {
328
+ this.#mutex.unlock();
329
+ }
330
+ }
331
+
332
+ async execute<Row = Record<string, unknown>>(
333
+ request: ElectronSqliteExecuteRequest
334
+ ): Promise<ElectronSqliteExecuteResponse<Row>> {
335
+ await this.#mutex.lock();
336
+ try {
337
+ await this.#ensureOpenLocked();
338
+
339
+ const connection = this.#connection;
340
+ if (!connection) {
341
+ throw new Error('SQLite connection was not initialized');
342
+ }
343
+
344
+ const result = await connection.executeQuery<Row>(
345
+ CompiledQuery.raw(request.sql, [...request.parameters])
346
+ );
347
+ const numAffectedRows = toWireInteger(result.numAffectedRows);
348
+ const insertId = toWireInteger(result.insertId);
349
+
350
+ return {
351
+ rows: result.rows,
352
+ ...(numAffectedRows === undefined ? {} : { numAffectedRows }),
353
+ ...(insertId === undefined ? {} : { insertId }),
354
+ };
355
+ } finally {
356
+ this.#mutex.unlock();
357
+ }
358
+ }
359
+
360
+ async close(): Promise<void> {
361
+ await this.#mutex.lock();
362
+ try {
363
+ if (!this.#opened) {
364
+ return;
365
+ }
366
+
367
+ const connection = this.#connection;
368
+ const driver = this.#driver;
369
+
370
+ this.#connection = undefined;
371
+ this.#driver = undefined;
372
+ this.#opened = false;
373
+
374
+ if (driver && connection) {
375
+ await driver.releaseConnection(connection);
376
+ }
377
+
378
+ if (driver) {
379
+ await driver.destroy();
380
+ }
381
+ } finally {
382
+ this.#mutex.unlock();
383
+ }
384
+ }
385
+
386
+ async #ensureOpenLocked(): Promise<void> {
387
+ if (this.#opened) {
388
+ return;
389
+ }
390
+
391
+ const driver = this.#dialect.createDriver();
392
+
393
+ try {
394
+ await driver.init();
395
+ const connection = await driver.acquireConnection();
396
+
397
+ this.#driver = driver;
398
+ this.#connection = connection;
399
+ this.#opened = true;
400
+ } catch (error) {
401
+ await driver.destroy().catch(() => undefined);
402
+ throw error;
403
+ }
404
+ }
405
+ }
406
+
407
+ // ---------------------------------------------------------------------------
408
+ // Kysely Dialect implementation for Electron IPC SQLite
409
+ // ---------------------------------------------------------------------------
410
+
411
+ class ElectronSqliteDialect implements Dialect {
412
+ readonly #options: ElectronSqliteOptions;
413
+
414
+ constructor(options: ElectronSqliteOptions) {
415
+ this.#options = options;
416
+ }
417
+
418
+ createAdapter(): DialectAdapter {
419
+ return new SqliteAdapter();
420
+ }
421
+
422
+ createDriver(): Driver {
423
+ return new ElectronSqliteDriver(this.#options);
424
+ }
425
+
426
+ createQueryCompiler(): QueryCompiler {
427
+ return new SqliteQueryCompiler();
428
+ }
429
+
430
+ createIntrospector(db: Kysely<unknown>): DatabaseIntrospector {
431
+ return new SqliteIntrospector(db);
432
+ }
433
+ }
434
+
435
+ class ElectronSqliteDriver implements Driver {
436
+ readonly #bridge: ElectronSqliteBridge;
437
+ readonly #connectionMutex = new ConnectionMutex();
438
+ readonly #connection: ElectronSqliteConnection;
439
+
440
+ #opened = false;
441
+
442
+ constructor(bridge: ElectronSqliteBridge) {
443
+ this.#bridge = bridge;
444
+ this.#connection = new ElectronSqliteConnection(bridge.execute);
445
+ }
446
+
447
+ async init(): Promise<void> {
448
+ await this.#ensureOpen();
449
+ }
450
+
451
+ async acquireConnection(): Promise<DatabaseConnection> {
452
+ await this.#connectionMutex.lock();
453
+ return this.#connection;
454
+ }
455
+
456
+ async beginTransaction(
457
+ connection: DatabaseConnection,
458
+ _settings: TransactionSettings
459
+ ): Promise<void> {
460
+ await connection.executeQuery(CompiledQuery.raw('begin'));
461
+ }
462
+
463
+ async commitTransaction(connection: DatabaseConnection): Promise<void> {
464
+ await connection.executeQuery(CompiledQuery.raw('commit'));
465
+ }
466
+
467
+ async rollbackTransaction(connection: DatabaseConnection): Promise<void> {
468
+ await connection.executeQuery(CompiledQuery.raw('rollback'));
469
+ }
470
+
471
+ async releaseConnection(_connection: DatabaseConnection): Promise<void> {
472
+ this.#connectionMutex.unlock();
473
+ }
474
+
475
+ async destroy(): Promise<void> {
476
+ if (this.#bridge.close) {
477
+ await this.#bridge.close();
478
+ }
479
+ this.#opened = false;
480
+ }
481
+
482
+ async #ensureOpen(): Promise<void> {
483
+ if (this.#opened) {
484
+ return;
485
+ }
486
+
487
+ if (this.#bridge.open) {
488
+ await this.#bridge.open();
489
+ }
490
+
491
+ this.#opened = true;
492
+ }
493
+ }
494
+
495
+ class ElectronSqliteConnection implements DatabaseConnection {
496
+ readonly #execute: ElectronSqliteBridge['execute'];
497
+
498
+ constructor(execute: ElectronSqliteBridge['execute']) {
499
+ this.#execute = execute;
500
+ }
501
+
502
+ async executeQuery<R>(compiledQuery: CompiledQuery): Promise<QueryResult<R>> {
503
+ const response = await this.#execute<R>({
504
+ sql: compiledQuery.sql,
505
+ parameters: compiledQuery.parameters,
506
+ });
507
+
508
+ const hasReturning = /\breturning\b/i.test(compiledQuery.sql);
509
+ const numAffectedRows = toBigInt(
510
+ response.numAffectedRows ??
511
+ (hasReturning ? response.rows.length : undefined)
512
+ );
513
+ const insertId = toBigInt(response.insertId);
514
+
515
+ return {
516
+ rows: response.rows,
517
+ ...(numAffectedRows === undefined ? {} : { numAffectedRows }),
518
+ ...(insertId === undefined ? {} : { insertId }),
519
+ };
520
+ }
521
+
522
+ async *streamQuery<R>(
523
+ compiledQuery: CompiledQuery,
524
+ _chunkSize?: number
525
+ ): AsyncIterableIterator<QueryResult<R>> {
526
+ yield await this.executeQuery<R>(compiledQuery);
527
+ }
528
+ }
529
+
530
+ class ConnectionMutex {
531
+ #promise: Promise<void> | undefined;
532
+ #resolve: (() => void) | undefined;
533
+
534
+ async lock(): Promise<void> {
535
+ while (this.#promise) {
536
+ await this.#promise;
537
+ }
538
+
539
+ this.#promise = new Promise((resolve) => {
540
+ this.#resolve = resolve;
541
+ });
542
+ }
543
+
544
+ unlock(): void {
545
+ const resolve = this.#resolve;
546
+ this.#promise = undefined;
547
+ this.#resolve = undefined;
548
+ resolve?.();
549
+ }
550
+ }
551
+
552
+ function toBigInt(
553
+ value: ElectronSqliteInteger | null | undefined
554
+ ): bigint | undefined {
555
+ if (value == null) {
556
+ return undefined;
557
+ }
558
+
559
+ if (typeof value === 'bigint') {
560
+ return value;
561
+ }
562
+
563
+ if (typeof value === 'number') {
564
+ if (!Number.isInteger(value)) {
565
+ throw new Error(`Expected integer number but received: ${value}`);
566
+ }
567
+ return BigInt(value);
568
+ }
569
+
570
+ if (!INTEGER_PATTERN.test(value)) {
571
+ throw new Error(`Expected integer string but received: ${value}`);
572
+ }
573
+
574
+ return BigInt(value);
575
+ }
576
+
577
+ function toWireInteger(
578
+ value: bigint | undefined
579
+ ): ElectronSqliteInteger | undefined {
580
+ if (value === undefined) {
581
+ return undefined;
582
+ }
583
+
584
+ const asNumber = Number(value);
585
+ if (Number.isSafeInteger(asNumber)) {
586
+ return asNumber;
587
+ }
588
+
589
+ return value.toString();
590
+ }
591
+
592
+ function resolveWindowBridge(
593
+ options: ElectronSqliteWindowOptions
594
+ ): ElectronSqliteBridge {
595
+ const bridgeKey = options.bridgeKey ?? DEFAULT_WINDOW_BRIDGE_KEY;
596
+ const windowRef = resolveWindowRef(options.window);
597
+ const bridge = windowRef.electronAPI?.[bridgeKey];
598
+
599
+ if (!bridge) {
600
+ throw new Error(
601
+ `Electron sqlite API not available at window.electronAPI.${bridgeKey}`
602
+ );
603
+ }
604
+
605
+ return bridge;
606
+ }
607
+
608
+ function resolveWindowRef(
609
+ explicitWindow: ElectronSqliteWindowLike | undefined
610
+ ): ElectronSqliteWindowLike {
611
+ if (explicitWindow) {
612
+ return explicitWindow;
613
+ }
614
+
615
+ if (typeof window === 'undefined') {
616
+ throw new Error(
617
+ 'window is not available. Pass options.window explicitly in non-renderer environments.'
618
+ );
619
+ }
620
+
621
+ return window;
622
+ }
623
+
624
+ declare global {
625
+ interface Window extends ElectronSqliteWindowLike {}
626
+ }