Skip to content

Commit e06f3a9

Browse files
wip: split worker db interfaces and instantiation
1 parent e937099 commit e06f3a9

23 files changed

+854
-643
lines changed

demos/react-supabase-todolist/src/components/providers/SystemProvider.tsx

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { AppSchema } from '@/library/powersync/AppSchema';
33
import { SupabaseConnector } from '@/library/powersync/SupabaseConnector';
44
import { CircularProgress } from '@mui/material';
55
import { PowerSyncContext } from '@powersync/react';
6-
import { PowerSyncDatabase } from '@powersync/web';
6+
import { PowerSyncDatabase, WASQLiteOpenFactory, WASQLiteVFS } from '@powersync/web';
77
import Logger from 'js-logger';
88
import React, { Suspense } from 'react';
99
import { NavigationPanelContextProvider } from '../navigation/NavigationPanelContext';
@@ -13,18 +13,13 @@ export const useSupabase = () => React.useContext(SupabaseContext);
1313

1414
export const db = new PowerSyncDatabase({
1515
schema: AppSchema,
16-
database: {
17-
dbFilename: 's.sqlite'
18-
}
19-
// database: new WASQLiteOpenFactory({
20-
// dbFilename: 'examplse.db',
21-
// vfs: WASQLiteVFS.OPFSCoopSyncVFS,
22-
// // Can't use a shared worker for OPFS
23-
// flags: { enableMultiTabs: false }
24-
// }),
25-
// flags: {
26-
// enableMultiTabs: false
16+
// database: {
17+
// dbFilename: 's.sqlite'
2718
// }
19+
database: new WASQLiteOpenFactory({
20+
dbFilename: 'examplse.db',
21+
vfs: WASQLiteVFS.OPFSCoopSyncVFS
22+
})
2823
});
2924

