Skip to content

Commit 00c7a6c

Browse files
Merge remote-tracking branch 'origin/drizzle-read-only-queries' into drizzle-fix
2 parents 4eba027 + 94afea2 commit 00c7a6c

File tree

9 files changed

+320
-111
lines changed

9 files changed

+320
-111
lines changed

.changeset/hot-socks-love.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@powersync/drizzle-driver': minor
3+
'@powersync/node': minor
4+
---
5+
6+
Add support for concurrent read queries with Drizzle.

packages/drizzle-driver/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,9 @@
5050
"@powersync/web": "workspace:*",
5151
"@journeyapps/wa-sqlite": "^1.3.2",
5252
"@types/node": "^20.17.6",
53-
"drizzle-orm": "^0.35.2",
53+
"drizzle-orm": "^0.44.7",
5454
"vite": "^6.1.0",
5555
"vite-plugin-top-level-await": "^1.4.4",
5656
"vite-plugin-wasm": "^3.3.0"
5757
}
58-
}
58+
}

packages/drizzle-driver/src/sqlite/PowerSyncSQLiteBaseSession.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { QueryResult } from '@powersync/common';
1+
import type { QueryResult } from '@powersync/common';
2+
import type { WithCacheConfig } from 'drizzle-orm/cache/core/types';
23
import { entityKind } from 'drizzle-orm/entity';
34
import type { Logger } from 'drizzle-orm/logger';
45
import { NoopLogger } from 'drizzle-orm/logger';
@@ -53,7 +54,12 @@ export class PowerSyncSQLiteBaseSession<
5354
fields: SelectedFieldsOrdered | undefined,
5455
executeMethod: SQLiteExecuteMethod,
5556
isResponseInArrayMode: boolean,
56-
customResultMapper?: (rows: unknown[][], mapColumnValue?: (value: unknown) => unknown) => unknown
57+
customResultMapper?: (rows: unknown[][], mapColumnValue?: (value: unknown) => unknown) => unknown,
58+
queryMetadata?: {
59+
type: 'select' | 'update' | 'delete' | 'insert';
60+
tables: string[];
61+
},
62+
cacheConfig?: WithCacheConfig
5763
): PowerSyncSQLitePreparedQuery<T> {
5864
return new PowerSyncSQLitePreparedQuery(
5965
this.contextProvider,
@@ -62,7 +68,10 @@ export class PowerSyncSQLiteBaseSession<
6268
fields,
6369
executeMethod,
6470
isResponseInArrayMode,
65-
customResultMapper
71+
customResultMapper,
72+
undefined, // cache not supported yet
73+
queryMetadata,
74+
cacheConfig
6675
);
6776
}
6877

