Skip to content

Commit 4740853

Browse files
Add unit tests and autocommit check for tests
1 parent 2717b7a commit 4740853

File tree

7 files changed

+153
-40
lines changed

7 files changed

+153
-40
lines changed

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ export interface AsyncDatabaseConnection<Config extends ResolvedWebSQLOpenOption
3535
* @param holdId The hold ID to release.
3636
*/
3737
releaseHold(holdId: string): Promise<void>;
38+
/**
39+
* Checks if the database connection is in autocommit mode.
40+
* @returns true if in autocommit mode, false if in a transaction
41+
*/
42+
isAutoCommit(): Promise<boolean>;
3843
execute(sql: string, params?: any[]): Promise<ProxiedQueryResult>;
3944
executeRaw(sql: string, params?: any[]): Promise<any[][]>;
4045
executeBatch(sql: string, params?: any[]): Promise<ProxiedQueryResult>;

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,15 @@ export class WorkerWrappedAsyncDatabaseConnection<Config extends ResolvedWebSQLO
6161
}
6262

6363
markHold(): Promise<string> {
64-
return this.baseConnection.markHold();
64+
return this.withRemote(() => this.baseConnection.markHold());
6565
}
6666

6767
releaseHold(holdId: string): Promise<void> {
68-
return this.baseConnection.releaseHold(holdId);
68+
return this.withRemote(() => this.baseConnection.releaseHold(holdId));
69+
}
70+
71+
isAutoCommit(): Promise<boolean> {
72+
return this.baseConnection.isAutoCommit();
6973
}
7074

