From 4981b07133058c4084d48631a8bcf82d0a96d2b8 Mon Sep 17 00:00:00 2001 From: Dallas Hoffman Date: Sun, 1 Dec 2024 22:06:11 -0500 Subject: [PATCH] Add onInit hook; add reason to onConnect --- docs/api/deletedatabasefile.md | 2 +- docs/api/overwritedatabasefile.md | 2 +- docs/guide/setup.md | 6 ++- src/client.ts | 29 ++++++++----- src/processor.ts | 32 +++++++++----- src/types.ts | 11 ++++- test/create-scalar-function.test.ts | 16 ++++++- test/delete-database-file.test.ts | 63 ++++++++++++++++++++-------- test/init.test.ts | 29 ++++++++++++- test/overwrite-database-file.test.ts | 56 ++++++++++++++++++++----- 10 files changed, 192 insertions(+), 54 deletions(-) diff --git a/docs/api/deletedatabasefile.md b/docs/api/deletedatabasefile.md index 9dea56a..2a73b68 100644 --- a/docs/api/deletedatabasefile.md +++ b/docs/api/deletedatabasefile.md @@ -28,4 +28,4 @@ await deleteDatabaseFile(async () => { }); ``` -Since calling `deleteDatabaseFile` will reset all connections to the database file, the [`onConnect` hook](../guide/setup.md#options) will re-run on any SQLocal clients connected to the database when it is cleared. The client that initiated the deletion will have its `onConnect` hook run first, before the method's callback, and the other clients' `onConnect` hooks will run after the callback. +Since calling `deleteDatabaseFile` will reset all connections to the database file, the configured `onInit` statements and `onConnect` hook (see [Options](../guide/setup.md#options)) will re-run on any SQLocal clients connected to the database when it is cleared. The client that initiated the deletion will have its `onConnect` hook run first, before the method's callback, and the other clients' `onConnect` hooks will run after the callback. diff --git a/docs/api/overwritedatabasefile.md b/docs/api/overwritedatabasefile.md index 0d0039c..b3eedee 100644 --- a/docs/api/overwritedatabasefile.md +++ b/docs/api/overwritedatabasefile.md @@ -49,4 +49,4 @@ await overwriteDatabaseFile(databaseFile, async () => { }); ``` -Since calling `overwriteDatabaseFile` will reset all connections to the database file, the [`onConnect` hook](../guide/setup.md#options) will re-run on any SQLocal clients connected to the database when it is overwritten. The client that initiated the overwrite will have its `onConnect` hook run first, before the method's callback, and the other clients' `onConnect` hooks will run after the callback. +Since calling `overwriteDatabaseFile` will reset all connections to the database file, the configured `onInit` statements and `onConnect` hook (see [Options](../guide/setup.md#options)) will re-run on any SQLocal clients connected to the database when it is overwritten. The client that initiated the overwrite will have its `onConnect` hook run first, before the method's callback, and the other clients' `onConnect` hooks will run after the callback. diff --git a/docs/guide/setup.md b/docs/guide/setup.md index b8078e7..85ec4ed 100644 --- a/docs/guide/setup.md +++ b/docs/guide/setup.md @@ -64,14 +64,16 @@ export const db = new SQLocal({ databasePath: 'database.sqlite3', readOnly: true, verbose: true, - onConnect: () => {}, + onInit: (sql) => {}, + onConnect: (reason) => {}, }); ``` - **`databasePath`** (`string`) - The file name for the database file. This is the only required option. - **`readOnly`** (`boolean`) - If `true`, connect to the database in read-only mode. Attempts to run queries that would mutate the database will throw an error. - **`verbose`** (`boolean`) - If `true`, any SQL executed on the database will be logged to the console. -- **`onConnect`** (`function`) - A callback that will be run when the client has connected to the database. This will happen at initialization and after [`overwriteDatabaseFile`](/api/overwritedatabasefile) or [`deleteDatabaseFile`](/api/deletedatabasefile) is called on any SQLocal client connected to the same database. This callback is a good place to set up any `PRAGMA` settings, temporary tables, views, or triggers for the connection. +- **`onInit`** (`function`) - A callback that will be run once when the client has initialized but before it has connected to the database. This callback should return an array of SQL statements (using the passed `sql` tagged template function, similar to the [`batch` method](../api/batch.md)) that should be executed before any other statements on the database connection. The `onInit` callback will be called only once, but the statements will be executed every time the client creates a new database connection. This makes it the best way to set up any `PRAGMA` settings, temporary tables, views, or triggers for the connection. +- **`onConnect`** (`function`) - A callback that will be run after the client has connected to the database. This will happen at initialization and any time [`overwriteDatabaseFile`](/api/overwritedatabasefile) or [`deleteDatabaseFile`](/api/deletedatabasefile) is called on any SQLocal client connected to the same database. The callback is passed a string (`'initial' | 'overwrite' | 'delete'`) that indicates why the callback was executed. This callback is useful for syncing your application's state with data from the newly-connected database. ## Vite Configuration diff --git a/src/client.ts b/src/client.ts index ac89b61..9d8dba9 100644 --- a/src/client.ts +++ b/src/client.ts @@ -24,6 +24,7 @@ import type { DeleteMessage, DatabasePath, ExportMessage, + ReinitMessage, } from './types.js'; import { SQLocalProcessor } from './processor.js'; import { sqlTag } from './lib/sql-tag.js'; @@ -58,19 +59,17 @@ export class SQLocal { constructor(config: DatabasePath | ClientConfig) { const clientConfig = typeof config === 'string' ? { databasePath: config } : config; + const { onInit, onConnect, ...commonConfig } = clientConfig; + this.config = clientConfig; this.clientKey = getQueryKey(); - - const { onConnect, ...commonConfig } = clientConfig; - const processorConfig = { ...commonConfig, clientKey: this.clientKey }; - this.reinitChannel = new BroadcastChannel( - `_sqlocal_reinit_(${clientConfig.databasePath})` + `_sqlocal_reinit_(${commonConfig.databasePath})` ); if ( typeof globalThis.Worker !== 'undefined' && - processorConfig.databasePath !== ':memory:' + commonConfig.databasePath !== ':memory:' ) { this.processor = new Worker(new URL('./worker', import.meta.url), { type: 'module', @@ -85,7 +84,11 @@ export class SQLocal { this.processor.postMessage({ type: 'config', - config: processorConfig, + config: { + ...commonConfig, + clientKey: this.clientKey, + onInitStatements: onInit?.(sqlTag) ?? [], + }, } satisfies ConfigMessage); } @@ -123,7 +126,7 @@ export class SQLocal { break; case 'event': - this.config.onConnect?.(); + this.config.onConnect?.(message.reason); break; } }; @@ -447,7 +450,10 @@ export class SQLocal { await beforeUnlock(); } - this.reinitChannel.postMessage(this.clientKey); + this.reinitChannel.postMessage({ + clientKey: this.clientKey, + reason: 'overwrite', + } satisfies ReinitMessage); } finally { this.bypassMutationLock = false; } @@ -468,7 +474,10 @@ export class SQLocal { await beforeUnlock(); } - this.reinitChannel.postMessage(this.clientKey); + this.reinitChannel.postMessage({ + clientKey: this.clientKey, + reason: 'delete', + } satisfies ReinitMessage); } finally { this.bypassMutationLock = false; } diff --git a/src/processor.ts b/src/processor.ts index 1039b9b..c1a415d 100644 --- a/src/processor.ts +++ b/src/processor.ts @@ -21,6 +21,8 @@ import type { TransactionMessage, DeleteMessage, ExportMessage, + ConnectReason, + ReinitMessage, } from './types.js'; import { createMutex } from './lib/create-mutex.js'; import { execOnDb } from './lib/exec-on-db.js'; @@ -47,10 +49,10 @@ export class SQLocalProcessor { constructor(sameContext: boolean) { const proxy = sameContext ? globalThis : coincident(globalThis); this.proxy = proxy as WorkerProxy; - this.init(); + this.init('initial'); } - protected init = async (): Promise => { + protected init = async (reason: ConnectReason): Promise => { if (!this.config.databasePath) return; await this.initMutex.lock(); @@ -88,15 +90,16 @@ export class SQLocalProcessor { this.reinitChannel = new BroadcastChannel( `_sqlocal_reinit_(${databasePath})` ); - this.reinitChannel.onmessage = (message: MessageEvent) => { - if (this.config.clientKey !== message.data) { - this.init(); + this.reinitChannel.onmessage = (event: MessageEvent) => { + if (this.config.clientKey !== event.data.clientKey) { + this.init(event.data.reason); } }; } this.userFunctions.forEach(this.initUserFunction); - this.emitMessage({ type: 'event', event: 'connect' }); + this.execInitStatements(); + this.emitMessage({ type: 'event', event: 'connect', reason }); } catch (error) { this.emitMessage({ type: 'error', @@ -161,7 +164,7 @@ export class SQLocalProcessor { protected editConfig = (message: ConfigMessage): void => { this.config = message.config; - this.init(); + this.init('initial'); }; protected exec = async ( @@ -198,7 +201,7 @@ export class SQLocalProcessor { case 'batch': try { await this.transactionMutex.lock(); - this.db.transaction((tx: Sqlite3Db) => { + this.db.transaction((tx) => { for (let statement of message.statements) { const statementData = execOnDb(tx, statement); response.data.push(statementData); @@ -239,6 +242,14 @@ export class SQLocalProcessor { } }; + protected execInitStatements = (): void => { + if (this.db && this.config.onInitStatements) { + for (let statement of this.config.onInitStatements) { + execOnDb(this.db, statement); + } + } + }; + protected getDatabaseInfo = async ( message: GetInfoMessage ): Promise => { @@ -358,6 +369,7 @@ export class SQLocalProcessor { : this.sqlite3.capi.SQLITE_DESERIALIZE_RESIZEABLE ); this.db.checkRc(resultCode); + this.execInitStatements(); } } catch (error) { this.emitMessage({ @@ -368,7 +380,7 @@ export class SQLocalProcessor { errored = true; } finally { if (this.dbStorageType !== 'memory') { - await this.init(); + await this.init('overwrite'); } } @@ -442,7 +454,7 @@ export class SQLocalProcessor { }); errored = true; } finally { - await this.init(); + await this.init('delete'); } if (!errored) { diff --git a/src/types.ts b/src/types.ts index 62e6f36..eb422db 100644 --- a/src/types.ts +++ b/src/types.ts @@ -55,7 +55,8 @@ export type ClientConfig = { databasePath: DatabasePath; readOnly?: boolean; verbose?: boolean; - onConnect?: () => void; + onInit?: (sql: typeof sqlTag) => void | Statement[]; + onConnect?: (reason: ConnectReason) => void; }; export type ProcessorConfig = { @@ -63,6 +64,7 @@ export type ProcessorConfig = { readOnly?: boolean; verbose?: boolean; clientKey?: QueryKey; + onInitStatements?: Statement[]; }; export type DatabaseInfo = { @@ -79,6 +81,7 @@ export type QueryKey = string; export type OmitQueryKey = T extends Message ? Omit : never; export type WorkerProxy = (typeof globalThis | ProxyHandler) & Record any>; +export type ConnectReason = 'initial' | 'overwrite' | 'delete'; export type InputMessage = | QueryMessage @@ -189,6 +192,12 @@ export type InfoMessage = { export type EventMessage = { type: 'event'; event: 'connect'; + reason: ConnectReason; +}; + +export type ReinitMessage = { + clientKey: QueryKey; + reason: ConnectReason; }; // User functions diff --git a/test/create-scalar-function.test.ts b/test/create-scalar-function.test.ts index 0a50bec..fa9ca84 100644 --- a/test/create-scalar-function.test.ts +++ b/test/create-scalar-function.test.ts @@ -4,7 +4,7 @@ import { SQLocal } from '../src/index.js'; describe.each([ { type: 'opfs', path: 'create-scalar-function-test.sqlite3' }, { type: 'memory', path: ':memory:' }, -])('createScalarFunction ($type)', ({ path }) => { +])('createScalarFunction ($type)', ({ path, type }) => { const { sql, createScalarFunction } = new SQLocal(path); beforeEach(async () => { @@ -64,4 +64,18 @@ describe.each([ await sql`SELECT num FROM nums WHERE num REGEXP '^(4|5).*[89]$'`; expect(results3).toEqual([{ num: 4578 }, { num: 59 }, { num: 5428 }]); }); + + it('should not overwrite a function from a different client instance', async () => { + const db1 = new SQLocal(type === 'opfs' ? 'dupe-fn-db1.sqlite3' : path); + const db2 = new SQLocal(type === 'opfs' ? 'dupe-fn-db2.sqlite3' : path); + + await db1.createScalarFunction('addTax', (num: number) => num * 1.06); + await db2.createScalarFunction('addTax', (num: number) => num * 1.07); + + const [result1] = await db1.sql`SELECT addTax(2) as withTax`; + const [result2] = await db2.sql`SELECT addTax(2) as withTax`; + + expect(result1.withTax).toBe(2.12); + expect(result2.withTax).toBe(2.14); + }); }); diff --git a/test/delete-database-file.test.ts b/test/delete-database-file.test.ts index bce5b66..c273828 100644 --- a/test/delete-database-file.test.ts +++ b/test/delete-database-file.test.ts @@ -1,22 +1,23 @@ import { describe, it, expect, vi } from 'vitest'; import { SQLocal } from '../src/index.js'; import { sleep } from './test-utils/sleep.js'; +import type { ClientConfig, ConnectReason } from '../src/types.js'; describe.each([ { type: 'opfs', path: 'delete-db-test.sqlite3' }, { type: 'memory', path: ':memory:' }, ])('deleteDatabaseFile ($type)', ({ path, type }) => { it('should delete the database file', async () => { - let onConnectCalled = false; + let onConnectReason: ConnectReason | null = null; let beforeUnlockCalled = false; const { sql, deleteDatabaseFile, destroy } = new SQLocal({ databasePath: path, - onConnect: () => (onConnectCalled = true), + onConnect: (reason) => (onConnectReason = reason), }); - await vi.waitUntil(() => onConnectCalled === true); - onConnectCalled = false; + await vi.waitUntil(() => onConnectReason === 'initial'); + onConnectReason = null; await sql`CREATE TABLE nums (num INTEGER NOT NULL)`; await sql`INSERT INTO nums (num) VALUES (123)`; @@ -28,7 +29,7 @@ describe.each([ beforeUnlockCalled = true; }); - expect(onConnectCalled).toBe(true); + expect(onConnectReason).toBe('delete'); expect(beforeUnlockCalled).toBe(true); await sql`CREATE TABLE letters (letter TEXT NOT NULL)`; @@ -45,33 +46,33 @@ describe.each([ }); it('should or should not notify other instances of a delete', async () => { - let onConnectCalled1 = false; - let onConnectCalled2 = false; + let onConnectReason1: ConnectReason | null = null; + let onConnectReason2: ConnectReason | null = null; const db1 = new SQLocal({ databasePath: path, - onConnect: () => (onConnectCalled1 = true), + onConnect: (reason) => (onConnectReason1 = reason), }); const db2 = new SQLocal({ databasePath: path, - onConnect: () => (onConnectCalled2 = true), + onConnect: (reason) => (onConnectReason2 = reason), }); - await vi.waitUntil(() => onConnectCalled1 === true); - onConnectCalled1 = false; - await vi.waitUntil(() => onConnectCalled2 === true); - onConnectCalled2 = false; + await vi.waitUntil(() => onConnectReason1 === 'initial'); + onConnectReason1 = null; + await vi.waitUntil(() => onConnectReason2 === 'initial'); + onConnectReason2 = null; await db1.deleteDatabaseFile(); if (type !== 'memory') { - await vi.waitUntil(() => onConnectCalled2 === true); - expect(onConnectCalled2).toBe(true); + await vi.waitUntil(() => onConnectReason2 === 'delete'); + expect(onConnectReason2).toBe('delete'); } else { - expect(onConnectCalled2).toBe(false); + expect(onConnectReason2).toBe(null); } - expect(onConnectCalled1).toBe(true); + expect(onConnectReason1).toBe('delete'); await db2.deleteDatabaseFile(); await db2.destroy(); @@ -126,4 +127,32 @@ describe.each([ await deleteDatabaseFile(); await destroy(); }); + + it('should run onInit statements before other queries after deletion', async () => { + const databasePath = path; + const onInit: ClientConfig['onInit'] = (sql) => { + return [sql`PRAGMA foreign_keys = ON`]; + }; + + const results: number[] = []; + + const db1 = new SQLocal({ databasePath, onInit }); + const db2 = new SQLocal({ databasePath, onInit }); + + const [{ foreign_keys: result1 }] = await db1.sql`PRAGMA foreign_keys`; + results.push(result1); + await db1.sql`PRAGMA foreign_keys = OFF`; + const [{ foreign_keys: result2 }] = await db1.sql`PRAGMA foreign_keys`; + results.push(result2); + await db1.deleteDatabaseFile(); + const [{ foreign_keys: result3 }] = await db1.sql`PRAGMA foreign_keys`; + results.push(result3); + const [{ foreign_keys: result4 }] = await db2.sql`PRAGMA foreign_keys`; + results.push(result4); + + expect(results).toEqual([1, 0, 1, 1]); + + await db1.destroy(); + await db2.destroy(); + }); }); diff --git a/test/init.test.ts b/test/init.test.ts index 11296f3..90529fe 100644 --- a/test/init.test.ts +++ b/test/init.test.ts @@ -1,4 +1,12 @@ -import { afterAll, afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + afterAll, + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; import { SQLocal } from '../src/index.js'; describe.each([ @@ -36,6 +44,25 @@ describe.each([ } }); + it('should call onInit and onConnect', async () => { + let onInitCalled = false; + let onConnectCalled = false; + + const db = new SQLocal({ + databasePath: path, + onInit: () => { + onInitCalled = true; + }, + onConnect: () => { + onConnectCalled = true; + }, + }); + + expect(onInitCalled).toBe(true); + await vi.waitUntil(() => onConnectCalled === true); + await db.destroy(); + }); + it('should enable read-only mode', async () => { const { sql, destroy } = new SQLocal({ databasePath: path, diff --git a/test/overwrite-database-file.test.ts b/test/overwrite-database-file.test.ts index b53ae78..6a3387f 100644 --- a/test/overwrite-database-file.test.ts +++ b/test/overwrite-database-file.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; import { SQLocal } from '../src/index.js'; import { sleep } from './test-utils/sleep.js'; +import type { ClientConfig } from '../src/types.js'; describe.each([ { type: 'opfs', path: 'overwrite-db-test.sqlite3' }, @@ -10,11 +11,11 @@ describe.each([ const eventValues = new Set(); const db1 = new SQLocal({ databasePath: type === 'opfs' ? 'overwrite-test-db1.sqlite3' : path, - onConnect: () => eventValues.add('connect1'), + onConnect: (reason) => eventValues.add(`connect1(${reason})`), }); const db2 = new SQLocal({ databasePath: type === 'opfs' ? 'overwrite-test-db2.sqlite3' : path, - onConnect: () => eventValues.add('connect2'), + onConnect: (reason) => eventValues.add(`connect2(${reason})`), }); await db1.sql`CREATE TABLE letters (letter TEXT NOT NULL)`; @@ -24,7 +25,10 @@ describe.each([ await db2.sql`INSERT INTO nums (num) VALUES (1), (2), (3)`; await vi.waitUntil(() => { - return eventValues.has('connect1') && eventValues.has('connect2'); + return ( + eventValues.has('connect1(initial)') && + eventValues.has('connect2(initial)') + ); }); eventValues.clear(); @@ -41,8 +45,8 @@ describe.each([ expect(eventValues.has('unlock1')).toBe(true); if (type !== 'memory') { - expect(eventValues.has('connect1')).toBe(true); - expect(eventValues.has('connect2')).toBe(false); + expect(eventValues.has('connect1(overwrite)')).toBe(true); + expect(eventValues.has('connect2(overwrite)')).toBe(false); } const letters1 = db1.sql`SELECT * FROM letters`; @@ -83,18 +87,21 @@ describe.each([ const eventValues = new Set(); const db1 = new SQLocal({ databasePath: path, - onConnect: () => eventValues.add('connect1'), + onConnect: (reason) => eventValues.add(`connect1(${reason})`), }); const db2 = new SQLocal({ databasePath: path, - onConnect: () => eventValues.add('connect2'), + onConnect: (reason) => eventValues.add(`connect2(${reason})`), }); await db2.sql`CREATE TABLE nums (num INTEGER NOT NULL)`; await db2.sql`INSERT INTO nums (num) VALUES (123)`; await vi.waitUntil(() => { - return eventValues.has('connect1') && eventValues.has('connect2'); + return ( + eventValues.has('connect1(initial)') && + eventValues.has('connect2(initial)') + ); }); eventValues.clear(); @@ -113,8 +120,8 @@ describe.each([ if (type !== 'memory') { await vi.waitUntil(() => eventValues.size === 3); expect(eventValues.has('unlock1')).toBe(true); - expect(eventValues.has('connect1')).toBe(true); - expect(eventValues.has('connect2')).toBe(true); + expect(eventValues.has('connect1(overwrite)')).toBe(true); + expect(eventValues.has('connect2(overwrite)')).toBe(true); } else { await vi.waitUntil(() => eventValues.size === 1); expect(eventValues.has('unlock1')).toBe(true); @@ -189,4 +196,33 @@ describe.each([ await deleteDatabaseFile(); await destroy(); }); + + it('should run onInit statements before other queries after overwrite', async () => { + const databasePath = path; + const onInit: ClientConfig['onInit'] = (sql) => { + return [sql`PRAGMA foreign_keys = ON`]; + }; + + const results: number[] = []; + + const db1 = new SQLocal({ databasePath, onInit }); + const db2 = new SQLocal({ databasePath, onInit }); + + const [{ foreign_keys: result1 }] = await db1.sql`PRAGMA foreign_keys`; + results.push(result1); + await db1.sql`PRAGMA foreign_keys = OFF`; + const [{ foreign_keys: result2 }] = await db1.sql`PRAGMA foreign_keys`; + results.push(result2); + const file = await db2.getDatabaseFile(); + await db1.overwriteDatabaseFile(file); + const [{ foreign_keys: result3 }] = await db1.sql`PRAGMA foreign_keys`; + results.push(result3); + const [{ foreign_keys: result4 }] = await db2.sql`PRAGMA foreign_keys`; + results.push(result4); + + expect(results).toEqual([1, 0, 1, 1]); + + await db1.destroy(); + await db2.destroy(); + }); });