3025
export const SystemProvider = ({ children }: { children: React.ReactNode }) => {

packages/web/src/db/PowerSyncDatabase.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,21 +14,22 @@ import {
1414
StreamingSyncImplementation
1515
} from '@powersync/common';
1616
import { Mutex } from 'async-mutex';
17+
import { getNavigatorLocks } from '../shared/navigator';
1718
import { WASQLiteOpenFactory } from './adapters/wa-sqlite/WASQLiteOpenFactory';
1819
import {
1920
DEFAULT_WEB_SQL_FLAGS,
2021
ResolvedWebSQLOpenOptions,
2122
resolveWebSQLFlags,
2223
WebSQLFlags
2324
} from './adapters/web-sql-flags';
25+
import { WorkerDBAdapter } from './adapters/WorkerDBAdapter';
2426
import { SharedWebStreamingSyncImplementation } from './sync/SharedWebStreamingSyncImplementation';
2527
import { SSRStreamingSyncImplementation } from './sync/SSRWebStreamingSyncImplementation';
2628
import { WebRemote } from './sync/WebRemote';
2729
import {
2830
WebStreamingSyncImplementation,
2931
WebStreamingSyncImplementationOptions
3032
} from './sync/WebStreamingSyncImplementation';
31-
import { getNavigatorLocks } from '../shared/navigator';
3233

3334
export interface WebPowerSyncFlags extends WebSQLFlags {
3435
/**
@@ -191,7 +192,10 @@ export class PowerSyncDatabase extends AbstractPowerSyncDatabase {
191192
const logger = this.options.logger;
192193
logger ? logger.warn(warning) : console.warn(warning);
193194
}
194-
return new SharedWebStreamingSyncImplementation(syncOptions);
195+
return new SharedWebStreamingSyncImplementation({
196+
...syncOptions,
197+
workerDatabase: this.database as WorkerDBAdapter // This should always be the case
198+
});
195199
default:
196200
return new WebStreamingSyncImplementation(syncOptions);
197201
}

packages/web/src/db/adapters/AbstractWebSQLOpenFactory.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import { DBAdapter, SQLOpenFactory } from '@powersync/common';
2+
import Logger, { ILogger } from 'js-logger';
23
import { SSRDBAdapter } from './SSRDBAdapter';
34
import { ResolvedWebSQLFlags, WebSQLOpenFactoryOptions, isServerSide, resolveWebSQLFlags } from './web-sql-flags';
45

56
export abstract class AbstractWebSQLOpenFactory implements SQLOpenFactory {
67
protected resolvedFlags: ResolvedWebSQLFlags;
8+
protected logger: ILogger;
79

810
constructor(protected options: WebSQLOpenFactoryOptions) {
911
this.resolvedFlags = resolveWebSQLFlags(options.flags);
12+
this.logger = options.logger ?? Logger.get(`AbstractWebSQLOpenFactory - ${this.options.dbFilename}`);
1013
}
1114

1215
/**

packages/web/src/db/adapters/AsyncDatabaseConnection.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { BatchedUpdateNotification, QueryResult } from '@powersync/common';
1+
import { BatchedUpdateNotification, QueryResult, SQLOpenOptions } from '@powersync/common';
22

33
/**
44
* Proxied query result does not contain a function for accessing row values
@@ -21,5 +21,9 @@ export interface AsyncDatabaseConnection {
2121
close(): Promise<void>;
2222
execute(sql: string, params?: any[]): Promise<ProxiedQueryResult>;
2323
executeBatch(sql: string, params?: any[]): Promise<ProxiedQueryResult>;
24-
registerOnTableChange(callback: OnTableChangeCallback): () => void;
24+
registerOnTableChange(callback: OnTableChangeCallback): Promise<() => void>;
2525
}
26+
27+
export type OpenAsyncDatabaseConnection<Options extends SQLOpenOptions = SQLOpenOptions> = (
28+
options: Options
29+
) => AsyncDatabaseConnection;
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
import {
2+
BaseObserver,
3+
DBAdapter,
4+
DBAdapterListener,
5+
DBGetUtils,
6+
DBLockOptions,
7+
LockContext,
8+
QueryResult,
9+
Transaction
10+
} from '@powersync/common';
11+
import Logger, { ILogger } from 'js-logger';
12+
import { getNavigatorLocks } from '../..//shared/navigator';
13+
import { AsyncDatabaseConnection } from './AsyncDatabaseConnection';
14+
15+
/**
16+
* @internal
17+
*/
18+
export interface LockedAsyncDatabaseAdapterOptions {
19+
name: string;
20+
openConnection: () => Promise<AsyncDatabaseConnection>;
21+
debugMode?: boolean;
22+
logger?: ILogger;
23+
}
24+
25+
type LockedAsyncDatabaseAdapterListener = DBAdapterListener & {
26+
initialized?: () => void;
27+
};
28+
29+
/**
30+
* @internal
31+
* Wraps a {@link AsyncDatabaseConnection} and provides exclusive locking functions in
32+
* order to implement {@link DBAdapter}.
33+
*/
34+
export class LockedAsyncDatabaseAdapter extends BaseObserver<LockedAsyncDatabaseAdapterListener> implements DBAdapter {
35+
private logger: ILogger;
36+
private dbGetHelpers: DBGetUtils | null;
37+
private debugMode: boolean;
38+
private _dbIdentifier: string;
39+
private _isInitialized = false;
40+
private _db: AsyncDatabaseConnection | null = null;
41+
private _disposeTableChangeListener: (() => void) | null = null;
42+
43+
constructor(protected options: LockedAsyncDatabaseAdapterOptions) {
44+
super();
45+
this._dbIdentifier = options.name;
46+
this.logger = options.logger ?? Logger.get(`LockedAsyncDatabaseAdapter - ${this._dbIdentifier}`);
47+
// Set the name if provided. We can query for the name if not available yet
48+
this.debugMode = options.debugMode ?? false;
49+
if (this.debugMode) {
50+
const originalExecute = this._execute.bind(this);
51+
this._execute = async (sql, bindings) => {
52+
const start = performance.now();
53+
try {
54+
const r = await originalExecute(sql, bindings);
55+
performance.measure(`[SQL] ${sql}`, { start });
56+
return r;
57+
} catch (e: any) {
58+
performance.measure(`[SQL] [ERROR: ${e.message}] ${sql}`, { start });
59+
throw e;
60+
}
61+
};
62+
}
63+
64+
this.dbGetHelpers = this.generateDBHelpers({
65+
execute: (query, params) => this.acquireLock(() => this._execute(query, params))
66+
});
67+
}
68+
69+
protected get baseDB() {
70+
if (!this._db) {
71+
throw new Error(`Initialization has not completed yet. Cannot access base db`);
72+
}
73+
return this._db;
74+
}
75+
76+
get name() {
77+
return this._dbIdentifier;
78+
}
79+
80+
async init() {
81+
this._db = await this.options.openConnection();
82+
await this._db.init();
83+
this._disposeTableChangeListener = await this._db.registerOnTableChange((event) => {
84+
this.iterateListeners((cb) => cb.tablesUpdated?.(event));
85+
});
86+
this._isInitialized = true;
87+
this.iterateListeners((cb) => cb.initialized?.());
88+
}
89+
90+
protected async waitForInitialized() {
91+
if (this._isInitialized) {
92+
return;
93+
}
94+
return new Promise<void>((resolve) => {
95+
const l = this.registerListener({
96+
initialized: () => {
97+
resolve();
98+
l();
99+
}
100+
});
101+
});
102+
}
103+
104+
/**
105+
* This is currently a no-op on web
106+
*/
107+
async refreshSchema(): Promise<void> {}
108+
109+
async execute(query: string, params?: any[] | undefined): Promise<QueryResult> {
110+
return this.writeLock((ctx) => ctx.execute(query, params));
111+
}
112+
113+
async executeBatch(query: string, params?: any[][]): Promise<QueryResult> {
114+
return this.writeLock((ctx) => this._executeBatch(query, params));
115+
}
116+
117+
/**
118+
* Attempts to close the connection.
119+
* Shared workers might not actually close the connection if other
120+
* tabs are still using it.
121+
*/
122+
close() {
123+
this._disposeTableChangeListener?.();
124+
this.baseDB?.close?.();
125+
}
126+
127+
async getAll<T>(sql: string, parameters?: any[] | undefined): Promise<T[]> {
128+
await this.waitForInitialized();
129+
return this.dbGetHelpers!.getAll(sql, parameters);
130+
}
131+
132+
async getOptional<T>(sql: string, parameters?: any[] | undefined): Promise<T | null> {
133+
await this.waitForInitialized();
134+
return this.dbGetHelpers!.getOptional(sql, parameters);
135+
}
136+
137+
async get<T>(sql: string, parameters?: any[] | undefined): Promise<T> {
138+
await this.waitForInitialized();
139+
return this.dbGetHelpers!.get(sql, parameters);
140+
}
141+
142+
async readLock<T>(fn: (tx: LockContext) => Promise<T>, options?: DBLockOptions | undefined): Promise<T> {
143+
await this.waitForInitialized();
144+
return this.acquireLock(async () => fn(this.generateDBHelpers({ execute: this._execute })));
145+
}
146+
147+
async writeLock<T>(fn: (tx: LockContext) => Promise<T>, options?: DBLockOptions | undefined): Promise<T> {
148+
await this.waitForInitialized();
149+
return this.acquireLock(async () => fn(this.generateDBHelpers({ execute: this._execute })));
150+
}
151+
152+
protected acquireLock(callback: () => Promise<any>): Promise<any> {
153+
return getNavigatorLocks().request(`db-lock-${this._dbIdentifier}`, callback);
154+
}
155+
156+
async readTransaction<T>(fn: (tx: Transaction) => Promise<T>, options?: DBLockOptions | undefined): Promise<T> {
157+
return this.readLock(this.wrapTransaction(fn));
158+
}
159+
160+
writeTransaction<T>(fn: (tx: Transaction) => Promise<T>, options?: DBLockOptions | undefined): Promise<T> {
161+
return this.writeLock(this.wrapTransaction(fn));
162+
}
163+
164+
private generateDBHelpers<T extends { execute: (sql: string, params?: any[]) => Promise<QueryResult> }>(
165+
tx: T
166+
): T & DBGetUtils {
167+
return {
168+
...tx,
169+
/**
170+
* Execute a read-only query and return results
171+
*/
172+
async getAll<T>(sql: string, parameters?: any[]): Promise<T[]> {
173+
const res = await tx.execute(sql, parameters);
174+
return res.rows?._array ?? [];
175+
},
176+
177+
/**
178+
* Execute a read-only query and return the first result, or null if the ResultSet is empty.
179+
*/
180+
async getOptional<T>(sql: string, parameters?: any[]): Promise<T | null> {
181+
const res = await tx.execute(sql, parameters);
182+
return res.rows?.item(0) ?? null;
183+
},
184+
185+
/**
186+
* Execute a read-only query and return the first result, error if the ResultSet is empty.
187+
*/
188+
async get<T>(sql: string, parameters?: any[]): Promise<T> {
189+
const res = await tx.execute(sql, parameters);
190+
const first = res.rows?.item(0);
191+
if (!first) {
192+
throw new Error('Result set is empty');
193+
}
194+
return first;
195+
}
196+
};
197+
}
198+
199+
/**
200+
* Wraps a lock context into a transaction context
201+
*/
202+
private wrapTransaction<T>(cb: (tx: Transaction) => Promise<T>) {
203+
return async (tx: LockContext): Promise<T> => {
204+
await this._execute('BEGIN TRANSACTION');
205+
let finalized = false;
206+
const commit = async (): Promise<QueryResult> => {
207+
if (finalized) {
208+
return { rowsAffected: 0 };
209+
}
210+
finalized = true;
211+
return this._execute('COMMIT');
212+
};
213+
214+
const rollback = () => {
215+
finalized = true;
216+
return this._execute('ROLLBACK');
217+
};
218+
219+
try {
220+
const result = await cb({
221+
...tx,
222+
commit,
223+
rollback
224+
});
225+
226+
if (!finalized) {
227+
await commit();
228+
}
229+
return result;
230+
} catch (ex) {
231+
this.logger.debug('Caught ex in transaction', ex);
232+
try {
233+
await rollback();
234+
} catch (ex2) {
235+
// In rare cases, a rollback may fail.
236+
// Safe to ignore.
237+
}
238+
throw ex;
239+
}
240+
};
241+
}
242+
243+
/**
244+
* Wraps the worker execute function, awaiting for it to be available
245+
*/
246+
private _execute = async (sql: string, bindings?: any[]): Promise<QueryResult> => {
247+
await this.waitForInitialized();
248+
const result = await this.baseDB.execute(sql, bindings);
249+
return {
250+
...result,
251+
rows: {
252+
...result.rows,
253+
item: (idx: number) => result.rows._array[idx]
254+
}
255+
};
256+
};
257+
258+
/**
259+
* Wraps the worker executeBatch function, awaiting for it to be available
260+
*/
261+
private _executeBatch = async (query: string, params?: any[]): Promise<QueryResult> => {
262+
await this.waitForInitialized();
263+
const result = await this.baseDB.executeBatch(query, params);
264+
return {
265+
...result,
266+
rows: undefined
267+
};
268+
};
269+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import * as Comlink from 'comlink';
2+
import { AsyncDatabaseConnection, OnTableChangeCallback } from './AsyncDatabaseConnection';
3+
4+
/**
5+
* @internal
6+
* Proxies an {@link AsyncDatabaseConnection} which allows for registering table change notification
7+
* callbacks over a worker channel
8+
*/
9+
export function ProxiedAsyncDatabaseConnection(base: AsyncDatabaseConnection) {
10+
return new Proxy(base, {
11+
get(target, prop: keyof AsyncDatabaseConnection, receiver) {
12+
const original = Reflect.get(target, prop, receiver);
13+
if (typeof original === 'function' && prop === 'registerOnTableChange') {
14+
return function (callback: OnTableChangeCallback) {
15+
return base.registerOnTableChange(Comlink.proxy(callback));
16+
};
17+
}
18+
return original;
19+
}
20+
});
21+
}

0 commit comments

Comments
 (0)