7175
private withRemote<T>(workerPromise: () => Promise<T>): Promise<T> {
@@ -74,6 +78,8 @@ export class WorkerWrappedAsyncDatabaseConnection<Config extends ResolvedWebSQLO
7478
return new Promise((resolve, reject) => {
7579
if (controller.signal.aborted) {
7680
reject(new Error('Called operation on closed remote'));
81+
// Don't run the operation if we're going to reject
82+
return;
7783
}
7884

7985
function handleAbort() {

packages/web/src/db/adapters/wa-sqlite/WASQLiteConnection.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,14 @@ export class WASqliteConnection
193193
return this._dbP;
194194
}
195195

196+
/**
197+
* Checks if the database connection is in autocommit mode.
198+
* @returns true if in autocommit mode, false if in a transaction
199+
*/
200+
async isAutoCommit(): Promise<boolean> {
201+
return this.sqliteAPI.get_autocommit(this.dbP) != 0;
202+
}
203+
196204
async markHold(): Promise<string> {
197205
const previousHoldId = this._holdId;
198206
this._holdId = `${++this._holdCounter}`;

packages/web/src/worker/db/SharedWASQLiteConnection.ts

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,22 @@ export class SharedWASQLiteConnection implements AsyncDatabaseConnection {
3434
this.activeHoldId = null;
3535
}
3636

37+
protected get logger() {
38+
return this.options.logger;
39+
}
40+
41+
protected get dbEntry() {
42+
return this.options.dbMap.get(this.options.dbFilename)!;
43+
}
44+
45+
protected get connection() {
46+
return this.dbEntry.db;
47+
}
48+
49+
protected get clientIds() {
50+
return this.dbEntry.clientIds;
51+
}
52+
3753
async init(): Promise<void> {
3854
// No-op since the connection is already initialized when it was created
3955
}
@@ -51,20 +67,8 @@ export class SharedWASQLiteConnection implements AsyncDatabaseConnection {
5167
}
5268
}
5369

54-
protected get logger() {
55-
return this.options.logger;
56-
}
57-
58-
protected get dbEntry() {
59-
return this.options.dbMap.get(this.options.dbFilename)!;
60-
}
61-
62-
protected get connection() {
63-
return this.dbEntry.db;
64-
}
65-
66-
protected get clientIds() {
67-
return this.dbEntry.clientIds;
70+
async isAutoCommit(): Promise<boolean> {
71+
return this.connection.isAutoCommit();
6872
}
6973

7074
/**

packages/web/src/worker/db/WASQLiteDB.worker.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { AsyncDatabaseConnection } from '../../db/adapters/AsyncDatabaseConnecti
99
import { WorkerDBOpenerOptions } from '../../db/adapters/wa-sqlite/WASQLiteOpenFactory';
1010
import { getNavigatorLocks } from '../../shared/navigator';
1111
import { SharedDBWorkerConnection, SharedWASQLiteConnection } from './SharedWASQLiteConnection';
12-
import { WorkerWASQLiteConnection, proxyWASQLiteConnection } from './WorkerWASQLiteConnection';
12+
import { WorkerWASQLiteConnection } from './WorkerWASQLiteConnection';
1313

1414
const baseLogger = createBaseLogger();
1515
baseLogger.useDefaults();
@@ -59,7 +59,7 @@ const openDBShared = async (options: WorkerDBOpenerOptions): Promise<AsyncDataba
5959
logger
6060
});
6161

62-
return proxyWASQLiteConnection(sharedConnection);
62+
return Comlink.proxy(sharedConnection);
6363
});
6464
};
6565

packages/web/src/worker/db/WorkerWASQLiteConnection.ts

Lines changed: 3 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,11 @@
11
import * as Comlink from 'comlink';
2-
import { AsyncDatabaseConnection, OnTableChangeCallback } from '../../db/adapters/AsyncDatabaseConnection';
2+
import { OnTableChangeCallback } from '../../db/adapters/AsyncDatabaseConnection';
33
import { WASqliteConnection } from '../../db/adapters/wa-sqlite/WASQLiteConnection';
44

55
/**
6-
* Fully proxies a WASQLiteConnection to be used as an AsyncDatabaseConnection.
6+
* A Small proxy wrapper around the WASqliteConnection.
7+
* This ensures that certain return types are properly proxied.
78
*/
8-
export function proxyWASQLiteConnection(connection: AsyncDatabaseConnection): AsyncDatabaseConnection {
9-
return Comlink.proxy({
10-
init: Comlink.proxy(() => connection.init()),
11-
close: Comlink.proxy(() => connection.close()),
12-
markHold: Comlink.proxy(() => connection.markHold()),
13-
releaseHold: Comlink.proxy((holdId: string) => connection.releaseHold(holdId)),
14-
execute: Comlink.proxy((sql: string, params?: any[]) => connection.execute(sql, params)),
15-
executeRaw: Comlink.proxy((sql: string, params?: any[]) => connection.executeRaw(sql, params)),
16-
executeBatch: Comlink.proxy((sql: string, params?: any[]) => connection.executeBatch(sql, params)),
17-
registerOnTableChange: Comlink.proxy((callback: OnTableChangeCallback) =>
18-
connection.registerOnTableChange(callback)
19-
),
20-
getConfig: Comlink.proxy(() => connection.getConfig())
21-
});
22-
}
23-
249
export class WorkerWASQLiteConnection extends WASqliteConnection {
2510
async registerOnTableChange(callback: OnTableChangeCallback): Promise<() => void> {
2611
// Proxy the callback remove function

packages/web/tests/multiple_instances.test.ts

Lines changed: 109 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,31 @@ import {
33
createBaseLogger,
44
createLogger,
55
DEFAULT_CRUD_UPLOAD_THROTTLE_MS,
6-
DEFAULT_STREAMING_SYNC_OPTIONS,
76
SqliteBucketStorage,
87
SyncStatus
98
} from '@powersync/common';
109
import {
10+
OpenAsyncDatabaseConnection,
1111
SharedWebStreamingSyncImplementation,
1212
SharedWebStreamingSyncImplementationOptions,
13+
WASqliteConnection,
1314
WebRemote
1415
} from '@powersync/web';
15-
16-
import { beforeAll, describe, expect, it, vi } from 'vitest';
16+
import * as Comlink from 'comlink';
17+
import { beforeAll, describe, expect, it, onTestFinished, vi } from 'vitest';
18+
import { LockedAsyncDatabaseAdapter } from '../src/db/adapters/LockedAsyncDatabaseAdapter';
1719
import { WebDBAdapter } from '../src/db/adapters/WebDBAdapter';
20+
import { WorkerWrappedAsyncDatabaseConnection } from '../src/db/adapters/WorkerWrappedAsyncDatabaseConnection';
1821
import { TestConnector } from './utils/MockStreamOpenFactory';
1922
import { generateTestDb, testSchema } from './utils/testDb';
2023

24+
const DB_FILENAME = 'test-multiple-instances.db';
25+
2126
describe('Multiple Instances', { sequential: true }, () => {
2227
const openDatabase = () =>
2328
generateTestDb({
2429
database: {
25-
dbFilename: `test-multiple-instances.db`
30+
dbFilename: DB_FILENAME
2631
},
2732
schema: testSchema
2833
});
@@ -99,6 +104,106 @@ describe('Multiple Instances', { sequential: true }, () => {
99104
await createAsset(powersync2);
100105
});
101106

107+
it('should handled interrupted transactions', { timeout: Infinity }, async () => {
108+
//Create a shared PowerSync database. We'll just use this for internally managing connections.
109+
const powersync = openDatabase();
110+
await powersync.init();
111+
112+
// Now get a shared connection to the same database
113+
const webAdapter = powersync.database as WebDBAdapter;
114+
115+
// Allow us to share the connection. This is what shared sync workers will use.
116+
const shared = await webAdapter.shareConnection();
117+
const config = webAdapter.getConfiguration();
118+
const opener = Comlink.wrap<OpenAsyncDatabaseConnection>(shared.port);
119+
120+
// Open up a shared connection
121+
const initialSharedConnection = (await opener(config)) as Comlink.Remote<WASqliteConnection>;
122+
onTestFinished(async () => {
123+
await initialSharedConnection.close();
124+
});
125+
126+
// This will simulate another subsequent shared connection
127+
const subsequentSharedConnection = (await opener(config)) as Comlink.Remote<WASqliteConnection>;
128+
onTestFinished(async () => {
129+
await subsequentSharedConnection.close();
130+
});
131+
132+
// In the beginning, we should not be in a transaction
133+
const isAutoCommit = await initialSharedConnection.isAutoCommit();
134+
// Should be true initially
135+
expect(isAutoCommit).true;
136+
137+
// Now we'll simulate the locked connections which are used by the shared sync worker
138+
const wrappedInitialSharedConnection = new WorkerWrappedAsyncDatabaseConnection({
139+
baseConnection: initialSharedConnection,
140+
identifier: DB_FILENAME,
141+
remoteCanCloseUnexpectedly: true,
142+
remote: opener
143+
});
144+
145+
// Wrap the second connection in a locked adapter, this simulates the actual use case
146+
const lockedInitialConnection = new LockedAsyncDatabaseAdapter({
147+
name: DB_FILENAME,
148+
openConnection: async () => wrappedInitialSharedConnection
149+
});
150+
151+
// Allows us to unblock a transaction which is awaiting a promise
152+
let unblockTransaction: (() => void) | undefined;
153+
154+
// Start a transaction that will be interrupted
155+
const transactionPromise = lockedInitialConnection.writeTransaction(async (tx) => {
156+
// Transaction should be started now
157+
158+
// Wait till we are unblocked. Keep this transaction open.
159+
await new Promise<void>((resolve) => {
160+
unblockTransaction = resolve;
161+
});
162+
163+
// This should throw if the db was closed
164+
await tx.get('SELECT 1');
165+
});
166+
167+
// Wait for the transaction to have started
168+
await vi.waitFor(() => expect(unblockTransaction).toBeDefined(), { timeout: 2000 });
169+
170+
// Since we're in a transaction from above
171+
expect(await initialSharedConnection.isAutoCommit()).false;
172+
173+
// The in-use connection should be closed now
174+
// This simulates a tab being closed.
175+
await wrappedInitialSharedConnection.close();
176+
wrappedInitialSharedConnection.markRemoteClosed();
177+
178+
// The transaction should be unblocked now
179+
unblockTransaction?.();
180+
181+
// Since we closed while in the transaction, the execution call should have thrown
182+
await expect(transactionPromise).rejects.toThrow('Called operation on closed remote');
183+
184+
// It will still be false until we request a new hold
185+
// Requesting a new hold will cleanup the previous transaction.
186+
expect(await subsequentSharedConnection.isAutoCommit()).false;
187+
188+
// Allows us to simulate a new locked shared connection.
189+
const lockedSubsequentConnection = new LockedAsyncDatabaseAdapter({
190+
name: DB_FILENAME,
191+
openConnection: async () =>
192+
new WorkerWrappedAsyncDatabaseConnection({
193+
baseConnection: subsequentSharedConnection,
194+
identifier: DB_FILENAME,
195+
remoteCanCloseUnexpectedly: true,
196+
remote: opener
197+
})
198+
});
199+
200+
// Starting a new transaction should work cleanup the old and work as expected
201+
await lockedSubsequentConnection.writeTransaction(async (tx) => {
202+
await tx.get('SELECT 1');
203+
expect(await subsequentSharedConnection.isAutoCommit()).false;
204+
});
205+
});
206+
102207
it('should watch table changes between instances', async () => {
103208
const db1 = openDatabase();
104209
const db2 = openDatabase();

0 commit comments

Comments
 (0)