diff --git a/.changeset/calm-baboons-worry.md b/.changeset/calm-baboons-worry.md new file mode 100644 index 00000000..74383228 --- /dev/null +++ b/.changeset/calm-baboons-worry.md @@ -0,0 +1,5 @@ +--- +'@powersync/drizzle-driver': minor +--- + +Added `watch()` function to Drizzle wrapper to support watched queries. This function invokes `execute()` on the Drizzle query which improves support for complex queries such as those which are relational. diff --git a/.changeset/curly-poets-explode.md b/.changeset/curly-poets-explode.md deleted file mode 100644 index 32f77bc8..00000000 --- a/.changeset/curly-poets-explode.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@powersync/web': patch ---- - -Added a bin/cli utilty that can be invoked with `npx powersync-web copy-assets` or `pnpm powersync-web copy-assets`. diff --git a/.changeset/empty-chefs-smell.md b/.changeset/empty-chefs-smell.md new file mode 100644 index 00000000..735fece9 --- /dev/null +++ b/.changeset/empty-chefs-smell.md @@ -0,0 +1,5 @@ +--- +'@powersync/kysely-driver': minor +--- + +Added `watch()` function to Kysely wrapper to support watched queries. This function invokes `execute()` on the Kysely query which improves support for complex queries and Kysely plugins. diff --git a/.changeset/gold-beers-smoke.md b/.changeset/gold-beers-smoke.md new file mode 100644 index 00000000..52c73bae --- /dev/null +++ b/.changeset/gold-beers-smoke.md @@ -0,0 +1,5 @@ +--- +'@powersync/common': minor +--- + +Added `compilableQueryWatch()` utility function which allows any compilable query to be watched. diff --git a/demos/django-react-native-todolist/android/build.gradle b/demos/django-react-native-todolist/android/build.gradle index 932bf7b3..745154e7 100644 --- a/demos/django-react-native-todolist/android/build.gradle +++ b/demos/django-react-native-todolist/android/build.gradle @@ -3,7 +3,7 @@ buildscript { ext { buildToolsVersion = findProperty('android.buildToolsVersion') ?: '34.0.0' - minSdkVersion = Integer.parseInt(findProperty('android.minSdkVersion') ?: '23') + minSdkVersion = Integer.parseInt(findProperty('android.minSdkVersion') ?: '24') compileSdkVersion = Integer.parseInt(findProperty('android.compileSdkVersion') ?: '34') targetSdkVersion = Integer.parseInt(findProperty('android.targetSdkVersion') ?: '34') kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.23' diff --git a/demos/django-react-native-todolist/android/gradle.properties b/demos/django-react-native-todolist/android/gradle.properties index 8fc5c34b..e56387de 100644 --- a/demos/django-react-native-todolist/android/gradle.properties +++ b/demos/django-react-native-todolist/android/gradle.properties @@ -58,7 +58,7 @@ EX_DEV_CLIENT_NETWORK_INSPECTOR=true # Use legacy packaging to compress native libraries in the resulting APK. expo.useLegacyPackaging=false -android.minSdkVersion=23 +android.minSdkVersion=24 android.compileSdkVersion=34 android.targetSdkVersion=34 android.buildToolsVersion=34.0.0 diff --git a/demos/django-react-native-todolist/app.json b/demos/django-react-native-todolist/app.json index d87fcfa7..a6075524 100644 --- a/demos/django-react-native-todolist/app.json +++ b/demos/django-react-native-todolist/app.json @@ -37,7 +37,7 @@ "newArchEnabled": false }, "android": { - "minSdkVersion": 23, + "minSdkVersion": 24, "compileSdkVersion": 34, "targetSdkVersion": 34, "buildToolsVersion": "34.0.0", diff --git a/demos/django-react-native-todolist/package.json b/demos/django-react-native-todolist/package.json index 41fe342a..05de298e 100644 --- a/demos/django-react-native-todolist/package.json +++ b/demos/django-react-native-todolist/package.json @@ -10,7 +10,7 @@ "dependencies": { "@azure/core-asynciterator-polyfill": "^1.0.2", "@expo/vector-icons": "^14.0.0", - "@journeyapps/react-native-quick-sqlite": "^2.1.2", + "@journeyapps/react-native-quick-sqlite": "^2.2.0", "@powersync/common": "workspace:*", "@powersync/react": "workspace:*", "@powersync/react-native": "workspace:*", diff --git a/demos/react-native-supabase-group-chat/android/build.gradle b/demos/react-native-supabase-group-chat/android/build.gradle index 932bf7b3..745154e7 100644 --- a/demos/react-native-supabase-group-chat/android/build.gradle +++ b/demos/react-native-supabase-group-chat/android/build.gradle @@ -3,7 +3,7 @@ buildscript { ext { buildToolsVersion = findProperty('android.buildToolsVersion') ?: '34.0.0' - minSdkVersion = Integer.parseInt(findProperty('android.minSdkVersion') ?: '23') + minSdkVersion = Integer.parseInt(findProperty('android.minSdkVersion') ?: '24') compileSdkVersion = Integer.parseInt(findProperty('android.compileSdkVersion') ?: '34') targetSdkVersion = Integer.parseInt(findProperty('android.targetSdkVersion') ?: '34') kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.23' diff --git a/demos/react-native-supabase-group-chat/android/gradle.properties b/demos/react-native-supabase-group-chat/android/gradle.properties index a5a1c32d..9dcd787f 100644 --- a/demos/react-native-supabase-group-chat/android/gradle.properties +++ b/demos/react-native-supabase-group-chat/android/gradle.properties @@ -55,7 +55,7 @@ EX_DEV_CLIENT_NETWORK_INSPECTOR=false # Use legacy packaging to compress native libraries in the resulting APK. expo.useLegacyPackaging=false -android.minSdkVersion=23 +android.minSdkVersion=24 android.compileSdkVersion=34 android.targetSdkVersion=34 android.buildToolsVersion=34.0.0 diff --git a/demos/react-native-supabase-group-chat/app.config.ts b/demos/react-native-supabase-group-chat/app.config.ts index 2fd168f9..de6763bf 100644 --- a/demos/react-native-supabase-group-chat/app.config.ts +++ b/demos/react-native-supabase-group-chat/app.config.ts @@ -60,7 +60,7 @@ const config: ExpoConfig = { newArchEnabled: false }, android: { - minSdkVersion: 23, + minSdkVersion: 24, compileSdkVersion: 34, targetSdkVersion: 34, buildToolsVersion: '34.0.0', diff --git a/demos/react-native-supabase-group-chat/package.json b/demos/react-native-supabase-group-chat/package.json index dbb75975..48ca2543 100644 --- a/demos/react-native-supabase-group-chat/package.json +++ b/demos/react-native-supabase-group-chat/package.json @@ -21,7 +21,7 @@ "dependencies": { "@azure/core-asynciterator-polyfill": "^1.0.2", "@faker-js/faker": "8.3.1", - "@journeyapps/react-native-quick-sqlite": "^2.1.2", + "@journeyapps/react-native-quick-sqlite": "^2.2.0", "@powersync/common": "workspace:*", "@powersync/react": "workspace:*", "@powersync/react-native": "workspace:*", diff --git a/demos/react-native-supabase-todolist/android/build.gradle b/demos/react-native-supabase-todolist/android/build.gradle index 9ae2aca2..b3ccc671 100644 --- a/demos/react-native-supabase-todolist/android/build.gradle +++ b/demos/react-native-supabase-todolist/android/build.gradle @@ -3,7 +3,7 @@ buildscript { ext { buildToolsVersion = findProperty('android.buildToolsVersion') ?: '34.0.0' - minSdkVersion = Integer.parseInt(findProperty('android.minSdkVersion') ?: '23') + minSdkVersion = Integer.parseInt(findProperty('android.minSdkVersion') ?: '24') compileSdkVersion = Integer.parseInt(findProperty('android.compileSdkVersion') ?: '34') targetSdkVersion = Integer.parseInt(findProperty('android.targetSdkVersion') ?: '34') kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.23' diff --git a/demos/react-native-supabase-todolist/android/gradle.properties b/demos/react-native-supabase-todolist/android/gradle.properties index dded52cd..ad2039a1 100644 --- a/demos/react-native-supabase-todolist/android/gradle.properties +++ b/demos/react-native-supabase-todolist/android/gradle.properties @@ -58,7 +58,7 @@ EX_DEV_CLIENT_NETWORK_INSPECTOR=true # Use legacy packaging to compress native libraries in the resulting APK. expo.useLegacyPackaging=false -android.minSdkVersion=23 +android.minSdkVersion=24 android.compileSdkVersion=34 android.targetSdkVersion=34 android.buildToolsVersion=34.0.0 diff --git a/demos/react-native-supabase-todolist/app.config.ts b/demos/react-native-supabase-todolist/app.config.ts index 47d54e8d..a1132ed4 100644 --- a/demos/react-native-supabase-todolist/app.config.ts +++ b/demos/react-native-supabase-todolist/app.config.ts @@ -62,7 +62,7 @@ const config: ExpoConfig = { newArchEnabled: true }, android: { - minSdkVersion: 23, + minSdkVersion: 24, compileSdkVersion: 34, targetSdkVersion: 34, buildToolsVersion: '34.0.0', diff --git a/demos/react-native-web-supabase-todolist/android/build.gradle b/demos/react-native-web-supabase-todolist/android/build.gradle index 9ae2aca2..b3ccc671 100644 --- a/demos/react-native-web-supabase-todolist/android/build.gradle +++ b/demos/react-native-web-supabase-todolist/android/build.gradle @@ -3,7 +3,7 @@ buildscript { ext { buildToolsVersion = findProperty('android.buildToolsVersion') ?: '34.0.0' - minSdkVersion = Integer.parseInt(findProperty('android.minSdkVersion') ?: '23') + minSdkVersion = Integer.parseInt(findProperty('android.minSdkVersion') ?: '24') compileSdkVersion = Integer.parseInt(findProperty('android.compileSdkVersion') ?: '34') targetSdkVersion = Integer.parseInt(findProperty('android.targetSdkVersion') ?: '34') kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.23' diff --git a/demos/react-native-web-supabase-todolist/android/gradle.properties b/demos/react-native-web-supabase-todolist/android/gradle.properties index a955bf8c..5449ab86 100644 --- a/demos/react-native-web-supabase-todolist/android/gradle.properties +++ b/demos/react-native-web-supabase-todolist/android/gradle.properties @@ -58,7 +58,7 @@ EX_DEV_CLIENT_NETWORK_INSPECTOR=true # Use legacy packaging to compress native libraries in the resulting APK. expo.useLegacyPackaging=false -android.minSdkVersion=23 +android.minSdkVersion=24 android.compileSdkVersion=34 android.targetSdkVersion=34 android.buildToolsVersion=34.0.0 diff --git a/demos/react-native-web-supabase-todolist/package.json b/demos/react-native-web-supabase-todolist/package.json index cb2aad66..7d75077b 100644 --- a/demos/react-native-web-supabase-todolist/package.json +++ b/demos/react-native-web-supabase-todolist/package.json @@ -13,7 +13,7 @@ "@azure/core-asynciterator-polyfill": "^1.0.2", "@expo/metro-runtime": "^3.2.1", "@expo/vector-icons": "^14.0.0", - "@journeyapps/react-native-quick-sqlite": "^2.1.2", + "@journeyapps/react-native-quick-sqlite": "^2.2.0", "@powersync/attachments": "workspace:*", "@powersync/common": "workspace:*", "@powersync/react": "workspace:*", diff --git a/demos/vue-supabase-todolist/package.json b/demos/vue-supabase-todolist/package.json index 29a264ac..2a85040a 100644 --- a/demos/vue-supabase-todolist/package.json +++ b/demos/vue-supabase-todolist/package.json @@ -35,6 +35,6 @@ "vite-plugin-top-level-await": "^1.4.1", "vite-plugin-vuetify": "^2.0.3", "vite-plugin-wasm": "^3.3.0", - "vue-tsc": "^2.0.6" + "vue-tsc": "2.0.6" } } diff --git a/packages/common/src/client/compilableQueryWatch.ts b/packages/common/src/client/compilableQueryWatch.ts new file mode 100644 index 00000000..0b973567 --- /dev/null +++ b/packages/common/src/client/compilableQueryWatch.ts @@ -0,0 +1,55 @@ +import { CompilableQuery } from './../types/types.js'; +import { AbstractPowerSyncDatabase, SQLWatchOptions } from './AbstractPowerSyncDatabase.js'; +import { runOnSchemaChange } from './runOnSchemaChange.js'; + +export interface CompilableQueryWatchHandler { + onResult: (results: T[]) => void; + onError?: (error: Error) => void; +} + +export function compilableQueryWatch( + db: AbstractPowerSyncDatabase, + query: CompilableQuery, + handler: CompilableQueryWatchHandler, + options?: SQLWatchOptions +): void { + const { onResult, onError = (e: Error) => {} } = handler ?? {}; + if (!onResult) { + throw new Error('onResult is required'); + } + + const watchQuery = async (abortSignal: AbortSignal) => { + try { + const toSql = query.compile(); + const resolvedTables = await db.resolveTables(toSql.sql, toSql.parameters as [], options); + + // Fetch initial data + const result = await query.execute(); + onResult(result); + + db.onChangeWithCallback( + { + onChange: async () => { + try { + const result = await query.execute(); + onResult(result); + } catch (error: any) { + onError(error); + } + }, + onError + }, + { + ...(options ?? {}), + tables: resolvedTables, + // Override the abort signal since we intercept it + signal: abortSignal + } + ); + } catch (error: any) { + onError(error); + } + }; + + runOnSchemaChange(watchQuery, db, options); +} diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index b55cca84..99497d15 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -5,6 +5,7 @@ export * from './client/connection/PowerSyncBackendConnector.js'; export * from './client/connection/PowerSyncCredentials.js'; export * from './client/sync/bucket/BucketStorageAdapter.js'; export { runOnSchemaChange } from './client/runOnSchemaChange.js'; +export { CompilableQueryWatchHandler, compilableQueryWatch } from './client/compilableQueryWatch.js'; export { UpdateType, CrudEntry, OpId } from './client/sync/bucket/CrudEntry.js'; export * from './client/sync/bucket/SqliteBucketStorage.js'; export * from './client/sync/bucket/CrudBatch.js'; diff --git a/packages/drizzle-driver/src/index.ts b/packages/drizzle-driver/src/index.ts index 6f5c1537..6dd870c8 100644 --- a/packages/drizzle-driver/src/index.ts +++ b/packages/drizzle-driver/src/index.ts @@ -1,4 +1,4 @@ -import { wrapPowerSyncWithDrizzle, type PowerSyncSQLiteDatabase } from './sqlite/db'; +import { wrapPowerSyncWithDrizzle, type DrizzleQuery, type PowerSyncSQLiteDatabase } from './sqlite/db'; import { toCompilableQuery } from './utils/compilableQuery'; -export { wrapPowerSyncWithDrizzle, toCompilableQuery, PowerSyncSQLiteDatabase }; +export { wrapPowerSyncWithDrizzle, toCompilableQuery, DrizzleQuery, PowerSyncSQLiteDatabase }; diff --git a/packages/drizzle-driver/src/sqlite/db.ts b/packages/drizzle-driver/src/sqlite/db.ts index 77d2e54c..e9276a4f 100644 --- a/packages/drizzle-driver/src/sqlite/db.ts +++ b/packages/drizzle-driver/src/sqlite/db.ts @@ -1,4 +1,11 @@ -import { AbstractPowerSyncDatabase, QueryResult } from '@powersync/common'; +import { + AbstractPowerSyncDatabase, + compilableQueryWatch, + CompilableQueryWatchHandler, + QueryResult, + SQLWatchOptions +} from '@powersync/common'; +import { Query } from 'drizzle-orm'; import { DefaultLogger } from 'drizzle-orm/logger'; import { createTableRelationsHelpers, @@ -11,42 +18,60 @@ import { SQLiteTransaction } from 'drizzle-orm/sqlite-core'; import { BaseSQLiteDatabase } from 'drizzle-orm/sqlite-core/db'; import { SQLiteAsyncDialect } from 'drizzle-orm/sqlite-core/dialect'; import type { DrizzleConfig } from 'drizzle-orm/utils'; +import { toCompilableQuery } from './../utils/compilableQuery'; import { PowerSyncSQLiteSession, PowerSyncSQLiteTransactionConfig } from './sqlite-session'; -export interface PowerSyncSQLiteDatabase = Record> - extends BaseSQLiteDatabase<'async', QueryResult, TSchema> { - transaction( +export type DrizzleQuery = { toSQL(): Query; execute(): Promise }; + +export class PowerSyncSQLiteDatabase< + TSchema extends Record = Record +> extends BaseSQLiteDatabase<'async', QueryResult, TSchema> { + private db: AbstractPowerSyncDatabase; + + constructor(db: AbstractPowerSyncDatabase, config: DrizzleConfig = {}) { + const dialect = new SQLiteAsyncDialect({ casing: config.casing }); + let logger; + if (config.logger === true) { + logger = new DefaultLogger(); + } else if (config.logger !== false) { + logger = config.logger; + } + + let schema: RelationalSchemaConfig | undefined; + if (config.schema) { + const tablesConfig = extractTablesRelationalConfig(config.schema, createTableRelationsHelpers); + schema = { + fullSchema: config.schema, + schema: tablesConfig.tables, + tableNamesMap: tablesConfig.tableNamesMap + }; + } + + const session = new PowerSyncSQLiteSession(db, dialect, schema, { + logger + }); + + super('async', dialect, session as any, schema as any); + this.db = db; + } + + override transaction( transaction: ( tx: SQLiteTransaction<'async', QueryResult, TSchema, ExtractTablesWithRelations> ) => Promise, config?: PowerSyncSQLiteTransactionConfig - ): Promise; + ): Promise { + return super.transaction(transaction, config); + } + + watch(query: DrizzleQuery, handler: CompilableQueryWatchHandler, options?: SQLWatchOptions): void { + compilableQueryWatch(this.db, toCompilableQuery(query), handler, options); + } } export function wrapPowerSyncWithDrizzle = Record>( db: AbstractPowerSyncDatabase, config: DrizzleConfig = {} ): PowerSyncSQLiteDatabase { - const dialect = new SQLiteAsyncDialect({casing: config.casing}); - let logger; - if (config.logger === true) { - logger = new DefaultLogger(); - } else if (config.logger !== false) { - logger = config.logger; - } - - let schema: RelationalSchemaConfig | undefined; - if (config.schema) { - const tablesConfig = extractTablesRelationalConfig(config.schema, createTableRelationsHelpers); - schema = { - fullSchema: config.schema, - schema: tablesConfig.tables, - tableNamesMap: tablesConfig.tableNamesMap - }; - } - - const session = new PowerSyncSQLiteSession(db, dialect, schema, { - logger - }); - return new BaseSQLiteDatabase('async', dialect, session, schema) as PowerSyncSQLiteDatabase; + return new PowerSyncSQLiteDatabase(db, config); } diff --git a/packages/drizzle-driver/tests/sqlite/watch.test.ts b/packages/drizzle-driver/tests/sqlite/watch.test.ts new file mode 100644 index 00000000..54ed2b40 --- /dev/null +++ b/packages/drizzle-driver/tests/sqlite/watch.test.ts @@ -0,0 +1,283 @@ +import { AbstractPowerSyncDatabase, column, Schema, Table } from '@powersync/common'; +import { PowerSyncDatabase } from '@powersync/web'; +import { count, eq, sql } from 'drizzle-orm'; +import { integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as SUT from '../../src/sqlite/db'; + +vi.useRealTimers(); + +const assetsPs = new Table( + { + created_at: column.text, + make: column.text, + model: column.text, + serial_number: column.text, + quantity: column.integer, + user_id: column.text, + customer_id: column.text, + description: column.text + }, + { indexes: { makemodel: ['make, model'] } } +); + +const customersPs = new Table({ + name: column.text, + email: column.text +}); + +const PsSchema = new Schema({ assets: assetsPs, customers: customersPs }); + +const assets = sqliteTable( + 'assets', + { + id: text('id'), + created_at: text('created_at'), + make: text('make'), + model: text('model'), + serial_number: text('serial_number'), + quantity: integer('quantity'), + user_id: text('user_id'), + customer_id: text('customer_id'), + description: text('description') + }, + (table) => ({ + makemodelIndex: uniqueIndex('makemodel').on(table.make, table.model) + }) +); + +const customers = sqliteTable('customers', { + id: text('id'), + name: text('name'), + email: text('email') +}); + +const DrizzleSchema = { assets, customers }; + +/** + * There seems to be an issue with Vitest browser mode's setTimeout and + * fake timer functionality. + * e.g. calling: + * await new Promise((resolve) => setTimeout(resolve, 10)); + * waits for 1 second instead of 10ms. + * Setting this to 1 second as a work around. + */ +const throttleDuration = 1000; + +describe('Watch Tests', () => { + let powerSyncDb: AbstractPowerSyncDatabase; + let db: SUT.PowerSyncSQLiteDatabase; + + beforeEach(async () => { + powerSyncDb = new PowerSyncDatabase({ + database: { + dbFilename: 'test.db' + }, + schema: PsSchema + }); + db = SUT.wrapPowerSyncWithDrizzle(powerSyncDb, { schema: DrizzleSchema, logger: { logQuery: () => {} } }); + + await powerSyncDb.init(); + }); + + afterEach(async () => { + await powerSyncDb.disconnectAndClear(); + }); + + it('watch outside throttle limits', async () => { + const abortController = new AbortController(); + + const updatesCount = 2; + let receivedUpdatesCount = 0; + + /** + * Promise which resolves once we received the same amount of update + * notifications as there are inserts. + */ + const receivedUpdates = new Promise((resolve) => { + const onUpdate = () => { + receivedUpdatesCount++; + + if (receivedUpdatesCount == updatesCount) { + abortController.abort(); + resolve(); + } + }; + + const query = db + .select({ count: count() }) + .from(assets) + .innerJoin(customers, eq(customers.id, assets.customer_id)); + + db.watch(query, { onResult: onUpdate }, { signal: abortController.signal, throttleMs: throttleDuration }); + }); + + for (let updateCount = 0; updateCount < updatesCount; updateCount++) { + await db + .insert(assets) + .values({ + id: sql`uuid()`, + make: 'test', + customer_id: sql`uuid()` + }) + .execute(); + + // Wait the throttle duration, ensuring a watch update for each insert + await new Promise((resolve) => setTimeout(resolve, throttleDuration)); + } + + await receivedUpdates; + expect(receivedUpdatesCount).equals(updatesCount); + }); + + it('watch inside throttle limits', async () => { + const abortController = new AbortController(); + + const updatesCount = 5; + let receivedUpdatesCount = 0; + + const onUpdate = () => { + receivedUpdatesCount++; + }; + const query = db.select({ count: count() }).from(assets).innerJoin(customers, eq(customers.id, assets.customer_id)); + db.watch(query, { onResult: onUpdate }, { signal: abortController.signal, throttleMs: throttleDuration }); + + // Create the inserts as fast as possible + for (let updateCount = 0; updateCount < updatesCount; updateCount++) { + await db + .insert(assets) + .values({ + id: sql`uuid()`, + make: 'test', + customer_id: sql`uuid()` + }) + .execute(); + } + + await new Promise((resolve) => setTimeout(resolve, throttleDuration * 2)); + abortController.abort(); + + // There should be one initial result plus one throttled result + expect(receivedUpdatesCount).equals(2); + }); + + it('should only watch tables inside query', async () => { + const assetsAbortController = new AbortController(); + + let receivedAssetsUpdatesCount = 0; + const onWatchAssets = () => { + receivedAssetsUpdatesCount++; + }; + + const queryAssets = db.select({ count: count() }).from(assets); + + db.watch( + queryAssets, + { onResult: onWatchAssets }, + { + signal: assetsAbortController.signal + } + ); + + const customersAbortController = new AbortController(); + + let receivedCustomersUpdatesCount = 0; + const onWatchCustomers = () => { + receivedCustomersUpdatesCount++; + }; + + const queryCustomers = db.select({ count: count() }).from(customers); + db.watch( + queryCustomers, + { onResult: onWatchCustomers }, + { + signal: customersAbortController.signal + } + ); + + // Ensures insert doesn't form part of initial result + await new Promise((resolve) => setTimeout(resolve, throttleDuration)); + + await db + .insert(assets) + .values({ + id: sql`uuid()`, + make: 'test', + customer_id: sql`uuid()` + }) + .execute(); + + await new Promise((resolve) => setTimeout(resolve, throttleDuration * 2)); + assetsAbortController.abort(); + customersAbortController.abort(); + + // There should be one initial result plus one throttled result + expect(receivedAssetsUpdatesCount).equals(2); + + // Only the initial result should have yielded. + expect(receivedCustomersUpdatesCount).equals(1); + }); + + it('should handle watch onError', async () => { + const abortController = new AbortController(); + const onResult = () => {}; // no-op + let receivedErrorCount = 0; + + const receivedError = new Promise(async (resolve) => { + const onError = () => { + receivedErrorCount++; + resolve(); + }; + + const query = db + .select({ + id: sql`fakeFunction()` // Simulate an error with invalid function + }) + .from(assets); + + db.watch(query, { onResult, onError }, { signal: abortController.signal, throttleMs: throttleDuration }); + }); + abortController.abort(); + + await receivedError; + expect(receivedErrorCount).equals(1); + }); + + it('should throttle watch overflow', async () => { + const overflowAbortController = new AbortController(); + const updatesCount = 25; + + let receivedWithManagedOverflowCount = 0; + const firstResultReceived = new Promise((resolve) => { + const onResultOverflow = () => { + if (receivedWithManagedOverflowCount === 0) { + resolve(); + } + receivedWithManagedOverflowCount++; + }; + const query = db.select({ count: count() }).from(assets); + db.watch(query, { onResult: onResultOverflow }, { signal: overflowAbortController.signal, throttleMs: 1 }); + }); + + await firstResultReceived; + + // Perform a large number of inserts to trigger overflow + for (let i = 0; i < updatesCount; i++) { + db.insert(assets) + .values({ + id: sql`uuid()`, + make: 'test', + customer_id: sql`uuid()` + }) + .execute(); + } + + await new Promise((resolve) => setTimeout(resolve, 1 * throttleDuration)); + + overflowAbortController.abort(); + + // This fluctuates between 3 and 4 based on timing, but should never be 25 + expect(receivedWithManagedOverflowCount).greaterThan(2); + expect(receivedWithManagedOverflowCount).toBeLessThanOrEqual(4); + }); +}); diff --git a/packages/kysely-driver/src/index.ts b/packages/kysely-driver/src/index.ts index 12f341e6..750e3047 100644 --- a/packages/kysely-driver/src/index.ts +++ b/packages/kysely-driver/src/index.ts @@ -1,4 +1,4 @@ -import { wrapPowerSyncWithKysely } from './sqlite/db'; +import { wrapPowerSyncWithKysely, type PowerSyncKyselyDatabase } from './sqlite/db'; import { type ColumnType, type Insertable, @@ -19,5 +19,6 @@ export { KyselyConfig, sql, Kysely, + PowerSyncKyselyDatabase, wrapPowerSyncWithKysely }; diff --git a/packages/kysely-driver/src/sqlite/db.ts b/packages/kysely-driver/src/sqlite/db.ts index 15e209a7..f0c9f509 100644 --- a/packages/kysely-driver/src/sqlite/db.ts +++ b/packages/kysely-driver/src/sqlite/db.ts @@ -1,4 +1,10 @@ -import { type AbstractPowerSyncDatabase } from '@powersync/common'; +import { + CompilableQuery, + compilableQueryWatch, + CompilableQueryWatchHandler, + SQLWatchOptions, + type AbstractPowerSyncDatabase +} from '@powersync/common'; import { Dialect, Kysely, type KyselyConfig } from 'kysely'; import { PowerSyncDialect } from './sqlite-dialect'; @@ -9,11 +15,25 @@ export type PowerSyncKyselyOptions = Omit & { dialect?: Dialect; }; -export const wrapPowerSyncWithKysely = (db: AbstractPowerSyncDatabase, options?: PowerSyncKyselyOptions) => { - return new Kysely({ - dialect: new PowerSyncDialect({ - db - }), - ...options - }); +export class PowerSyncKyselyDatabase extends Kysely { + private db: AbstractPowerSyncDatabase; + + constructor(db: AbstractPowerSyncDatabase, options?: PowerSyncKyselyOptions) { + super({ + dialect: new PowerSyncDialect({ db }), + ...options + }); + this.db = db; + } + + watch(query: CompilableQuery, handler: CompilableQueryWatchHandler, options?: SQLWatchOptions): void { + compilableQueryWatch(this.db, query, handler, options); + } +} + +export const wrapPowerSyncWithKysely = ( + db: AbstractPowerSyncDatabase, + options?: PowerSyncKyselyOptions +): PowerSyncKyselyDatabase => { + return new PowerSyncKyselyDatabase(db, options); }; diff --git a/packages/kysely-driver/tests/sqlite/watch.test.ts b/packages/kysely-driver/tests/sqlite/watch.test.ts new file mode 100644 index 00000000..1c39a32e --- /dev/null +++ b/packages/kysely-driver/tests/sqlite/watch.test.ts @@ -0,0 +1,264 @@ +import { AbstractPowerSyncDatabase, column, Schema, Table } from '@powersync/common'; +import { PowerSyncDatabase } from '@powersync/web'; +import { sql } from 'kysely'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as SUT from '../../src/sqlite/db'; + +vi.useRealTimers(); + +const assetsPs = new Table( + { + created_at: column.text, + make: column.text, + model: column.text, + serial_number: column.text, + quantity: column.integer, + user_id: column.text, + customer_id: column.text, + description: column.text + }, + { indexes: { makemodel: ['make, model'] } } +); + +const customersPs = new Table({ + name: column.text, + email: column.text +}); + +const PsSchema = new Schema({ assets: assetsPs, customers: customersPs }); +export type Database = (typeof PsSchema)['types']; + +/** + * There seems to be an issue with Vitest browser mode's setTimeout and + * fake timer functionality. + * e.g. calling: + * await new Promise((resolve) => setTimeout(resolve, 10)); + * waits for 1 second instead of 10ms. + * Setting this to 1 second as a work around. + */ +const throttleDuration = 1000; + +describe('Watch Tests', () => { + let powerSyncDb: AbstractPowerSyncDatabase; + let db: SUT.PowerSyncKyselyDatabase; + + beforeEach(async () => { + powerSyncDb = new PowerSyncDatabase({ + database: { + dbFilename: 'test.db' + }, + schema: PsSchema + }); + db = SUT.wrapPowerSyncWithKysely(powerSyncDb); + + await powerSyncDb.init(); + }); + + afterEach(async () => { + await powerSyncDb.disconnectAndClear(); + }); + + it('watch outside throttle limits', async () => { + const abortController = new AbortController(); + + const updatesCount = 2; + let receivedUpdatesCount = 0; + + /** + * Promise which resolves once we received the same amount of update + * notifications as there are inserts. + */ + const receivedUpdates = new Promise((resolve) => { + const onUpdate = () => { + receivedUpdatesCount++; + + if (receivedUpdatesCount == updatesCount) { + abortController.abort(); + resolve(); + } + }; + + const query = db + .selectFrom('assets') + .innerJoin('customers', 'customers.id', 'assets.customer_id') + .select(db.fn.count('assets.id').as('count')); + + db.watch(query, { onResult: onUpdate }, { signal: abortController.signal, throttleMs: throttleDuration }); + }); + + for (let updateCount = 0; updateCount < updatesCount; updateCount++) { + await db + .insertInto('assets') + .values({ + id: sql`uuid()`, + make: 'test', + customer_id: sql`uuid()` + }) + .execute(); + + // Wait the throttle duration, ensuring a watch update for each insert + await new Promise((resolve) => setTimeout(resolve, throttleDuration)); + } + + await receivedUpdates; + expect(receivedUpdatesCount).equals(updatesCount); + }); + + it('watch inside throttle limits', async () => { + const abortController = new AbortController(); + + const updatesCount = 5; + let receivedUpdatesCount = 0; + + const onUpdate = () => { + receivedUpdatesCount++; + }; + + const query = db + .selectFrom('assets') + .innerJoin('customers', 'customers.id', 'assets.customer_id') + .select(db.fn.count('assets.id').as('count')); + + db.watch(query, { onResult: onUpdate }, { signal: abortController.signal, throttleMs: throttleDuration }); + + // Create the inserts as fast as possible + for (let updateCount = 0; updateCount < updatesCount; updateCount++) { + await db + .insertInto('assets') + .values({ + id: sql`uuid()`, + make: 'test', + customer_id: sql`uuid()` + }) + .execute(); + } + + await new Promise((resolve) => setTimeout(resolve, throttleDuration * 2)); + abortController.abort(); + + // There should be one initial result plus one throttled result + expect(receivedUpdatesCount).equals(2); + }); + + it('should only watch tables inside query', async () => { + const assetsAbortController = new AbortController(); + + let receivedAssetsUpdatesCount = 0; + const onWatchAssets = () => { + receivedAssetsUpdatesCount++; + }; + + const queryAssets = db.selectFrom('assets').select(db.fn.count('assets.id').as('count')); + db.watch( + queryAssets, + { onResult: onWatchAssets }, + { + signal: assetsAbortController.signal + } + ); + + const customersAbortController = new AbortController(); + + let receivedCustomersUpdatesCount = 0; + const onWatchCustomers = () => { + receivedCustomersUpdatesCount++; + }; + + const queryCustomers = db.selectFrom('customers').select(db.fn.count('customers.id').as('count')); + + db.watch( + queryCustomers, + { onResult: onWatchCustomers }, + { + signal: customersAbortController.signal + } + ); + + // Ensures insert doesn't form part of initial result + await new Promise((resolve) => setTimeout(resolve, throttleDuration)); + + await db + .insertInto('assets') + .values({ + id: sql`uuid()`, + make: 'test', + customer_id: sql`uuid()` + }) + .execute(); + + await new Promise((resolve) => setTimeout(resolve, throttleDuration * 2)); + assetsAbortController.abort(); + customersAbortController.abort(); + + // There should be one initial result plus one throttled result + expect(receivedAssetsUpdatesCount).equals(2); + + // Only the initial result should have yielded. + expect(receivedCustomersUpdatesCount).equals(1); + }); + + it('should handle watch onError', async () => { + const abortController = new AbortController(); + const onResult = () => {}; // no-op + let receivedErrorCount = 0; + + const receivedError = new Promise(async (resolve) => { + const onError = () => { + receivedErrorCount++; + resolve(); + }; + + const query = db.selectFrom('assets').select([ + () => { + const fullName = sql`fakeFunction()`; // Simulate an error with invalid function + return fullName.as('full_name'); + } + ]); + + db.watch(query, { onResult, onError }, { signal: abortController.signal, throttleMs: throttleDuration }); + }); + abortController.abort(); + + await receivedError; + expect(receivedErrorCount).equals(1); + }); + + it('should throttle watch overflow', async () => { + const overflowAbortController = new AbortController(); + const updatesCount = 25; + + let receivedWithManagedOverflowCount = 0; + const firstResultReceived = new Promise((resolve) => { + const onResultOverflow = () => { + if (receivedWithManagedOverflowCount === 0) { + resolve(); + } + receivedWithManagedOverflowCount++; + }; + + const query = db.selectFrom('assets').select(db.fn.count('assets.id').as('count')); + db.watch(query, { onResult: onResultOverflow }, { signal: overflowAbortController.signal, throttleMs: 1 }); + }); + + await firstResultReceived; + + // Perform a large number of inserts to trigger overflow + for (let i = 0; i < updatesCount; i++) { + db.insertInto('assets') + .values({ + id: sql`uuid()`, + make: 'test', + customer_id: sql`uuid()` + }) + .execute(); + } + + await new Promise((resolve) => setTimeout(resolve, 1 * throttleDuration)); + + overflowAbortController.abort(); + + // This fluctuates between 3 and 4 based on timing, but should never be 25 + expect(receivedWithManagedOverflowCount).greaterThan(2); + expect(receivedWithManagedOverflowCount).toBeLessThanOrEqual(4); + }); +}); diff --git a/packages/powersync-op-sqlite/CHANGELOG.md b/packages/powersync-op-sqlite/CHANGELOG.md index 9fb33bd9..2ae6fa50 100644 --- a/packages/powersync-op-sqlite/CHANGELOG.md +++ b/packages/powersync-op-sqlite/CHANGELOG.md @@ -1,5 +1,11 @@ # @powersync/op-sqlite +## 0.1.2 + +### Patch Changes + +- 7c9c41d: Update op-sqlite to v10.1.0 for compatibility with React Native >0.76 + ## 0.1.1 ### Patch Changes diff --git a/packages/powersync-op-sqlite/package.json b/packages/powersync-op-sqlite/package.json index b89d0c46..af3c0b40 100644 --- a/packages/powersync-op-sqlite/package.json +++ b/packages/powersync-op-sqlite/package.json @@ -1,6 +1,6 @@ { "name": "@powersync/op-sqlite", - "version": "0.1.1", + "version": "0.1.2", "description": "PowerSync - sync Postgres or MongoDB with SQLite in your React Native app for offline-first and real-time data", "source": "./src/index.ts", "main": "./lib/commonjs/index.js", @@ -65,7 +65,7 @@ "access": "public" }, "peerDependencies": { - "@op-engineering/op-sqlite": "^9.2.1", + "@op-engineering/op-sqlite": "^10.1.0", "@powersync/common": "workspace:^1.21.0", "react": "*", "react-native": "*" @@ -75,7 +75,7 @@ "async-lock": "^1.4.0" }, "devDependencies": { - "@op-engineering/op-sqlite": "^9.2.1", + "@op-engineering/op-sqlite": "^10.1.0", "@react-native/eslint-config": "^0.73.1", "@types/async-lock": "^1.4.0", "@types/react": "^18.2.44", @@ -133,4 +133,4 @@ "javaPackageName": "com.powersync.opsqlite" } } -} +} \ No newline at end of file diff --git a/packages/powersync-op-sqlite/src/db/OPSQLiteConnection.ts b/packages/powersync-op-sqlite/src/db/OPSQLiteConnection.ts index 3e342b56..52f73531 100644 --- a/packages/powersync-op-sqlite/src/db/OPSQLiteConnection.ts +++ b/packages/powersync-op-sqlite/src/db/OPSQLiteConnection.ts @@ -64,12 +64,12 @@ export class OPSQLiteConnection extends BaseObserver { async getAll(sql: string, parameters?: any[]): Promise { const result = await this.DB.execute(sql, parameters); - return result.rows ?? []; + return (result.rows ?? []) as T[]; } async getOptional(sql: string, parameters?: any[]): Promise { const result = await this.DB.execute(sql, parameters); - return result.rows?.[0] ?? null; + return (result.rows?.[0] as T) ?? null; } async get(sql: string, parameters?: any[]): Promise { diff --git a/packages/tanstack-react-query/CHANGELOG.md b/packages/tanstack-react-query/CHANGELOG.md index f5e93b26..2d309d41 100644 --- a/packages/tanstack-react-query/CHANGELOG.md +++ b/packages/tanstack-react-query/CHANGELOG.md @@ -1,5 +1,11 @@ # @powersync/tanstack-react-query +## 0.0.9 + +### Patch Changes + +- 3f9df96: Fixed issue with compilable queries needing a parameter value specified and fixed issue related to compilable query errors causing infinite rendering. + ## 0.0.8 ### Patch Changes diff --git a/packages/tanstack-react-query/package.json b/packages/tanstack-react-query/package.json index 45a618c9..ff2a2793 100644 --- a/packages/tanstack-react-query/package.json +++ b/packages/tanstack-react-query/package.json @@ -1,6 +1,6 @@ { "name": "@powersync/tanstack-react-query", - "version": "0.0.8", + "version": "0.0.9", "publishConfig": { "registry": "https://registry.npmjs.org/", "access": "public" @@ -15,6 +15,7 @@ "build": "tsc -b", "build:prod": "tsc -b --sourceMap false", "clean": "rm -rf lib tsconfig.tsbuildinfo", + "test": "vitest", "watch": "tsc -b -w" }, "repository": { @@ -37,6 +38,7 @@ "@tanstack/react-query": "^5.55.4" }, "devDependencies": { + "@testing-library/react": "^15.0.2", "@types/react": "^18.2.34", "jsdom": "^24.0.0", "react": "18.2.0", diff --git a/packages/tanstack-react-query/src/hooks/useQuery.ts b/packages/tanstack-react-query/src/hooks/useQuery.ts index 8c24bc56..aa1034ed 100644 --- a/packages/tanstack-react-query/src/hooks/useQuery.ts +++ b/packages/tanstack-react-query/src/hooks/useQuery.ts @@ -1,4 +1,4 @@ -import { parseQuery, type CompilableQuery, type ParsedQuery, type SQLWatchOptions } from '@powersync/common'; +import { parseQuery, type CompilableQuery } from '@powersync/common'; import { usePowerSync } from '@powersync/react'; import React from 'react'; @@ -65,15 +65,10 @@ function useQueryCore< throw new Error('PowerSync is not available'); } - const [error, setError] = React.useState(null); - const [tables, setTables] = React.useState([]); - const { query, parameters, ...resolvedOptions } = options; + let error: Error | undefined = undefined; - React.useEffect(() => { - if (error) { - setError(null); - } - }, [powerSync, query, parameters, options.queryKey]); + const [tables, setTables] = React.useState([]); + const { query, parameters = [], ...resolvedOptions } = options; let sqlStatement = ''; let queryParameters = []; @@ -85,7 +80,7 @@ function useQueryCore< sqlStatement = parsedQuery.sqlStatement; queryParameters = parsedQuery.parameters; } catch (e) { - setError(e); + error = e; } } @@ -97,12 +92,12 @@ function useQueryCore< const tables = await powerSync.resolveTables(sqlStatement, queryParameters); setTables(tables); } catch (e) { - setError(e); + error = e; } }; React.useEffect(() => { - if (!query) return () => {}; + if (error || !query) return () => {}; (async () => { await fetchTables(); @@ -128,7 +123,7 @@ function useQueryCore< } catch (e) { return Promise.reject(e); } - }, [powerSync, query, parameters, stringifiedKey, error]); + }, [powerSync, query, parameters, stringifiedKey]); React.useEffect(() => { if (error || !query) return () => {}; @@ -142,7 +137,7 @@ function useQueryCore< }); }, onError: (e) => { - setError(e); + error = e; } }, { @@ -151,7 +146,7 @@ function useQueryCore< } ); return () => abort.abort(); - }, [powerSync, queryClient, stringifiedKey, tables, error]); + }, [powerSync, queryClient, stringifiedKey, tables]); return useQueryFn( { diff --git a/packages/tanstack-react-query/tests/tsconfig.json b/packages/tanstack-react-query/tests/tsconfig.json new file mode 100644 index 00000000..41af59e8 --- /dev/null +++ b/packages/tanstack-react-query/tests/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "baseUrl": "./", + "esModuleInterop": true, + "jsx": "react", + "rootDir": "../", + "composite": true, + "outDir": "./lib", + "lib": ["esnext", "DOM"], + "module": "esnext", + "sourceMap": true, + "moduleResolution": "node", + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "noImplicitUseStrict": false, + "noStrictGenericChecks": false, + "resolveJsonModule": true, + "skipLibCheck": true, + "target": "esnext" + }, + "include": ["../src/**/*"] +} diff --git a/packages/tanstack-react-query/tests/useQuery.test.tsx b/packages/tanstack-react-query/tests/useQuery.test.tsx new file mode 100644 index 00000000..e6aa2fd6 --- /dev/null +++ b/packages/tanstack-react-query/tests/useQuery.test.tsx @@ -0,0 +1,144 @@ +import * as commonSdk from '@powersync/common'; +import { cleanup, renderHook, waitFor } from '@testing-library/react'; +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { PowerSyncContext } from '@powersync/react/'; +import { useQuery } from '../src/hooks/useQuery'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +const mockPowerSync = { + currentStatus: { status: 'initial' }, + registerListener: vi.fn(() => { }), + resolveTables: vi.fn(() => ['table1', 'table2']), + onChangeWithCallback: vi.fn(), + getAll: vi.fn(() => Promise.resolve(['list1', 'list2'])) +}; + +vi.mock('./PowerSyncContext', () => ({ + useContext: vi.fn(() => mockPowerSync) +})); + +describe('useQuery', () => { + let queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + } + }) + + const wrapper = ({ children }) => ( + + {children} + + ); + + beforeEach(() => { + queryClient.clear(); + + vi.clearAllMocks(); + cleanup(); // Cleanup the DOM after each test + }); + + + it('should set loading states on initial load', async () => { + const { result } = renderHook(() => useQuery({ + queryKey: ['lists'], + query: 'SELECT * from lists' + }), { wrapper }); + const currentResult = result.current; + expect(currentResult.isLoading).toEqual(true); + expect(currentResult.isFetching).toEqual(true); + }); + + it('should execute string queries', async () => { + const query = () => + useQuery({ + queryKey: ['lists'], + query: "SELECT * from lists" + }); + const { result } = renderHook(query, { wrapper }); + + await vi.waitFor(() => { + expect(result.current.data![0]).toEqual('list1'); + expect(result.current.data![1]).toEqual('list2'); + }, { timeout: 500 }); + }); + + it('should set error during query execution', async () => { + const mockPowerSyncError = { + currentStatus: { status: 'initial' }, + registerListener: vi.fn(() => { }), + onChangeWithCallback: vi.fn(), + resolveTables: vi.fn(() => ['table1', 'table2']), + getAll: vi.fn(() => { + throw new Error('some error'); + }) + }; + + const wrapper = ({ children }) => ( + + {children} + + ); + + const { result } = renderHook(() => useQuery({ + queryKey: ['lists'], + query: 'SELECT * from lists' + }), { wrapper }); + + await waitFor( + async () => { + expect(result.current.error).toEqual(Error('some error')); + }, + { timeout: 100 } + ); + }); + + it('should execute compatible queries', async () => { + const compilableQuery = { + execute: () => [{ test: 'custom' }] as any, + compile: () => ({ sql: 'SELECT * from lists' }) + } as commonSdk.CompilableQuery; + + const query = () => + useQuery({ + queryKey: ['lists'], + query: compilableQuery + }); + const { result } = renderHook(query, { wrapper }); + + await vi.waitFor(() => { + expect(result.current.data![0].test).toEqual('custom'); + }, { timeout: 500 }); + }); + + it('should show an error if parsing the query results in an error', async () => { + const compilableQuery = { + execute: () => [] as any, + compile: () => ({ sql: 'SELECT * from lists', parameters: ['param'] }) + } as commonSdk.CompilableQuery; + + const { result } = renderHook( + () => + useQuery({ + queryKey: ['lists'], + query: compilableQuery, + parameters: ['redundant param'] + }), + { wrapper } + ); + + await waitFor( + async () => { + const currentResult = result.current; + expect(currentResult.isLoading).toEqual(false); + expect(currentResult.isFetching).toEqual(false); + expect(currentResult.error).toEqual(Error('You cannot pass parameters to a compiled query.')); + expect(currentResult.data).toBeUndefined() + }, + { timeout: 100 } + ); + }); + +}); diff --git a/packages/tanstack-react-query/vitest.config.ts b/packages/tanstack-react-query/vitest.config.ts new file mode 100644 index 00000000..f96ac563 --- /dev/null +++ b/packages/tanstack-react-query/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig, UserConfigExport } from 'vitest/config'; + +const config: UserConfigExport = { + test: { + environment: 'jsdom' + } +}; + +export default defineConfig(config); diff --git a/packages/web/CHANGELOG.md b/packages/web/CHANGELOG.md index 9a285605..95c7c83f 100644 --- a/packages/web/CHANGELOG.md +++ b/packages/web/CHANGELOG.md @@ -1,5 +1,15 @@ # @powersync/web +## 1.12.0 + +### Minor Changes + +- 36af0c8: Added `temporaryStorage` option to `WebSQLOpenFactoryOptions`. The `temp_store` value will now defaults to "MEMORY". + +### Patch Changes + +- 7e23d65: Added a bin/cli utilty that can be invoked with `npx powersync-web copy-assets` or `pnpm powersync-web copy-assets`. + ## 1.11.0 ### Minor Changes diff --git a/packages/web/package.json b/packages/web/package.json index 33404b95..5e92b0e9 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@powersync/web", - "version": "1.11.0", + "version": "1.12.0", "description": "A Web SDK for JourneyApps PowerSync", "main": "lib/src/index.js", "types": "lib/src/index.d.ts", diff --git a/packages/web/src/db/adapters/wa-sqlite/WASQLiteDBAdapter.ts b/packages/web/src/db/adapters/wa-sqlite/WASQLiteDBAdapter.ts index b7104ea2..28d06bf6 100644 --- a/packages/web/src/db/adapters/wa-sqlite/WASQLiteDBAdapter.ts +++ b/packages/web/src/db/adapters/wa-sqlite/WASQLiteDBAdapter.ts @@ -14,7 +14,7 @@ import Logger, { type ILogger } from 'js-logger'; import type { DBFunctionsInterface, OpenDB } from '../../../shared/types'; import { _openDB } from '../../../shared/open-db'; import { getWorkerDatabaseOpener, resolveWorkerDatabasePortFactory } from '../../../worker/db/open-worker-database'; -import { ResolvedWebSQLOpenOptions, resolveWebSQLFlags, WebSQLFlags } from '../web-sql-flags'; +import { ResolvedWebSQLOpenOptions, resolveWebSQLFlags, TemporaryStorageOption, WebSQLFlags } from '../web-sql-flags'; import { getNavigatorLocks } from '../../../shared/navigator'; /** @@ -33,6 +33,8 @@ export interface WASQLiteDBAdapterOptions extends Omit Worker | SharedWorker); + temporaryStorage?: TemporaryStorageOption; + /** * Encryption key for the database. * If set, the database will be encrypted using SQLCipher. @@ -92,6 +94,8 @@ export class WASQLiteDBAdapter extends BaseObserver implement this.logger.warn('Multiple tabs are not enabled in this browser'); } + const tempStoreQuery = `PRAGMA temp_store = ${this.options.temporaryStorage ?? TemporaryStorageOption.MEMORY};`; + if (useWebWorker) { const optionsDbWorker = this.options.worker; @@ -109,6 +113,7 @@ export class WASQLiteDBAdapter extends BaseObserver implement : getWorkerDatabaseOpener(this.options.dbFilename, enableMultiTabs, optionsDbWorker); this.methods = await dbOpener(this.options.dbFilename); + await this.methods!.execute(tempStoreQuery); this.methods.registerOnTableChange( Comlink.proxy((event) => { this.iterateListeners((cb) => cb.tablesUpdated?.(event)); @@ -118,6 +123,7 @@ export class WASQLiteDBAdapter extends BaseObserver implement return; } this.methods = await _openDB(this.options.dbFilename, this.options.encryptionKey, { useWebWorker: false }); + await this.methods!.execute(tempStoreQuery); this.methods.registerOnTableChange((event) => { this.iterateListeners((cb) => cb.tablesUpdated?.(event)); }); diff --git a/packages/web/src/db/adapters/web-sql-flags.ts b/packages/web/src/db/adapters/web-sql-flags.ts index 8b36dae7..59b0084d 100644 --- a/packages/web/src/db/adapters/web-sql-flags.ts +++ b/packages/web/src/db/adapters/web-sql-flags.ts @@ -42,6 +42,11 @@ export interface ResolvedWebSQLOpenOptions extends SQLOpenOptions { flags: ResolvedWebSQLFlags; } +export enum TemporaryStorageOption { + MEMORY = 'memory', + FILESYSTEM = 'file' +} + /** * Options for opening a Web SQL connection */ @@ -56,6 +61,12 @@ export interface WebSQLOpenFactoryOptions extends SQLOpenOptions { */ worker?: string | URL | ((options: ResolvedWebSQLOpenOptions) => Worker | SharedWorker); + /** + * Where to store SQLite temporary files. Defaults to 'MEMORY'. + * Setting this to `FILESYSTEM` can cause issues with larger queries or datasets. + */ + temporaryStorage?: TemporaryStorageOption; + /** * Encryption key for the database. * If set, the database will be encrypted using Multiple Ciphers. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 83b9e175..7c427614 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -106,8 +106,8 @@ importers: specifier: ^14.0.0 version: 14.0.4 '@journeyapps/react-native-quick-sqlite': - specifier: ^2.1.2 - version: 2.1.2(react-native@0.74.5)(react@18.2.0) + specifier: ^2.2.0 + version: 2.2.0(react-native@0.74.5)(react@18.2.0) '@powersync/common': specifier: workspace:* version: link:../../packages/common @@ -632,8 +632,8 @@ importers: specifier: 8.3.1 version: 8.3.1 '@journeyapps/react-native-quick-sqlite': - specifier: ^2.1.2 - version: 2.1.2(react-native@0.74.1)(react@18.2.0) + specifier: ^2.2.0 + version: 2.2.0(react-native@0.74.1)(react@18.2.0) '@powersync/common': specifier: workspace:* version: link:../../packages/common @@ -913,8 +913,8 @@ importers: specifier: ^14.0.0 version: 14.0.4 '@journeyapps/react-native-quick-sqlite': - specifier: ^2.1.2 - version: 2.1.2(react-native@0.74.5)(react@18.2.0) + specifier: ^2.2.0 + version: 2.2.0(react-native@0.74.5)(react@18.2.0) '@powersync/attachments': specifier: workspace:* version: link:../../packages/attachments @@ -1304,8 +1304,8 @@ importers: specifier: ^3.3.0 version: 3.3.0(vite@5.4.8) vue-tsc: - specifier: ^2.0.6 - version: 2.1.6(typescript@5.5.4) + specifier: 2.0.6 + version: 2.0.6(typescript@5.5.4) demos/yjs-react-supabase-text-collab: dependencies: @@ -1697,8 +1697,8 @@ importers: version: 1.4.1 devDependencies: '@op-engineering/op-sqlite': - specifier: ^9.2.1 - version: 9.2.1(react-native@0.75.3)(react@18.3.1) + specifier: ^10.1.0 + version: 10.1.0(react-native@0.75.3)(react@18.3.1) '@react-native/eslint-config': specifier: ^0.73.1 version: 0.73.2(eslint@8.57.1)(prettier@3.3.3)(typescript@5.5.4) @@ -1843,6 +1843,9 @@ importers: specifier: ^5.55.4 version: 5.59.14(react@18.2.0) devDependencies: + '@testing-library/react': + specifier: ^15.0.2 + version: 15.0.7(@types/react@18.3.11)(react-dom@18.2.0)(react@18.2.0) '@types/react': specifier: ^18.2.34 version: 18.3.11 @@ -10657,35 +10660,25 @@ packages: '@types/yargs': 17.0.33 chalk: 4.1.2 - /@journeyapps/react-native-quick-sqlite@2.1.2(react-native@0.74.1)(react@18.2.0): - resolution: {integrity: sha512-pID4cnAmLPvhzHt0fWsvpjr1y0X8Y2mKpuSV6/7rzJ0FB0MYxYJXYMYln1dkLyU60kfDFLFT2E422CkqDpLpbA==} - peerDependencies: - react: '*' - react-native: '*' - dependencies: - react: 18.2.0 - react-native: 0.74.1(@babel/core@7.24.5)(@babel/preset-env@7.25.7)(@types/react@18.3.11)(react@18.2.0) - dev: false - - /@journeyapps/react-native-quick-sqlite@2.1.2(react-native@0.74.5)(react@18.2.0): - resolution: {integrity: sha512-pID4cnAmLPvhzHt0fWsvpjr1y0X8Y2mKpuSV6/7rzJ0FB0MYxYJXYMYln1dkLyU60kfDFLFT2E422CkqDpLpbA==} + /@journeyapps/react-native-quick-sqlite@2.2.0(react-native@0.72.4)(react@18.2.0): + resolution: {integrity: sha512-9abJ5YCgQ2Jie9B3mGtopfx8LhUB9S9I+DSd9ux1CA6DxnJMZagYlIE/x8nOghRCvtQF3jaF5DvrNoSkadkLVw==} peerDependencies: react: '*' react-native: '*' dependencies: react: 18.2.0 - react-native: 0.74.5(@babel/core@7.24.5)(@babel/preset-env@7.25.7)(@types/react@18.2.79)(react@18.2.0) - dev: false + react-native: 0.72.4(@babel/core@7.24.5)(@babel/preset-env@7.25.7)(react@18.2.0) + dev: true - /@journeyapps/react-native-quick-sqlite@2.2.0(react-native@0.72.4)(react@18.2.0): + /@journeyapps/react-native-quick-sqlite@2.2.0(react-native@0.74.1)(react@18.2.0): resolution: {integrity: sha512-9abJ5YCgQ2Jie9B3mGtopfx8LhUB9S9I+DSd9ux1CA6DxnJMZagYlIE/x8nOghRCvtQF3jaF5DvrNoSkadkLVw==} peerDependencies: react: '*' react-native: '*' dependencies: react: 18.2.0 - react-native: 0.72.4(@babel/core@7.24.5)(@babel/preset-env@7.25.7)(react@18.2.0) - dev: true + react-native: 0.74.1(@babel/core@7.24.5)(@babel/preset-env@7.25.7)(@types/react@18.3.11)(react@18.2.0) + dev: false /@journeyapps/react-native-quick-sqlite@2.2.0(react-native@0.74.5)(react@18.2.0): resolution: {integrity: sha512-9abJ5YCgQ2Jie9B3mGtopfx8LhUB9S9I+DSd9ux1CA6DxnJMZagYlIE/x8nOghRCvtQF3jaF5DvrNoSkadkLVw==} @@ -12050,8 +12043,8 @@ packages: deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. dev: true - /@op-engineering/op-sqlite@9.2.1(react-native@0.75.3)(react@18.3.1): - resolution: {integrity: sha512-n1SwLWk08KDcFViX2adfbBv8hGcvUUpWO7FZ3KmUIPNDDH9XQxZM4sCHWo6wFFTK0CY38JQ9qoKlJQAWF8la5g==} + /@op-engineering/op-sqlite@10.1.0(react-native@0.75.3)(react@18.3.1): + resolution: {integrity: sha512-h72JQ8zK3ULiEp3Tngnl2LNsfMuxpxddhRrt3XYyeoQGeh7vj5B6nQwkpKXR02Hl/EKPmbRsmxue8V7bhXr7ug==} peerDependencies: react: '*' react-native: '>0.73.0' @@ -15367,7 +15360,6 @@ packages: cpu: [arm64] os: [darwin] requiresBuild: true - dev: true optional: true /@swc/core-darwin-arm64@1.7.26: @@ -15385,7 +15377,6 @@ packages: cpu: [x64] os: [darwin] requiresBuild: true - dev: true optional: true /@swc/core-darwin-x64@1.7.26: @@ -15403,7 +15394,6 @@ packages: cpu: [arm] os: [linux] requiresBuild: true - dev: true optional: true /@swc/core-linux-arm-gnueabihf@1.7.26: @@ -15421,7 +15411,6 @@ packages: cpu: [arm64] os: [linux] requiresBuild: true - dev: true optional: true /@swc/core-linux-arm64-gnu@1.7.26: @@ -15439,7 +15428,6 @@ packages: cpu: [arm64] os: [linux] requiresBuild: true - dev: true optional: true /@swc/core-linux-arm64-musl@1.7.26: @@ -15457,7 +15445,6 @@ packages: cpu: [x64] os: [linux] requiresBuild: true - dev: true optional: true /@swc/core-linux-x64-gnu@1.7.26: @@ -15475,7 +15462,6 @@ packages: cpu: [x64] os: [linux] requiresBuild: true - dev: true optional: true /@swc/core-linux-x64-musl@1.7.26: @@ -15493,7 +15479,6 @@ packages: cpu: [arm64] os: [win32] requiresBuild: true - dev: true optional: true /@swc/core-win32-arm64-msvc@1.7.26: @@ -15511,7 +15496,6 @@ packages: cpu: [ia32] os: [win32] requiresBuild: true - dev: true optional: true /@swc/core-win32-ia32-msvc@1.7.26: @@ -15529,7 +15513,6 @@ packages: cpu: [x64] os: [win32] requiresBuild: true - dev: true optional: true /@swc/core-win32-x64-msvc@1.7.26: @@ -15564,7 +15547,6 @@ packages: '@swc/core-win32-arm64-msvc': 1.6.13 '@swc/core-win32-ia32-msvc': 1.6.13 '@swc/core-win32-x64-msvc': 1.6.13 - dev: true /@swc/core@1.7.26: resolution: {integrity: sha512-f5uYFf+TmMQyYIoxkn/evWhNGuUzC730dFwAKGwBVHHVoPyak1/GvJUm6i1SKl+2Hrj9oN0i3WSoWWZ4pgI8lw==} @@ -15605,7 +15587,6 @@ packages: resolution: {integrity: sha512-wBJA+SdtkbFhHjTMYH+dEH1y4VpfGdAc2Kw/LK09i9bXd/K6j6PkDcFCEzb6iVfZMkPRrl/q0e3toqTAJdkIVA==} dependencies: '@swc/counter': 0.1.3 - dev: true /@szmarczak/http-timer@4.0.6: resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} @@ -18501,22 +18482,23 @@ packages: tinyrainbow: 1.2.0 dev: true - /@volar/language-core@2.4.6: - resolution: {integrity: sha512-FxUfxaB8sCqvY46YjyAAV6c3mMIq/NWQMVvJ+uS4yxr1KzOvyg61gAuOnNvgCvO4TZ7HcLExBEsWcDu4+K4E8A==} + /@volar/language-core@2.1.6: + resolution: {integrity: sha512-pAlMCGX/HatBSiDFMdMyqUshkbwWbLxpN/RL7HCQDOo2gYBE+uS+nanosLc1qR6pTQ/U8q00xt8bdrrAFPSC0A==} dependencies: - '@volar/source-map': 2.4.6 + '@volar/source-map': 2.1.6 dev: true - /@volar/source-map@2.4.6: - resolution: {integrity: sha512-Nsh7UW2ruK+uURIPzjJgF0YRGP5CX9nQHypA2OMqdM2FKy7rh+uv3XgPnWPw30JADbKvZ5HuBzG4gSbVDYVtiw==} + /@volar/source-map@2.1.6: + resolution: {integrity: sha512-TeyH8pHHonRCHYI91J7fWUoxi0zWV8whZTVRlsWHSYfjm58Blalkf9LrZ+pj6OiverPTmrHRkBsG17ScQyWECw==} + dependencies: + muggle-string: 0.4.1 dev: true - /@volar/typescript@2.4.6: - resolution: {integrity: sha512-NMIrA7y5OOqddL9VtngPWYmdQU03htNKFtAYidbYfWA0TOhyGVd9tfcP4TsLWQ+RBWDZCbBqsr8xzU0ZOxYTCQ==} + /@volar/typescript@2.1.6: + resolution: {integrity: sha512-JgPGhORHqXuyC3r6skPmPHIZj4LoMmGlYErFTuPNBq9Nhc9VTv7ctHY7A3jMN3ngKEfRrfnUcwXHztvdSQqNfw==} dependencies: - '@volar/language-core': 2.4.6 + '@volar/language-core': 2.1.6 path-browserify: 1.0.1 - vscode-uri: 3.0.8 dev: true /@vue/compiler-core@3.4.21: @@ -18601,34 +18583,26 @@ packages: '@vue/shared': 3.5.11 dev: true - /@vue/compiler-vue2@2.7.16: - resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==} - dependencies: - de-indent: 1.0.2 - he: 1.2.0 - dev: true - /@vue/devtools-api@6.6.4: resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} dev: false - /@vue/language-core@2.1.6(typescript@5.5.4): - resolution: {integrity: sha512-MW569cSky9R/ooKMh6xa2g1D0AtRKbL56k83dzus/bx//RDJk24RHWkMzbAlXjMdDNyxAaagKPRquBIxkxlCkg==} + /@vue/language-core@2.0.6(typescript@5.5.4): + resolution: {integrity: sha512-UzqU12tzf9XLqRO3TiWPwRNpP4fyUzE6MAfOQWQNZ4jy6a30ARRUpmODDKq6O8C4goMc2AlPqTmjOHPjHkilSg==} peerDependencies: typescript: '*' peerDependenciesMeta: typescript: optional: true dependencies: - '@volar/language-core': 2.4.6 + '@volar/language-core': 2.1.6 '@vue/compiler-dom': 3.5.11 - '@vue/compiler-vue2': 2.7.16 '@vue/shared': 3.5.11 computeds: 0.0.1 minimatch: 9.0.5 - muggle-string: 0.4.1 path-browserify: 1.0.1 typescript: 5.5.4 + vue-template-compiler: 2.7.16 dev: true /@vue/reactivity@3.4.21: @@ -19757,7 +19731,7 @@ packages: '@babel/core': 7.24.5 find-cache-dir: 4.0.0 schema-utils: 4.2.0 - webpack: 5.95.0(webpack-cli@5.1.4) + webpack: 5.95.0(@swc/core@1.6.13) /babel-plugin-dynamic-import-node@2.3.3: resolution: {integrity: sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ==} @@ -36178,7 +36152,6 @@ packages: serialize-javascript: 6.0.2 terser: 5.34.1 webpack: 5.95.0(@swc/core@1.6.13) - dev: true /terser-webpack-plugin@5.3.10(esbuild@0.23.0)(webpack@5.94.0): resolution: {integrity: sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==} @@ -38085,10 +38058,6 @@ packages: resolution: {integrity: sha512-AFbieoL7a5LMqcnOF04ji+rpXadgOXnZsxQr//r83kLPr7biP7am3g9zbaZIaBGwBRWeSvoMD4mgPdX3e4NWBg==} dev: true - /vscode-uri@3.0.8: - resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==} - dev: true - /vue-demi@0.13.11(vue@3.4.21): resolution: {integrity: sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==} engines: {node: '>=12'} @@ -38132,14 +38101,21 @@ packages: vue: 3.4.21(typescript@5.5.4) dev: false - /vue-tsc@2.1.6(typescript@5.5.4): - resolution: {integrity: sha512-f98dyZp5FOukcYmbFpuSCJ4Z0vHSOSmxGttZJCsFeX0M4w/Rsq0s4uKXjcSRsZqsRgQa6z7SfuO+y0HVICE57Q==} + /vue-template-compiler@2.7.16: + resolution: {integrity: sha512-AYbUWAJHLGGQM7+cNTELw+KsOG9nl2CnSv467WobS5Cv9uk3wFcnr1Etsz2sEIHEZvw1U+o9mRlEO6QbZvUPGQ==} + dependencies: + de-indent: 1.0.2 + he: 1.2.0 + dev: true + + /vue-tsc@2.0.6(typescript@5.5.4): + resolution: {integrity: sha512-kK50W4XqQL34vHRkxlRWLicrT6+F9xfgCgJ4KSmCHcytKzc1u3c94XXgI+CjmhOSxyw0krpExF7Obo7y4+0dVQ==} hasBin: true peerDependencies: - typescript: '>=5.0.0' + typescript: '*' dependencies: - '@volar/typescript': 2.4.6 - '@vue/language-core': 2.1.6(typescript@5.5.4) + '@volar/typescript': 2.1.6 + '@vue/language-core': 2.0.6(typescript@5.5.4) semver: 7.6.3 typescript: 5.5.4 dev: true @@ -38750,7 +38726,6 @@ packages: - '@swc/core' - esbuild - uglify-js - dev: true /webpack@5.95.0(webpack-cli@5.1.4): resolution: {integrity: sha512-2t3XstrKULz41MNMBF+cJ97TyHdyQ8HCt//pqErqDvNjU9YQBnZxIHa11VXsi7F3mb5/aO2tuDxdeTPdU7xu9Q==} diff --git a/tools/diagnostics-app/CHANGELOG.md b/tools/diagnostics-app/CHANGELOG.md index 10d89eb4..f1ff47f7 100644 --- a/tools/diagnostics-app/CHANGELOG.md +++ b/tools/diagnostics-app/CHANGELOG.md @@ -1,5 +1,13 @@ # diagnostics-app +## 0.7.3 + +### Patch Changes + +- Updated dependencies [7e23d65] +- Updated dependencies [36af0c8] + - @powersync/web@1.12.0 + ## 0.7.2 ### Patch Changes diff --git a/tools/diagnostics-app/package.json b/tools/diagnostics-app/package.json index 0a771952..bec45ade 100644 --- a/tools/diagnostics-app/package.json +++ b/tools/diagnostics-app/package.json @@ -1,6 +1,6 @@ { "name": "@powersync/diagnostics-app", - "version": "0.7.2", + "version": "0.7.3", "private": true, "scripts": { "dev": "vite",