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/.changeset/tasty-birds-lay.md b/.changeset/tasty-birds-lay.md deleted file mode 100644 index 9ff3007d..00000000 --- a/.changeset/tasty-birds-lay.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@powersync/web': minor ---- - -Added `temporaryStorage` option to `WebSQLOpenFactoryOptions`. The `temp_store` value will now defaults to "MEMORY". 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-supabase-todolist/package.json b/demos/react-native-supabase-todolist/package.json index 8fa6dee0..1aa52ff6 100644 --- a/demos/react-native-supabase-todolist/package.json +++ b/demos/react-native-supabase-todolist/package.json @@ -10,7 +10,7 @@ "dependencies": { "@azure/core-asynciterator-polyfill": "^1.0.2", "@expo/vector-icons": "^14.0.3", - "@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/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/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 f7692f34..07c6e550 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'; import { DrizzleAppSchema, @@ -15,6 +15,7 @@ export { DrizzleAppSchema, DrizzleTablePowerSyncOptions, DrizzleTableWithPowerSyncOptions, + DrizzleQuery, Expand, ExtractPowerSyncColumns, 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 4bcb9684..6dbc0125 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/pnpm-lock.yaml b/pnpm-lock.yaml index a973c854..92a8f657 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(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(@types/react@18.2.79)(encoding@0.1.13)(react@18.2.0))(react@18.2.0) + specifier: ^2.2.0 + version: 2.2.0(react-native@0.74.5(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(@types/react@18.2.79)(encoding@0.1.13)(react@18.2.0))(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(@babel/core@7.24.5)(@babel/preset-env@7.25.7(@babel/core@7.24.5))(@types/react@18.3.11)(encoding@0.1.13)(react@18.2.0))(react@18.2.0) + specifier: ^2.2.0 + version: 2.2.0(react-native@0.74.1(@babel/core@7.24.5)(@babel/preset-env@7.25.7(@babel/core@7.24.5))(@types/react@18.3.11)(encoding@0.1.13)(react@18.2.0))(react@18.2.0) '@powersync/common': specifier: workspace:* version: link:../../packages/common @@ -768,8 +768,8 @@ importers: specifier: ^14.0.3 version: 14.0.4 '@journeyapps/react-native-quick-sqlite': - specifier: ^2.1.2 - version: 2.1.2(react-native@0.74.5(@babel/core@7.24.5)(@babel/preset-env@7.25.7(@babel/core@7.24.5))(@types/react@18.2.79)(encoding@0.1.13)(react@18.2.0))(react@18.2.0) + specifier: ^2.2.0 + version: 2.2.0(react-native@0.74.5(@babel/core@7.24.5)(@babel/preset-env@7.25.7(@babel/core@7.24.5))(@types/react@18.2.79)(encoding@0.1.13)(react@18.2.0))(react@18.2.0) '@powersync/attachments': specifier: workspace:* version: link:../../packages/attachments @@ -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(@babel/core@7.24.5)(@babel/preset-env@7.25.7(@babel/core@7.24.5))(@types/react@18.2.79)(encoding@0.1.13)(react@18.2.0))(react@18.2.0) + specifier: ^2.2.0 + version: 2.2.0(react-native@0.74.5(@babel/core@7.24.5)(@babel/preset-env@7.25.7(@babel/core@7.24.5))(@types/react@18.2.79)(encoding@0.1.13)(react@18.2.0))(react@18.2.0) '@powersync/attachments': specifier: workspace:* version: link:../../packages/attachments @@ -1615,7 +1615,7 @@ importers: version: 2.1.4(@types/node@20.17.6)(typescript@5.6.3)(vite@5.4.11(@types/node@20.17.6)(less@4.2.0)(sass@1.79.4)(terser@5.34.1))(vitest@2.1.4)(webdriverio@9.2.12) drizzle-orm: specifier: ^0.35.2 - version: 0.35.2(@op-engineering/op-sqlite@9.2.1(react-native@0.75.3(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(@types/react@18.3.12)(encoding@0.1.13)(react@18.3.1)(typescript@5.6.3))(react@18.3.1))(@types/react@18.3.12)(kysely@0.27.4)(react@18.3.1) + version: 0.35.2(@op-engineering/op-sqlite@10.1.0(react-native@0.75.3(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(@types/react@18.3.12)(encoding@0.1.13)(react@18.3.1)(typescript@5.6.3))(react@18.3.1))(@types/react@18.3.12)(kysely@0.27.4)(react@18.3.1) ts-loader: specifier: ^9.5.1 version: 9.5.1(typescript@5.6.3)(webpack@5.95.0(@swc/core@1.7.26(@swc/helpers@0.5.5))) @@ -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(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(@types/react@18.3.11)(encoding@0.1.13)(react@18.3.1)(typescript@5.5.4))(react@18.3.1) + specifier: ^10.1.0 + version: 10.1.0(react-native@0.75.3(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(@types/react@18.3.11)(encoding@0.1.13)(react@18.3.1)(typescript@5.5.4))(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))(react@18.2.0) '@types/react': specifier: ^18.2.34 version: 18.3.11 @@ -1873,7 +1876,7 @@ importers: version: 5.5.4 vitest: specifier: ^1.5.1 - version: 1.6.0(@types/node@22.7.4)(@vitest/browser@1.6.0)(jsdom@24.1.3)(less@4.2.0)(sass@1.79.4)(terser@5.34.1) + version: 1.6.0(@types/node@22.7.4)(jsdom@24.1.3)(less@4.2.0)(sass@1.79.4)(terser@5.34.1) vue: specifier: 3.4.21 version: 3.4.21(typescript@5.5.4) @@ -4564,12 +4567,6 @@ packages: resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - '@journeyapps/react-native-quick-sqlite@2.1.2': - resolution: {integrity: sha512-pID4cnAmLPvhzHt0fWsvpjr1y0X8Y2mKpuSV6/7rzJ0FB0MYxYJXYMYln1dkLyU60kfDFLFT2E422CkqDpLpbA==} - peerDependencies: - react: '*' - react-native: '*' - '@journeyapps/react-native-quick-sqlite@2.2.0': resolution: {integrity: sha512-9abJ5YCgQ2Jie9B3mGtopfx8LhUB9S9I+DSd9ux1CA6DxnJMZagYlIE/x8nOghRCvtQF3jaF5DvrNoSkadkLVw==} peerDependencies: @@ -5171,8 +5168,8 @@ packages: engines: {node: '>=12.0.0'} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. - '@op-engineering/op-sqlite@9.2.1': - resolution: {integrity: sha512-n1SwLWk08KDcFViX2adfbBv8hGcvUUpWO7FZ3KmUIPNDDH9XQxZM4sCHWo6wFFTK0CY38JQ9qoKlJQAWF8la5g==} + '@op-engineering/op-sqlite@10.1.0': + resolution: {integrity: sha512-h72JQ8zK3ULiEp3Tngnl2LNsfMuxpxddhRrt3XYyeoQGeh7vj5B6nQwkpKXR02Hl/EKPmbRsmxue8V7bhXr7ug==} peerDependencies: react: '*' react-native: '>0.73.0' @@ -7806,21 +7803,6 @@ packages: vite: ^5.0.0 vue: ^3.2.25 - '@vitest/browser@1.6.0': - resolution: {integrity: sha512-3Wpp9h1hf++rRVPvoXevkdHybLhJVn7MwIMKMIh08tVaoDMmT6fnNhbP222Z48V9PptpYeA5zvH9Ct/ZcaAzmQ==} - peerDependencies: - playwright: '*' - safaridriver: '*' - vitest: 1.6.0 - webdriverio: '*' - peerDependenciesMeta: - playwright: - optional: true - safaridriver: - optional: true - webdriverio: - optional: true - '@vitest/browser@2.1.4': resolution: {integrity: sha512-89SrvShW6kWzmEYtBj5k1gBq88emoC2qrngw5hE1vNpRFteQ5/1URbKIVww391rIALTpzhhCt5yJt5tjLPZxYw==} peerDependencies: @@ -9456,7 +9438,6 @@ packages: critters@0.0.24: resolution: {integrity: sha512-Oyqew0FGM0wYUSNqR0L6AteO5MpMoUU0rhKRieXeiKs+PmRTxiJMyaunYB2KF6fQ3dzChXKCpbFOEJx3OQ1v/Q==} - deprecated: Ownership of Critters has moved to the Nuxt team, who will be maintaining the project going forward. If you'd like to keep using Critters, please switch to the actively-maintained fork at https://github.com/danielroe/beasties cross-dirname@0.1.0: resolution: {integrity: sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==} @@ -19097,9 +19078,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/eslint-parser@7.25.8(@babel/core@7.25.7)(eslint@8.57.1)': + '@babel/eslint-parser@7.25.8(@babel/core@7.24.5)(eslint@8.57.1)': dependencies: - '@babel/core': 7.25.7 + '@babel/core': 7.24.5 '@nicolo-ribaudo/eslint-scope-5-internals': 5.1.1-v1 eslint: 8.57.1 eslint-visitor-keys: 2.1.0 @@ -23728,7 +23709,7 @@ snapshots: fs-extra: 9.0.0 getenv: 1.0.0 jimp-compact: 0.16.1 - node-fetch: 2.7.0(encoding@0.1.13) + node-fetch: 2.6.7(encoding@0.1.13) parse-png: 2.1.0 resolve-from: 5.0.0 semver: 7.3.2 @@ -23900,7 +23881,7 @@ snapshots: '@expo/config-plugins': 7.8.4 '@expo/config-types': 50.0.0 '@expo/image-utils': 0.4.2(encoding@0.1.13) - '@expo/json-file': 8.3.3 + '@expo/json-file': 8.2.37 debug: 4.3.7(supports-color@8.1.1) expo-modules-autolinking: 1.11.1 fs-extra: 9.1.0 @@ -24519,25 +24500,25 @@ snapshots: '@types/yargs': 17.0.33 chalk: 4.1.2 - '@journeyapps/react-native-quick-sqlite@2.1.2(react-native@0.74.1(@babel/core@7.24.5)(@babel/preset-env@7.25.7(@babel/core@7.24.5))(@types/react@18.3.11)(encoding@0.1.13)(react@18.2.0))(react@18.2.0)': + '@journeyapps/react-native-quick-sqlite@2.2.0(react-native@0.72.4(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(encoding@0.1.13)(react@18.2.0))(react@18.2.0)': dependencies: react: 18.2.0 - react-native: 0.74.1(@babel/core@7.24.5)(@babel/preset-env@7.25.7(@babel/core@7.24.5))(@types/react@18.3.11)(encoding@0.1.13)(react@18.2.0) + react-native: 0.72.4(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(encoding@0.1.13)(react@18.2.0) - '@journeyapps/react-native-quick-sqlite@2.1.2(react-native@0.74.5(@babel/core@7.24.5)(@babel/preset-env@7.25.7(@babel/core@7.24.5))(@types/react@18.2.79)(encoding@0.1.13)(react@18.2.0))(react@18.2.0)': + '@journeyapps/react-native-quick-sqlite@2.2.0(react-native@0.74.1(@babel/core@7.24.5)(@babel/preset-env@7.25.7(@babel/core@7.24.5))(@types/react@18.3.11)(encoding@0.1.13)(react@18.2.0))(react@18.2.0)': dependencies: react: 18.2.0 - react-native: 0.74.5(@babel/core@7.24.5)(@babel/preset-env@7.25.7(@babel/core@7.24.5))(@types/react@18.2.79)(encoding@0.1.13)(react@18.2.0) + react-native: 0.74.1(@babel/core@7.24.5)(@babel/preset-env@7.25.7(@babel/core@7.24.5))(@types/react@18.3.11)(encoding@0.1.13)(react@18.2.0) - '@journeyapps/react-native-quick-sqlite@2.1.2(react-native@0.74.5(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(@types/react@18.2.79)(encoding@0.1.13)(react@18.2.0))(react@18.2.0)': + '@journeyapps/react-native-quick-sqlite@2.2.0(react-native@0.74.5(@babel/core@7.24.5)(@babel/preset-env@7.25.7(@babel/core@7.24.5))(@types/react@18.2.79)(encoding@0.1.13)(react@18.2.0))(react@18.2.0)': dependencies: react: 18.2.0 - react-native: 0.74.5(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(@types/react@18.2.79)(encoding@0.1.13)(react@18.2.0) + react-native: 0.74.5(@babel/core@7.24.5)(@babel/preset-env@7.25.7(@babel/core@7.24.5))(@types/react@18.2.79)(encoding@0.1.13)(react@18.2.0) - '@journeyapps/react-native-quick-sqlite@2.2.0(react-native@0.72.4(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(encoding@0.1.13)(react@18.2.0))(react@18.2.0)': + '@journeyapps/react-native-quick-sqlite@2.2.0(react-native@0.74.5(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(@types/react@18.2.79)(encoding@0.1.13)(react@18.2.0))(react@18.2.0)': dependencies: react: 18.2.0 - react-native: 0.72.4(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(encoding@0.1.13)(react@18.2.0) + react-native: 0.74.5(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(@types/react@18.2.79)(encoding@0.1.13)(react@18.2.0) '@journeyapps/wa-sqlite@0.4.2': {} @@ -25396,12 +25377,12 @@ snapshots: '@oclif/screen@3.0.8': {} - '@op-engineering/op-sqlite@9.2.1(react-native@0.75.3(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(@types/react@18.3.11)(encoding@0.1.13)(react@18.3.1)(typescript@5.5.4))(react@18.3.1)': + '@op-engineering/op-sqlite@10.1.0(react-native@0.75.3(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(@types/react@18.3.11)(encoding@0.1.13)(react@18.3.1)(typescript@5.5.4))(react@18.3.1)': dependencies: react: 18.3.1 react-native: 0.75.3(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(@types/react@18.3.11)(encoding@0.1.13)(react@18.3.1)(typescript@5.5.4) - '@op-engineering/op-sqlite@9.2.1(react-native@0.75.3(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(@types/react@18.3.12)(encoding@0.1.13)(react@18.3.1)(typescript@5.6.3))(react@18.3.1)': + '@op-engineering/op-sqlite@10.1.0(react-native@0.75.3(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(@types/react@18.3.12)(encoding@0.1.13)(react@18.3.1)(typescript@5.6.3))(react@18.3.1)': dependencies: react: 18.3.1 react-native: 0.75.3(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(@types/react@18.3.12)(encoding@0.1.13)(react@18.3.1)(typescript@5.6.3) @@ -26894,7 +26875,7 @@ snapshots: '@react-native/eslint-config@0.73.2(eslint@8.57.1)(prettier@3.3.3)(typescript@5.5.4)': dependencies: '@babel/core': 7.24.5 - '@babel/eslint-parser': 7.25.8(@babel/core@7.25.7)(eslint@8.57.1) + '@babel/eslint-parser': 7.25.8(@babel/core@7.24.5)(eslint@8.57.1) '@react-native/eslint-plugin': 0.73.1 '@typescript-eslint/eslint-plugin': 5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.5.4))(eslint@8.57.1)(typescript@5.5.4) '@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@5.5.4) @@ -29794,16 +29775,6 @@ snapshots: vite: 5.4.8(@types/node@22.7.4)(less@4.2.0)(sass@1.79.4)(terser@5.34.1) vue: 3.4.21(typescript@5.5.4) - '@vitest/browser@1.6.0(vitest@1.6.0)(webdriverio@9.2.12)': - dependencies: - '@vitest/utils': 1.6.0 - magic-string: 0.30.12 - sirv: 2.0.4 - vitest: 1.6.0(@types/node@22.7.4)(@vitest/browser@1.6.0)(jsdom@24.1.3)(less@4.2.0)(sass@1.79.4)(terser@5.34.1) - optionalDependencies: - webdriverio: 9.2.12 - optional: true - '@vitest/browser@2.1.4(@types/node@20.17.6)(typescript@5.6.3)(vite@5.4.11(@types/node@20.17.6)(less@4.2.0)(sass@1.79.4)(terser@5.34.1))(vitest@2.1.4)(webdriverio@9.2.12)': dependencies: '@testing-library/dom': 10.4.0 @@ -32747,9 +32718,9 @@ snapshots: dotenv@16.4.5: {} - drizzle-orm@0.35.2(@op-engineering/op-sqlite@9.2.1(react-native@0.75.3(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(@types/react@18.3.12)(encoding@0.1.13)(react@18.3.1)(typescript@5.6.3))(react@18.3.1))(@types/react@18.3.12)(kysely@0.27.4)(react@18.3.1): + drizzle-orm@0.35.2(@op-engineering/op-sqlite@10.1.0(react-native@0.75.3(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(@types/react@18.3.12)(encoding@0.1.13)(react@18.3.1)(typescript@5.6.3))(react@18.3.1))(@types/react@18.3.12)(kysely@0.27.4)(react@18.3.1): optionalDependencies: - '@op-engineering/op-sqlite': 9.2.1(react-native@0.75.3(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(@types/react@18.3.12)(encoding@0.1.13)(react@18.3.1)(typescript@5.6.3))(react@18.3.1) + '@op-engineering/op-sqlite': 10.1.0(react-native@0.75.3(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(@types/react@18.3.12)(encoding@0.1.13)(react@18.3.1)(typescript@5.6.3))(react@18.3.1) '@types/react': 18.3.12 kysely: 0.27.4 react: 18.3.1 @@ -33369,7 +33340,7 @@ snapshots: eslint-plugin-ft-flow@2.0.3(@babel/eslint-parser@7.25.8(@babel/core@7.24.5)(eslint@8.57.1))(eslint@8.57.1): dependencies: - '@babel/eslint-parser': 7.25.8(@babel/core@7.25.7)(eslint@8.57.1) + '@babel/eslint-parser': 7.25.8(@babel/core@7.24.5)(eslint@8.57.1) eslint: 8.57.1 lodash: 4.17.21 string-natural-compare: 3.0.1 @@ -43251,7 +43222,7 @@ snapshots: sass: 1.79.4 terser: 5.34.1 - vitest@1.6.0(@types/node@22.7.4)(@vitest/browser@1.6.0)(jsdom@24.1.3)(less@4.2.0)(sass@1.79.4)(terser@5.34.1): + vitest@1.6.0(@types/node@22.7.4)(jsdom@24.1.3)(less@4.2.0)(sass@1.79.4)(terser@5.34.1): dependencies: '@vitest/expect': 1.6.0 '@vitest/runner': 1.6.0 @@ -43275,7 +43246,6 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.7.4 - '@vitest/browser': 1.6.0(vitest@1.6.0)(webdriverio@9.2.12) jsdom: 24.1.3 transitivePeerDependencies: - less 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",