packages/drizzle-driver/src/sqlite/PowerSyncSQLitePreparedQuery.ts

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
import { LockContext, QueryResult } from '@powersync/common';
2-
import { Column, DriverValueDecoder, SQL, getTableName } from 'drizzle-orm';
1+
import type { QueryResult } from '@powersync/common';
2+
import { Column, DriverValueDecoder, getTableName, SQL } from 'drizzle-orm';
3+
import type { Cache } from 'drizzle-orm/cache/core';
4+
import type { WithCacheConfig } from 'drizzle-orm/cache/core/types';
35
import { entityKind, is } from 'drizzle-orm/entity';
46
import type { Logger } from 'drizzle-orm/logger';
57
import { fillPlaceholders, type Query } from 'drizzle-orm/sql/sql';
@@ -42,16 +44,27 @@ export class PowerSyncSQLitePreparedQuery<
4244
}> {
4345
static readonly [entityKind]: string = 'PowerSyncSQLitePreparedQuery';
4446

47+
private readOnly = false;
48+
4549
constructor(
4650
private contextProvider: ContextProvider,
4751
query: Query,
4852
private logger: Logger,
4953
private fields: SelectedFieldsOrdered | undefined,
5054
executeMethod: SQLiteExecuteMethod,
5155
private _isResponseInArrayMode: boolean,
52-
private customResultMapper?: (rows: unknown[][]) => unknown
56+
private customResultMapper?: (rows: unknown[][]) => unknown,
57+
cache?: Cache | undefined,
58+
queryMetadata?:
59+
| {
60+
type: 'select' | 'update' | 'delete' | 'insert';
61+
tables: string[];
62+
}
63+
| undefined,
64+
cacheConfig?: WithCacheConfig | undefined
5365
) {
54-
super('async', executeMethod, query);
66+
super('async', executeMethod, query, cache, queryMetadata, cacheConfig);
67+
this.readOnly = queryMetadata?.type == 'select';
5568
}
5669

5770
async run(placeholderValues?: Record<string, unknown>): Promise<QueryResult> {
@@ -115,9 +128,15 @@ export class PowerSyncSQLitePreparedQuery<
115128
const params = fillPlaceholders(this.query.params, placeholderValues ?? {});
116129
this.logger.logQuery(this.query.sql, params);
117130

118-
return await this.contextProvider.useReadContext(async (ctx) => {
119-
return await ctx.executeRaw(this.query.sql, params);
120-
});
131+
if (this.readOnly) {
132+
return await this.contextProvider.useReadContext(async (ctx) => {
133+
return await ctx.executeRaw(this.query.sql, params);
134+
});
135+
} else {
136+
return await this.contextProvider.useWriteContext(async (ctx) => {
137+
return await ctx.executeRaw(this.query.sql, params);
138+
});
139+
}
121140
}
122141

123142
isResponseInArrayMode(): boolean {
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { AbstractPowerSyncDatabase, LockContext, QueryResult } from '@powersync/common';
2+
3+
/**
4+
* Like LockContext, but only includes the specific methods needed for Drizzle.
5+
*
6+
* We extend it by adding getAllRaw, to support read-only queries with executeRaw.
7+
*/
8+
export interface QueryContext {
9+
execute(query: string, params?: any[] | undefined): Promise<QueryResult>;
10+
executeRaw(query: string, params?: any[] | undefined): Promise<any[][]>;
11+
12+
get<T>(sql: string, parameters?: any[]): Promise<T>;
13+
getAll<T>(sql: string, parameters?: any[]): Promise<T[]>;
14+
/**
15+
* Like executeRaw, but for read-only queries.
16+
*/
17+
getAllRaw(query: string, params?: any[] | undefined): Promise<any[][]>;
18+
}
19+
20+
export class DatabaseQueryContext implements QueryContext {
21+
constructor(private db: AbstractPowerSyncDatabase) {}
22+
execute(query: string, params?: any[] | undefined): Promise<QueryResult> {
23+
return this.db.execute(query, params);
24+
}
25+
executeRaw(query: string, params?: any[] | undefined) {
26+
return this.db.executeRaw(query, params);
27+
}
28+
get<T>(sql: string, parameters?: any[]) {
29+
return this.db.get<T>(sql, parameters);
30+
}
31+
getAll<T>(sql: string, parameters?: any[]) {
32+
return this.db.getAll<T>(sql, parameters);
33+
}
34+
getAllRaw(query: string, params?: any[] | undefined) {
35+
return this.db.readLock(async (ctx) => {
36+
return ctx.executeRaw(query, params);
37+
});
38+
}
39+
}
40+
41+
export class LockQueryContext implements QueryContext {
42+
constructor(private ctx: LockContext) {}
43+
execute(query: string, params?: any[] | undefined): Promise<QueryResult> {
44+
return this.ctx.execute(query, params);
45+
}
46+
executeRaw(query: string, params?: any[] | undefined) {
47+
return this.ctx.executeRaw(query, params);
48+
}
49+
get<T>(sql: string, parameters?: any[]) {
50+
return this.ctx.get<T>(sql, parameters);
51+
}
52+
getAll<T>(sql: string, parameters?: any[]) {
53+
return this.ctx.getAll<T>(sql, parameters);
54+
}
55+
getAllRaw(query: string, params?: any[] | undefined) {
56+
return this.ctx.executeRaw(query, params);
57+
}
58+
}

packages/node/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@
7676
"@powersync/drizzle-driver": "workspace:*",
7777
"@types/node": "^24.2.0",
7878
"better-sqlite3": "^12.2.0",
79-
"drizzle-orm": "^0.35.2",
79+
"drizzle-orm": "^0.44.7",
8080
"rollup": "4.14.3",
8181
"typescript": "^5.5.3",
8282
"vitest": "^3.2.4"
@@ -88,4 +88,4 @@
8888
"real-time data stream",
8989
"live data"
9090
]
91-
}
91+
}

