Skip to content

Commit

Permalink
Add onInit hook; add reason to onConnect
Browse files Browse the repository at this point in the history
  • Loading branch information
DallasHoff committed Dec 2, 2024
1 parent 2722197 commit 4981b07
Show file tree
Hide file tree
Showing 10 changed files with 192 additions and 54 deletions.
2 changes: 1 addition & 1 deletion docs/api/deletedatabasefile.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion docs/api/overwritedatabasefile.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
6 changes: 4 additions & 2 deletions docs/guide/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
29 changes: 19 additions & 10 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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',
Expand All @@ -85,7 +84,11 @@ export class SQLocal {

this.processor.postMessage({
type: 'config',
config: processorConfig,
config: {
...commonConfig,
clientKey: this.clientKey,
onInitStatements: onInit?.(sqlTag) ?? [],
},
} satisfies ConfigMessage);
}

Expand Down Expand Up @@ -123,7 +126,7 @@ export class SQLocal {
break;

case 'event':
this.config.onConnect?.();
this.config.onConnect?.(message.reason);
break;
}
};
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
}
Expand Down
32 changes: 22 additions & 10 deletions src/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<void> => {
protected init = async (reason: ConnectReason): Promise<void> => {
if (!this.config.databasePath) return;

await this.initMutex.lock();
Expand Down Expand Up @@ -88,15 +90,16 @@ export class SQLocalProcessor {
this.reinitChannel = new BroadcastChannel(
`_sqlocal_reinit_(${databasePath})`
);
this.reinitChannel.onmessage = (message: MessageEvent<QueryKey>) => {
if (this.config.clientKey !== message.data) {
this.init();
this.reinitChannel.onmessage = (event: MessageEvent<ReinitMessage>) => {
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',
Expand Down Expand Up @@ -161,7 +164,7 @@ export class SQLocalProcessor {

protected editConfig = (message: ConfigMessage): void => {
this.config = message.config;
this.init();
this.init('initial');
};

protected exec = async (
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<void> => {
Expand Down Expand Up @@ -358,6 +369,7 @@ export class SQLocalProcessor {
: this.sqlite3.capi.SQLITE_DESERIALIZE_RESIZEABLE
);
this.db.checkRc(resultCode);
this.execInitStatements();
}
} catch (error) {
this.emitMessage({
Expand All @@ -368,7 +380,7 @@ export class SQLocalProcessor {
errored = true;
} finally {
if (this.dbStorageType !== 'memory') {
await this.init();
await this.init('overwrite');
}
}

Expand Down Expand Up @@ -442,7 +454,7 @@ export class SQLocalProcessor {
});
errored = true;
} finally {
await this.init();
await this.init('delete');
}

if (!errored) {
Expand Down
11 changes: 10 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,16 @@ export type ClientConfig = {
databasePath: DatabasePath;
readOnly?: boolean;
verbose?: boolean;
onConnect?: () => void;
onInit?: (sql: typeof sqlTag) => void | Statement[];
onConnect?: (reason: ConnectReason) => void;
};

export type ProcessorConfig = {
databasePath?: DatabasePath;
readOnly?: boolean;
verbose?: boolean;
clientKey?: QueryKey;
onInitStatements?: Statement[];
};

export type DatabaseInfo = {
Expand All @@ -79,6 +81,7 @@ export type QueryKey = string;
export type OmitQueryKey<T> = T extends Message ? Omit<T, 'queryKey'> : never;
export type WorkerProxy = (typeof globalThis | ProxyHandler<Worker>) &
Record<string, (...args: any) => any>;
export type ConnectReason = 'initial' | 'overwrite' | 'delete';

export type InputMessage =
| QueryMessage
Expand Down Expand Up @@ -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
Expand Down
16 changes: 15 additions & 1 deletion test/create-scalar-function.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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);
});
});
63 changes: 46 additions & 17 deletions test/delete-database-file.test.ts
Original file line number Diff line number Diff line change
@@ -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)`;
Expand All @@ -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)`;
Expand All @@ -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();
Expand Down Expand Up @@ -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();
});
});
Loading

0 comments on commit 4981b07

Please sign in to comment.