packages/node/tests/DrizzleNode.test.ts

Lines changed: 133 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { sqliteTable, text } from 'drizzle-orm/sqlite-core';
22
import { eq, relations } from 'drizzle-orm';
33

4-
import { databaseTest } from './utils';
4+
import { customDatabaseTest, databaseTest } from './utils';
55
import { wrapPowerSyncWithDrizzle } from '@powersync/drizzle-driver';
66
import { PowerSyncDatabase } from '../lib';
77
import { expect } from 'vitest';
@@ -53,6 +53,15 @@ databaseTest('should retrieve a list with todos', async ({ database }) => {
5353
expect(result).toEqual([{ id: '1', name: 'list 1', todos: [{ id: '33', content: 'Post content', list_id: '1' }] }]);
5454
});
5555

56+
databaseTest('insert returning', async ({ database }) => {
57+
const db = await setupDrizzle(database);
58+
59+
// This is a special case since it's an insert query that returns values
60+
const result = await db.insert(drizzleLists).values({ id: '2', name: 'list 2' }).returning();
61+
62+
expect(result).toEqual([{ id: '2', name: 'list 2' }]);
63+
});
64+
5665
databaseTest('should retrieve a todo with its list', async ({ database }) => {
5766
const db = await setupDrizzle(database);
5867

@@ -97,3 +106,126 @@ databaseTest('should return a list and todos using fullJoin', async ({ database
97106
expect(result[0].lists).toEqual({ id: '1', name: 'list 1' });
98107
expect(result[0].todos).toEqual({ id: '33', content: 'Post content', list_id: '1' });
99108
});
109+
110+
customDatabaseTest({ database: { readWorkerCount: 2 } as any })(
111+
'should execute transactions concurrently',
112+
{},
113+
async ({ database }) => {
114+
// This test opens one write transaction and two read transactions, testing that they can all execute
115+
// concurrently.
116+
const db = await setupDrizzle(database);
117+
118+
const openedWrite = deferred();
119+
const openedRead1 = deferred();
120+
const openedRead2 = deferred();
121+
const completedWrite = deferred();
122+
123+
const t1 = db.transaction(async (tx) => {
124+
await tx.insert(drizzleLists).values({ id: '2', name: 'list 2' });
125+
126+
openedWrite.resolve();
127+
128+
await openedRead1.promise;
129+
await openedRead2.promise;
130+
131+
completedWrite.resolve();
132+
});
133+
134+
await openedWrite.promise;
135+
136+
const t2 = db.transaction(
137+
async (tx) => {
138+
const result = await tx.query.lists.findMany();
139+
expect(result).toEqual([{ id: '1', name: 'list 1' }]);
140+
openedRead1.resolve();
141+
await completedWrite.promise;
142+
},
143+
{ accessMode: 'read only' }
144+
);
145+
146+
const t3 = db.transaction(
147+
async (tx) => {
148+
await openedRead1.promise;
149+
const result = await tx.query.lists.findMany();
150+
expect(result).toEqual([{ id: '1', name: 'list 1' }]);
151+
152+
openedRead2.resolve();
153+
await completedWrite.promise;
154+
},
155+
{ accessMode: 'read only' }
156+
);
157+
158+
await Promise.all([t1, t2, t3]);
159+
160+
const result = await db.query.lists.findMany();
161+
expect(result).toEqual([
162+
{ id: '1', name: 'list 1' },
163+
{ id: '2', name: 'list 2' }
164+
]);
165+
}
166+
);
167+
168+
customDatabaseTest({ database: { readWorkerCount: 2 } as any })(
169+
'should execute select queries concurrently',
170+
async ({ database }) => {
171+
// This test opens one write transaction and two read transactions, testing that they can all execute
172+
// concurrently.
173+
const db = await setupDrizzle(database);
174+
175+
const openedWrite = deferred();
176+
const completedRead = deferred();
177+
const completedWrite = deferred();
178+
179+
const t1 = db.transaction(async (tx) => {
180+
await tx.insert(drizzleLists).values({ id: '2', name: 'list 2' });
181+
182+
openedWrite.resolve();
183+
184+
await completedRead.promise;
185+
186+
completedWrite.resolve();
187+
});
188+
189+
await openedWrite.promise;
190+
191+
const result1 = await db.select().from(drizzleLists);
192+
expect(result1).toEqual([{ id: '1', name: 'list 1' }]);
193+
194+
const result2 = await db
195+
.select()
196+
.from(drizzleLists)
197+
.fullJoin(drizzleTodos, eq(drizzleLists.id, drizzleTodos.list_id));
198+
199+
expect(result2[0].lists).toEqual({ id: '1', name: 'list 1' });
200+
expect(result2[0].todos).toEqual({ id: '33', content: 'Post content', list_id: '1' });
201+
202+
// Note: This case is not supported yet (drizzle 0.44.7), since it doesn't set
203+
// queryMetadata for these queries
204+
// const result3 = await db.query.lists.findMany();
205+
// expect(result3).toEqual([{ id: '1', name: 'list 1' }]);
206+
207+
completedRead.resolve();
208+
209+
await t1;
210+
211+
const resultAfter = await db.query.lists.findMany();
212+
expect(resultAfter).toEqual([
213+
{ id: '1', name: 'list 1' },
214+
{ id: '2', name: 'list 2' }
215+
]);
216+
}
217+
);
218+
219+
function deferred<T = void>() {
220+
let resolve: (value: T) => void;
221+
let reject: (err) => void;
222+
const promise = new Promise<T>((a, b) => {
223+
resolve = a;
224+
reject = b;
225+
});
226+
return {
227+
promise,
228+
resolve,
229+
reject
230+
};
231+
}

packages/node/tests/utils.ts

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
PowerSyncBackendConnector,
1515
PowerSyncCredentials,
1616
PowerSyncDatabase,
17+
PowerSyncDatabaseOptions,
1718
Schema,
1819
StreamingSyncCheckpoint,
1920
StreamingSyncLine,
@@ -65,27 +66,32 @@ export async function createDatabase(
6566

6667
const database = new PowerSyncDatabase({
6768
schema: AppSchema,
69+
...options,
70+
logger: defaultLogger,
6871
database: {
6972
dbFilename: 'test.db',
7073
dbLocation: tmpdir,
7174
// Using a single read worker (instead of multiple, the default) seems to improve the reliability of tests in GH
7275
// actions. So far, we've not been able to reproduce these failures locally.
73-
readWorkerCount: 1
74-
},
75-
logger: defaultLogger,
76-
...options
76+
readWorkerCount: 1,
77+
...options.database
78+
}
7779
});
7880
await database.init();
7981
return database;
8082
}
8183

82-
export const databaseTest = tempDirectoryTest.extend<{ database: PowerSyncDatabase }>({
83-
database: async ({ tmpdir }, use) => {
84-
const db = await createDatabase(tmpdir);
85-
await use(db);
86-
await db.close();
87-
}
88-
});
84+
export const customDatabaseTest = (options?: Partial<NodePowerSyncDatabaseOptions>) => {
85+
return tempDirectoryTest.extend<{ database: PowerSyncDatabase }>({
86+
database: async ({ tmpdir }, use) => {
87+
const db = await createDatabase(tmpdir, options);
88+
await use(db);
89+
await db.close();
90+
}
91+
});
92+
};
93+
94+
export const databaseTest = customDatabaseTest();
8995

9096
// TODO: Unify this with the test setup for the web SDK.
9197
export const mockSyncServiceTest = tempDirectoryTest.extend<{

0 commit comments

Comments
 (0)