From 8eb570d0398068741ea5b160615e37bc7b2f7319 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 13 May 2025 09:01:26 +0200 Subject: [PATCH 01/75] wip --- .../src/client/watched/WatchComparator.ts | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 packages/common/src/client/watched/WatchComparator.ts diff --git a/packages/common/src/client/watched/WatchComparator.ts b/packages/common/src/client/watched/WatchComparator.ts new file mode 100644 index 000000000..1e2ebc3e0 --- /dev/null +++ b/packages/common/src/client/watched/WatchComparator.ts @@ -0,0 +1,86 @@ +export interface Comparable { + identity: string; + hash: string; +} + +export interface WatchComparisonResult { + added: T[]; + removed: T[]; + updated: T[]; + unchanged: T[]; + isEqual: boolean; +} + +export interface WatchComparator { + compare(a: T[], b: T[]): WatchComparisonResult; +} + +export abstract class AbstractWatchComparator implements WatchComparator { + abstract identify(item: T): string; + abstract hash(item: T): string; + + compare(a: T[], b: T[]): WatchComparisonResult { + const mapEntries = a.map((item) => [this.identify(item), this.hash(item), item]) as [string, string, T][]; + const aMap = new Map(mapEntries.map(([id, hash]) => [id, hash])); + const aRemoved = new Map(mapEntries.map(([id, _, item]) => [id, item])); + + const result: WatchComparisonResult = { + added: [], + removed: [], + updated: [], + unchanged: [], + isEqual: false + }; + + for (const item of b) { + const identifier = this.identify(item); + // This item is present, it has not been removed from the first array + aRemoved.delete(identifier); + + if (!aMap.has(identifier)) { + result.added.push(item); + continue; + } + + const hash = this.hash(item); + if (aMap.get(identifier) !== hash) { + result.updated.push(item); + continue; + } + + result.unchanged.push(item); + } + + result.removed = Array.from(aRemoved.values()); + result.isEqual = result.added.length == 0 && result.updated.length == 0; + return result; + } +} + +export type InlineWatchComparatorOptions = { + identify: (item: T) => string; + hash: (item: T) => string; +}; + +export class InlineWatchComparator extends AbstractWatchComparator { + constructor(protected options: InlineWatchComparatorOptions) { + super(); + } + + identify(item: T): string { + return this.options.identify(item); + } + + hash(item: T): string { + return this.options.hash(item); + } +} + +export class DefaultWatchComparator extends InlineWatchComparator { + constructor() { + super({ + identify: (item: T) => item.id, + hash: (item: T) => JSON.stringify(item) + }); + } +} From 861fc2bc3bfda991760f7a4507c862c52ae67ae8 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Thu, 15 May 2025 18:11:46 +0200 Subject: [PATCH 02/75] wip --- .../src/app/views/sql-console/page.tsx | 11 +- .../src/components/widgets/ListItemWidget.tsx | 27 ++-- packages/common/rollup.config.mjs | 5 +- .../src/client/AbstractPowerSyncDatabase.ts | 35 ++++- .../src/client/sync/stream/AbstractRemote.ts | 19 ++- .../src/client/watched/WatchComparator.ts | 86 ----------- .../common/src/client/watched/WatchedQuery.ts | 36 +++++ .../src/client/watched/WatchedQueryImpl.ts | 58 +++++++ .../src/client/watched/WatchedQueryResult.ts | 30 ++++ .../processors/AbstractQueryProcessor.ts | 112 ++++++++++++++ .../comparison/ComparisonQueryProcessor.ts | 83 ++++++++++ .../processors/comparison/WatchComparator.ts | 141 +++++++++++++++++ packages/common/src/utils/DataStream.ts | 3 + packages/react/src/hooks/useQuery.ts | 142 +++++------------- 14 files changed, 571 insertions(+), 217 deletions(-) delete mode 100644 packages/common/src/client/watched/WatchComparator.ts create mode 100644 packages/common/src/client/watched/WatchedQuery.ts create mode 100644 packages/common/src/client/watched/WatchedQueryImpl.ts create mode 100644 packages/common/src/client/watched/WatchedQueryResult.ts create mode 100644 packages/common/src/client/watched/processors/AbstractQueryProcessor.ts create mode 100644 packages/common/src/client/watched/processors/comparison/ComparisonQueryProcessor.ts create mode 100644 packages/common/src/client/watched/processors/comparison/WatchComparator.ts diff --git a/demos/react-supabase-todolist/src/app/views/sql-console/page.tsx b/demos/react-supabase-todolist/src/app/views/sql-console/page.tsx index 6b0a13e1d..9c48affaf 100644 --- a/demos/react-supabase-todolist/src/app/views/sql-console/page.tsx +++ b/demos/react-supabase-todolist/src/app/views/sql-console/page.tsx @@ -1,8 +1,8 @@ -import React from 'react'; -import { useQuery } from '@powersync/react'; +import { NavigationPage } from '@/components/navigation/NavigationPage'; import { Box, Button, Grid, TextField, styled } from '@mui/material'; import { DataGrid } from '@mui/x-data-grid'; -import { NavigationPage } from '@/components/navigation/NavigationPage'; +import { useQuery } from '@powersync/react'; +import React from 'react'; export type LoginFormParams = { email: string; @@ -18,7 +18,7 @@ export default function SQLConsolePage() { const queryDataGridResult = React.useMemo(() => { const firstItem = querySQLResult?.[0]; - + console.log('running a query render'); return { columns: firstItem ? Object.keys(firstItem).map((field) => ({ @@ -57,8 +57,7 @@ export default function SQLConsolePage() { if (queryInput) { setQuery(queryInput); } - }} - > + }}> Execute Query diff --git a/demos/react-supabase-todolist/src/components/widgets/ListItemWidget.tsx b/demos/react-supabase-todolist/src/components/widgets/ListItemWidget.tsx index bdb68b82d..dac08705a 100644 --- a/demos/react-supabase-todolist/src/components/widgets/ListItemWidget.tsx +++ b/demos/react-supabase-todolist/src/components/widgets/ListItemWidget.tsx @@ -1,18 +1,18 @@ -import React from 'react'; import { - ListItem, + Avatar, + Box, IconButton, + ListItem, ListItemAvatar, - Avatar, + ListItemButton, ListItemText, - Box, Paper, - styled, - ListItemButton + styled } from '@mui/material'; +import React from 'react'; -import DeleteIcon from '@mui/icons-material/DeleteOutline'; import RightIcon from '@mui/icons-material/ArrowRightAlt'; +import DeleteIcon from '@mui/icons-material/DeleteOutline'; import ListIcon from '@mui/icons-material/ListAltOutlined'; export type ListItemWidgetProps = { @@ -24,6 +24,7 @@ export type ListItemWidgetProps = { }; export const ListItemWidget: React.FC = (props) => { + console.log('ListItemWidget', props); return ( = (props) => { aria-label="delete" onClick={(event) => { props.onDelete(); - }} - > + }}> = (props) => { aria-label="proceed" onClick={(event) => { props.onPress(); - }} - > + }}> - } - > + }> { props.onPress(); }} - selected={props.selected} - > + selected={props.selected}> diff --git a/packages/common/rollup.config.mjs b/packages/common/rollup.config.mjs index ad05faf6a..4e64e9212 100644 --- a/packages/common/rollup.config.mjs +++ b/packages/common/rollup.config.mjs @@ -2,7 +2,6 @@ import commonjs from '@rollup/plugin-commonjs'; import inject from '@rollup/plugin-inject'; import json from '@rollup/plugin-json'; import nodeResolve from '@rollup/plugin-node-resolve'; -import terser from '@rollup/plugin-terser'; export default (commandLineArgs) => { const sourcemap = (commandLineArgs.sourceMap || 'true') == 'true'; @@ -26,8 +25,8 @@ export default (commandLineArgs) => { ReadableStream: ['web-streams-polyfill/ponyfill', 'ReadableStream'], // Used by can-ndjson-stream TextDecoder: ['text-encoding', 'TextDecoder'] - }), - terser() + }) + // terser() ], // This makes life easier external: [ diff --git a/packages/common/src/client/AbstractPowerSyncDatabase.ts b/packages/common/src/client/AbstractPowerSyncDatabase.ts index c78912c99..e82df514e 100644 --- a/packages/common/src/client/AbstractPowerSyncDatabase.ts +++ b/packages/common/src/client/AbstractPowerSyncDatabase.ts @@ -9,13 +9,14 @@ import { UpdateNotification, isBatchedUpdateNotification } from '../db/DBAdapter.js'; +import { FULL_SYNC_PRIORITY } from '../db/crud/SyncProgress.js'; import { SyncPriorityStatus, SyncStatus } from '../db/crud/SyncStatus.js'; import { UploadQueueStats } from '../db/crud/UploadQueueStatus.js'; import { Schema } from '../db/schema/Schema.js'; import { BaseObserver } from '../utils/BaseObserver.js'; import { ControlledExecutor } from '../utils/ControlledExecutor.js'; -import { mutexRunExclusive } from '../utils/mutex.js'; import { throttleTrailing } from '../utils/async.js'; +import { mutexRunExclusive } from '../utils/mutex.js'; import { SQLOpenFactory, SQLOpenOptions, isDBAdapter, isSQLOpenFactory, isSQLOpenOptions } from './SQLOpenFactory.js'; import { PowerSyncBackendConnector } from './connection/PowerSyncBackendConnector.js'; import { runOnSchemaChange } from './runOnSchemaChange.js'; @@ -32,7 +33,10 @@ import { type PowerSyncConnectionOptions, type RequiredAdditionalConnectionOptions } from './sync/stream/AbstractStreamingSyncImplementation.js'; -import { FULL_SYNC_PRIORITY } from '../db/crud/SyncProgress.js'; +import { WatchedQuery } from './watched/WatchedQuery.js'; +import { WatchedQueryImpl } from './watched/WatchedQueryImpl.js'; +import { ComparisonQueryProcessor } from './watched/processors/comparison/ComparisonQueryProcessor.js'; +import { InlineWatchComparator } from './watched/processors/comparison/WatchComparator.js'; export interface DisconnectAndClearOptions { /** When set to false, data in local-only tables is preserved. */ @@ -82,6 +86,10 @@ export interface SQLWatchOptions { * by not removing PowerSync table name prefixes */ rawTableNames?: boolean; + /** + * Emits an empty result set immediately + */ + triggerImmediate?: boolean; } export interface WatchOnChangeEvent { @@ -857,6 +865,25 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver(options: { sql: string; parameters?: any[]; throttleMs?: number }): WatchedQuery { + return new WatchedQueryImpl({ + processor: new ComparisonQueryProcessor({ + db: this, + comparator: new InlineWatchComparator({ + hash: (row) => JSON.stringify(row), + identify: (row) => (row as any).id ?? JSON.stringify(row) // TODO + }), + watchedQuery: { + query: options.sql, + parameters: options.parameters, + throttleMs: options.throttleMs ?? DEFAULT_WATCH_THROTTLE_MS + // queryExecutor: todo + } + }) + }); + } + /** * Execute a read query every time the source tables are modified. * Use {@link SQLWatchOptions.throttleMs} to specify the minimum interval between queries. @@ -1051,6 +1078,10 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver { try { diff --git a/packages/common/src/client/sync/stream/AbstractRemote.ts b/packages/common/src/client/sync/stream/AbstractRemote.ts index 79907443b..52d8fac6b 100644 --- a/packages/common/src/client/sync/stream/AbstractRemote.ts +++ b/packages/common/src/client/sync/stream/AbstractRemote.ts @@ -260,10 +260,24 @@ export abstract class AbstractRemote { // automatically as a header. const userAgent = this.getUserAgent(); + let r: (value: Error | null) => void; + let socketError: Promise = new Promise((resolve) => { + r = resolve; + }); + const connector = new RSocketConnector({ transport: new WebsocketClientTransport({ url: this.options.socketUrlTransformer(request.url), - wsCreator: (url) => this.createSocket(url) + wsCreator: (url) => { + const s = this.createSocket(url); + s.addEventListener('error', (e) => { + // This is a workaround for the fact that the socket error event + // does not provide the error message + r(new Error(`WebSocket error: ${JSON.stringify(e)}`)); + }); + s.addEventListener('open', () => r(null)); + return s; + } }), setup: { keepAlive: KEEP_ALIVE_MS, @@ -290,7 +304,8 @@ export abstract class AbstractRemote { * On React native the connection exception can be `undefined` this causes issues * with detecting the exception inside async-mutex */ - throw new Error(`Could not connect to PowerSync instance: ${JSON.stringify(ex)}`); + const e = await socketError; + throw new Error(`Could not connect to PowerSync instance: ${JSON.stringify(ex)} ${e?.message}`); } const stream = new DataStream({ diff --git a/packages/common/src/client/watched/WatchComparator.ts b/packages/common/src/client/watched/WatchComparator.ts deleted file mode 100644 index 1e2ebc3e0..000000000 --- a/packages/common/src/client/watched/WatchComparator.ts +++ /dev/null @@ -1,86 +0,0 @@ -export interface Comparable { - identity: string; - hash: string; -} - -export interface WatchComparisonResult { - added: T[]; - removed: T[]; - updated: T[]; - unchanged: T[]; - isEqual: boolean; -} - -export interface WatchComparator { - compare(a: T[], b: T[]): WatchComparisonResult; -} - -export abstract class AbstractWatchComparator implements WatchComparator { - abstract identify(item: T): string; - abstract hash(item: T): string; - - compare(a: T[], b: T[]): WatchComparisonResult { - const mapEntries = a.map((item) => [this.identify(item), this.hash(item), item]) as [string, string, T][]; - const aMap = new Map(mapEntries.map(([id, hash]) => [id, hash])); - const aRemoved = new Map(mapEntries.map(([id, _, item]) => [id, item])); - - const result: WatchComparisonResult = { - added: [], - removed: [], - updated: [], - unchanged: [], - isEqual: false - }; - - for (const item of b) { - const identifier = this.identify(item); - // This item is present, it has not been removed from the first array - aRemoved.delete(identifier); - - if (!aMap.has(identifier)) { - result.added.push(item); - continue; - } - - const hash = this.hash(item); - if (aMap.get(identifier) !== hash) { - result.updated.push(item); - continue; - } - - result.unchanged.push(item); - } - - result.removed = Array.from(aRemoved.values()); - result.isEqual = result.added.length == 0 && result.updated.length == 0; - return result; - } -} - -export type InlineWatchComparatorOptions = { - identify: (item: T) => string; - hash: (item: T) => string; -}; - -export class InlineWatchComparator extends AbstractWatchComparator { - constructor(protected options: InlineWatchComparatorOptions) { - super(); - } - - identify(item: T): string { - return this.options.identify(item); - } - - hash(item: T): string { - return this.options.hash(item); - } -} - -export class DefaultWatchComparator extends InlineWatchComparator { - constructor() { - super({ - identify: (item: T) => item.id, - hash: (item: T) => JSON.stringify(item) - }); - } -} diff --git a/packages/common/src/client/watched/WatchedQuery.ts b/packages/common/src/client/watched/WatchedQuery.ts new file mode 100644 index 000000000..0cb304d95 --- /dev/null +++ b/packages/common/src/client/watched/WatchedQuery.ts @@ -0,0 +1,36 @@ +import { DataStream } from '../../utils/DataStream.js'; +import { WatchedQueryResult } from './WatchedQueryResult.js'; + +export interface WatchedQueryState { + loading: boolean; + fetching: boolean; + error: Error | null; + lastUpdated: Date | null; + data: WatchedQueryResult; +} + +/** + * Performs underlaying watching and yields a stream of results. + */ +export interface WatchedQueryProcessor { + readonly state: WatchedQueryState; + + generateStream(): Promise>>; + + updateQuery(query: WatchedQueryOptions): void; +} + +export interface WatchedQueryOptions { + query: string; + parameters?: any[]; + /** The minimum interval between queries. */ + throttleMs?: number; + queryExecutor?: () => Promise; +} + +export interface WatchedQuery { + readonly state: WatchedQueryState; + stream(): DataStream>; + updateQuery(query: WatchedQueryOptions): void; + close(): void; +} diff --git a/packages/common/src/client/watched/WatchedQueryImpl.ts b/packages/common/src/client/watched/WatchedQueryImpl.ts new file mode 100644 index 000000000..6e4740dd3 --- /dev/null +++ b/packages/common/src/client/watched/WatchedQueryImpl.ts @@ -0,0 +1,58 @@ +import { DataStream } from '../../utils/DataStream.js'; +import { WatchedQuery, WatchedQueryOptions, WatchedQueryProcessor, WatchedQueryState } from './WatchedQuery.js'; + +export interface WatchedQueryImplOptions { + processor: WatchedQueryProcessor; +} + +export class WatchedQueryImpl implements WatchedQuery { + protected lazyStreamPromise: Promise>>; + + constructor(protected options: WatchedQueryImplOptions) { + this.lazyStreamPromise = this.options.processor.generateStream(); + } + + get state() { + return this.options.processor.state; + } + + updateQuery(query: WatchedQueryOptions): void { + this.options.processor.updateQuery(query); + } + + stream(): DataStream> { + // Return a new stream which can independently be closed from the original + const stream = new DataStream>({ + closeOnError: true + }); + + // pipe the lazy stream to the new stream + this.lazyStreamPromise + .then((s) => { + s.registerListener({ + data: async (data) => { + stream.enqueueData(data); + }, + closed: () => { + stream.close(); + }, + error: (error) => { + stream.iterateListeners((l) => l.error?.(error)); + } + }); + }) + .catch((error) => { + stream.iterateListeners((l) => l.error?.(error)); + }); + + return stream; + } + + close(): void { + console.log('Closing WatchedQueryImpl for ', this.options.processor); + this.lazyStreamPromise.then((s) => { + console.log('Closing stream for ', this.options.processor); + s.close(); + }); + } +} diff --git a/packages/common/src/client/watched/WatchedQueryResult.ts b/packages/common/src/client/watched/WatchedQueryResult.ts new file mode 100644 index 000000000..dfe45fcfe --- /dev/null +++ b/packages/common/src/client/watched/WatchedQueryResult.ts @@ -0,0 +1,30 @@ +export interface WatchedQueryDelta { + /** + * Rows added since the previous result set + */ + added: T[]; + /** + * Rows removed since the previous result set + */ + removed: T[]; + /** + * Rows which have changed since the previous result set + */ + updated: T[]; + /** + * Rows which are unchanged since the previous result set + */ + unchanged: T[]; +} + +export interface WatchedQueryResult { + /** + * All the current rows in the result set + */ + all: T[]; + + /** + * The delta since the last result set + */ + delta(): WatchedQueryDelta; +} diff --git a/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts b/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts new file mode 100644 index 000000000..3202d8603 --- /dev/null +++ b/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts @@ -0,0 +1,112 @@ +import { AbstractPowerSyncDatabase } from '../../../client/AbstractPowerSyncDatabase.js'; +import { BaseListener, BaseObserver } from '../../../utils/BaseObserver.js'; +import { DataStream } from '../../../utils/DataStream.js'; +import { WatchedQueryOptions, WatchedQueryProcessor, WatchedQueryState } from '../WatchedQuery.js'; + +export interface AbstractQueryProcessorOptions { + db: AbstractPowerSyncDatabase; + watchedQuery: WatchedQueryOptions; +} + +export interface AbstractQueryListener extends BaseListener { + queryUpdated: (query: WatchedQueryOptions) => Promise; +} + +export interface LinkQueryStreamOptions { + stream: DataStream>; + abortSignal: AbortSignal; + query: WatchedQueryOptions; +} + +export abstract class AbstractQueryProcessor + extends BaseObserver> + implements WatchedQueryProcessor +{ + readonly state: WatchedQueryState = { + loading: true, + fetching: true, + error: null, + lastUpdated: null, + data: { + all: [], + delta: () => ({ added: [], removed: [], unchanged: [], updated: [] }) + } + }; + + protected _stream: DataStream> | null; + + constructor(protected options: AbstractQueryProcessorOptions) { + super(); + this._stream = null; + } + + /** + * Updates the underlaying query. + */ + updateQuery(query: WatchedQueryOptions) { + this.options.watchedQuery = query; + if (this._stream) { + this.iterateAsyncListeners(async (l) => l.queryUpdated?.(query)).catch((error) => { + this._stream!.iterateListeners((l) => l.error?.(error)); + }); + } + } + + /** + * This method is called when the stream is created or the PowerSync schema has updated. + * It links the stream to the underlaying query. + * @param stream The stream to link to the underlaying query. + * @param abortSignal The signal to abort the underlaying query. + */ + protected abstract linkStream(options: LinkQueryStreamOptions): Promise; + + async generateStream() { + if (this._stream) { + return this._stream; + } + + const { db } = this.options; + + const stream = new DataStream>({ + logger: db.logger + }); + + let abortController: AbortController | null = null; + + const link = async (query: WatchedQueryOptions) => { + abortController?.abort(); + abortController = new AbortController(); + await this.linkStream({ + stream, + abortSignal: abortController.signal, + query + }); + }; + + await link(this.options.watchedQuery); + + db.registerListener({ + schemaChanged: async () => { + try { + await link(this.options.watchedQuery); + } catch (error) { + stream.iterateListeners((l) => l.error?.(error)); + } + } + }); + + this.registerListener({ + queryUpdated: async (query) => { + await link(query); + } + }); + + // Cancel the underlaying query if the stream is closed + stream.registerListener({ + closed: () => abortController?.abort() + }); + + this._stream = stream; + return stream; + } +} diff --git a/packages/common/src/client/watched/processors/comparison/ComparisonQueryProcessor.ts b/packages/common/src/client/watched/processors/comparison/ComparisonQueryProcessor.ts new file mode 100644 index 000000000..79122c131 --- /dev/null +++ b/packages/common/src/client/watched/processors/comparison/ComparisonQueryProcessor.ts @@ -0,0 +1,83 @@ +import { WatchedQueryOptions, WatchedQueryState } from '../../WatchedQuery.js'; +import { + AbstractQueryProcessor, + AbstractQueryProcessorOptions, + LinkQueryStreamOptions +} from '../AbstractQueryProcessor.js'; +import { WatchResultComparator } from './WatchComparator.js'; + +export interface ComparisonQueryProcessorOptions extends AbstractQueryProcessorOptions { + comparator: WatchResultComparator; + watchedQuery: WatchedQueryOptions; +} + +export class ComparisonQueryProcessor extends AbstractQueryProcessor { + readonly state: WatchedQueryState = { + loading: true, + fetching: true, + error: null, + lastUpdated: null, + data: { + all: [], + delta: () => ({ added: [], removed: [], unchanged: [], updated: [] }) + } + }; + + constructor(protected options: ComparisonQueryProcessorOptions) { + super(options); + } + + protected async linkStream(options: LinkQueryStreamOptions): Promise { + const { db, watchedQuery } = this.options; + const { stream, abortSignal } = options; + + const tables = await db.resolveTables(watchedQuery.query, watchedQuery.parameters); + + db.onChangeWithCallback( + { + onChange: async () => { + console.log('onChange trigger for', this); + // This fires for each change of the relevant tables + try { + this.state.fetching = true; + stream.enqueueData(this.state); + + // Always run the query if an underlaying table has changed + const result = watchedQuery.queryExecutor + ? await watchedQuery.queryExecutor() + : await db.getAll(watchedQuery.query, watchedQuery.parameters); + this.state.fetching = false; + this.state.loading = false; + + // Check if the result has changed + const comparison = this.options.comparator.compare(this.state.data.all, result); + if (!comparison.isEqual()) { + this.state.data = { + all: result, + delta: () => comparison.delta() // lazy evaluation + }; + this.state.lastUpdated = new Date(); + } + // This is here to cancel any fetching state. Need to verify this does not cause excessive re-renders + stream.enqueueData(this.state); + } catch (error) { + this.state.error = error; + stream.enqueueData(this.state); + // TODO; + //stream.iterateListeners((l) => l.error?.(error)); + } + }, + onError: (error) => { + stream.close(); + stream.iterateListeners((l) => l.error?.(error)); + } + }, + { + signal: abortSignal, + tables, + throttleMs: watchedQuery.throttleMs, + triggerImmediate: true + } + ); + } +} diff --git a/packages/common/src/client/watched/processors/comparison/WatchComparator.ts b/packages/common/src/client/watched/processors/comparison/WatchComparator.ts new file mode 100644 index 000000000..bdc39fafd --- /dev/null +++ b/packages/common/src/client/watched/processors/comparison/WatchComparator.ts @@ -0,0 +1,141 @@ +import { WatchedQueryDelta } from '../../WatchedQueryResult.js'; + +export interface Comparable { + identity: string; + hash: string; +} + +export interface WatchComparableResult { + delta(): WatchedQueryDelta; + isEqual(): boolean; +} + +export interface WatchResultComparator { + compare(previous: T[], current: T[]): WatchComparableResult; +} + +export interface SteppedComparisonState { + currentItems: T[]; + resumeIndex: number; + delta: WatchedQueryDelta; + previousHashes: Map; + previousRemovalTracker: Map; + isEqual?: boolean; +} + +export abstract class AbstractWatchComparator implements WatchResultComparator { + abstract identify(item: T): string; + abstract hash(item: T): string; + + protected stepComparison( + state: SteppedComparisonState, + options: { + /** + * Only checks if the comparison is equal, the delta is not fully updated. + */ + validateEquality?: boolean; + } + ): void { + const { validateEquality } = options; + + if (state.resumeIndex >= state.currentItems.length) { + // No more items to compare, we are done + return; + } + + if (validateEquality && state.isEqual != null) { + return; + } + + for (; state.resumeIndex < state.currentItems.length; state.resumeIndex++) { + const item = state.currentItems[state.resumeIndex]; + + const identifier = this.identify(item); + // This item is present, it has not been removed from the first array + state.previousRemovalTracker.delete(identifier); + + if (!state.previousHashes.has(identifier)) { + state.delta.added.push(item); + if (validateEquality) { + state.isEqual = false; + return; + } else { + continue; + } + } + + const hash = this.hash(item); + if (state.previousHashes.get(identifier) !== hash) { + state.delta.updated.push(item); + if (validateEquality) { + state.isEqual = false; + return; + } else { + continue; + } + } + + state.delta.unchanged.push(item); + } + + state.delta.removed = Array.from(state.previousRemovalTracker.values()); + state.isEqual = + state.delta.added.length === 0 && state.delta.removed.length === 0 && state.delta.updated.length === 0; + } + + compare(previous: T[], current: T[]): WatchComparableResult { + const mapEntries = previous.map((item) => [this.identify(item), this.hash(item), item]) as [string, string, T][]; + + const comparisonState: SteppedComparisonState = { + currentItems: current, + resumeIndex: 0, + delta: { + added: [], + removed: [], + updated: [], + unchanged: [] + }, + previousHashes: new Map(mapEntries.map(([id, hash]) => [id, hash])), + previousRemovalTracker: new Map(mapEntries.map(([id, _, item]) => [id, item])) + }; + + return { + delta: () => { + this.stepComparison(comparisonState, { validateEquality: false }); + return comparisonState.delta; + }, + isEqual: () => { + this.stepComparison(comparisonState, { validateEquality: true }); + return comparisonState.isEqual!; + } + } satisfies WatchComparableResult; + } +} + +export type InlineWatchComparatorOptions = { + identify: (item: T) => string; + hash: (item: T) => string; +}; + +export class InlineWatchComparator extends AbstractWatchComparator { + constructor(protected options: InlineWatchComparatorOptions) { + super(); + } + + identify(item: T): string { + return this.options.identify(item); + } + + hash(item: T): string { + return this.options.hash(item); + } +} + +export class DefaultWatchComparator extends InlineWatchComparator { + constructor() { + super({ + identify: (item: T) => item.id, + hash: (item: T) => JSON.stringify(item) + }); + } +} diff --git a/packages/common/src/utils/DataStream.ts b/packages/common/src/utils/DataStream.ts index 9dcf1564a..3aaac4694 100644 --- a/packages/common/src/utils/DataStream.ts +++ b/packages/common/src/utils/DataStream.ts @@ -163,6 +163,9 @@ export class DataStream extends BaseObserver { stream.enqueueData(callback(data)); }, + error: (ex) => { + stream.iterateListeners((l) => l.error?.(ex)); + }, closed: () => { stream.close(); l?.(); diff --git a/packages/react/src/hooks/useQuery.ts b/packages/react/src/hooks/useQuery.ts index b7400eaea..17a41abc0 100644 --- a/packages/react/src/hooks/useQuery.ts +++ b/packages/react/src/hooks/useQuery.ts @@ -1,4 +1,5 @@ import { parseQuery, type CompilableQuery, type ParsedQuery, type SQLWatchOptions } from '@powersync/common'; +import { WatchedQueryState } from '@powersync/common/src/client/watched/WatchedQuery'; import React from 'react'; import { usePowerSync } from './PowerSyncContext'; @@ -57,123 +58,58 @@ export const useQuery = ( const { sqlStatement, parameters: queryParameters } = parsedQuery; - const [data, setData] = React.useState([]); - const [error, setError] = React.useState(undefined); - const [isLoading, setIsLoading] = React.useState(true); - const [isFetching, setIsFetching] = React.useState(true); - const [tables, setTables] = React.useState([]); - const memoizedParams = React.useMemo(() => queryParameters, [JSON.stringify(queryParameters)]); const memoizedOptions = React.useMemo(() => options, [JSON.stringify(options)]); - const abortController = React.useRef(new AbortController()); const previousQueryRef = React.useRef({ sqlStatement, memoizedParams }); - - // Indicates that the query will be re-fetched due to a change in the query. - // Used when `isFetching` hasn't been set to true yet due to React execution. - const shouldFetch = React.useMemo( - () => - previousQueryRef.current.sqlStatement !== sqlStatement || - JSON.stringify(previousQueryRef.current.memoizedParams) != JSON.stringify(memoizedParams), - [powerSync, sqlStatement, memoizedParams, isFetching] + // TODO implement runQueryOnce + const [watchedQuery] = React.useState(() => { + return powerSync.watch2({ + sql: sqlStatement, + parameters: queryParameters, + throttleMs: options.throttleMs + }); + }); + + const mapState = React.useCallback( + (state: WatchedQueryState) => ({ + isFetching: state.fetching, + isLoading: state.loading, + data: state.data.all, + error: state.error, + refresh: async () => {} + }), + [] ); - const handleResult = (result: T[]) => { - previousQueryRef.current = { sqlStatement, memoizedParams }; - setData(result); - setIsLoading(false); - setIsFetching(false); - setError(undefined); - }; - - const handleError = (e: Error) => { - previousQueryRef.current = { sqlStatement, memoizedParams }; - setData([]); - setIsLoading(false); - setIsFetching(false); - const wrappedError = new Error('PowerSync failed to fetch data: ' + e.message); - wrappedError.cause = e; - setError(wrappedError); - }; - - const fetchData = async (signal?: AbortSignal) => { - setIsFetching(true); - try { - const result = - typeof query == 'string' ? await powerSync.getAll(sqlStatement, queryParameters) : await query.execute(); - - if (signal?.aborted) { - return; - } - - handleResult(result); - } catch (e) { - logger.error('Failed to fetch data:', e); - handleError(e); - } - }; - - const fetchTables = async (signal?: AbortSignal) => { - try { - const tables = await powerSync.resolveTables(sqlStatement, memoizedParams, memoizedOptions); - - if (signal?.aborted) { - return; - } - - setTables(tables); - } catch (e) { - logger.error('Failed to fetch tables:', e); - handleError(e); - } - }; + const [output, setOutputState] = React.useState(mapState(watchedQuery.state)); React.useEffect(() => { - const abortController = new AbortController(); - const updateData = async () => { - await fetchTables(abortController.signal); - await fetchData(abortController.signal); - }; - - updateData(); - - const l = powerSync.registerListener({ - schemaChanged: updateData + watchedQuery.stream().forEach(async (val) => { + console.log('updating state'); + setOutputState(mapState(val)); }); return () => { - abortController.abort(); - l?.(); + watchedQuery.close(); }; - }, [powerSync, memoizedParams, sqlStatement]); + }, []); + // Indicates that the query will be re-fetched due to a change in the query. + // Used when `isFetching` hasn't been set to true yet due to React execution. React.useEffect(() => { - // Abort any previous watches - abortController.current?.abort(); - abortController.current = new AbortController(); - - if (!options.runQueryOnce) { - powerSync.onChangeWithCallback( - { - onChange: async () => { - await fetchData(abortController.current.signal); - }, - onError(e) { - handleError(e); - } - }, - { - ...options, - signal: abortController.current.signal, - tables - } - ); + if ( + previousQueryRef.current.sqlStatement !== sqlStatement || + JSON.stringify(previousQueryRef.current.memoizedParams) != JSON.stringify(memoizedParams) + ) { + console.log('updating watched'); + watchedQuery.updateQuery({ + query: sqlStatement, + parameters: queryParameters, + throttleMs: options.throttleMs + }); } + }, [powerSync, sqlStatement, memoizedParams]); - return () => { - abortController.current?.abort(); - }; - }, [powerSync, sqlStatement, memoizedParams, memoizedOptions, tables]); - - return { isLoading, isFetching: isFetching || shouldFetch, data, error, refresh: fetchData }; + return output; }; From f6b3ef46144777df71f7231700bee89bac15d80a Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Fri, 16 May 2025 13:26:55 +0200 Subject: [PATCH 03/75] Improve hook implementation --- .../src/app/views/sql-console/page.tsx | 64 +++--- .../src/client/AbstractPowerSyncDatabase.ts | 13 +- .../common/src/client/watched/WatchedQuery.ts | 7 + .../processors/AbstractQueryProcessor.ts | 4 + .../processors/OnChangeQueryProcessor.ts | 86 ++++++++ .../comparison/ComparisonQueryProcessor.ts | 91 ++------ .../processors/comparison/WatchComparator.ts | 5 + packages/common/src/index.ts | 29 +-- packages/react/README.md | 112 ++++++++++ packages/react/src/hooks/useQuery.ts | 202 +++++++++++++----- 10 files changed, 446 insertions(+), 167 deletions(-) create mode 100644 packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts diff --git a/demos/react-supabase-todolist/src/app/views/sql-console/page.tsx b/demos/react-supabase-todolist/src/app/views/sql-console/page.tsx index 9c48affaf..a7e8f8e16 100644 --- a/demos/react-supabase-todolist/src/app/views/sql-console/page.tsx +++ b/demos/react-supabase-todolist/src/app/views/sql-console/page.tsx @@ -11,14 +11,10 @@ export type LoginFormParams = { const DEFAULT_QUERY = 'SELECT * FROM lists'; -export default function SQLConsolePage() { - const inputRef = React.useRef(); - const [query, setQuery] = React.useState(DEFAULT_QUERY); - const { data: querySQLResult } = useQuery(query); - +const TableDisplay = ({ data }: { data: any[] }) => { + console.log('Rendering table display', data); const queryDataGridResult = React.useMemo(() => { - const firstItem = querySQLResult?.[0]; - console.log('running a query render'); + const firstItem = data?.[0]; return { columns: firstItem ? Object.keys(firstItem).map((field) => ({ @@ -26,9 +22,37 @@ export default function SQLConsolePage() { flex: 1 })) : [], - rows: querySQLResult + rows: data }; - }, [querySQLResult]); + }, [data]); + + return ( + + ({ ...r, id: r.id ?? index })) ?? []} + columns={queryDataGridResult.columns} + initialState={{ + pagination: { + paginationModel: { + pageSize: 20 + } + } + }} + pageSizeOptions={[20]} + disableRowSelectionOnClick + /> + + ); +}; +export default function SQLConsolePage() { + const inputRef = React.useRef(); + const [query, setQuery] = React.useState(DEFAULT_QUERY); + const { data } = useQuery(query, [], { reportFetching: false }); + + React.useEffect(() => { + console.log('Query result changed', data); + }, [data]); return ( @@ -62,27 +86,7 @@ export default function SQLConsolePage() { - - {queryDataGridResult ? ( - - {queryDataGridResult.columns ? ( - ({ ...r, id: r.id ?? index })) ?? []} - columns={queryDataGridResult.columns} - initialState={{ - pagination: { - paginationModel: { - pageSize: 20 - } - } - }} - pageSizeOptions={[20]} - disableRowSelectionOnClick - /> - ) : null} - - ) : null} + ); diff --git a/packages/common/src/client/AbstractPowerSyncDatabase.ts b/packages/common/src/client/AbstractPowerSyncDatabase.ts index e82df514e..c93d5d14b 100644 --- a/packages/common/src/client/AbstractPowerSyncDatabase.ts +++ b/packages/common/src/client/AbstractPowerSyncDatabase.ts @@ -866,7 +866,13 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver(options: { sql: string; parameters?: any[]; throttleMs?: number }): WatchedQuery { + incrementalWatch(options: { + sql: string; + parameters?: any[]; + throttleMs?: number; + queryExecutor?: () => Promise; + reportFetching?: boolean; + }): WatchedQuery { return new WatchedQueryImpl({ processor: new ComparisonQueryProcessor({ db: this, @@ -877,8 +883,9 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver { /** The minimum interval between queries. */ throttleMs?: number; queryExecutor?: () => Promise; + /** + * If true (default) the watched query will update its state to report + * on the fetching state of the query. + * Setting to false reduces the number of state changes if the fetch status + * is not relevant to the consumer. + */ + reportFetching?: boolean; } export interface WatchedQuery { diff --git a/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts b/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts index 3202d8603..758ee066f 100644 --- a/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts +++ b/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts @@ -40,6 +40,10 @@ export abstract class AbstractQueryProcessor this._stream = null; } + protected get reportFetching() { + return this.options.watchedQuery.reportFetching ?? true; + } + /** * Updates the underlaying query. */ diff --git a/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts b/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts new file mode 100644 index 000000000..d501a687f --- /dev/null +++ b/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts @@ -0,0 +1,86 @@ +import { WatchedQueryResult } from '../WatchedQueryResult.js'; +import { AbstractQueryProcessor, LinkQueryStreamOptions } from './AbstractQueryProcessor.js'; + +/** + * Uses the PowerSync onChange event to trigger watched queries. + * Results are emitted on every change of the relevant tables. + */ +export class OnChangeQueryProcessor extends AbstractQueryProcessor { + /** + * Always returns the result set on every onChange event. Deltas are not supported by this processor. + */ + protected processResultSet(result: T[]): WatchedQueryResult | null { + return { + all: result, + delta: () => { + throw new Error('Delta not implemented for OnChangeQueryProcessor'); + } + }; + } + + protected async linkStream(options: LinkQueryStreamOptions): Promise { + const { db, watchedQuery } = this.options; + const { stream, abortSignal } = options; + + const tables = await db.resolveTables(watchedQuery.query, watchedQuery.parameters); + + db.onChangeWithCallback( + { + onChange: async () => { + console.log('onChange trigger for', this); + // This fires for each change of the relevant tables + try { + if (this.reportFetching) { + this.state.fetching = true; + stream.enqueueData(this.state); + } + + let dirty = false; + + // Always run the query if an underlaying table has changed + const result = watchedQuery.queryExecutor + ? await watchedQuery.queryExecutor() + : await db.getAll(watchedQuery.query, watchedQuery.parameters); + + if (this.reportFetching) { + this.state.fetching = false; + dirty = true; + } + + if (this.state.loading) { + this.state.loading = false; + dirty = true; + } + + // Check if the result has changed + const watchedQueryResult = this.processResultSet(result); + if (watchedQueryResult) { + this.state.data = watchedQueryResult; + this.state.lastUpdated = new Date(); + dirty = true; + } + + if (dirty) { + stream.enqueueData(this.state); + } + } catch (error) { + this.state.error = error; + stream.enqueueData(this.state); + // TODO? + //stream.iterateListeners((l) => l.error?.(error)); + } + }, + onError: (error) => { + stream.close(); + stream.iterateListeners((l) => l.error?.(error)); + } + }, + { + signal: abortSignal, + tables, + throttleMs: watchedQuery.throttleMs, + triggerImmediate: true // used to emit the initial state + } + ); + } +} diff --git a/packages/common/src/client/watched/processors/comparison/ComparisonQueryProcessor.ts b/packages/common/src/client/watched/processors/comparison/ComparisonQueryProcessor.ts index 79122c131..6fc259c26 100644 --- a/packages/common/src/client/watched/processors/comparison/ComparisonQueryProcessor.ts +++ b/packages/common/src/client/watched/processors/comparison/ComparisonQueryProcessor.ts @@ -1,83 +1,34 @@ -import { WatchedQueryOptions, WatchedQueryState } from '../../WatchedQuery.js'; -import { - AbstractQueryProcessor, - AbstractQueryProcessorOptions, - LinkQueryStreamOptions -} from '../AbstractQueryProcessor.js'; +import { WatchedQueryResult } from '../../WatchedQueryResult.js'; +import { AbstractQueryProcessorOptions } from '../AbstractQueryProcessor.js'; +import { OnChangeQueryProcessor } from '../OnChangeQueryProcessor.js'; import { WatchResultComparator } from './WatchComparator.js'; export interface ComparisonQueryProcessorOptions extends AbstractQueryProcessorOptions { comparator: WatchResultComparator; - watchedQuery: WatchedQueryOptions; } - -export class ComparisonQueryProcessor extends AbstractQueryProcessor { - readonly state: WatchedQueryState = { - loading: true, - fetching: true, - error: null, - lastUpdated: null, - data: { - all: [], - delta: () => ({ added: [], removed: [], unchanged: [], updated: [] }) - } - }; - +/** + * TODO: + * This currently checks if the entire result set has changed. + * In some cases a deep comparison of the result might be required. + * For example if result[1] is unchanged, it might be useful to keep the same object reference. + */ +export class ComparisonQueryProcessor extends OnChangeQueryProcessor { constructor(protected options: ComparisonQueryProcessorOptions) { super(options); } - protected async linkStream(options: LinkQueryStreamOptions): Promise { - const { db, watchedQuery } = this.options; - const { stream, abortSignal } = options; + protected processResultSet(result: T[]): WatchedQueryResult | null { + const { comparator } = this.options; + const previous = this.state.data.all; + const delta = comparator.compare(previous, result); - const tables = await db.resolveTables(watchedQuery.query, watchedQuery.parameters); - - db.onChangeWithCallback( - { - onChange: async () => { - console.log('onChange trigger for', this); - // This fires for each change of the relevant tables - try { - this.state.fetching = true; - stream.enqueueData(this.state); - - // Always run the query if an underlaying table has changed - const result = watchedQuery.queryExecutor - ? await watchedQuery.queryExecutor() - : await db.getAll(watchedQuery.query, watchedQuery.parameters); - this.state.fetching = false; - this.state.loading = false; + if (delta.isEqual()) { + return null; // the stream will not emit a change of data + } - // Check if the result has changed - const comparison = this.options.comparator.compare(this.state.data.all, result); - if (!comparison.isEqual()) { - this.state.data = { - all: result, - delta: () => comparison.delta() // lazy evaluation - }; - this.state.lastUpdated = new Date(); - } - // This is here to cancel any fetching state. Need to verify this does not cause excessive re-renders - stream.enqueueData(this.state); - } catch (error) { - this.state.error = error; - stream.enqueueData(this.state); - // TODO; - //stream.iterateListeners((l) => l.error?.(error)); - } - }, - onError: (error) => { - stream.close(); - stream.iterateListeners((l) => l.error?.(error)); - } - }, - { - signal: abortSignal, - tables, - throttleMs: watchedQuery.throttleMs, - triggerImmediate: true - } - ); + return { + all: result, + delta: () => delta.delta() // lazy evaluation + }; } } diff --git a/packages/common/src/client/watched/processors/comparison/WatchComparator.ts b/packages/common/src/client/watched/processors/comparison/WatchComparator.ts index bdc39fafd..80e8ff21c 100644 --- a/packages/common/src/client/watched/processors/comparison/WatchComparator.ts +++ b/packages/common/src/client/watched/processors/comparison/WatchComparator.ts @@ -38,6 +38,11 @@ export abstract class AbstractWatchComparator implements WatchResultComparato ): void { const { validateEquality } = options; + if (state.currentItems.length == 0 && state.previousHashes.size == 0) { + state.isEqual = true; + return; + } + if (state.resumeIndex >= state.currentItems.length) { // No more items to compare, we are done return; diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 96fdb08c1..ba1415636 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -1,36 +1,37 @@ export * from './client/AbstractPowerSyncDatabase.js'; export * from './client/AbstractPowerSyncOpenFactory.js'; -export * from './client/SQLOpenFactory.js'; +export { compilableQueryWatch, CompilableQueryWatchHandler } from './client/compilableQueryWatch.js'; export * from './client/connection/PowerSyncBackendConnector.js'; export * from './client/connection/PowerSyncCredentials.js'; -export * from './client/sync/bucket/BucketStorageAdapter.js'; +export { MAX_OP_ID } from './client/constants.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/SQLOpenFactory.js'; +export * from './client/sync/bucket/BucketStorageAdapter.js'; export * from './client/sync/bucket/CrudBatch.js'; +export { CrudEntry, OpId, UpdateType } from './client/sync/bucket/CrudEntry.js'; export * from './client/sync/bucket/CrudTransaction.js'; +export * from './client/sync/bucket/OplogEntry.js'; +export * from './client/sync/bucket/OpType.js'; +export * from './client/sync/bucket/SqliteBucketStorage.js'; export * from './client/sync/bucket/SyncDataBatch.js'; export * from './client/sync/bucket/SyncDataBucket.js'; -export * from './client/sync/bucket/OpType.js'; -export * from './client/sync/bucket/OplogEntry.js'; export * from './client/sync/stream/AbstractRemote.js'; export * from './client/sync/stream/AbstractStreamingSyncImplementation.js'; export * from './client/sync/stream/streaming-sync-types.js'; -export { MAX_OP_ID } from './client/constants.js'; export { ProgressWithOperations, SyncProgress } from './db/crud/SyncProgress.js'; export * from './db/crud/SyncStatus.js'; export * from './db/crud/UploadQueueStatus.js'; -export * from './db/schema/Schema.js'; -export * from './db/schema/Table.js'; +export * from './db/DBAdapter.js'; +export * from './db/schema/Column.js'; export * from './db/schema/Index.js'; export * from './db/schema/IndexedColumn.js'; -export * from './db/schema/Column.js'; +export * from './db/schema/Schema.js'; +export * from './db/schema/Table.js'; export * from './db/schema/TableV2.js'; -export * from './db/crud/SyncStatus.js'; -export * from './db/crud/UploadQueueStatus.js'; -export * from './db/DBAdapter.js'; + +// TODO other exports +export * from './client/watched/WatchedQuery.js'; export * from './utils/AbortOperation.js'; export * from './utils/BaseObserver.js'; diff --git a/packages/react/README.md b/packages/react/README.md index e02344e50..89a49fb02 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -271,3 +271,115 @@ export const TodoListDisplaySuspense = () => { ); }; ``` + +## Preventing unecessary renders + +The `useQuery` hook returns a stateful object which contains query fetching/loading state values and the query result set data. + +```tsx +function MyWidget() { + // ... Widget code + // result is an object which contains `isLoading`, `isFetching`, `data` members. + const result = useQuery(...) + + // ... Widget code +} +``` + +### High Order Components + +The returned object is a new JS object reference whenever the internal state changes e.g. if the query `isFetching` alternates in value. The parent component which calls `useQuery` will render each time the watched query state changes - this can result in other child widgets re-rendering if they are not memoized. Using the `result` object in child component props will cause those children to re-render on any state change of the watched query. The first step to avoid re-renders is to call `useQuery` in a Higher Order Component which passes query results to memoized children. + +```tsx +function MyWidget() { + // ... Widget code + // result is an object which contains `isLoading`, `isFetching`, `data` members. + const result = useQuery(...) + + // ... Widget code + + return ( + // Other components + // MyWatchedWidget will rerender whenever the watched query state changes + // (MyWatchedWidget will also rerender if the result object is unchanged if it is not memoized) + + ) +} +``` + +The above example is incomplete, but is required for the optimizations below. + +### Incremental Queries + +By default watched queries are queried whenever a change to the underlaying tables has been detected. These changes might not be relevant to the actual query, but will still trigger a query and `data` update. + +```tsx +function MyWidget() { + // ... Widget code + // This query will update with a new data Array whenever any change is made to the `cats` table + // E.g. `INSERT INTO cats(name) VALUES ('silvester')` will return a new Array reference for `data` + const { data } = useQuery(`SELECT * FROM cats WHERE name = 'bob'`) + + // ... Widget code + + return ( + // Other components + // This will rerender for any change to the `cats` table + // Memoization cannot prevent this component from re-rendering since `data[0]` is always new object reference + // whenever a query has been triggered + + ) +} +``` + +Incremental watched queries ensure that the `data` member of the `useQuery` result maintains the same Array reference if the result set is unchanged. + +```tsx +function MyWidget() { + // ... Widget code + // This query will be fetched/queried whenever any change is made to the `cats` table. + // The `data` reference will only be changed if there have been changes since the previous value. + // This method performs a comparison in memory in order to determine changes. + // Note that isFetching is set (by default) whenever the query is being fetched/checked. + // This will result in `MyWidget` re-rendering for any change to the `cats` table. + const { data, isLoading, isFetching } = useQuery(`SELECT * FROM cats WHERE breed = 'tabby'`, [], { + processor: WatchedQueryProcessor.COMPARISON // TODO + }) + + // ... Widget code + + return ( + // Other components + // The data array is the same reference if no changes have occurred between fetches + // Note: The array is a new reference is there are any changes in the result set (individual row object references are not preserved) + // Note: CatCollection requires memoization in order to prevent re-rendering (due to the parent re-rendering on fetch) + + ) +} +``` + +`useQuery` can be configured to disable reporting `isFetching` status. Disabling this setting reduces the number of events emitted from the hook, which can reduce renders in some circumstances. + +```tsx +function MyWidget() { + // ... Widget code + // This query will be fetched/queried whenever any change is made to the `cats` table. + // The `data` reference will only be changed if there have been changes since the previous value. + // When reportFetching == false the object returned from useQuery will only be changed when the data, isLoading or error state changes. + // This method performs a comparison in memory in order to determine changes. + const { data, isLoading, isFetching } = useQuery(`SELECT * FROM cats WHERE breed = 'tabby'`, [], { + processor: WatchedQueryProcessor.COMPARISON // TODO + reportFetching: false + }) + + // ... Widget code + + return ( + // Other components + // The data array is the same reference if no changes have occurred between fetches + // Note: The array is a new reference is there are any changes in the result set (individual row object references are not preserved) + // Note: CatCollection requires memoization in order to prevent re-rendering (due to the parent re-rendering on fetch) + + ) +} +``` diff --git a/packages/react/src/hooks/useQuery.ts b/packages/react/src/hooks/useQuery.ts index 17a41abc0..821a639b1 100644 --- a/packages/react/src/hooks/useQuery.ts +++ b/packages/react/src/hooks/useQuery.ts @@ -1,9 +1,19 @@ -import { parseQuery, type CompilableQuery, type ParsedQuery, type SQLWatchOptions } from '@powersync/common'; -import { WatchedQueryState } from '@powersync/common/src/client/watched/WatchedQuery'; +import { + AbstractPowerSyncDatabase, + parseQuery, + WatchedQueryState, + type CompilableQuery, + type ParsedQuery, + type SQLWatchOptions +} from '@powersync/common'; import React from 'react'; import { usePowerSync } from './PowerSyncContext'; -export interface AdditionalOptions extends Omit { +interface HookWatchOptions extends Omit { + reportFetching?: boolean; +} + +export interface AdditionalOptions extends HookWatchOptions { runQueryOnce?: boolean; } @@ -24,50 +34,97 @@ export type QueryResult = { refresh?: (signal?: AbortSignal) => Promise; }; -/** - * A hook to access the results of a watched query. - * @example - * export const Component = () => { - * const { data: lists } = useQuery('SELECT * from lists'); - * - * return - * {lists.map((l) => ( - * {JSON.stringify(l)} - * ))} - * - * } - */ -export const useQuery = ( - query: string | CompilableQuery, +const checkQueryChanged = (sqlStatement: string, queryParameters: any[], options: AdditionalOptions) => { + const stringifiedParams = JSON.stringify(queryParameters); + const stringifiedOptions = JSON.stringify(options); + + const previousQueryRef = React.useRef({ sqlStatement, stringifiedParams, stringifiedOptions }); + + if ( + previousQueryRef.current.sqlStatement !== sqlStatement || + previousQueryRef.current.stringifiedParams != stringifiedParams || + previousQueryRef.current.stringifiedOptions != stringifiedOptions + ) { + previousQueryRef.current.sqlStatement = sqlStatement; + previousQueryRef.current.stringifiedParams = stringifiedParams; + previousQueryRef.current.stringifiedOptions = stringifiedOptions; + + return true; + } + + return false; +}; + +const useSingleQuery = ( + query: string, parameters: any[] = [], - options: AdditionalOptions = { runQueryOnce: false } + powerSync: AbstractPowerSyncDatabase, + queryExecutor?: () => Promise | null, + queryChanged: boolean = false ): QueryResult => { - const powerSync = usePowerSync(); - const logger = powerSync?.logger ?? console; - if (!powerSync) { - return { isLoading: false, isFetching: false, data: [], error: new Error('PowerSync not configured.') }; - } + const [output, setOutputState] = React.useState>({ + isLoading: true, + isFetching: true, + data: [], + error: undefined + }); - let parsedQuery: ParsedQuery; - try { - parsedQuery = parseQuery(query, parameters); - } catch (error) { - logger.error('Failed to parse query:', error); - return { isLoading: false, isFetching: false, data: [], error }; - } + // TODO, how was this signal used? + const runQuery = React.useCallback( + async (signal?: AbortSignal) => { + setOutputState((prev) => ({ ...prev, isLoading: true, isFetching: true, error: undefined })); + try { + const result = queryExecutor ? await queryExecutor() : await powerSync.getAll(query, parameters); + setOutputState((prev) => ({ + ...prev, + isLoading: false, + isFetching: false, + data: result ?? [], + error: undefined + })); + } catch (error) { + setOutputState((prev) => ({ + ...prev, + isLoading: false, + isFetching: false, + data: [], + error + })); + } + }, + [queryChanged, queryExecutor] + ); - const { sqlStatement, parameters: queryParameters } = parsedQuery; + // Trigger initial query execution + React.useEffect(() => { + const abortController = new AbortController(); + runQuery(abortController.signal); + return () => { + abortController.abort(); + }; + }, [powerSync, queryChanged]); - const memoizedParams = React.useMemo(() => queryParameters, [JSON.stringify(queryParameters)]); - const memoizedOptions = React.useMemo(() => options, [JSON.stringify(options)]); + return { + ...output, + refresh: runQuery + }; +}; - const previousQueryRef = React.useRef({ sqlStatement, memoizedParams }); - // TODO implement runQueryOnce +const useWatchedQuery = ( + query: string, + parameters: any[] = [], + powerSync: AbstractPowerSyncDatabase, + queryExecutor?: () => Promise | null, + queryChanged: boolean = false, + options: HookWatchOptions = {} +): QueryResult => { const [watchedQuery] = React.useState(() => { - return powerSync.watch2({ - sql: sqlStatement, - parameters: queryParameters, - throttleMs: options.throttleMs + return powerSync.incrementalWatch({ + sql: query, + parameters, + queryExecutor, + throttleMs: options.throttleMs, + reportFetching: options.reportFetching }); }); @@ -86,7 +143,7 @@ export const useQuery = ( React.useEffect(() => { watchedQuery.stream().forEach(async (val) => { - console.log('updating state'); + console.log('Updating watched query state', val); setOutputState(mapState(val)); }); @@ -98,18 +155,63 @@ export const useQuery = ( // Indicates that the query will be re-fetched due to a change in the query. // Used when `isFetching` hasn't been set to true yet due to React execution. React.useEffect(() => { - if ( - previousQueryRef.current.sqlStatement !== sqlStatement || - JSON.stringify(previousQueryRef.current.memoizedParams) != JSON.stringify(memoizedParams) - ) { - console.log('updating watched'); + if (queryChanged) { + console.log('Query changed, re-evaluating', query, parameters); watchedQuery.updateQuery({ - query: sqlStatement, - parameters: queryParameters, - throttleMs: options.throttleMs + query, + parameters: parameters, + throttleMs: options.throttleMs, + queryExecutor, + reportFetching: options.reportFetching }); } - }, [powerSync, sqlStatement, memoizedParams]); + }, [queryChanged]); return output; }; + +/** + * A hook to access the results of a watched query. + * @example + * export const Component = () => { + * const { data: lists } = useQuery('SELECT * from lists'); + * + * return + * {lists.map((l) => ( + * {JSON.stringify(l)} + * ))} + * + * } + */ +export const useQuery = ( + query: string | CompilableQuery, + parameters: any[] = [], + options: AdditionalOptions = { runQueryOnce: false } +): QueryResult => { + const powerSync = usePowerSync(); + const logger = powerSync?.logger ?? console; + if (!powerSync) { + return { isLoading: false, isFetching: false, data: [], error: new Error('PowerSync not configured.') }; + } + + let parsedQuery: ParsedQuery; + try { + parsedQuery = parseQuery(query, parameters); + } catch (error) { + logger.error('Failed to parse query:', error); + return { isLoading: false, isFetching: false, data: [], error }; + } + + const { sqlStatement, parameters: queryParameters } = parsedQuery; + + const queryChanged = checkQueryChanged(sqlStatement, queryParameters, options); + const queryExecutor = typeof query == 'object' ? query.execute : undefined; + + switch (options.runQueryOnce) { + case true: + return useSingleQuery(sqlStatement, queryParameters, powerSync, queryExecutor, queryChanged); + default: + // TODO handle if powersync changed + return useWatchedQuery(sqlStatement, queryParameters, powerSync, queryExecutor, queryChanged, options); + } +}; From 6a18a4aa7c6b1537f8153f627ae3af40588aa68b Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Fri, 16 May 2025 15:15:43 +0200 Subject: [PATCH 04/75] update tests to use web db --- .../src/client/AbstractPowerSyncDatabase.ts | 3 + .../src/client/watched/WatchedQueryImpl.ts | 14 +- .../processors/AbstractQueryProcessor.ts | 37 +- .../processors/OnChangeQueryProcessor.ts | 31 +- packages/react/package.json | 1 + packages/react/src/hooks/useQuery.ts | 1 - packages/react/tests/useQuery.test.tsx | 147 ++- .../react/tests/useSuspenseQuery.test.tsx | 9 +- packages/react/vitest.config.ts | 38 +- pnpm-lock.yaml | 925 +++++++++--------- 10 files changed, 646 insertions(+), 560 deletions(-) diff --git a/packages/common/src/client/AbstractPowerSyncDatabase.ts b/packages/common/src/client/AbstractPowerSyncDatabase.ts index c93d5d14b..024de3bd8 100644 --- a/packages/common/src/client/AbstractPowerSyncDatabase.ts +++ b/packages/common/src/client/AbstractPowerSyncDatabase.ts @@ -109,6 +109,7 @@ export interface WatchOnChangeHandler { export interface PowerSyncDBListener extends StreamingSyncImplementationListener { initialized: () => void; schemaChanged: (schema: Schema) => void; + closing: () => void; } export interface PowerSyncCloseOptions { @@ -515,6 +516,8 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver cb.closing?.()); + const { disconnect } = options; if (disconnect) { await this.disconnect(); diff --git a/packages/common/src/client/watched/WatchedQueryImpl.ts b/packages/common/src/client/watched/WatchedQueryImpl.ts index 6e4740dd3..6a3313330 100644 --- a/packages/common/src/client/watched/WatchedQueryImpl.ts +++ b/packages/common/src/client/watched/WatchedQueryImpl.ts @@ -49,10 +49,14 @@ export class WatchedQueryImpl implements WatchedQuery { } close(): void { - console.log('Closing WatchedQueryImpl for ', this.options.processor); - this.lazyStreamPromise.then((s) => { - console.log('Closing stream for ', this.options.processor); - s.close(); - }); + this.lazyStreamPromise + .then((s) => { + s.close(); + }) + .catch(() => { + // In rare cases where the DB might be closed before the stream is created + // this can throw an error. + // This should not affect closing + }); } } diff --git a/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts b/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts index 758ee066f..067696694 100644 --- a/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts +++ b/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts @@ -49,9 +49,10 @@ export abstract class AbstractQueryProcessor */ updateQuery(query: WatchedQueryOptions) { this.options.watchedQuery = query; + if (this._stream) { this.iterateAsyncListeners(async (l) => l.queryUpdated?.(query)).catch((error) => { - this._stream!.iterateListeners((l) => l.error?.(error)); + this.updateState({ error }); }); } } @@ -64,6 +65,18 @@ export abstract class AbstractQueryProcessor */ protected abstract linkStream(options: LinkQueryStreamOptions): Promise; + protected updateState = (update: Partial>) => { + Object.assign(this.state, update); + + if (this._stream?.closed) { + // Don't enqueue data in a closed stream. + // it should be safe to ignore this. + // This can be triggered if the stream is closed while data is being fetched. + return; + } + this._stream?.enqueueData({ ...this.state }); + }; + async generateStream() { if (this._stream) { return this._stream; @@ -75,6 +88,8 @@ export abstract class AbstractQueryProcessor logger: db.logger }); + this._stream = stream; + let abortController: AbortController | null = null; const link = async (query: WatchedQueryOptions) => { @@ -87,21 +102,26 @@ export abstract class AbstractQueryProcessor }); }; - await link(this.options.watchedQuery); - db.registerListener({ schemaChanged: async () => { try { await link(this.options.watchedQuery); } catch (error) { - stream.iterateListeners((l) => l.error?.(error)); + this.updateState({ error }); } + }, + closing: () => { + stream.close().catch(() => {}); } }); this.registerListener({ queryUpdated: async (query) => { - await link(query); + try { + await link(query); + } catch (error) { + this.updateState({ error }); + } } }); @@ -110,7 +130,12 @@ export abstract class AbstractQueryProcessor closed: () => abortController?.abort() }); - this._stream = stream; + try { + await link(this.options.watchedQuery); + } catch (error) { + this.updateState({ error }); + } + return stream; } } diff --git a/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts b/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts index d501a687f..c47b9f4f8 100644 --- a/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts +++ b/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts @@ -1,3 +1,4 @@ +import { WatchedQueryState } from '../WatchedQuery.js'; import { WatchedQueryResult } from '../WatchedQueryResult.js'; import { AbstractQueryProcessor, LinkQueryStreamOptions } from './AbstractQueryProcessor.js'; @@ -27,15 +28,13 @@ export class OnChangeQueryProcessor extends AbstractQueryProcessor { db.onChangeWithCallback( { onChange: async () => { - console.log('onChange trigger for', this); // This fires for each change of the relevant tables try { if (this.reportFetching) { - this.state.fetching = true; - stream.enqueueData(this.state); + this.updateState({ fetching: true }); } - let dirty = false; + const partialStateUpdate: Partial> = {}; // Always run the query if an underlaying table has changed const result = watchedQuery.queryExecutor @@ -43,36 +42,30 @@ export class OnChangeQueryProcessor extends AbstractQueryProcessor { : await db.getAll(watchedQuery.query, watchedQuery.parameters); if (this.reportFetching) { - this.state.fetching = false; - dirty = true; + partialStateUpdate.fetching = false; } if (this.state.loading) { - this.state.loading = false; - dirty = true; + partialStateUpdate.loading = false; } // Check if the result has changed const watchedQueryResult = this.processResultSet(result); if (watchedQueryResult) { - this.state.data = watchedQueryResult; - this.state.lastUpdated = new Date(); - dirty = true; + partialStateUpdate.data = watchedQueryResult; + partialStateUpdate.lastUpdated = new Date(); } - if (dirty) { - stream.enqueueData(this.state); + if (Object.keys(partialStateUpdate).length > 0) { + this.updateState(partialStateUpdate); } } catch (error) { - this.state.error = error; - stream.enqueueData(this.state); - // TODO? - //stream.iterateListeners((l) => l.error?.(error)); + this.updateState({ error }); } }, onError: (error) => { - stream.close(); - stream.iterateListeners((l) => l.error?.(error)); + this.updateState({ error }); + stream.close().catch(() => {}); } }, { diff --git a/packages/react/package.json b/packages/react/package.json index cf16e7123..4b243fe94 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -34,6 +34,7 @@ }, "devDependencies": { "@powersync/common": "workspace:*", + "@powersync/web": "workspace:*", "@testing-library/react": "^15.0.2", "@types/react": "^18.3.1", "jsdom": "^24.0.0", diff --git a/packages/react/src/hooks/useQuery.ts b/packages/react/src/hooks/useQuery.ts index 821a639b1..0b08a5c3f 100644 --- a/packages/react/src/hooks/useQuery.ts +++ b/packages/react/src/hooks/useQuery.ts @@ -143,7 +143,6 @@ const useWatchedQuery = ( React.useEffect(() => { watchedQuery.stream().forEach(async (val) => { - console.log('Updating watched query state', val); setOutputState(mapState(val)); }); diff --git a/packages/react/tests/useQuery.test.tsx b/packages/react/tests/useQuery.test.tsx index b92bbaa38..5f70446a1 100644 --- a/packages/react/tests/useQuery.test.tsx +++ b/packages/react/tests/useQuery.test.tsx @@ -1,21 +1,28 @@ import * as commonSdk from '@powersync/common'; -import { cleanup, renderHook, waitFor } from '@testing-library/react'; +import { PowerSyncDatabase } from '@powersync/web'; +import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; import React from 'react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'; import { PowerSyncContext } from '../src/hooks/PowerSyncContext'; import { useQuery } from '../src/hooks/useQuery'; -const mockPowerSync = { - currentStatus: { status: 'initial' }, - registerListener: vi.fn(() => {}), - resolveTables: vi.fn(() => ['table1', 'table2']), - onChangeWithCallback: vi.fn(), - getAll: vi.fn(() => Promise.resolve(['list1', 'list2'])) -}; +const openPowerSync = () => { + const db = new PowerSyncDatabase({ + database: { dbFilename: 'test.db' }, + schema: new commonSdk.Schema({ + lists: new commonSdk.Table({ + name: commonSdk.column.text + }) + }) + }); + + onTestFinished(async () => { + await db.disconnectAndClear(); + await db.close(); + }); -vi.mock('./PowerSyncContext', () => ({ - useContext: vi.fn(() => mockPowerSync) -})); + return db; +}; describe('useQuery', () => { beforeEach(() => { @@ -33,7 +40,7 @@ describe('useQuery', () => { it('should set isLoading to true on initial load', async () => { const wrapper = ({ children }) => ( - {children} + {children} ); const { result } = renderHook(() => useQuery('SELECT * from lists'), { wrapper }); @@ -42,30 +49,35 @@ describe('useQuery', () => { }); it('should run the query once if runQueryOnce flag is set', async () => { - const wrapper = ({ children }) => ( - {children} - ); + const db = openPowerSync(); + const onChangeSpy = vi.spyOn(db, 'onChangeWithCallback'); + const getAllSpy = vi.spyOn(db, 'getAll'); - const { result } = renderHook(() => useQuery('SELECT * from lists', [], { runQueryOnce: true }), { wrapper }); + const wrapper = ({ children }) => {children}; + + await db.execute('INSERT INTO lists(id, name) VALUES (uuid(), ?)', ['list1']); + + const { result } = renderHook(() => useQuery('SELECT name from lists', [], { runQueryOnce: true }), { wrapper }); expect(result.current.isLoading).toEqual(true); await waitFor( async () => { const currentResult = result.current; - expect(currentResult.data).toEqual(['list1', 'list2']); + expect(currentResult.data).toEqual([{ name: 'list1' }]); expect(currentResult.isLoading).toEqual(false); expect(currentResult.isFetching).toEqual(false); - expect(mockPowerSync.onChangeWithCallback).not.toHaveBeenCalled(); - expect(mockPowerSync.getAll).toHaveBeenCalledTimes(1); + expect(onChangeSpy).not.toHaveBeenCalled(); + expect(getAllSpy).toHaveBeenCalledTimes(1); }, { timeout: 100 } ); }); it('should rerun the query when refresh is used', async () => { - const wrapper = ({ children }) => ( - {children} - ); + const db = openPowerSync(); + const getAllSpy = vi.spyOn(db, 'getAll'); + + const wrapper = ({ children }) => {children}; const { result } = renderHook(() => useQuery('SELECT * from lists', [], { runQueryOnce: true }), { wrapper }); @@ -74,73 +86,56 @@ describe('useQuery', () => { let refresh; await waitFor( - async () => { + () => { const currentResult = result.current; refresh = currentResult.refresh; expect(currentResult.isLoading).toEqual(false); - expect(mockPowerSync.getAll).toHaveBeenCalledTimes(1); + expect(getAllSpy).toHaveBeenCalledTimes(1); }, - { timeout: 100 } + { timeout: 500, interval: 100 } ); - await refresh(); - expect(mockPowerSync.getAll).toHaveBeenCalledTimes(2); + await act(() => refresh()); + + expect(getAllSpy).toHaveBeenCalledTimes(2); }); it('should set error when error occurs and runQueryOnce flag is set', 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 db = openPowerSync(); - const wrapper = ({ children }) => ( - {children} - ); + const wrapper = ({ children }) => {children}; - const { result } = renderHook(() => useQuery('SELECT * from lists', [], { runQueryOnce: true }), { wrapper }); + const { result } = renderHook(() => useQuery('SELECT * from faketable', [], { runQueryOnce: true }), { wrapper }); await waitFor( async () => { - expect(result.current.error?.message).equal('PowerSync failed to fetch data: some error'); + expect(result.current.error?.message).equal('no such table: faketable'); }, - { timeout: 100 } + { timeout: 500, interval: 100 } ); }); - it('should set error when error occurs', 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'); - }) - }; + it('should set error when error occurs with watched query', async () => { + const db = openPowerSync(); - const wrapper = ({ children }) => ( - {children} - ); + const wrapper = ({ children }) => {children}; - const { result } = renderHook(() => useQuery('SELECT * from lists', []), { wrapper }); + const { result } = renderHook(() => useQuery('SELECT * from faketable', []), { wrapper }); await waitFor( async () => { - expect(result.current.error?.message).equals('PowerSync failed to fetch data: some error'); + expect(result.current.error?.message).equals('no such table: faketable'); }, - { timeout: 100 } + { timeout: 500, interval: 100 } ); + + console.log('got to this point'); }); it('should accept compilable queries', async () => { - const wrapper = ({ children }) => ( - {children} - ); + const db = openPowerSync(); + + const wrapper = ({ children }) => {children}; const { result } = renderHook( () => useQuery({ execute: () => [] as any, compile: () => ({ sql: 'SELECT * from lists', parameters: [] }) }), @@ -151,9 +146,9 @@ describe('useQuery', () => { }); it('should execute compatible queries', async () => { - const wrapper = ({ children }) => ( - {children} - ); + const db = openPowerSync(); + + const wrapper = ({ children }) => {children}; const query = () => useQuery({ @@ -162,24 +157,26 @@ describe('useQuery', () => { }); const { result } = renderHook(query, { wrapper }); - await vi.waitFor(() => { - expect(result.current.data[0]?.test).toEqual('custom'); - }); + await vi.waitFor( + () => { + expect(result.current.data[0]?.test).toEqual('custom'); + }, + { timeout: 500, interval: 100 } + ); }); it('should show an error if parsing the query results in an error', async () => { - const wrapper = ({ children }) => ( - {children} - ); - vi.spyOn(commonSdk, 'parseQuery').mockImplementation(() => { - throw new Error('error'); - }); + const db = openPowerSync(); + + const wrapper = ({ children }) => {children}; const { result } = renderHook( () => useQuery({ execute: () => [] as any, - compile: () => ({ sql: 'SELECT * from lists', parameters: ['param'] }) + compile: () => { + throw new Error('error'); + } }), { wrapper } ); diff --git a/packages/react/tests/useSuspenseQuery.test.tsx b/packages/react/tests/useSuspenseQuery.test.tsx index f83c164d2..badafe779 100644 --- a/packages/react/tests/useSuspenseQuery.test.tsx +++ b/packages/react/tests/useSuspenseQuery.test.tsx @@ -1,4 +1,3 @@ -import * as commonSdk from '@powersync/common'; import { cleanup, renderHook, screen, waitFor } from '@testing-library/react'; import React, { Suspense } from 'react'; import { ErrorBoundary } from 'react-error-boundary'; @@ -214,15 +213,13 @@ describe('useSuspenseQuery', () => { }); it('should show an error if parsing the query results in an error', async () => { - vi.spyOn(commonSdk, 'parseQuery').mockImplementation(() => { - throw new Error('error'); - }); - const { result } = renderHook( () => useSuspenseQuery({ execute: () => [] as any, - compile: () => ({ sql: 'SELECT * from lists', parameters: ['param'] }) + compile: () => { + throw Error('error'); + } }), { wrapper } ); diff --git a/packages/react/vitest.config.ts b/packages/react/vitest.config.ts index f96ac5630..f734d8f1f 100644 --- a/packages/react/vitest.config.ts +++ b/packages/react/vitest.config.ts @@ -1,8 +1,44 @@ import { defineConfig, UserConfigExport } from 'vitest/config'; +import topLevelAwait from 'vite-plugin-top-level-await'; +import wasm from 'vite-plugin-wasm'; + const config: UserConfigExport = { + // This is only needed for local tests to resolve the package name correctly + worker: { + format: 'es', + plugins: () => [wasm(), topLevelAwait()] + }, + optimizeDeps: { + // Don't optimise these packages as they contain web workers and WASM files. + // https://github.com/vitejs/vite/issues/11672#issuecomment-1415820673 + exclude: ['@journeyapps/wa-sqlite', '@powersync/web'], + include: ['bson'] + }, + plugins: [wasm(), topLevelAwait()], test: { - environment: 'jsdom' + globals: true, + include: ['tests/**/*.test.tsx'], + maxConcurrency: 1, + // This doesn't currently seem to work in browser mode, but setting this for one day when it does + sequence: { + shuffle: false, // Disable shuffling of test files + concurrent: false // Run test files sequentially + }, + browser: { + enabled: true, + /** + * Starts each test in a new iFrame + */ + isolate: true, + provider: 'playwright', + headless: false, + instances: [ + { + browser: 'chromium' + } + ] + } } }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 960d68690..16926512c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,7 +19,7 @@ importers: version: 4.0.11(@pnpm/logger@5.2.0) '@vitest/browser': specifier: ^3.0.8 - version: 3.0.8(@testing-library/dom@10.4.0)(@types/node@22.7.4)(playwright@1.51.0)(typescript@5.8.2)(vite@6.2.3(@types/node@22.7.4)(less@4.2.2)(lightningcss@1.28.2)(sass@1.85.0)(terser@5.39.0))(vitest@3.0.8)(webdriverio@9.8.0) + version: 3.0.8(@testing-library/dom@10.4.0)(@types/node@22.7.4)(playwright@1.51.0)(typescript@5.8.2)(vite@6.2.3(@types/node@22.7.4)(jiti@1.21.6)(less@4.2.2)(lightningcss@1.28.2)(sass@1.85.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.6.1))(vitest@3.0.8)(webdriverio@9.8.0) husky: specifier: ^9.0.11 version: 9.1.6 @@ -89,10 +89,10 @@ importers: devDependencies: '@angular-builders/custom-webpack': specifier: ^19.0.0 - version: 19.0.0(@angular/compiler-cli@19.2.4(@angular/compiler@19.2.4)(typescript@5.5.4))(@angular/compiler@19.2.4)(@angular/service-worker@19.2.4(@angular/core@19.2.4(rxjs@7.8.1)(zone.js@0.15.0))(rxjs@7.8.1))(@rspack/core@1.1.8)(@swc/core@1.10.1)(@types/node@22.7.4)(chokidar@4.0.1)(html-webpack-plugin@5.6.0(@rspack/core@1.1.8)(webpack@5.98.0(@swc/core@1.10.1)))(jest-environment-jsdom@29.7.0)(jest@29.7.0(@types/node@22.7.4))(jiti@1.21.6)(lightningcss@1.28.2)(tailwindcss@3.4.13)(tsx@4.19.3)(typescript@5.5.4)(vite@6.2.3(@types/node@22.7.4)(jiti@1.21.6)(less@4.2.2)(lightningcss@1.28.2)(sass@1.85.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.6.1))(yaml@2.6.1) + version: 19.0.0(@angular/compiler-cli@19.2.4(@angular/compiler@19.2.4)(typescript@5.5.4))(@angular/compiler@19.2.4)(@angular/service-worker@19.2.4(@angular/core@19.2.4(rxjs@7.8.1)(zone.js@0.15.0))(rxjs@7.8.1))(@rspack/core@1.1.8(@swc/helpers@0.5.5))(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(chokidar@4.0.1)(html-webpack-plugin@5.6.0(@rspack/core@1.1.8(@swc/helpers@0.5.5))(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))))(jest-environment-jsdom@29.7.0)(jest@29.7.0(@types/node@22.7.4)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.5.4)))(jiti@1.21.6)(lightningcss@1.28.2)(tailwindcss@3.4.13(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.5.4)))(tsx@4.19.3)(typescript@5.5.4)(vite@6.2.3(@types/node@22.7.4)(jiti@1.21.6)(less@4.2.2)(lightningcss@1.28.2)(sass@1.85.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.6.1))(yaml@2.6.1) '@angular-devkit/build-angular': specifier: ^19.2.5 - version: 19.2.5(@angular/compiler-cli@19.2.4(@angular/compiler@19.2.4)(typescript@5.5.4))(@angular/compiler@19.2.4)(@angular/service-worker@19.2.4(@angular/core@19.2.4(rxjs@7.8.1)(zone.js@0.15.0))(rxjs@7.8.1))(@rspack/core@1.1.8)(@swc/core@1.10.1)(@types/node@22.7.4)(chokidar@4.0.1)(html-webpack-plugin@5.6.0(@rspack/core@1.1.8)(webpack@5.98.0(@swc/core@1.10.1)))(jest-environment-jsdom@29.7.0)(jest@29.7.0(@types/node@22.7.4))(jiti@1.21.6)(lightningcss@1.28.2)(tailwindcss@3.4.13)(tsx@4.19.3)(typescript@5.5.4)(vite@6.2.3(@types/node@22.7.4)(jiti@1.21.6)(less@4.2.2)(lightningcss@1.28.2)(sass@1.85.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.6.1))(yaml@2.6.1) + version: 19.2.5(@angular/compiler-cli@19.2.4(@angular/compiler@19.2.4)(typescript@5.5.4))(@angular/compiler@19.2.4)(@angular/service-worker@19.2.4(@angular/core@19.2.4(rxjs@7.8.1)(zone.js@0.15.0))(rxjs@7.8.1))(@rspack/core@1.1.8(@swc/helpers@0.5.5))(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(chokidar@4.0.1)(html-webpack-plugin@5.6.0(@rspack/core@1.1.8(@swc/helpers@0.5.5))(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))))(jest-environment-jsdom@29.7.0)(jest@29.7.0(@types/node@22.7.4)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.5.4)))(jiti@1.21.6)(lightningcss@1.28.2)(tailwindcss@3.4.13(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.5.4)))(tsx@4.19.3)(typescript@5.5.4)(vite@6.2.3(@types/node@22.7.4)(jiti@1.21.6)(less@4.2.2)(lightningcss@1.28.2)(sass@1.85.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.6.1))(yaml@2.6.1) '@angular/cli': specifier: ^19.2.5 version: 19.2.5(@types/node@22.7.4)(chokidar@4.0.1) @@ -441,16 +441,16 @@ importers: version: 7.7.0 '@electron-forge/plugin-webpack': specifier: ^7.7.0 - version: 7.7.0(@rspack/core@1.1.8)(@swc/core@1.10.1) + version: 7.7.0(@rspack/core@1.1.8(@swc/helpers@0.5.5))(@swc/core@1.10.1(@swc/helpers@0.5.5)) '@vercel/webpack-asset-relocator-loader': specifier: 1.7.3 version: 1.7.3 copy-webpack-plugin: specifier: ^13.0.0 - version: 13.0.0(webpack@5.98.0(@swc/core@1.10.1)) + version: 13.0.0(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))) css-loader: specifier: ^6.11.0 - version: 6.11.0(@rspack/core@1.1.8)(webpack@5.98.0(@swc/core@1.10.1)) + version: 6.11.0(@rspack/core@1.1.8(@swc/helpers@0.5.5))(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))) dotenv: specifier: ^16.4.7 version: 16.4.7 @@ -462,19 +462,19 @@ importers: version: 3.2.9 fork-ts-checker-webpack-plugin: specifier: ^9.0.2 - version: 9.0.2(typescript@5.8.2)(webpack@5.98.0(@swc/core@1.10.1)) + version: 9.0.2(typescript@5.8.2)(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))) node-loader: specifier: ^2.1.0 - version: 2.1.0(webpack@5.98.0(@swc/core@1.10.1)) + version: 2.1.0(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))) style-loader: specifier: ^3.3.4 - version: 3.3.4(webpack@5.98.0(@swc/core@1.10.1)) + version: 3.3.4(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))) ts-loader: specifier: ^9.5.2 - version: 9.5.2(typescript@5.8.2)(webpack@5.98.0(@swc/core@1.10.1)) + version: 9.5.2(typescript@5.8.2)(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))) ts-node: specifier: ^10.9.2 - version: 10.9.2(@swc/core@1.10.1)(@types/node@22.7.4)(typescript@5.8.2) + version: 10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.8.2) tsx: specifier: ^4.19.3 version: 4.19.3 @@ -483,7 +483,7 @@ importers: version: 5.8.2 webpack: specifier: ^5.90.1 - version: 5.98.0(@swc/core@1.10.1) + version: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1) demos/example-nextjs: dependencies: @@ -544,10 +544,10 @@ importers: version: 10.4.20(postcss@8.5.3) babel-loader: specifier: ^9.1.3 - version: 9.2.1(@babel/core@7.26.10)(webpack@5.98.0) + version: 9.2.1(@babel/core@7.26.10)(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))) css-loader: specifier: ^6.11.0 - version: 6.11.0(@rspack/core@1.1.8)(webpack@5.98.0) + version: 6.11.0(@rspack/core@1.1.8(@swc/helpers@0.5.5))(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))) eslint: specifier: ^8.57.0 version: 8.57.1 @@ -562,13 +562,13 @@ importers: version: 1.85.0 sass-loader: specifier: ^13.3.3 - version: 13.3.3(sass@1.85.0)(webpack@5.98.0) + version: 13.3.3(sass@1.85.0)(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))) style-loader: specifier: ^3.3.4 - version: 3.3.4(webpack@5.98.0) + version: 3.3.4(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))) tailwindcss: specifier: ^3.4.3 - version: 3.4.13(ts-node@10.9.2(@types/node@20.17.12)(typescript@5.8.2)) + version: 3.4.13(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@20.17.12)(typescript@5.8.2)) demos/example-node: dependencies: @@ -581,7 +581,7 @@ importers: devDependencies: ts-node: specifier: ^10.9.2 - version: 10.9.2(@swc/core@1.10.1)(@types/node@22.7.4)(typescript@5.8.2) + version: 10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.8.2) typescript: specifier: ^5.8.2 version: 5.8.2 @@ -632,10 +632,10 @@ importers: devDependencies: '@types/webpack': specifier: ^5.28.5 - version: 5.28.5(webpack-cli@5.1.4(webpack@5.98.0)) + version: 5.28.5(webpack-cli@5.1.4) html-webpack-plugin: specifier: ^5.6.0 - version: 5.6.0(@rspack/core@1.1.8)(webpack@5.98.0(webpack-cli@5.1.4)) + version: 5.6.0(@rspack/core@1.1.8(@swc/helpers@0.5.5))(webpack@5.98.0) serve: specifier: ^14.2.1 version: 14.2.3 @@ -778,7 +778,7 @@ importers: version: 0.77.0(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10)) '@react-native/eslint-config': specifier: 0.77.0 - version: 0.77.0(eslint@8.57.1)(jest@29.7.0(@types/node@22.7.4))(prettier@3.3.3)(typescript@5.8.2) + version: 0.77.0(eslint@8.57.1)(jest@29.7.0(@types/node@22.7.4)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.8.2)))(prettier@3.3.3)(typescript@5.8.2) '@react-native/metro-config': specifier: 0.77.0 version: 0.77.0(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10)) @@ -914,7 +914,7 @@ importers: version: 18.3.18 eas-cli: specifier: ^7.2.0 - version: 7.8.5(@swc/core@1.10.1)(@types/node@22.7.4)(encoding@0.1.13)(expo-modules-autolinking@2.0.8)(typescript@5.3.3) + version: 7.8.5(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(encoding@0.1.13)(expo-modules-autolinking@2.0.8)(typescript@5.3.3) eslint: specifier: 8.55.0 version: 8.55.0 @@ -1213,10 +1213,10 @@ importers: version: 18.3.1 jest: specifier: ^29.2.1 - version: 29.7.0(@types/node@20.17.12)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.12)(typescript@5.8.2)) + version: 29.7.0(@types/node@20.17.12)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@20.17.12)(typescript@5.8.2)) jest-expo: specifier: ~52.0.3 - version: 52.0.6(7b5slbnfo75rnbl2fciupcm2u4) + version: 52.0.6(s5kr4tfzuibd4a7iqdxtxzakqy) react-test-renderer: specifier: 18.3.1 version: 18.3.1(react@18.3.1) @@ -1721,7 +1721,7 @@ importers: version: 20.17.12 drizzle-orm: specifier: ^0.35.2 - version: 0.35.2(@op-engineering/op-sqlite@11.4.8(react@18.3.1))(@types/better-sqlite3@7.6.12)(@types/react@18.3.18)(better-sqlite3@11.7.2)(kysely@0.28.0)(react@18.3.1) + version: 0.35.2(@op-engineering/op-sqlite@11.4.8(react-native@0.77.0(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@react-native-community/cli-server-api@15.1.3)(@types/react@18.3.18)(react@18.3.1))(react@18.3.1))(@types/better-sqlite3@7.6.12)(@types/react@18.3.18)(better-sqlite3@11.7.2)(kysely@0.28.0)(react@18.3.1) vite: specifier: ^6.1.0 version: 6.2.3(@types/node@20.17.12)(jiti@1.21.6)(less@4.2.2)(lightningcss@1.28.2)(sass@1.85.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.6.1) @@ -1795,7 +1795,7 @@ importers: version: 1.4.2 drizzle-orm: specifier: ^0.35.2 - version: 0.35.2(@op-engineering/op-sqlite@11.4.8(react@18.3.1))(@types/better-sqlite3@7.6.12)(@types/react@18.3.18)(better-sqlite3@11.7.2)(kysely@0.28.0)(react@18.3.1) + version: 0.35.2(@op-engineering/op-sqlite@11.4.8(react-native@0.77.0(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@react-native-community/cli-server-api@15.1.3)(@types/react@18.3.18)(react@18.3.1))(react@18.3.1))(@types/better-sqlite3@7.6.12)(@types/react@18.3.18)(better-sqlite3@11.7.2)(kysely@0.28.0)(react@18.3.1) rollup: specifier: 4.14.3 version: 4.14.3 @@ -1860,6 +1860,9 @@ importers: '@powersync/common': specifier: workspace:* version: link:../common + '@powersync/web': + specifier: workspace:* + version: link:../web '@testing-library/react': specifier: ^15.0.2 version: 15.0.7(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -2012,13 +2015,13 @@ importers: version: 4.0.1 source-map-loader: specifier: ^5.0.0 - version: 5.0.0(webpack@5.98.0(webpack-cli@5.1.4)) + version: 5.0.0(webpack@5.98.0) stream-browserify: specifier: ^3.0.0 version: 3.0.0 terser-webpack-plugin: specifier: ^5.3.9 - version: 5.3.14(webpack@5.98.0(webpack-cli@5.1.4)) + version: 5.3.14(webpack@5.98.0) uuid: specifier: ^9.0.1 version: 9.0.1 @@ -19974,10 +19977,10 @@ snapshots: '@jridgewell/gen-mapping': 0.3.5 '@jridgewell/trace-mapping': 0.3.25 - '@angular-builders/common@3.0.0(@swc/core@1.10.1)(@types/node@22.7.4)(chokidar@4.0.1)(typescript@5.5.4)': + '@angular-builders/common@3.0.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(chokidar@4.0.1)(typescript@5.5.4)': dependencies: '@angular-devkit/core': 19.2.5(chokidar@4.0.1) - ts-node: 10.9.2(@swc/core@1.10.1)(@types/node@22.7.4)(typescript@5.5.4) + ts-node: 10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.5.4) tsconfig-paths: 4.2.0 transitivePeerDependencies: - '@swc/core' @@ -19986,11 +19989,11 @@ snapshots: - chokidar - typescript - '@angular-builders/custom-webpack@19.0.0(@angular/compiler-cli@19.2.4(@angular/compiler@19.2.4)(typescript@5.5.4))(@angular/compiler@19.2.4)(@angular/service-worker@19.2.4(@angular/core@19.2.4(rxjs@7.8.1)(zone.js@0.15.0))(rxjs@7.8.1))(@rspack/core@1.1.8)(@swc/core@1.10.1)(@types/node@22.7.4)(chokidar@4.0.1)(html-webpack-plugin@5.6.0(@rspack/core@1.1.8)(webpack@5.98.0(@swc/core@1.10.1)))(jest-environment-jsdom@29.7.0)(jest@29.7.0(@types/node@22.7.4))(jiti@1.21.6)(lightningcss@1.28.2)(tailwindcss@3.4.13)(tsx@4.19.3)(typescript@5.5.4)(vite@6.2.3(@types/node@22.7.4)(jiti@1.21.6)(less@4.2.2)(lightningcss@1.28.2)(sass@1.85.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.6.1))(yaml@2.6.1)': + '@angular-builders/custom-webpack@19.0.0(@angular/compiler-cli@19.2.4(@angular/compiler@19.2.4)(typescript@5.5.4))(@angular/compiler@19.2.4)(@angular/service-worker@19.2.4(@angular/core@19.2.4(rxjs@7.8.1)(zone.js@0.15.0))(rxjs@7.8.1))(@rspack/core@1.1.8(@swc/helpers@0.5.5))(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(chokidar@4.0.1)(html-webpack-plugin@5.6.0(@rspack/core@1.1.8(@swc/helpers@0.5.5))(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))))(jest-environment-jsdom@29.7.0)(jest@29.7.0(@types/node@22.7.4)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.5.4)))(jiti@1.21.6)(lightningcss@1.28.2)(tailwindcss@3.4.13(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.5.4)))(tsx@4.19.3)(typescript@5.5.4)(vite@6.2.3(@types/node@22.7.4)(jiti@1.21.6)(less@4.2.2)(lightningcss@1.28.2)(sass@1.85.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.6.1))(yaml@2.6.1)': dependencies: - '@angular-builders/common': 3.0.0(@swc/core@1.10.1)(@types/node@22.7.4)(chokidar@4.0.1)(typescript@5.5.4) + '@angular-builders/common': 3.0.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(chokidar@4.0.1)(typescript@5.5.4) '@angular-devkit/architect': 0.1902.5(chokidar@4.0.1) - '@angular-devkit/build-angular': 19.2.5(@angular/compiler-cli@19.2.4(@angular/compiler@19.2.4)(typescript@5.5.4))(@angular/compiler@19.2.4)(@angular/service-worker@19.2.4(@angular/core@19.2.4(rxjs@7.8.1)(zone.js@0.15.0))(rxjs@7.8.1))(@rspack/core@1.1.8)(@swc/core@1.10.1)(@types/node@22.7.4)(chokidar@4.0.1)(html-webpack-plugin@5.6.0(@rspack/core@1.1.8)(webpack@5.98.0(@swc/core@1.10.1)))(jest-environment-jsdom@29.7.0)(jest@29.7.0(@types/node@22.7.4))(jiti@1.21.6)(lightningcss@1.28.2)(tailwindcss@3.4.13)(tsx@4.19.3)(typescript@5.5.4)(vite@6.2.3(@types/node@22.7.4)(jiti@1.21.6)(less@4.2.2)(lightningcss@1.28.2)(sass@1.85.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.6.1))(yaml@2.6.1) + '@angular-devkit/build-angular': 19.2.5(@angular/compiler-cli@19.2.4(@angular/compiler@19.2.4)(typescript@5.5.4))(@angular/compiler@19.2.4)(@angular/service-worker@19.2.4(@angular/core@19.2.4(rxjs@7.8.1)(zone.js@0.15.0))(rxjs@7.8.1))(@rspack/core@1.1.8(@swc/helpers@0.5.5))(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(chokidar@4.0.1)(html-webpack-plugin@5.6.0(@rspack/core@1.1.8(@swc/helpers@0.5.5))(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))))(jest-environment-jsdom@29.7.0)(jest@29.7.0(@types/node@22.7.4)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.5.4)))(jiti@1.21.6)(lightningcss@1.28.2)(tailwindcss@3.4.13(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.5.4)))(tsx@4.19.3)(typescript@5.5.4)(vite@6.2.3(@types/node@22.7.4)(jiti@1.21.6)(less@4.2.2)(lightningcss@1.28.2)(sass@1.85.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.6.1))(yaml@2.6.1) '@angular-devkit/core': 19.2.5(chokidar@4.0.1) '@angular/compiler-cli': 19.2.4(@angular/compiler@19.2.4)(typescript@5.5.4) lodash: 4.17.21 @@ -20039,13 +20042,13 @@ snapshots: transitivePeerDependencies: - chokidar - '@angular-devkit/build-angular@19.2.5(@angular/compiler-cli@19.2.4(@angular/compiler@19.2.4)(typescript@5.5.4))(@angular/compiler@19.2.4)(@angular/service-worker@19.2.4(@angular/core@19.2.4(rxjs@7.8.1)(zone.js@0.15.0))(rxjs@7.8.1))(@rspack/core@1.1.8)(@swc/core@1.10.1)(@types/node@22.7.4)(chokidar@4.0.1)(html-webpack-plugin@5.6.0(@rspack/core@1.1.8)(webpack@5.98.0(@swc/core@1.10.1)))(jest-environment-jsdom@29.7.0)(jest@29.7.0(@types/node@22.7.4))(jiti@1.21.6)(lightningcss@1.28.2)(tailwindcss@3.4.13)(tsx@4.19.3)(typescript@5.5.4)(vite@6.2.3(@types/node@22.7.4)(jiti@1.21.6)(less@4.2.2)(lightningcss@1.28.2)(sass@1.85.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.6.1))(yaml@2.6.1)': + '@angular-devkit/build-angular@19.2.5(@angular/compiler-cli@19.2.4(@angular/compiler@19.2.4)(typescript@5.5.4))(@angular/compiler@19.2.4)(@angular/service-worker@19.2.4(@angular/core@19.2.4(rxjs@7.8.1)(zone.js@0.15.0))(rxjs@7.8.1))(@rspack/core@1.1.8(@swc/helpers@0.5.5))(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(chokidar@4.0.1)(html-webpack-plugin@5.6.0(@rspack/core@1.1.8(@swc/helpers@0.5.5))(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))))(jest-environment-jsdom@29.7.0)(jest@29.7.0(@types/node@22.7.4)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.5.4)))(jiti@1.21.6)(lightningcss@1.28.2)(tailwindcss@3.4.13(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.5.4)))(tsx@4.19.3)(typescript@5.5.4)(vite@6.2.3(@types/node@22.7.4)(jiti@1.21.6)(less@4.2.2)(lightningcss@1.28.2)(sass@1.85.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.6.1))(yaml@2.6.1)': dependencies: '@ampproject/remapping': 2.3.0 '@angular-devkit/architect': 0.1902.5(chokidar@4.0.1) - '@angular-devkit/build-webpack': 0.1902.5(chokidar@4.0.1)(webpack-dev-server@5.2.0(webpack@5.98.0(@swc/core@1.10.1)(esbuild@0.25.1)))(webpack@5.98.0(@swc/core@1.10.1)(esbuild@0.25.1)) + '@angular-devkit/build-webpack': 0.1902.5(chokidar@4.0.1)(webpack-dev-server@5.2.0(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))))(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))) '@angular-devkit/core': 19.2.5(chokidar@4.0.1) - '@angular/build': 19.2.5(@angular/compiler-cli@19.2.4(@angular/compiler@19.2.4)(typescript@5.5.4))(@angular/compiler@19.2.4)(@angular/service-worker@19.2.4(@angular/core@19.2.4(rxjs@7.8.1)(zone.js@0.15.0))(rxjs@7.8.1))(@types/node@22.7.4)(chokidar@4.0.1)(jiti@1.21.6)(less@4.2.2)(lightningcss@1.28.2)(postcss@8.5.2)(tailwindcss@3.4.13)(terser@5.39.0)(tsx@4.19.3)(typescript@5.5.4)(yaml@2.6.1) + '@angular/build': 19.2.5(@angular/compiler-cli@19.2.4(@angular/compiler@19.2.4)(typescript@5.5.4))(@angular/compiler@19.2.4)(@angular/service-worker@19.2.4(@angular/core@19.2.4(rxjs@7.8.1)(zone.js@0.15.0))(rxjs@7.8.1))(@types/node@22.7.4)(chokidar@4.0.1)(jiti@1.21.6)(less@4.2.2)(lightningcss@1.28.2)(postcss@8.5.2)(tailwindcss@3.4.13(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.5.4)))(terser@5.39.0)(tsx@4.19.3)(typescript@5.5.4)(yaml@2.6.1) '@angular/compiler-cli': 19.2.4(@angular/compiler@19.2.4)(typescript@5.5.4) '@babel/core': 7.26.10 '@babel/generator': 7.26.10 @@ -20057,14 +20060,14 @@ snapshots: '@babel/preset-env': 7.26.9(@babel/core@7.26.10) '@babel/runtime': 7.26.10 '@discoveryjs/json-ext': 0.6.3 - '@ngtools/webpack': 19.2.5(@angular/compiler-cli@19.2.4(@angular/compiler@19.2.4)(typescript@5.5.4))(typescript@5.5.4)(webpack@5.98.0(@swc/core@1.10.1)(esbuild@0.25.1)) + '@ngtools/webpack': 19.2.5(@angular/compiler-cli@19.2.4(@angular/compiler@19.2.4)(typescript@5.5.4))(typescript@5.5.4)(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))) '@vitejs/plugin-basic-ssl': 1.2.0(vite@6.2.3(@types/node@22.7.4)(jiti@1.21.6)(less@4.2.2)(lightningcss@1.28.2)(sass@1.85.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.6.1)) ansi-colors: 4.1.3 autoprefixer: 10.4.20(postcss@8.5.2) - babel-loader: 9.2.1(@babel/core@7.26.10)(webpack@5.98.0(@swc/core@1.10.1)(esbuild@0.25.1)) + babel-loader: 9.2.1(@babel/core@7.26.10)(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))) browserslist: 4.24.4 - copy-webpack-plugin: 12.0.2(webpack@5.98.0(@swc/core@1.10.1)(esbuild@0.25.1)) - css-loader: 7.1.2(@rspack/core@1.1.8)(webpack@5.98.0(@swc/core@1.10.1)(esbuild@0.25.1)) + copy-webpack-plugin: 12.0.2(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))) + css-loader: 7.1.2(@rspack/core@1.1.8(@swc/helpers@0.5.5))(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))) esbuild-wasm: 0.25.1 fast-glob: 3.3.3 http-proxy-middleware: 3.0.3 @@ -20072,38 +20075,38 @@ snapshots: jsonc-parser: 3.3.1 karma-source-map-support: 1.4.0 less: 4.2.2 - less-loader: 12.2.0(@rspack/core@1.1.8)(less@4.2.2)(webpack@5.98.0(@swc/core@1.10.1)(esbuild@0.25.1)) - license-webpack-plugin: 4.0.2(webpack@5.98.0(@swc/core@1.10.1)(esbuild@0.25.1)) + less-loader: 12.2.0(@rspack/core@1.1.8(@swc/helpers@0.5.5))(less@4.2.2)(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))) + license-webpack-plugin: 4.0.2(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))) loader-utils: 3.3.1 - mini-css-extract-plugin: 2.9.2(webpack@5.98.0(@swc/core@1.10.1)(esbuild@0.25.1)) + mini-css-extract-plugin: 2.9.2(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))) open: 10.1.0 ora: 5.4.1 picomatch: 4.0.2 piscina: 4.8.0 postcss: 8.5.2 - postcss-loader: 8.1.1(@rspack/core@1.1.8)(postcss@8.5.2)(typescript@5.5.4)(webpack@5.98.0(@swc/core@1.10.1)(esbuild@0.25.1)) + postcss-loader: 8.1.1(@rspack/core@1.1.8(@swc/helpers@0.5.5))(postcss@8.5.2)(typescript@5.5.4)(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))) resolve-url-loader: 5.0.0 rxjs: 7.8.1 sass: 1.85.0 - sass-loader: 16.0.5(@rspack/core@1.1.8)(sass@1.85.0)(webpack@5.98.0(@swc/core@1.10.1)(esbuild@0.25.1)) + sass-loader: 16.0.5(@rspack/core@1.1.8(@swc/helpers@0.5.5))(sass@1.85.0)(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))) semver: 7.7.1 - source-map-loader: 5.0.0(webpack@5.98.0(@swc/core@1.10.1)(esbuild@0.25.1)) + source-map-loader: 5.0.0(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))) source-map-support: 0.5.21 terser: 5.39.0 tree-kill: 1.2.2 tslib: 2.8.1 typescript: 5.5.4 - webpack: 5.98.0(@swc/core@1.10.1)(esbuild@0.25.1) - webpack-dev-middleware: 7.4.2(webpack@5.98.0(@swc/core@1.10.1)(esbuild@0.25.1)) - webpack-dev-server: 5.2.0(webpack@5.98.0(@swc/core@1.10.1)(esbuild@0.25.1)) + webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1) + webpack-dev-middleware: 7.4.2(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))) + webpack-dev-server: 5.2.0(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))) webpack-merge: 6.0.1 - webpack-subresource-integrity: 5.1.0(html-webpack-plugin@5.6.0(@rspack/core@1.1.8)(webpack@5.98.0(@swc/core@1.10.1)))(webpack@5.98.0(@swc/core@1.10.1)(esbuild@0.25.1)) + webpack-subresource-integrity: 5.1.0(html-webpack-plugin@5.6.0(@rspack/core@1.1.8(@swc/helpers@0.5.5))(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))))(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))) optionalDependencies: '@angular/service-worker': 19.2.4(@angular/core@19.2.4(rxjs@7.8.1)(zone.js@0.15.0))(rxjs@7.8.1) esbuild: 0.25.1 - jest: 29.7.0(@types/node@22.7.4) + jest: 29.7.0(@types/node@22.7.4)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.5.4)) jest-environment-jsdom: 29.7.0 - tailwindcss: 3.4.13(ts-node@10.9.2(@types/node@20.17.12)(typescript@5.8.2)) + tailwindcss: 3.4.13(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.5.4)) transitivePeerDependencies: - '@angular/compiler' - '@rspack/core' @@ -20127,12 +20130,12 @@ snapshots: - webpack-cli - yaml - '@angular-devkit/build-webpack@0.1902.5(chokidar@4.0.1)(webpack-dev-server@5.2.0(webpack@5.98.0(@swc/core@1.10.1)(esbuild@0.25.1)))(webpack@5.98.0(@swc/core@1.10.1)(esbuild@0.25.1))': + '@angular-devkit/build-webpack@0.1902.5(chokidar@4.0.1)(webpack-dev-server@5.2.0(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))))(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5)))': dependencies: '@angular-devkit/architect': 0.1902.5(chokidar@4.0.1) rxjs: 7.8.1 - webpack: 5.98.0(@swc/core@1.10.1)(esbuild@0.25.1) - webpack-dev-server: 5.2.0(webpack@5.98.0(@swc/core@1.10.1)(esbuild@0.25.1)) + webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1) + webpack-dev-server: 5.2.0(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))) transitivePeerDependencies: - chokidar @@ -20162,7 +20165,7 @@ snapshots: '@angular/core': 19.2.4(rxjs@7.8.1)(zone.js@0.15.0) tslib: 2.8.1 - '@angular/build@19.2.5(@angular/compiler-cli@19.2.4(@angular/compiler@19.2.4)(typescript@5.5.4))(@angular/compiler@19.2.4)(@angular/service-worker@19.2.4(@angular/core@19.2.4(rxjs@7.8.1)(zone.js@0.15.0))(rxjs@7.8.1))(@types/node@22.7.4)(chokidar@4.0.1)(jiti@1.21.6)(less@4.2.2)(lightningcss@1.28.2)(postcss@8.5.2)(tailwindcss@3.4.13)(terser@5.39.0)(tsx@4.19.3)(typescript@5.5.4)(yaml@2.6.1)': + '@angular/build@19.2.5(@angular/compiler-cli@19.2.4(@angular/compiler@19.2.4)(typescript@5.5.4))(@angular/compiler@19.2.4)(@angular/service-worker@19.2.4(@angular/core@19.2.4(rxjs@7.8.1)(zone.js@0.15.0))(rxjs@7.8.1))(@types/node@22.7.4)(chokidar@4.0.1)(jiti@1.21.6)(less@4.2.2)(lightningcss@1.28.2)(postcss@8.5.2)(tailwindcss@3.4.13(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.5.4)))(terser@5.39.0)(tsx@4.19.3)(typescript@5.5.4)(yaml@2.6.1)': dependencies: '@ampproject/remapping': 2.3.0 '@angular-devkit/architect': 0.1902.5(chokidar@4.0.1) @@ -20198,7 +20201,7 @@ snapshots: less: 4.2.2 lmdb: 3.2.6 postcss: 8.5.2 - tailwindcss: 3.4.13(ts-node@10.9.2(@types/node@20.17.12)(typescript@5.8.2)) + tailwindcss: 3.4.13(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.5.4)) transitivePeerDependencies: - '@types/node' - chokidar @@ -21982,10 +21985,10 @@ snapshots: postcss-loader: 7.3.4(postcss@8.5.3)(typescript@5.8.2)(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))) postcss-preset-env: 10.1.2(postcss@8.5.3) react-dev-utils: 12.0.1(eslint@8.57.1)(typescript@5.8.2)(vue-template-compiler@2.7.16)(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))) - terser-webpack-plugin: 5.3.14(@swc/core@1.10.1(@swc/helpers@0.5.5))(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))) + terser-webpack-plugin: 5.3.14(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1)(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))) tslib: 2.8.1 url-loader: 4.1.1(file-loader@6.2.0(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))))(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))) - webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5)) + webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1) webpackbar: 6.0.1(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))) optionalDependencies: '@docusaurus/faster': 3.7.0(@docusaurus/types@3.7.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@swc/helpers@0.5.5) @@ -22049,9 +22052,9 @@ snapshots: shelljs: 0.8.5 tslib: 2.7.0 update-notifier: 6.0.2 - webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5)) + webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1) webpack-bundle-analyzer: 4.10.2 - webpack-dev-server: 4.15.2(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))) + webpack-dev-server: 4.15.2(debug@4.4.0)(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))) webpack-merge: 6.0.1 transitivePeerDependencies: - '@docusaurus/faster' @@ -22089,7 +22092,7 @@ snapshots: lightningcss: 1.28.2 swc-loader: 0.2.6(@swc/core@1.10.1(@swc/helpers@0.5.5))(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))) tslib: 2.7.0 - webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5)) + webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1) transitivePeerDependencies: - '@swc/helpers' - esbuild @@ -22128,7 +22131,7 @@ snapshots: unist-util-visit: 5.0.0 url-loader: 4.1.1(file-loader@6.2.0(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))))(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))) vfile: 6.0.3 - webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5)) + webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1) transitivePeerDependencies: - '@swc/core' - esbuild @@ -22176,7 +22179,7 @@ snapshots: tslib: 2.8.1 unist-util-visit: 5.0.0 utility-types: 3.11.0 - webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5)) + webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1) transitivePeerDependencies: - '@docusaurus/faster' - '@mdx-js/react' @@ -22217,7 +22220,7 @@ snapshots: react-dom: 18.3.1(react@18.3.1) tslib: 2.8.1 utility-types: 3.11.0 - webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5)) + webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1) transitivePeerDependencies: - '@docusaurus/faster' - '@mdx-js/react' @@ -22249,7 +22252,7 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) tslib: 2.8.1 - webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5)) + webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1) transitivePeerDependencies: - '@docusaurus/faster' - '@mdx-js/react' @@ -22429,7 +22432,7 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) tslib: 2.8.1 - webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5)) + webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1) transitivePeerDependencies: - '@docusaurus/faster' - '@mdx-js/react' @@ -22631,7 +22634,7 @@ snapshots: react-dom: 18.3.1(react@18.3.1) react-helmet-async: '@slorber/react-helmet-async@1.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)' utility-types: 3.11.0 - webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5)) + webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1) webpack-merge: 5.10.0 transitivePeerDependencies: - '@swc/core' @@ -22693,7 +22696,7 @@ snapshots: tslib: 2.8.1 url-loader: 4.1.1(file-loader@6.2.0(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))))(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))) utility-types: 3.11.0 - webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5)) + webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1) transitivePeerDependencies: - '@swc/core' - esbuild @@ -22901,7 +22904,7 @@ snapshots: - supports-color - utf-8-validate - '@electron-forge/plugin-webpack@7.7.0(@rspack/core@1.1.8)(@swc/core@1.10.1)': + '@electron-forge/plugin-webpack@7.7.0(@rspack/core@1.1.8(@swc/helpers@0.5.5))(@swc/core@1.10.1(@swc/helpers@0.5.5))': dependencies: '@electron-forge/core-utils': 7.7.0 '@electron-forge/plugin-base': 7.7.0 @@ -22911,10 +22914,10 @@ snapshots: debug: 4.4.0(supports-color@8.1.1) fast-glob: 3.3.2 fs-extra: 10.1.0 - html-webpack-plugin: 5.6.0(@rspack/core@1.1.8)(webpack@5.98.0(@swc/core@1.10.1)) + html-webpack-plugin: 5.6.0(@rspack/core@1.1.8(@swc/helpers@0.5.5))(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))) listr2: 7.0.2 - webpack: 5.98.0(@swc/core@1.10.1) - webpack-dev-server: 4.15.2(debug@4.4.0)(webpack@5.98.0(@swc/core@1.10.1)) + webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1) + webpack-dev-server: 4.15.2(debug@4.4.0)(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))) webpack-merge: 5.10.0 transitivePeerDependencies: - '@rspack/core' @@ -23973,18 +23976,18 @@ snapshots: base64-js: 1.5.1 xmlbuilder: 14.0.0 - '@expo/plugin-help@5.1.23(@swc/core@1.10.1)(@types/node@22.7.4)(typescript@5.3.3)': + '@expo/plugin-help@5.1.23(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.3.3)': dependencies: - '@oclif/core': 2.16.0(@swc/core@1.10.1)(@types/node@22.7.4)(typescript@5.3.3) + '@oclif/core': 2.16.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.3.3) transitivePeerDependencies: - '@swc/core' - '@swc/wasm' - '@types/node' - typescript - '@expo/plugin-warn-if-update-available@2.5.1(@swc/core@1.10.1)(@types/node@22.7.4)(typescript@5.3.3)': + '@expo/plugin-warn-if-update-available@2.5.1(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.3.3)': dependencies: - '@oclif/core': 2.16.0(@swc/core@1.10.1)(@types/node@22.7.4)(typescript@5.3.3) + '@oclif/core': 2.16.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.3.3) chalk: 4.1.2 debug: 4.4.0(supports-color@8.1.1) ejs: 3.1.10 @@ -24476,7 +24479,78 @@ snapshots: jest-util: 29.7.0 slash: 3.0.0 - '@jest/core@29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.12)(typescript@5.8.2))': + '@jest/core@29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@20.17.12)(typescript@5.8.2))': + dependencies: + '@jest/console': 29.7.0 + '@jest/reporters': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.17.12 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 3.9.0 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-changed-files: 29.7.0 + jest-config: 29.7.0(@types/node@20.17.12)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@20.17.12)(typescript@5.8.2)) + jest-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-resolve-dependencies: 29.7.0 + jest-runner: 29.7.0 + jest-runtime: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + jest-watcher: 29.7.0 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-ansi: 6.0.1 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + - ts-node + + '@jest/core@29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.5.4))': + dependencies: + '@jest/console': 29.7.0 + '@jest/reporters': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.17.12 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 3.9.0 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-changed-files: 29.7.0 + jest-config: 29.7.0(@types/node@20.17.12)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.5.4)) + jest-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-resolve-dependencies: 29.7.0 + jest-runner: 29.7.0 + jest-runtime: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + jest-watcher: 29.7.0 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-ansi: 6.0.1 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + - ts-node + optional: true + + '@jest/core@29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.8.2))': dependencies: '@jest/console': 29.7.0 '@jest/reporters': 29.7.0 @@ -24490,7 +24564,7 @@ snapshots: exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@20.17.12)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.12)(typescript@5.8.2)) + jest-config: 29.7.0(@types/node@20.17.12)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.8.2)) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -24510,6 +24584,7 @@ snapshots: - babel-plugin-macros - supports-color - ts-node + optional: true '@jest/create-cache-key-function@29.7.0': dependencies: @@ -25388,11 +25463,11 @@ snapshots: '@next/swc-win32-x64-msvc@14.2.3': optional: true - '@ngtools/webpack@19.2.5(@angular/compiler-cli@19.2.4(@angular/compiler@19.2.4)(typescript@5.5.4))(typescript@5.5.4)(webpack@5.98.0(@swc/core@1.10.1)(esbuild@0.25.1))': + '@ngtools/webpack@19.2.5(@angular/compiler-cli@19.2.4(@angular/compiler@19.2.4)(typescript@5.5.4))(typescript@5.5.4)(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5)))': dependencies: '@angular/compiler-cli': 19.2.4(@angular/compiler@19.2.4)(typescript@5.5.4) typescript: 5.5.4 - webpack: 5.98.0(@swc/core@1.10.1)(esbuild@0.25.1) + webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1) '@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1': dependencies: @@ -25516,7 +25591,7 @@ snapshots: widest-line: 3.1.0 wrap-ansi: 7.0.0 - '@oclif/core@2.16.0(@swc/core@1.10.1)(@types/node@22.7.4)(typescript@5.3.3)': + '@oclif/core@2.16.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.3.3)': dependencies: '@types/cli-progress': 3.11.6 ansi-escapes: 4.3.2 @@ -25541,7 +25616,7 @@ snapshots: strip-ansi: 6.0.1 supports-color: 8.1.1 supports-hyperlinks: 2.3.0 - ts-node: 10.9.2(@swc/core@1.10.1)(@types/node@22.7.4)(typescript@5.3.3) + ts-node: 10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.3.3) tslib: 2.8.1 widest-line: 3.1.0 wordwrap: 1.0.0 @@ -25554,9 +25629,9 @@ snapshots: '@oclif/linewrap@1.0.0': {} - '@oclif/plugin-autocomplete@2.3.10(@swc/core@1.10.1)(@types/node@22.7.4)(typescript@5.3.3)': + '@oclif/plugin-autocomplete@2.3.10(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.3.3)': dependencies: - '@oclif/core': 2.16.0(@swc/core@1.10.1)(@types/node@22.7.4)(typescript@5.3.3) + '@oclif/core': 2.16.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.3.3) chalk: 4.1.2 debug: 4.4.0(supports-color@8.1.1) transitivePeerDependencies: @@ -26980,7 +27055,7 @@ snapshots: - supports-color - typescript - '@react-native/eslint-config@0.77.0(eslint@8.57.1)(jest@29.7.0(@types/node@22.7.4))(prettier@3.3.3)(typescript@5.8.2)': + '@react-native/eslint-config@0.77.0(eslint@8.57.1)(jest@29.7.0(@types/node@22.7.4)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.8.2)))(prettier@3.3.3)(typescript@5.8.2)': dependencies: '@babel/core': 7.26.10 '@babel/eslint-parser': 7.25.8(@babel/core@7.26.10)(eslint@8.57.1) @@ -26991,7 +27066,7 @@ snapshots: eslint-config-prettier: 8.10.0(eslint@8.57.1) eslint-plugin-eslint-comments: 3.2.0(eslint@8.57.1) eslint-plugin-ft-flow: 2.0.3(@babel/eslint-parser@7.25.8(@babel/core@7.26.10)(eslint@8.57.1))(eslint@8.57.1) - eslint-plugin-jest: 27.9.0(@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.2))(eslint@8.57.1)(typescript@5.8.2))(eslint@8.57.1)(jest@29.7.0(@types/node@22.7.4))(typescript@5.8.2) + eslint-plugin-jest: 27.9.0(@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.2))(eslint@8.57.1)(typescript@5.8.2))(eslint@8.57.1)(jest@29.7.0(@types/node@22.7.4)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.8.2)))(typescript@5.8.2) eslint-plugin-react: 7.37.1(eslint@8.57.1) eslint-plugin-react-hooks: 4.6.2(eslint@8.57.1) eslint-plugin-react-native: 4.1.0(eslint@8.57.1) @@ -27060,9 +27135,7 @@ snapshots: transitivePeerDependencies: - '@babel/core' - '@babel/preset-env' - - bufferutil - supports-color - - utf-8-validate '@react-native/normalize-color@2.1.0': {} @@ -29616,7 +29689,7 @@ snapshots: dependencies: vue: 2.7.16 - '@types/webpack@5.28.5(webpack-cli@5.1.4(webpack@5.98.0))': + '@types/webpack@5.28.5(webpack-cli@5.1.4)': dependencies: '@types/node': 20.17.12 tapable: 2.2.1 @@ -30007,10 +30080,10 @@ snapshots: - vite optional: true - '@vitest/browser@3.0.8(@testing-library/dom@10.4.0)(@types/node@22.7.4)(playwright@1.51.0)(typescript@5.8.2)(vite@6.2.3(@types/node@22.7.4)(less@4.2.2)(lightningcss@1.28.2)(sass@1.85.0)(terser@5.39.0))(vitest@3.0.8)(webdriverio@9.8.0)': + '@vitest/browser@3.0.8(@testing-library/dom@10.4.0)(@types/node@22.7.4)(playwright@1.51.0)(typescript@5.8.2)(vite@6.2.3(@types/node@22.7.4)(jiti@1.21.6)(less@4.2.2)(lightningcss@1.28.2)(sass@1.85.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.6.1))(vitest@3.0.8)(webdriverio@9.8.0)': dependencies: '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.0) - '@vitest/mocker': 3.0.8(msw@2.7.3(@types/node@22.7.4)(typescript@5.8.2))(vite@6.2.3(@types/node@22.7.4)(less@4.2.2)(lightningcss@1.28.2)(sass@1.85.0)(terser@5.39.0)) + '@vitest/mocker': 3.0.8(msw@2.7.3(@types/node@22.7.4)(typescript@5.8.2))(vite@6.2.3(@types/node@22.7.4)(jiti@1.21.6)(less@4.2.2)(lightningcss@1.28.2)(sass@1.85.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.6.1)) '@vitest/utils': 3.0.8 magic-string: 0.30.17 msw: 2.7.3(@types/node@22.7.4)(typescript@5.8.2) @@ -30045,7 +30118,7 @@ snapshots: msw: 2.7.3(@types/node@22.7.4)(typescript@5.8.2) vite: 5.4.11(@types/node@22.7.4)(less@4.2.2)(lightningcss@1.28.2)(sass@1.85.0)(terser@5.39.0) - '@vitest/mocker@3.0.8(msw@2.7.3(@types/node@22.7.4)(typescript@5.8.2))(vite@6.2.3(@types/node@22.7.4)(less@4.2.2)(lightningcss@1.28.2)(sass@1.85.0)(terser@5.39.0))': + '@vitest/mocker@3.0.8(msw@2.7.3(@types/node@22.7.4)(typescript@5.8.2))(vite@6.2.3(@types/node@22.7.4)(jiti@1.21.6)(less@4.2.2)(lightningcss@1.28.2)(sass@1.85.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.6.1))': dependencies: '@vitest/spy': 3.0.8 estree-walker: 3.0.3 @@ -30209,7 +30282,7 @@ snapshots: vue: 3.4.21(typescript@5.8.2) vue-demi: 0.13.11(vue@3.4.21(typescript@5.8.2)) - '@vuetify/loader-shared@2.0.3(vue@3.4.21(typescript@5.8.2))(vuetify@3.6.8(typescript@5.8.2)(vite-plugin-vuetify@2.0.4)(vue@3.4.21(typescript@5.8.2)))': + '@vuetify/loader-shared@2.0.3(vue@3.4.21(typescript@5.8.2))(vuetify@3.6.8)': dependencies: upath: 2.0.1 vue: 3.4.21(typescript@5.8.2) @@ -30423,17 +30496,17 @@ snapshots: dependencies: commander: 10.0.1 - '@webpack-cli/configtest@2.1.1(webpack-cli@5.1.4(webpack@5.98.0))(webpack@5.98.0(webpack-cli@5.1.4))': + '@webpack-cli/configtest@2.1.1(webpack-cli@5.1.4)(webpack@5.98.0)': dependencies: webpack: 5.98.0(webpack-cli@5.1.4) webpack-cli: 5.1.4(webpack@5.98.0) - '@webpack-cli/info@2.0.2(webpack-cli@5.1.4(webpack@5.98.0))(webpack@5.98.0(webpack-cli@5.1.4))': + '@webpack-cli/info@2.0.2(webpack-cli@5.1.4)(webpack@5.98.0)': dependencies: webpack: 5.98.0(webpack-cli@5.1.4) webpack-cli: 5.1.4(webpack@5.98.0) - '@webpack-cli/serve@2.0.5(webpack-cli@5.1.4(webpack@5.98.0))(webpack@5.98.0(webpack-cli@5.1.4))': + '@webpack-cli/serve@2.0.5(webpack-cli@5.1.4)(webpack@5.98.0)': dependencies: webpack: 5.98.0(webpack-cli@5.1.4) webpack-cli: 5.1.4(webpack@5.98.0) @@ -30945,14 +31018,7 @@ snapshots: '@babel/core': 7.26.10 find-cache-dir: 4.0.0 schema-utils: 4.2.0 - webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5)) - - babel-loader@9.2.1(@babel/core@7.26.10)(webpack@5.98.0(@swc/core@1.10.1)(esbuild@0.25.1)): - dependencies: - '@babel/core': 7.26.10 - find-cache-dir: 4.0.0 - schema-utils: 4.2.0 - webpack: 5.98.0(@swc/core@1.10.1)(esbuild@0.25.1) + webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1) babel-loader@9.2.1(@babel/core@7.26.10)(webpack@5.98.0(@swc/core@1.6.13(@swc/helpers@0.5.5))): dependencies: @@ -30961,13 +31027,6 @@ snapshots: schema-utils: 4.2.0 webpack: 5.98.0(@swc/core@1.6.13(@swc/helpers@0.5.5)) - babel-loader@9.2.1(@babel/core@7.26.10)(webpack@5.98.0): - dependencies: - '@babel/core': 7.26.10 - find-cache-dir: 4.0.0 - schema-utils: 4.2.0 - webpack: 5.98.0 - babel-plugin-dynamic-import-node@2.3.3: dependencies: object.assign: 4.1.5 @@ -32022,9 +32081,9 @@ snapshots: normalize-path: 3.0.0 schema-utils: 4.3.0 serialize-javascript: 6.0.2 - webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5)) + webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1) - copy-webpack-plugin@12.0.2(webpack@5.98.0(@swc/core@1.10.1)(esbuild@0.25.1)): + copy-webpack-plugin@12.0.2(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))): dependencies: fast-glob: 3.3.3 glob-parent: 6.0.2 @@ -32032,16 +32091,16 @@ snapshots: normalize-path: 3.0.0 schema-utils: 4.3.0 serialize-javascript: 6.0.2 - webpack: 5.98.0(@swc/core@1.10.1)(esbuild@0.25.1) + webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1) - copy-webpack-plugin@13.0.0(webpack@5.98.0(@swc/core@1.10.1)): + copy-webpack-plugin@13.0.0(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))): dependencies: glob-parent: 6.0.2 normalize-path: 3.0.0 schema-utils: 4.2.0 serialize-javascript: 6.0.2 tinyglobby: 0.2.12 - webpack: 5.98.0(@swc/core@1.10.1) + webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1) core-js-compat@3.38.1: dependencies: @@ -32150,13 +32209,28 @@ snapshots: safe-buffer: 5.2.1 sha.js: 2.4.11 - create-jest@29.7.0(@types/node@20.17.12)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.12)(typescript@5.8.2)): + create-jest@29.7.0(@types/node@20.17.12)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@20.17.12)(typescript@5.8.2)): + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-config: 29.7.0(@types/node@20.17.12)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@20.17.12)(typescript@5.8.2)) + jest-util: 29.7.0 + prompts: 2.4.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + create-jest@29.7.0(@types/node@22.7.4)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.5.4)): dependencies: '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@20.17.12)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.12)(typescript@5.8.2)) + jest-config: 29.7.0(@types/node@22.7.4)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.5.4)) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -32164,14 +32238,15 @@ snapshots: - babel-plugin-macros - supports-color - ts-node + optional: true - create-jest@29.7.0(@types/node@22.7.4): + create-jest@29.7.0(@types/node@22.7.4)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.8.2)): dependencies: '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@22.7.4) + jest-config: 29.7.0(@types/node@22.7.4)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.8.2)) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -32277,37 +32352,9 @@ snapshots: semver: 7.6.3 optionalDependencies: '@rspack/core': 1.1.8(@swc/helpers@0.5.5) - webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5)) - - css-loader@6.11.0(@rspack/core@1.1.8)(webpack@5.98.0(@swc/core@1.10.1)): - dependencies: - icss-utils: 5.1.0(postcss@8.5.1) - postcss: 8.5.1 - postcss-modules-extract-imports: 3.1.0(postcss@8.5.1) - postcss-modules-local-by-default: 4.0.5(postcss@8.5.1) - postcss-modules-scope: 3.2.0(postcss@8.5.1) - postcss-modules-values: 4.0.0(postcss@8.5.1) - postcss-value-parser: 4.2.0 - semver: 7.6.3 - optionalDependencies: - '@rspack/core': 1.1.8(@swc/helpers@0.5.5) - webpack: 5.98.0(@swc/core@1.10.1) - - css-loader@6.11.0(@rspack/core@1.1.8)(webpack@5.98.0): - dependencies: - icss-utils: 5.1.0(postcss@8.5.1) - postcss: 8.5.1 - postcss-modules-extract-imports: 3.1.0(postcss@8.5.1) - postcss-modules-local-by-default: 4.0.5(postcss@8.5.1) - postcss-modules-scope: 3.2.0(postcss@8.5.1) - postcss-modules-values: 4.0.0(postcss@8.5.1) - postcss-value-parser: 4.2.0 - semver: 7.6.3 - optionalDependencies: - '@rspack/core': 1.1.8(@swc/helpers@0.5.5) - webpack: 5.98.0 + webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1) - css-loader@7.1.2(@rspack/core@1.1.8)(webpack@5.98.0(@swc/core@1.10.1)(esbuild@0.25.1)): + css-loader@7.1.2(@rspack/core@1.1.8(@swc/helpers@0.5.5))(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))): dependencies: icss-utils: 5.1.0(postcss@8.5.3) postcss: 8.5.3 @@ -32319,7 +32366,7 @@ snapshots: semver: 7.7.1 optionalDependencies: '@rspack/core': 1.1.8(@swc/helpers@0.5.5) - webpack: 5.98.0(@swc/core@1.10.1)(esbuild@0.25.1) + webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1) css-minimizer-webpack-plugin@5.0.1(clean-css@5.3.3)(lightningcss@1.28.2)(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))): dependencies: @@ -32329,7 +32376,7 @@ snapshots: postcss: 8.5.3 schema-utils: 4.3.0 serialize-javascript: 6.0.2 - webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5)) + webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1) optionalDependencies: clean-css: 5.3.3 lightningcss: 1.28.2 @@ -32839,9 +32886,9 @@ snapshots: dotenv@16.4.7: {} - drizzle-orm@0.35.2(@op-engineering/op-sqlite@11.4.8(react@18.3.1))(@types/better-sqlite3@7.6.12)(@types/react@18.3.18)(better-sqlite3@11.7.2)(kysely@0.28.0)(react@18.3.1): + drizzle-orm@0.35.2(@op-engineering/op-sqlite@11.4.8(react-native@0.77.0(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@react-native-community/cli-server-api@15.1.3)(@types/react@18.3.18)(react@18.3.1))(react@18.3.1))(@types/better-sqlite3@7.6.12)(@types/react@18.3.18)(better-sqlite3@11.7.2)(kysely@0.28.0)(react@18.3.1): optionalDependencies: - '@op-engineering/op-sqlite': 11.4.8(react-native@0.75.3(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1)(typescript@5.8.2))(react@18.3.1) + '@op-engineering/op-sqlite': 11.4.8(react-native@0.77.0(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@react-native-community/cli-server-api@15.1.3)(@types/react@18.3.18)(react@18.3.1))(react@18.3.1) '@types/better-sqlite3': 7.6.12 '@types/react': 18.3.18 better-sqlite3: 11.7.2 @@ -32855,7 +32902,7 @@ snapshots: duplexer@0.1.2: {} - eas-cli@7.8.5(@swc/core@1.10.1)(@types/node@22.7.4)(encoding@0.1.13)(expo-modules-autolinking@2.0.8)(typescript@5.3.3): + eas-cli@7.8.5(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(encoding@0.1.13)(expo-modules-autolinking@2.0.8)(typescript@5.3.3): dependencies: '@expo/apple-utils': 1.7.0 '@expo/code-signing-certificates': 0.0.5 @@ -32871,8 +32918,8 @@ snapshots: '@expo/package-manager': 1.1.2 '@expo/pkcs12': 0.0.8 '@expo/plist': 0.0.20 - '@expo/plugin-help': 5.1.23(@swc/core@1.10.1)(@types/node@22.7.4)(typescript@5.3.3) - '@expo/plugin-warn-if-update-available': 2.5.1(@swc/core@1.10.1)(@types/node@22.7.4)(typescript@5.3.3) + '@expo/plugin-help': 5.1.23(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.3.3) + '@expo/plugin-warn-if-update-available': 2.5.1(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.3.3) '@expo/prebuild-config': 6.7.3(encoding@0.1.13)(expo-modules-autolinking@2.0.8) '@expo/results': 1.0.0 '@expo/rudder-sdk-node': 1.1.1(encoding@0.1.13) @@ -32880,7 +32927,7 @@ snapshots: '@expo/steps': 1.0.95 '@expo/timeago.js': 1.0.0 '@oclif/core': 1.26.2 - '@oclif/plugin-autocomplete': 2.3.10(@swc/core@1.10.1)(@types/node@22.7.4)(typescript@5.3.3) + '@oclif/plugin-autocomplete': 2.3.10(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.3.3) '@segment/ajv-human-errors': 2.13.0(ajv@8.11.0) '@urql/core': 4.0.11(graphql@16.8.1) '@urql/exchange-retry': 1.2.0(graphql@16.8.1) @@ -33448,7 +33495,7 @@ snapshots: debug: 4.4.0(supports-color@8.1.1) enhanced-resolve: 5.17.1 eslint: 8.57.1 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1) fast-glob: 3.3.3 get-tsconfig: 4.8.1 is-bun-module: 1.2.1 @@ -33471,7 +33518,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): + eslint-module-utils@2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: @@ -33541,7 +33588,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1) hasown: 2.0.2 is-core-module: 2.15.1 is-glob: 4.0.3 @@ -33569,13 +33616,13 @@ snapshots: - supports-color - typescript - eslint-plugin-jest@27.9.0(@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.2))(eslint@8.57.1)(typescript@5.8.2))(eslint@8.57.1)(jest@29.7.0(@types/node@22.7.4))(typescript@5.8.2): + eslint-plugin-jest@27.9.0(@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.2))(eslint@8.57.1)(typescript@5.8.2))(eslint@8.57.1)(jest@29.7.0(@types/node@22.7.4)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.8.2)))(typescript@5.8.2): dependencies: '@typescript-eslint/utils': 5.62.0(eslint@8.57.1)(typescript@5.8.2) eslint: 8.57.1 optionalDependencies: '@typescript-eslint/eslint-plugin': 7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.2))(eslint@8.57.1)(typescript@5.8.2) - jest: 29.7.0(@types/node@22.7.4) + jest: 29.7.0(@types/node@22.7.4)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.8.2)) transitivePeerDependencies: - supports-color - typescript @@ -34508,7 +34555,7 @@ snapshots: dependencies: loader-utils: 2.0.4 schema-utils: 3.3.0 - webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5)) + webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1) file-uri-to-path@1.0.0: {} @@ -34669,12 +34716,12 @@ snapshots: semver: 7.7.1 tapable: 1.1.3 typescript: 5.8.2 - webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5)) + webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1) optionalDependencies: eslint: 8.57.1 vue-template-compiler: 2.7.16 - fork-ts-checker-webpack-plugin@9.0.2(typescript@5.8.2)(webpack@5.98.0(@swc/core@1.10.1)): + fork-ts-checker-webpack-plugin@9.0.2(typescript@5.8.2)(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))): dependencies: '@babel/code-frame': 7.26.2 chalk: 4.1.2 @@ -34689,7 +34736,7 @@ snapshots: semver: 7.6.3 tapable: 2.2.1 typescript: 5.8.2 - webpack: 5.98.0(@swc/core@1.10.1) + webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1) form-data-encoder@2.1.4: {} @@ -35399,20 +35446,9 @@ snapshots: tapable: 2.2.1 optionalDependencies: '@rspack/core': 1.1.8(@swc/helpers@0.5.5) - webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5)) + webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1) - html-webpack-plugin@5.6.0(@rspack/core@1.1.8)(webpack@5.98.0(@swc/core@1.10.1)): - dependencies: - '@types/html-minifier-terser': 6.1.0 - html-minifier-terser: 6.1.0 - lodash: 4.17.21 - pretty-error: 4.0.0 - tapable: 2.2.1 - optionalDependencies: - '@rspack/core': 1.1.8(@swc/helpers@0.5.5) - webpack: 5.98.0(@swc/core@1.10.1) - - html-webpack-plugin@5.6.0(@rspack/core@1.1.8)(webpack@5.98.0(webpack-cli@5.1.4)): + html-webpack-plugin@5.6.0(@rspack/core@1.1.8(@swc/helpers@0.5.5))(webpack@5.98.0): dependencies: '@types/html-minifier-terser': 6.1.0 html-minifier-terser: 6.1.0 @@ -36111,16 +36147,35 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@29.7.0(@types/node@20.17.12)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.12)(typescript@5.8.2)): + jest-cli@29.7.0(@types/node@20.17.12)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@20.17.12)(typescript@5.8.2)): + dependencies: + '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@20.17.12)(typescript@5.8.2)) + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + chalk: 4.1.2 + create-jest: 29.7.0(@types/node@20.17.12)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@20.17.12)(typescript@5.8.2)) + exit: 0.1.2 + import-local: 3.2.0 + jest-config: 29.7.0(@types/node@20.17.12)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@20.17.12)(typescript@5.8.2)) + jest-util: 29.7.0 + jest-validate: 29.7.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + jest-cli@29.7.0(@types/node@22.7.4)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.5.4)): dependencies: - '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.12)(typescript@5.8.2)) + '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.5.4)) '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@20.17.12)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.12)(typescript@5.8.2)) + create-jest: 29.7.0(@types/node@22.7.4)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.5.4)) exit: 0.1.2 import-local: 3.2.0 - jest-config: 29.7.0(@types/node@20.17.12)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.12)(typescript@5.8.2)) + jest-config: 29.7.0(@types/node@22.7.4)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.5.4)) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.2 @@ -36129,17 +36184,18 @@ snapshots: - babel-plugin-macros - supports-color - ts-node + optional: true - jest-cli@29.7.0(@types/node@22.7.4): + jest-cli@29.7.0(@types/node@22.7.4)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.8.2)): dependencies: - '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.12)(typescript@5.8.2)) + '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.8.2)) '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@22.7.4) + create-jest: 29.7.0(@types/node@22.7.4)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.8.2)) exit: 0.1.2 import-local: 3.2.0 - jest-config: 29.7.0(@types/node@22.7.4) + jest-config: 29.7.0(@types/node@22.7.4)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.8.2)) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.2 @@ -36150,7 +36206,70 @@ snapshots: - ts-node optional: true - jest-config@29.7.0(@types/node@20.17.12)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.12)(typescript@5.8.2)): + jest-config@29.7.0(@types/node@20.17.12)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@20.17.12)(typescript@5.8.2)): + dependencies: + '@babel/core': 7.26.10 + '@jest/test-sequencer': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.26.10) + chalk: 4.1.2 + ci-info: 3.9.0 + deepmerge: 4.3.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 29.7.0(babel-plugin-macros@3.1.0) + jest-environment-node: 29.7.0 + jest-get-type: 29.6.3 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-runner: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 20.17.12 + ts-node: 10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@20.17.12)(typescript@5.8.2) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-config@29.7.0(@types/node@20.17.12)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.5.4)): + dependencies: + '@babel/core': 7.26.10 + '@jest/test-sequencer': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.26.10) + chalk: 4.1.2 + ci-info: 3.9.0 + deepmerge: 4.3.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 29.7.0(babel-plugin-macros@3.1.0) + jest-environment-node: 29.7.0 + jest-get-type: 29.6.3 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-runner: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 20.17.12 + ts-node: 10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.5.4) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + optional: true + + jest-config@29.7.0(@types/node@20.17.12)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.8.2)): dependencies: '@babel/core': 7.26.10 '@jest/test-sequencer': 29.7.0 @@ -36176,12 +36295,45 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 20.17.12 - ts-node: 10.9.2(@types/node@20.17.12)(typescript@5.8.2) + ts-node: 10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.8.2) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + optional: true + + jest-config@29.7.0(@types/node@22.7.4)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.5.4)): + dependencies: + '@babel/core': 7.26.10 + '@jest/test-sequencer': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.26.10) + chalk: 4.1.2 + ci-info: 3.9.0 + deepmerge: 4.3.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 29.7.0(babel-plugin-macros@3.1.0) + jest-environment-node: 29.7.0 + jest-get-type: 29.6.3 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-runner: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 22.7.4 + ts-node: 10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.5.4) transitivePeerDependencies: - babel-plugin-macros - supports-color + optional: true - jest-config@29.7.0(@types/node@22.7.4): + jest-config@29.7.0(@types/node@22.7.4)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.8.2)): dependencies: '@babel/core': 7.26.10 '@jest/test-sequencer': 29.7.0 @@ -36207,6 +36359,7 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 22.7.4 + ts-node: 10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.8.2) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -36255,7 +36408,7 @@ snapshots: jest-mock: 29.7.0 jest-util: 29.7.0 - jest-expo@52.0.6(7b5slbnfo75rnbl2fciupcm2u4): + jest-expo@52.0.6(s5kr4tfzuibd4a7iqdxtxzakqy): dependencies: '@expo/config': 10.0.11 '@expo/json-file': 9.0.2 @@ -36268,11 +36421,11 @@ snapshots: jest-environment-jsdom: 29.7.0 jest-snapshot: 29.7.0 jest-watch-select-projects: 2.0.0 - jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@20.17.12)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.12)(typescript@5.8.2))) + jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@20.17.12)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@20.17.12)(typescript@5.8.2))) json5: 2.2.3 lodash: 4.17.21 react-native: 0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.2))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1) - react-server-dom-webpack: 19.0.0-rc-6230622a1a-20240610(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(webpack@5.98.0) + react-server-dom-webpack: 19.0.0-rc-6230622a1a-20240610(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))) react-test-renderer: 18.3.1(react@18.3.1) server-only: 0.0.1 stacktrace-js: 2.0.2 @@ -36474,11 +36627,11 @@ snapshots: chalk: 3.0.0 prompts: 2.4.2 - jest-watch-typeahead@2.2.1(jest@29.7.0(@types/node@20.17.12)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.12)(typescript@5.8.2))): + jest-watch-typeahead@2.2.1(jest@29.7.0(@types/node@20.17.12)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@20.17.12)(typescript@5.8.2))): dependencies: ansi-escapes: 6.2.1 chalk: 4.1.2 - jest: 29.7.0(@types/node@20.17.12)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.12)(typescript@5.8.2)) + jest: 29.7.0(@types/node@20.17.12)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@20.17.12)(typescript@5.8.2)) jest-regex-util: 29.6.3 jest-watcher: 29.7.0 slash: 5.1.0 @@ -36509,24 +36662,37 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 - jest@29.7.0(@types/node@20.17.12)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.12)(typescript@5.8.2)): + jest@29.7.0(@types/node@20.17.12)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@20.17.12)(typescript@5.8.2)): dependencies: - '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.12)(typescript@5.8.2)) + '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@20.17.12)(typescript@5.8.2)) '@jest/types': 29.6.3 import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@20.17.12)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.12)(typescript@5.8.2)) + jest-cli: 29.7.0(@types/node@20.17.12)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@20.17.12)(typescript@5.8.2)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros - supports-color - ts-node - jest@29.7.0(@types/node@22.7.4): + jest@29.7.0(@types/node@22.7.4)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.5.4)): dependencies: - '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.12)(typescript@5.8.2)) + '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.5.4)) '@jest/types': 29.6.3 import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@22.7.4) + jest-cli: 29.7.0(@types/node@22.7.4)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.5.4)) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + optional: true + + jest@29.7.0(@types/node@22.7.4)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.8.2)): + dependencies: + '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.8.2)) + '@jest/types': 29.6.3 + import-local: 3.2.0 + jest-cli: 29.7.0(@types/node@22.7.4)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.8.2)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -36811,12 +36977,12 @@ snapshots: readable-stream: 2.3.8 optional: true - less-loader@12.2.0(@rspack/core@1.1.8)(less@4.2.2)(webpack@5.98.0(@swc/core@1.10.1)(esbuild@0.25.1)): + less-loader@12.2.0(@rspack/core@1.1.8(@swc/helpers@0.5.5))(less@4.2.2)(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))): dependencies: less: 4.2.2 optionalDependencies: '@rspack/core': 1.1.8(@swc/helpers@0.5.5) - webpack: 5.98.0(@swc/core@1.10.1)(esbuild@0.25.1) + webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1) less@4.2.2: dependencies: @@ -36845,11 +37011,11 @@ snapshots: dependencies: isomorphic.js: 0.2.5 - license-webpack-plugin@4.0.2(webpack@5.98.0(@swc/core@1.10.1)(esbuild@0.25.1)): + license-webpack-plugin@4.0.2(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))): dependencies: webpack-sources: 3.2.3 optionalDependencies: - webpack: 5.98.0(@swc/core@1.10.1)(esbuild@0.25.1) + webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1) lie@3.3.0: dependencies: @@ -38537,13 +38703,13 @@ snapshots: dependencies: schema-utils: 4.3.0 tapable: 2.2.1 - webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5)) + webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1) - mini-css-extract-plugin@2.9.2(webpack@5.98.0(@swc/core@1.10.1)(esbuild@0.25.1)): + mini-css-extract-plugin@2.9.2(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))): dependencies: schema-utils: 4.3.0 tapable: 2.2.1 - webpack: 5.98.0(@swc/core@1.10.1)(esbuild@0.25.1) + webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1) minimalistic-assert@1.0.1: {} @@ -38929,10 +39095,10 @@ snapshots: node-int64@0.4.0: {} - node-loader@2.1.0(webpack@5.98.0(@swc/core@1.10.1)): + node-loader@2.1.0(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))): dependencies: loader-utils: 2.0.4 - webpack: 5.98.0(@swc/core@1.10.1) + webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1) node-releases@2.0.18: {} @@ -39062,7 +39228,7 @@ snapshots: dependencies: loader-utils: 2.0.4 schema-utils: 3.3.0 - webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5)) + webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1) nullthrows@1.1.1: {} @@ -39771,13 +39937,22 @@ snapshots: '@csstools/utilities': 2.0.0(postcss@8.5.3) postcss: 8.5.3 - postcss-load-config@4.0.2(postcss@8.5.3)(ts-node@10.9.2(@types/node@20.17.12)(typescript@5.8.2)): + postcss-load-config@4.0.2(postcss@8.5.3)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@20.17.12)(typescript@5.8.2)): + dependencies: + lilconfig: 3.1.2 + yaml: 2.6.1 + optionalDependencies: + postcss: 8.5.3 + ts-node: 10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@20.17.12)(typescript@5.8.2) + + postcss-load-config@4.0.2(postcss@8.5.3)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.5.4)): dependencies: lilconfig: 3.1.2 yaml: 2.6.1 optionalDependencies: postcss: 8.5.3 - ts-node: 10.9.2(@types/node@20.17.12)(typescript@5.8.2) + ts-node: 10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.5.4) + optional: true postcss-loader@7.3.4(postcss@8.5.3)(typescript@5.8.2)(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))): dependencies: @@ -39785,11 +39960,11 @@ snapshots: jiti: 1.21.6 postcss: 8.5.3 semver: 7.7.1 - webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5)) + webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1) transitivePeerDependencies: - typescript - postcss-loader@8.1.1(@rspack/core@1.1.8)(postcss@8.5.2)(typescript@5.5.4)(webpack@5.98.0(@swc/core@1.10.1)(esbuild@0.25.1)): + postcss-loader@8.1.1(@rspack/core@1.1.8(@swc/helpers@0.5.5))(postcss@8.5.2)(typescript@5.5.4)(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))): dependencies: cosmiconfig: 9.0.0(typescript@5.5.4) jiti: 1.21.6 @@ -39797,7 +39972,7 @@ snapshots: semver: 7.7.1 optionalDependencies: '@rspack/core': 1.1.8(@swc/helpers@0.5.5) - webpack: 5.98.0(@swc/core@1.10.1)(esbuild@0.25.1) + webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1) transitivePeerDependencies: - typescript @@ -40515,7 +40690,7 @@ snapshots: shell-quote: 1.8.1 strip-ansi: 6.0.1 text-table: 0.2.0 - webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5)) + webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1) optionalDependencies: typescript: 5.8.2 transitivePeerDependencies: @@ -40603,7 +40778,7 @@ snapshots: dependencies: '@babel/runtime': 7.26.10 react-loadable: '@docusaurus/react-loadable@6.0.0(react@18.3.1)' - webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5)) + webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1) react-native-builder-bob@0.30.2(typescript@5.8.2): dependencies: @@ -41282,13 +41457,13 @@ snapshots: '@remix-run/router': 1.19.2 react: 18.3.1 - react-server-dom-webpack@19.0.0-rc-6230622a1a-20240610(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(webpack@5.98.0): + react-server-dom-webpack@19.0.0-rc-6230622a1a-20240610(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))): dependencies: acorn-loose: 8.4.0 neo-async: 2.6.2 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - webpack: 5.98.0 + webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1) react-shallow-renderer@16.15.0(react@18.3.1): dependencies: @@ -41950,20 +42125,20 @@ snapshots: safer-buffer@2.1.2: {} - sass-loader@13.3.3(sass@1.85.0)(webpack@5.98.0): + sass-loader@13.3.3(sass@1.85.0)(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))): dependencies: neo-async: 2.6.2 - webpack: 5.98.0 + webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1) optionalDependencies: sass: 1.85.0 - sass-loader@16.0.5(@rspack/core@1.1.8)(sass@1.85.0)(webpack@5.98.0(@swc/core@1.10.1)(esbuild@0.25.1)): + sass-loader@16.0.5(@rspack/core@1.1.8(@swc/helpers@0.5.5))(sass@1.85.0)(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))): dependencies: neo-async: 2.6.2 optionalDependencies: '@rspack/core': 1.1.8(@swc/helpers@0.5.5) sass: 1.85.0 - webpack: 5.98.0(@swc/core@1.10.1)(esbuild@0.25.1) + webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1) sass@1.85.0: dependencies: @@ -42369,13 +42544,13 @@ snapshots: source-map-js@1.2.1: {} - source-map-loader@5.0.0(webpack@5.98.0(@swc/core@1.10.1)(esbuild@0.25.1)): + source-map-loader@5.0.0(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))): dependencies: iconv-lite: 0.6.3 source-map-js: 1.2.1 - webpack: 5.98.0(@swc/core@1.10.1)(esbuild@0.25.1) + webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1) - source-map-loader@5.0.0(webpack@5.98.0(webpack-cli@5.1.4)): + source-map-loader@5.0.0(webpack@5.98.0): dependencies: iconv-lite: 0.6.3 source-map-js: 1.2.1 @@ -42691,18 +42866,14 @@ snapshots: structured-headers@0.4.1: {} - style-loader@3.3.4(webpack@5.98.0(@swc/core@1.10.1)): + style-loader@3.3.4(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))): dependencies: - webpack: 5.98.0(@swc/core@1.10.1) + webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1) style-loader@3.3.4(webpack@5.98.0(@swc/core@1.6.13(@swc/helpers@0.5.5))): dependencies: webpack: 5.98.0(@swc/core@1.6.13(@swc/helpers@0.5.5)) - style-loader@3.3.4(webpack@5.98.0): - dependencies: - webpack: 5.98.0 - style-to-object@0.4.4: dependencies: inline-style-parser: 0.1.1 @@ -42818,7 +42989,7 @@ snapshots: dependencies: '@swc/core': 1.10.1(@swc/helpers@0.5.5) '@swc/counter': 0.1.3 - webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5)) + webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1) symbol-observable@4.0.0: {} @@ -42831,7 +43002,34 @@ snapshots: tabbable@6.2.0: {} - tailwindcss@3.4.13(ts-node@10.9.2(@types/node@20.17.12)(typescript@5.8.2)): + tailwindcss@3.4.13(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@20.17.12)(typescript@5.8.2)): + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.2 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.6 + lilconfig: 2.1.0 + micromatch: 4.0.8 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.1.0 + postcss: 8.5.3 + postcss-import: 15.1.0(postcss@8.5.3) + postcss-js: 4.0.1(postcss@8.5.3) + postcss-load-config: 4.0.2(postcss@8.5.3)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@20.17.12)(typescript@5.8.2)) + postcss-nested: 6.2.0(postcss@8.5.3) + postcss-selector-parser: 6.1.2 + resolve: 1.22.8 + sucrase: 3.35.0 + transitivePeerDependencies: + - ts-node + + tailwindcss@3.4.13(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.5.4)): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -42850,13 +43048,14 @@ snapshots: postcss: 8.5.3 postcss-import: 15.1.0(postcss@8.5.3) postcss-js: 4.0.1(postcss@8.5.3) - postcss-load-config: 4.0.2(postcss@8.5.3)(ts-node@10.9.2(@types/node@20.17.12)(typescript@5.8.2)) + postcss-load-config: 4.0.2(postcss@8.5.3)(ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.5.4)) postcss-nested: 6.2.0(postcss@8.5.3) postcss-selector-parser: 6.1.2 resolve: 1.22.8 sucrase: 3.35.0 transitivePeerDependencies: - ts-node + optional: true tamagui@1.79.6(@types/react@18.3.18)(immer@9.0.21)(react-dom@18.3.1(react@18.3.1))(react-native-web@0.19.13(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1): dependencies: @@ -43025,40 +43224,18 @@ snapshots: ansi-escapes: 4.3.2 supports-hyperlinks: 2.3.0 - terser-webpack-plugin@5.3.14(@swc/core@1.10.1(@swc/helpers@0.5.5))(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))): - dependencies: - '@jridgewell/trace-mapping': 0.3.25 - jest-worker: 27.5.1 - schema-utils: 4.3.0 - serialize-javascript: 6.0.2 - terser: 5.34.1 - webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5)) - optionalDependencies: - '@swc/core': 1.10.1(@swc/helpers@0.5.5) - - terser-webpack-plugin@5.3.14(@swc/core@1.10.1)(esbuild@0.25.1)(webpack@5.98.0(@swc/core@1.10.1)(esbuild@0.25.1)): + terser-webpack-plugin@5.3.14(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1)(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))): dependencies: '@jridgewell/trace-mapping': 0.3.25 jest-worker: 27.5.1 schema-utils: 4.3.0 serialize-javascript: 6.0.2 terser: 5.34.1 - webpack: 5.98.0(@swc/core@1.10.1)(esbuild@0.25.1) + webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1) optionalDependencies: '@swc/core': 1.10.1(@swc/helpers@0.5.5) esbuild: 0.25.1 - terser-webpack-plugin@5.3.14(@swc/core@1.10.1)(webpack@5.98.0(@swc/core@1.10.1)): - dependencies: - '@jridgewell/trace-mapping': 0.3.25 - jest-worker: 27.5.1 - schema-utils: 4.3.0 - serialize-javascript: 6.0.2 - terser: 5.34.1 - webpack: 5.98.0(@swc/core@1.10.1) - optionalDependencies: - '@swc/core': 1.10.1(@swc/helpers@0.5.5) - terser-webpack-plugin@5.3.14(@swc/core@1.6.13(@swc/helpers@0.5.5))(webpack@5.95.0(@swc/core@1.6.13(@swc/helpers@0.5.5))): dependencies: '@jridgewell/trace-mapping': 0.3.25 @@ -43081,15 +43258,6 @@ snapshots: optionalDependencies: '@swc/core': 1.6.13(@swc/helpers@0.5.5) - terser-webpack-plugin@5.3.14(webpack@5.98.0(webpack-cli@5.1.4)): - dependencies: - '@jridgewell/trace-mapping': 0.3.25 - jest-worker: 27.5.1 - schema-utils: 4.3.0 - serialize-javascript: 6.0.2 - terser: 5.34.1 - webpack: 5.98.0(webpack-cli@5.1.4) - terser-webpack-plugin@5.3.14(webpack@5.98.0): dependencies: '@jridgewell/trace-mapping': 0.3.25 @@ -43097,7 +43265,7 @@ snapshots: schema-utils: 4.3.0 serialize-javascript: 6.0.2 terser: 5.34.1 - webpack: 5.98.0 + webpack: 5.98.0(webpack-cli@5.1.4) terser@5.34.1: dependencies: @@ -43255,7 +43423,7 @@ snapshots: ts-interface-checker@0.1.13: {} - ts-loader@9.5.2(typescript@5.8.2)(webpack@5.98.0(@swc/core@1.10.1)): + ts-loader@9.5.2(typescript@5.8.2)(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))): dependencies: chalk: 4.1.2 enhanced-resolve: 5.17.1 @@ -43263,29 +43431,30 @@ snapshots: semver: 7.6.3 source-map: 0.7.4 typescript: 5.8.2 - webpack: 5.98.0(@swc/core@1.10.1) + webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1) - ts-node@10.9.2(@swc/core@1.10.1)(@types/node@22.7.4)(typescript@5.3.3): + ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@20.17.12)(typescript@5.8.2): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.11 '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 - '@types/node': 22.7.4 + '@types/node': 20.17.12 acorn: 8.12.1 acorn-walk: 8.3.4 arg: 4.1.3 create-require: 1.1.1 diff: 4.0.2 make-error: 1.3.6 - typescript: 5.3.3 + typescript: 5.8.2 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 optionalDependencies: '@swc/core': 1.10.1(@swc/helpers@0.5.5) + optional: true - ts-node@10.9.2(@swc/core@1.10.1)(@types/node@22.7.4)(typescript@5.5.4): + ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.3.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.11 @@ -43299,13 +43468,13 @@ snapshots: create-require: 1.1.1 diff: 4.0.2 make-error: 1.3.6 - typescript: 5.5.4 + typescript: 5.3.3 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 optionalDependencies: '@swc/core': 1.10.1(@swc/helpers@0.5.5) - ts-node@10.9.2(@swc/core@1.10.1)(@types/node@22.7.4)(typescript@5.8.2): + ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.5.4): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.11 @@ -43319,33 +43488,33 @@ snapshots: create-require: 1.1.1 diff: 4.0.2 make-error: 1.3.6 - typescript: 5.8.2 + typescript: 5.5.4 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 optionalDependencies: '@swc/core': 1.10.1(@swc/helpers@0.5.5) - ts-node@10.9.2(@swc/core@1.6.13(@swc/helpers@0.5.5))(@types/node@20.17.12)(typescript@4.5.5): + ts-node@10.9.2(@swc/core@1.10.1(@swc/helpers@0.5.5))(@types/node@22.7.4)(typescript@5.8.2): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.11 '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 - '@types/node': 20.17.12 + '@types/node': 22.7.4 acorn: 8.12.1 acorn-walk: 8.3.4 arg: 4.1.3 create-require: 1.1.1 diff: 4.0.2 make-error: 1.3.6 - typescript: 4.5.5 + typescript: 5.8.2 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 optionalDependencies: - '@swc/core': 1.6.13(@swc/helpers@0.5.5) + '@swc/core': 1.10.1(@swc/helpers@0.5.5) - ts-node@10.9.2(@types/node@20.17.12)(typescript@5.8.2): + ts-node@10.9.2(@swc/core@1.6.13(@swc/helpers@0.5.5))(@types/node@20.17.12)(typescript@4.5.5): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.11 @@ -43359,10 +43528,11 @@ snapshots: create-require: 1.1.1 diff: 4.0.2 make-error: 1.3.6 - typescript: 5.8.2 + typescript: 4.5.5 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 - optional: true + optionalDependencies: + '@swc/core': 1.6.13(@swc/helpers@0.5.5) ts-object-utils@0.0.5: {} @@ -43783,7 +43953,7 @@ snapshots: loader-utils: 2.0.4 mime-types: 2.1.35 schema-utils: 3.3.0 - webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5)) + webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1) optionalDependencies: file-loader: 6.2.0(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))) @@ -43994,7 +44164,7 @@ snapshots: vite-plugin-vuetify@2.0.4(vite@5.4.11(@types/node@22.7.4)(less@4.2.2)(lightningcss@1.28.2)(sass@1.85.0)(terser@5.39.0))(vue@3.4.21(typescript@5.8.2))(vuetify@3.6.8): dependencies: - '@vuetify/loader-shared': 2.0.3(vue@3.4.21(typescript@5.8.2))(vuetify@3.6.8(typescript@5.8.2)(vite-plugin-vuetify@2.0.4)(vue@3.4.21(typescript@5.8.2))) + '@vuetify/loader-shared': 2.0.3(vue@3.4.21(typescript@5.8.2))(vuetify@3.6.8) debug: 4.3.7 upath: 2.0.1 vite: 5.4.11(@types/node@22.7.4)(less@4.2.2)(lightningcss@1.28.2)(sass@1.85.0)(terser@5.39.0) @@ -44300,9 +44470,9 @@ snapshots: webpack-cli@5.1.4(webpack@5.98.0): dependencies: '@discoveryjs/json-ext': 0.5.7 - '@webpack-cli/configtest': 2.1.1(webpack-cli@5.1.4(webpack@5.98.0))(webpack@5.98.0(webpack-cli@5.1.4)) - '@webpack-cli/info': 2.0.2(webpack-cli@5.1.4(webpack@5.98.0))(webpack@5.98.0(webpack-cli@5.1.4)) - '@webpack-cli/serve': 2.0.5(webpack-cli@5.1.4(webpack@5.98.0))(webpack@5.98.0(webpack-cli@5.1.4)) + '@webpack-cli/configtest': 2.1.1(webpack-cli@5.1.4)(webpack@5.98.0) + '@webpack-cli/info': 2.0.2(webpack-cli@5.1.4)(webpack@5.98.0) + '@webpack-cli/serve': 2.0.5(webpack-cli@5.1.4)(webpack@5.98.0) colorette: 2.0.20 commander: 10.0.1 cross-spawn: 7.0.3 @@ -44321,18 +44491,9 @@ snapshots: mime-types: 2.1.35 range-parser: 1.2.1 schema-utils: 4.3.0 - webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5)) + webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1) - webpack-dev-middleware@5.3.4(webpack@5.98.0(@swc/core@1.10.1)): - dependencies: - colorette: 2.0.20 - memfs: 3.5.3 - mime-types: 2.1.35 - range-parser: 1.2.1 - schema-utils: 4.3.0 - webpack: 5.98.0(@swc/core@1.10.1) - - webpack-dev-middleware@7.4.2(webpack@5.98.0(@swc/core@1.10.1)(esbuild@0.25.1)): + webpack-dev-middleware@7.4.2(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))): dependencies: colorette: 2.0.20 memfs: 4.12.0 @@ -44341,49 +44502,9 @@ snapshots: range-parser: 1.2.1 schema-utils: 4.3.0 optionalDependencies: - webpack: 5.98.0(@swc/core@1.10.1)(esbuild@0.25.1) - - webpack-dev-server@4.15.2(debug@4.4.0)(webpack@5.98.0(@swc/core@1.10.1)): - dependencies: - '@types/bonjour': 3.5.13 - '@types/connect-history-api-fallback': 1.5.4 - '@types/express': 4.17.21 - '@types/serve-index': 1.9.4 - '@types/serve-static': 1.15.7 - '@types/sockjs': 0.3.36 - '@types/ws': 8.5.12 - ansi-html-community: 0.0.8 - bonjour-service: 1.2.1 - chokidar: 3.6.0 - colorette: 2.0.20 - compression: 1.7.4 - connect-history-api-fallback: 2.0.0 - default-gateway: 6.0.3 - express: 4.21.2 - graceful-fs: 4.2.11 - html-entities: 2.5.2 - http-proxy-middleware: 2.0.7(@types/express@4.17.21)(debug@4.4.0) - ipaddr.js: 2.2.0 - launch-editor: 2.9.1 - open: 8.4.2 - p-retry: 4.6.2 - rimraf: 3.0.2 - schema-utils: 4.2.0 - selfsigned: 2.4.1 - serve-index: 1.9.1 - sockjs: 0.3.24 - spdy: 4.0.2 - webpack-dev-middleware: 5.3.4(webpack@5.98.0(@swc/core@1.10.1)) - ws: 8.18.1 - optionalDependencies: - webpack: 5.98.0(@swc/core@1.10.1) - transitivePeerDependencies: - - bufferutil - - debug - - supports-color - - utf-8-validate + webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1) - webpack-dev-server@4.15.2(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))): + webpack-dev-server@4.15.2(debug@4.4.0)(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))): dependencies: '@types/bonjour': 3.5.13 '@types/connect-history-api-fallback': 1.5.4 @@ -44416,14 +44537,14 @@ snapshots: webpack-dev-middleware: 5.3.4(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))) ws: 8.18.1 optionalDependencies: - webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5)) + webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1) transitivePeerDependencies: - bufferutil - debug - supports-color - utf-8-validate - webpack-dev-server@5.2.0(webpack@5.98.0(@swc/core@1.10.1)(esbuild@0.25.1)): + webpack-dev-server@5.2.0(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))): dependencies: '@types/bonjour': 3.5.13 '@types/connect-history-api-fallback': 1.5.4 @@ -44450,10 +44571,10 @@ snapshots: serve-index: 1.9.1 sockjs: 0.3.24 spdy: 4.0.2 - webpack-dev-middleware: 7.4.2(webpack@5.98.0(@swc/core@1.10.1)(esbuild@0.25.1)) + webpack-dev-middleware: 7.4.2(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))) ws: 8.18.1 optionalDependencies: - webpack: 5.98.0(@swc/core@1.10.1)(esbuild@0.25.1) + webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1) transitivePeerDependencies: - bufferutil - debug @@ -44476,12 +44597,12 @@ snapshots: webpack-sources@3.2.3: {} - webpack-subresource-integrity@5.1.0(html-webpack-plugin@5.6.0(@rspack/core@1.1.8)(webpack@5.98.0(@swc/core@1.10.1)))(webpack@5.98.0(@swc/core@1.10.1)(esbuild@0.25.1)): + webpack-subresource-integrity@5.1.0(html-webpack-plugin@5.6.0(@rspack/core@1.1.8(@swc/helpers@0.5.5))(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))))(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))): dependencies: typed-assert: 1.0.9 - webpack: 5.98.0(@swc/core@1.10.1)(esbuild@0.25.1) + webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1) optionalDependencies: - html-webpack-plugin: 5.6.0(@rspack/core@1.1.8)(webpack@5.98.0(@swc/core@1.10.1)) + html-webpack-plugin: 5.6.0(@rspack/core@1.1.8(@swc/helpers@0.5.5))(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))) webpack-virtual-modules@0.6.2: {} @@ -44515,67 +44636,7 @@ snapshots: - esbuild - uglify-js - webpack@5.98.0: - dependencies: - '@types/eslint-scope': 3.7.7 - '@types/estree': 1.0.6 - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/wasm-edit': 1.14.1 - '@webassemblyjs/wasm-parser': 1.14.1 - acorn: 8.14.1 - browserslist: 4.24.0 - chrome-trace-event: 1.0.4 - enhanced-resolve: 5.17.1 - es-module-lexer: 1.5.4 - eslint-scope: 5.1.1 - events: 3.3.0 - glob-to-regexp: 0.4.1 - graceful-fs: 4.2.11 - json-parse-even-better-errors: 2.3.1 - loader-runner: 4.3.0 - mime-types: 2.1.35 - neo-async: 2.6.2 - schema-utils: 4.3.0 - tapable: 2.2.1 - terser-webpack-plugin: 5.3.14(webpack@5.98.0) - watchpack: 2.4.2 - webpack-sources: 3.2.3 - transitivePeerDependencies: - - '@swc/core' - - esbuild - - uglify-js - - webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5)): - dependencies: - '@types/eslint-scope': 3.7.7 - '@types/estree': 1.0.6 - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/wasm-edit': 1.14.1 - '@webassemblyjs/wasm-parser': 1.14.1 - acorn: 8.14.1 - browserslist: 4.24.0 - chrome-trace-event: 1.0.4 - enhanced-resolve: 5.17.1 - es-module-lexer: 1.5.4 - eslint-scope: 5.1.1 - events: 3.3.0 - glob-to-regexp: 0.4.1 - graceful-fs: 4.2.11 - json-parse-even-better-errors: 2.3.1 - loader-runner: 4.3.0 - mime-types: 2.1.35 - neo-async: 2.6.2 - schema-utils: 4.3.0 - tapable: 2.2.1 - terser-webpack-plugin: 5.3.14(@swc/core@1.10.1(@swc/helpers@0.5.5))(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))) - watchpack: 2.4.2 - webpack-sources: 3.2.3 - transitivePeerDependencies: - - '@swc/core' - - esbuild - - uglify-js - - webpack@5.98.0(@swc/core@1.10.1): + webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1): dependencies: '@types/eslint-scope': 3.7.7 '@types/estree': 1.0.6 @@ -44597,37 +44658,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 4.3.0 tapable: 2.2.1 - terser-webpack-plugin: 5.3.14(@swc/core@1.10.1)(webpack@5.98.0(@swc/core@1.10.1)) - watchpack: 2.4.2 - webpack-sources: 3.2.3 - transitivePeerDependencies: - - '@swc/core' - - esbuild - - uglify-js - - webpack@5.98.0(@swc/core@1.10.1)(esbuild@0.25.1): - dependencies: - '@types/eslint-scope': 3.7.7 - '@types/estree': 1.0.6 - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/wasm-edit': 1.14.1 - '@webassemblyjs/wasm-parser': 1.14.1 - acorn: 8.14.1 - browserslist: 4.24.0 - chrome-trace-event: 1.0.4 - enhanced-resolve: 5.17.1 - es-module-lexer: 1.5.4 - eslint-scope: 5.1.1 - events: 3.3.0 - glob-to-regexp: 0.4.1 - graceful-fs: 4.2.11 - json-parse-even-better-errors: 2.3.1 - loader-runner: 4.3.0 - mime-types: 2.1.35 - neo-async: 2.6.2 - schema-utils: 4.3.0 - tapable: 2.2.1 - terser-webpack-plugin: 5.3.14(@swc/core@1.10.1)(esbuild@0.25.1)(webpack@5.98.0(@swc/core@1.10.1)(esbuild@0.25.1)) + terser-webpack-plugin: 5.3.14(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1)(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))) watchpack: 2.4.2 webpack-sources: 3.2.3 transitivePeerDependencies: @@ -44687,7 +44718,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 4.3.0 tapable: 2.2.1 - terser-webpack-plugin: 5.3.14(webpack@5.98.0(webpack-cli@5.1.4)) + terser-webpack-plugin: 5.3.14(webpack@5.98.0) watchpack: 2.4.2 webpack-sources: 3.2.3 optionalDependencies: @@ -44706,7 +44737,7 @@ snapshots: markdown-table: 2.0.0 pretty-time: 1.1.0 std-env: 3.8.0 - webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5)) + webpack: 5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))(esbuild@0.25.1) wrap-ansi: 7.0.0 websocket-driver@0.7.4: From e6f902ebadbd8753a95737917799edff8c74cd10 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Fri, 16 May 2025 15:51:52 +0200 Subject: [PATCH 05/75] Add comparison test --- packages/common/package.json | 1 + packages/react/src/hooks/useQuery.ts | 1 - packages/react/tests/useQuery.test.tsx | 67 +++++++++++++++++++++++++- pnpm-lock.yaml | 12 ++++- 4 files changed, 78 insertions(+), 3 deletions(-) diff --git a/packages/common/package.json b/packages/common/package.json index 9a629a198..1763f9a66 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -54,6 +54,7 @@ "can-ndjson-stream": "^1.0.2", "cross-fetch": "^4.0.0", "event-iterator": "^2.0.0", + "p-defer": "^4.0.1", "rollup": "4.14.3", "rsocket-core": "1.0.0-alpha.3", "rsocket-websocket-client": "1.0.0-alpha.3", diff --git a/packages/react/src/hooks/useQuery.ts b/packages/react/src/hooks/useQuery.ts index 0b08a5c3f..dea5753b3 100644 --- a/packages/react/src/hooks/useQuery.ts +++ b/packages/react/src/hooks/useQuery.ts @@ -155,7 +155,6 @@ const useWatchedQuery = ( // Used when `isFetching` hasn't been set to true yet due to React execution. React.useEffect(() => { if (queryChanged) { - console.log('Query changed, re-evaluating', query, parameters); watchedQuery.updateQuery({ query, parameters: parameters, diff --git a/packages/react/tests/useQuery.test.tsx b/packages/react/tests/useQuery.test.tsx index 5f70446a1..0a922207a 100644 --- a/packages/react/tests/useQuery.test.tsx +++ b/packages/react/tests/useQuery.test.tsx @@ -1,6 +1,7 @@ import * as commonSdk from '@powersync/common'; import { PowerSyncDatabase } from '@powersync/web'; import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; +import pDefer from 'p-defer'; import React from 'react'; import { beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'; import { PowerSyncContext } from '../src/hooks/PowerSyncContext'; @@ -189,8 +190,72 @@ describe('useQuery', () => { expect(currentResult.data).toEqual([]); expect(currentResult.error).toEqual(Error('error')); }, - { timeout: 100 } + { timeout: 500, interval: 100 } + ); + }); + + it('should emit result data when query changes', async () => { + const db = openPowerSync(); + const wrapper = ({ children }) => {children}; + const { result } = renderHook(() => useQuery('SELECT * FROM lists WHERE name = ?', ['aname']), { wrapper }); + + expect(result.current.isLoading).toEqual(true); + + await waitFor( + async () => { + const { current } = result; + expect(current.isLoading).toEqual(false); + }, + { timeout: 500, interval: 100 } + ); + + // This should trigger an update + await db.execute('INSERT INTO lists(id, name) VALUES (uuid(), ?)', ['aname']); + + await waitFor( + async () => { + const { current } = result; + expect(current.data.length).toEqual(1); + }, + { timeout: 500, interval: 100 } ); + + const { + current: { data } + } = result; + + const deferred = pDefer(); + + const baseGetAll = db.getAll; + const getSpy = vi.spyOn(db, 'getAll').mockImplementation(async (sql, params) => { + // Allow pausing this call + await deferred.promise; + return baseGetAll.call(db, sql, params); + }); + + // The number of calls should be incremented after we make a change + const numberOfCalls = getSpy.mock.calls.length + 1; + // This should not trigger an update + await db.execute('INSERT INTO lists(id, name) VALUES (uuid(), ?)', ['anothername']); + + await waitFor( + () => { + expect(result.current.isFetching).toEqual(true); + }, + { timeout: 500, interval: 100 } + ); + + // Allow the result to be returned + deferred.resolve(); + + // We should still read the data from the DB + await waitFor(() => { + expect(getSpy).toHaveBeenCalledTimes(numberOfCalls); + expect(result.current.isFetching).toEqual(false); + }); + + // The data reference should be the same as the previous time + expect(data == result.current.data).toEqual(true); }); // TODO: Add tests for powersync.onChangeWithCallback path diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 16926512c..bf3a55a62 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -392,7 +392,7 @@ importers: version: 10.4.20(postcss@8.5.3) babel-loader: specifier: ^9.1.3 - version: 9.2.1(@babel/core@7.26.10)(webpack@5.98.0(@swc/core@1.6.13(@swc/helpers@0.5.5))) + version: 9.2.1(@babel/core@7.26.10)(webpack@5.95.0(@swc/core@1.6.13(@swc/helpers@0.5.5))) electron: specifier: 30.0.2 version: 30.0.2 @@ -1688,6 +1688,9 @@ importers: event-iterator: specifier: ^2.0.0 version: 2.0.0 + p-defer: + specifier: ^4.0.1 + version: 4.0.1 rollup: specifier: 4.14.3 version: 4.14.3 @@ -31013,6 +31016,13 @@ snapshots: transitivePeerDependencies: - supports-color + babel-loader@9.2.1(@babel/core@7.26.10)(webpack@5.95.0(@swc/core@1.6.13(@swc/helpers@0.5.5))): + dependencies: + '@babel/core': 7.26.10 + find-cache-dir: 4.0.0 + schema-utils: 4.2.0 + webpack: 5.95.0(@swc/core@1.6.13(@swc/helpers@0.5.5)) + babel-loader@9.2.1(@babel/core@7.26.10)(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))): dependencies: '@babel/core': 7.26.10 From ddbf6a3f5fbe2d364547e2dd12de574f239628b1 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Mon, 19 May 2025 11:33:05 +0200 Subject: [PATCH 06/75] cleanup --- .../client/watched/processors/comparison/WatchComparator.ts | 1 + packages/react/src/hooks/useQuery.ts | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/common/src/client/watched/processors/comparison/WatchComparator.ts b/packages/common/src/client/watched/processors/comparison/WatchComparator.ts index 80e8ff21c..738a02787 100644 --- a/packages/common/src/client/watched/processors/comparison/WatchComparator.ts +++ b/packages/common/src/client/watched/processors/comparison/WatchComparator.ts @@ -80,6 +80,7 @@ export abstract class AbstractWatchComparator implements WatchResultComparato } } + // TODO, should maybe use previous objects here for reference equality state.delta.unchanged.push(item); } diff --git a/packages/react/src/hooks/useQuery.ts b/packages/react/src/hooks/useQuery.ts index dea5753b3..8f1393e00 100644 --- a/packages/react/src/hooks/useQuery.ts +++ b/packages/react/src/hooks/useQuery.ts @@ -133,8 +133,7 @@ const useWatchedQuery = ( isFetching: state.fetching, isLoading: state.loading, data: state.data.all, - error: state.error, - refresh: async () => {} + error: state.error }), [] ); From 9837ca907b9c45c2d7e460fd11310db3d9372ad0 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Mon, 19 May 2025 15:54:52 +0200 Subject: [PATCH 07/75] limit stream depth --- .../src/client/watched/WatchedQueryImpl.ts | 24 ++++++++++++++--- .../processors/AbstractQueryProcessor.ts | 27 +++++++++++++++---- 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/packages/common/src/client/watched/WatchedQueryImpl.ts b/packages/common/src/client/watched/WatchedQueryImpl.ts index 6a3313330..b538b42a5 100644 --- a/packages/common/src/client/watched/WatchedQueryImpl.ts +++ b/packages/common/src/client/watched/WatchedQueryImpl.ts @@ -1,4 +1,5 @@ import { DataStream } from '../../utils/DataStream.js'; +import { limitStreamDepth } from './processors/AbstractQueryProcessor.js'; import { WatchedQuery, WatchedQueryOptions, WatchedQueryProcessor, WatchedQueryState } from './WatchedQuery.js'; export interface WatchedQueryImplOptions { @@ -23,13 +24,24 @@ export class WatchedQueryImpl implements WatchedQuery { stream(): DataStream> { // Return a new stream which can independently be closed from the original const stream = new DataStream>({ - closeOnError: true + closeOnError: true, + pressure: { + // limit the number of events queued in the event of slow consumers + highWaterMark: 2 + } }); + limitStreamDepth(stream, 1); + // pipe the lazy stream to the new stream this.lazyStreamPromise - .then((s) => { - s.registerListener({ + .then((upstreamSource) => { + // Edge case where the stream is closed before the upstream source is created + if (stream.closed) { + return; + } + + const dispose = upstreamSource.registerListener({ data: async (data) => { stream.enqueueData(data); }, @@ -40,6 +52,12 @@ export class WatchedQueryImpl implements WatchedQuery { stream.iterateListeners((l) => l.error?.(error)); } }); + + stream.registerListener({ + closed: () => { + dispose(); + } + }); }) .catch((error) => { stream.iterateListeners((l) => l.error?.(error)); diff --git a/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts b/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts index 067696694..b601c20ef 100644 --- a/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts +++ b/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts @@ -18,6 +18,20 @@ export interface LinkQueryStreamOptions { query: WatchedQueryOptions; } +/** + * Limits a stream on high water to only keep the latest event. + */ +export const limitStreamDepth = (stream: DataStream, limit: number) => { + const l = stream.registerListener({ + closed: () => l(), + highWater: async () => { + // Splice the queue to only keep the latest event + stream.dataQueue.splice(0, stream.dataQueue.length - limit); + } + }); + return stream; +}; + export abstract class AbstractQueryProcessor extends BaseObserver> implements WatchedQueryProcessor @@ -60,12 +74,10 @@ export abstract class AbstractQueryProcessor /** * This method is called when the stream is created or the PowerSync schema has updated. * It links the stream to the underlaying query. - * @param stream The stream to link to the underlaying query. - * @param abortSignal The signal to abort the underlaying query. */ protected abstract linkStream(options: LinkQueryStreamOptions): Promise; - protected updateState = (update: Partial>) => { + protected updateState(update: Partial>) { Object.assign(this.state, update); if (this._stream?.closed) { @@ -75,7 +87,7 @@ export abstract class AbstractQueryProcessor return; } this._stream?.enqueueData({ ...this.state }); - }; + } async generateStream() { if (this._stream) { @@ -85,9 +97,14 @@ export abstract class AbstractQueryProcessor const { db } = this.options; const stream = new DataStream>({ - logger: db.logger + logger: db.logger, + pressure: { + highWaterMark: 2 // Trigger event when 2 events are queued + } }); + limitStreamDepth(stream, 1); + this._stream = stream; let abortController: AbortController | null = null; From 7b15fc3d840168f2afcc79402fce25ace6aced19 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Mon, 19 May 2025 16:25:41 +0200 Subject: [PATCH 08/75] use abort signal --- packages/react/src/hooks/useQuery.ts | 71 ++++++++++++++++++---------- 1 file changed, 45 insertions(+), 26 deletions(-) diff --git a/packages/react/src/hooks/useQuery.ts b/packages/react/src/hooks/useQuery.ts index 8f1393e00..82b36f08a 100644 --- a/packages/react/src/hooks/useQuery.ts +++ b/packages/react/src/hooks/useQuery.ts @@ -34,6 +34,14 @@ export type QueryResult = { refresh?: (signal?: AbortSignal) => Promise; }; +type InternalHookOptions = { + query: string; + parameters: any[]; + powerSync: AbstractPowerSyncDatabase; + queryChanged: boolean; + queryExecutor?: () => Promise | null; +}; + const checkQueryChanged = (sqlStatement: string, queryParameters: any[], options: AdditionalOptions) => { const stringifiedParams = JSON.stringify(queryParameters); const stringifiedOptions = JSON.stringify(options); @@ -55,13 +63,9 @@ const checkQueryChanged = (sqlStatement: string, queryParameters: any[], opti return false; }; -const useSingleQuery = ( - query: string, - parameters: any[] = [], - powerSync: AbstractPowerSyncDatabase, - queryExecutor?: () => Promise | null, - queryChanged: boolean = false -): QueryResult => { +const useSingleQuery = (options: InternalHookOptions): QueryResult => { + const { query, parameters, powerSync, queryExecutor, queryChanged } = options; + const [output, setOutputState] = React.useState>({ isLoading: true, isFetching: true, @@ -69,12 +73,14 @@ const useSingleQuery = ( error: undefined }); - // TODO, how was this signal used? const runQuery = React.useCallback( async (signal?: AbortSignal) => { setOutputState((prev) => ({ ...prev, isLoading: true, isFetching: true, error: undefined })); try { const result = queryExecutor ? await queryExecutor() : await powerSync.getAll(query, parameters); + if (signal.aborted) { + return; + } setOutputState((prev) => ({ ...prev, isLoading: false, @@ -110,23 +116,19 @@ const useSingleQuery = ( }; }; -const useWatchedQuery = ( - query: string, - parameters: any[] = [], - powerSync: AbstractPowerSyncDatabase, - queryExecutor?: () => Promise | null, - queryChanged: boolean = false, - options: HookWatchOptions = {} -): QueryResult => { - const [watchedQuery] = React.useState(() => { +const useWatchedQuery = (options: InternalHookOptions & { options: HookWatchOptions }): QueryResult => { + const { query, parameters, powerSync, queryExecutor, queryChanged, options: hookOptions } = options; + const createWatchedQuery = React.useCallback(() => { return powerSync.incrementalWatch({ sql: query, parameters, queryExecutor, - throttleMs: options.throttleMs, - reportFetching: options.reportFetching + throttleMs: hookOptions.throttleMs, + reportFetching: hookOptions.reportFetching }); - }); + }, []); + + const [watchedQuery, setWatchedQuery] = React.useState(createWatchedQuery); const mapState = React.useCallback( (state: WatchedQueryState) => ({ @@ -140,6 +142,11 @@ const useWatchedQuery = ( const [output, setOutputState] = React.useState(mapState(watchedQuery.state)); + React.useEffect(() => { + watchedQuery.close(); + setWatchedQuery(createWatchedQuery); + }, [powerSync]); + React.useEffect(() => { watchedQuery.stream().forEach(async (val) => { setOutputState(mapState(val)); @@ -148,7 +155,7 @@ const useWatchedQuery = ( return () => { watchedQuery.close(); }; - }, []); + }, [watchedQuery]); // Indicates that the query will be re-fetched due to a change in the query. // Used when `isFetching` hasn't been set to true yet due to React execution. @@ -157,9 +164,9 @@ const useWatchedQuery = ( watchedQuery.updateQuery({ query, parameters: parameters, - throttleMs: options.throttleMs, + throttleMs: hookOptions.throttleMs, queryExecutor, - reportFetching: options.reportFetching + reportFetching: hookOptions.reportFetching }); } }, [queryChanged]); @@ -206,9 +213,21 @@ export const useQuery = ( switch (options.runQueryOnce) { case true: - return useSingleQuery(sqlStatement, queryParameters, powerSync, queryExecutor, queryChanged); + return useSingleQuery({ + query: sqlStatement, + parameters: queryParameters, + powerSync, + queryExecutor, + queryChanged + }); default: - // TODO handle if powersync changed - return useWatchedQuery(sqlStatement, queryParameters, powerSync, queryExecutor, queryChanged, options); + return useWatchedQuery({ + query: sqlStatement, + parameters: queryParameters, + powerSync, + queryExecutor, + queryChanged, + options + }); } }; From 8874c751cf5296a63265d44afcbba276f6068e7f Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 20 May 2025 09:13:16 +0200 Subject: [PATCH 09/75] cleanup api --- .../src/client/AbstractPowerSyncDatabase.ts | 10 +- .../common/src/client/watched/WatchedQuery.ts | 11 +- .../src/client/watched/WatchedQueryImpl.ts | 15 +- .../src/client/watched/WatchedQueryResult.ts | 30 ---- .../processors/AbstractQueryProcessor.ts | 11 +- .../processors/OnChangeQueryProcessor.ts | 64 +++++--- .../comparison/ComparisonQueryProcessor.ts | 34 ---- .../processors/comparison/WatchComparator.ts | 147 ------------------ packages/react/src/hooks/useQuery.ts | 16 +- packages/web/tests/watch.test.ts | 94 +++++++++++ 10 files changed, 169 insertions(+), 263 deletions(-) delete mode 100644 packages/common/src/client/watched/WatchedQueryResult.ts delete mode 100644 packages/common/src/client/watched/processors/comparison/ComparisonQueryProcessor.ts delete mode 100644 packages/common/src/client/watched/processors/comparison/WatchComparator.ts diff --git a/packages/common/src/client/AbstractPowerSyncDatabase.ts b/packages/common/src/client/AbstractPowerSyncDatabase.ts index 024de3bd8..4fee8f8ff 100644 --- a/packages/common/src/client/AbstractPowerSyncDatabase.ts +++ b/packages/common/src/client/AbstractPowerSyncDatabase.ts @@ -35,8 +35,7 @@ import { } from './sync/stream/AbstractStreamingSyncImplementation.js'; import { WatchedQuery } from './watched/WatchedQuery.js'; import { WatchedQueryImpl } from './watched/WatchedQueryImpl.js'; -import { ComparisonQueryProcessor } from './watched/processors/comparison/ComparisonQueryProcessor.js'; -import { InlineWatchComparator } from './watched/processors/comparison/WatchComparator.js'; +import { OnChangeQueryProcessor } from './watched/processors/OnChangeQueryProcessor.js'; export interface DisconnectAndClearOptions { /** When set to false, data in local-only tables is preserved. */ @@ -877,12 +876,9 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver { return new WatchedQueryImpl({ - processor: new ComparisonQueryProcessor({ + processor: new OnChangeQueryProcessor({ db: this, - comparator: new InlineWatchComparator({ - hash: (row) => JSON.stringify(row), - identify: (row) => (row as any).id ?? JSON.stringify(row) // TODO - }), + compareBy: (item) => JSON.stringify(item), // TODO make configurable watchedQuery: { query: options.sql, parameters: options.parameters, diff --git a/packages/common/src/client/watched/WatchedQuery.ts b/packages/common/src/client/watched/WatchedQuery.ts index 2abc15085..01a97d554 100644 --- a/packages/common/src/client/watched/WatchedQuery.ts +++ b/packages/common/src/client/watched/WatchedQuery.ts @@ -1,16 +1,16 @@ import { DataStream } from '../../utils/DataStream.js'; -import { WatchedQueryResult } from './WatchedQueryResult.js'; export interface WatchedQueryState { - loading: boolean; - fetching: boolean; + isLoading: boolean; + isFetching: boolean; error: Error | null; lastUpdated: Date | null; - data: WatchedQueryResult; + data: T[]; } /** * Performs underlaying watching and yields a stream of results. + * @internal */ export interface WatchedQueryProcessor { readonly state: WatchedQueryState; @@ -20,6 +20,9 @@ export interface WatchedQueryProcessor { updateQuery(query: WatchedQueryOptions): void; } +/** + * @internal + */ export interface WatchedQueryOptions { query: string; parameters?: any[]; diff --git a/packages/common/src/client/watched/WatchedQueryImpl.ts b/packages/common/src/client/watched/WatchedQueryImpl.ts index b538b42a5..f672297a4 100644 --- a/packages/common/src/client/watched/WatchedQueryImpl.ts +++ b/packages/common/src/client/watched/WatchedQueryImpl.ts @@ -8,9 +8,14 @@ export interface WatchedQueryImplOptions { export class WatchedQueryImpl implements WatchedQuery { protected lazyStreamPromise: Promise>>; + protected _stream: DataStream> | null; constructor(protected options: WatchedQueryImplOptions) { - this.lazyStreamPromise = this.options.processor.generateStream(); + this._stream = null; + this.lazyStreamPromise = this.options.processor.generateStream().then((s) => { + this._stream = s; + return s; + }); } get state() { @@ -67,9 +72,13 @@ export class WatchedQueryImpl implements WatchedQuery { } close(): void { + if (this._stream) { + this._stream.close().catch(() => {}); + return; + } this.lazyStreamPromise - .then((s) => { - s.close(); + .then(async (s) => { + await s.close(); }) .catch(() => { // In rare cases where the DB might be closed before the stream is created diff --git a/packages/common/src/client/watched/WatchedQueryResult.ts b/packages/common/src/client/watched/WatchedQueryResult.ts deleted file mode 100644 index dfe45fcfe..000000000 --- a/packages/common/src/client/watched/WatchedQueryResult.ts +++ /dev/null @@ -1,30 +0,0 @@ -export interface WatchedQueryDelta { - /** - * Rows added since the previous result set - */ - added: T[]; - /** - * Rows removed since the previous result set - */ - removed: T[]; - /** - * Rows which have changed since the previous result set - */ - updated: T[]; - /** - * Rows which are unchanged since the previous result set - */ - unchanged: T[]; -} - -export interface WatchedQueryResult { - /** - * All the current rows in the result set - */ - all: T[]; - - /** - * The delta since the last result set - */ - delta(): WatchedQueryDelta; -} diff --git a/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts b/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts index b601c20ef..0003a4f6e 100644 --- a/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts +++ b/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts @@ -37,14 +37,11 @@ export abstract class AbstractQueryProcessor implements WatchedQueryProcessor { readonly state: WatchedQueryState = { - loading: true, - fetching: true, + isLoading: true, + isFetching: true, error: null, lastUpdated: null, - data: { - all: [], - delta: () => ({ added: [], removed: [], unchanged: [], updated: [] }) - } + data: [] }; protected _stream: DataStream> | null; @@ -78,7 +75,7 @@ export abstract class AbstractQueryProcessor protected abstract linkStream(options: LinkQueryStreamOptions): Promise; protected updateState(update: Partial>) { - Object.assign(this.state, update); + Object.assign(this.state, { lastUpdated: new Date() } satisfies Partial>, update); if (this._stream?.closed) { // Don't enqueue data in a closed stream. diff --git a/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts b/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts index c47b9f4f8..9cb2d90b9 100644 --- a/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts +++ b/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts @@ -1,22 +1,52 @@ import { WatchedQueryState } from '../WatchedQuery.js'; -import { WatchedQueryResult } from '../WatchedQueryResult.js'; -import { AbstractQueryProcessor, LinkQueryStreamOptions } from './AbstractQueryProcessor.js'; +import { + AbstractQueryProcessor, + AbstractQueryProcessorOptions, + LinkQueryStreamOptions +} from './AbstractQueryProcessor.js'; + +export interface OnChangeQueryProcessorOptions extends AbstractQueryProcessorOptions { + compareBy?: (element: T) => string; +} /** * Uses the PowerSync onChange event to trigger watched queries. * Results are emitted on every change of the relevant tables. */ export class OnChangeQueryProcessor extends AbstractQueryProcessor { - /** - * Always returns the result set on every onChange event. Deltas are not supported by this processor. + constructor(protected options: OnChangeQueryProcessorOptions) { + super(options); + } + + /* + * @returns If the sets are equal */ - protected processResultSet(result: T[]): WatchedQueryResult | null { - return { - all: result, - delta: () => { - throw new Error('Delta not implemented for OnChangeQueryProcessor'); + protected checkEquality(current: T[], previous: T[]): boolean { + if (current.length == 0 && previous.length == 0) { + return true; + } + + if (current.length !== previous.length) { + return false; + } + + const { compareBy } = this.options; + // Assume items are not equal if we can't compare them + if (!compareBy) { + return false; + } + + // At this point the lengths are equal + for (let i = 0; i < current.length; i++) { + const currentItem = compareBy(current[i]); + const previousItem = compareBy(previous[i]); + + if (currentItem !== previousItem) { + return false; } - }; + } + + return true; } protected async linkStream(options: LinkQueryStreamOptions): Promise { @@ -31,7 +61,7 @@ export class OnChangeQueryProcessor extends AbstractQueryProcessor { // This fires for each change of the relevant tables try { if (this.reportFetching) { - this.updateState({ fetching: true }); + this.updateState({ isFetching: true }); } const partialStateUpdate: Partial> = {}; @@ -42,18 +72,16 @@ export class OnChangeQueryProcessor extends AbstractQueryProcessor { : await db.getAll(watchedQuery.query, watchedQuery.parameters); if (this.reportFetching) { - partialStateUpdate.fetching = false; + partialStateUpdate.isFetching = false; } - if (this.state.loading) { - partialStateUpdate.loading = false; + if (this.state.isLoading) { + partialStateUpdate.isLoading = false; } // Check if the result has changed - const watchedQueryResult = this.processResultSet(result); - if (watchedQueryResult) { - partialStateUpdate.data = watchedQueryResult; - partialStateUpdate.lastUpdated = new Date(); + if (!this.checkEquality(result, this.state.data)) { + partialStateUpdate.data = result; } if (Object.keys(partialStateUpdate).length > 0) { diff --git a/packages/common/src/client/watched/processors/comparison/ComparisonQueryProcessor.ts b/packages/common/src/client/watched/processors/comparison/ComparisonQueryProcessor.ts deleted file mode 100644 index 6fc259c26..000000000 --- a/packages/common/src/client/watched/processors/comparison/ComparisonQueryProcessor.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { WatchedQueryResult } from '../../WatchedQueryResult.js'; -import { AbstractQueryProcessorOptions } from '../AbstractQueryProcessor.js'; -import { OnChangeQueryProcessor } from '../OnChangeQueryProcessor.js'; -import { WatchResultComparator } from './WatchComparator.js'; - -export interface ComparisonQueryProcessorOptions extends AbstractQueryProcessorOptions { - comparator: WatchResultComparator; -} -/** - * TODO: - * This currently checks if the entire result set has changed. - * In some cases a deep comparison of the result might be required. - * For example if result[1] is unchanged, it might be useful to keep the same object reference. - */ -export class ComparisonQueryProcessor extends OnChangeQueryProcessor { - constructor(protected options: ComparisonQueryProcessorOptions) { - super(options); - } - - protected processResultSet(result: T[]): WatchedQueryResult | null { - const { comparator } = this.options; - const previous = this.state.data.all; - const delta = comparator.compare(previous, result); - - if (delta.isEqual()) { - return null; // the stream will not emit a change of data - } - - return { - all: result, - delta: () => delta.delta() // lazy evaluation - }; - } -} diff --git a/packages/common/src/client/watched/processors/comparison/WatchComparator.ts b/packages/common/src/client/watched/processors/comparison/WatchComparator.ts deleted file mode 100644 index 738a02787..000000000 --- a/packages/common/src/client/watched/processors/comparison/WatchComparator.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { WatchedQueryDelta } from '../../WatchedQueryResult.js'; - -export interface Comparable { - identity: string; - hash: string; -} - -export interface WatchComparableResult { - delta(): WatchedQueryDelta; - isEqual(): boolean; -} - -export interface WatchResultComparator { - compare(previous: T[], current: T[]): WatchComparableResult; -} - -export interface SteppedComparisonState { - currentItems: T[]; - resumeIndex: number; - delta: WatchedQueryDelta; - previousHashes: Map; - previousRemovalTracker: Map; - isEqual?: boolean; -} - -export abstract class AbstractWatchComparator implements WatchResultComparator { - abstract identify(item: T): string; - abstract hash(item: T): string; - - protected stepComparison( - state: SteppedComparisonState, - options: { - /** - * Only checks if the comparison is equal, the delta is not fully updated. - */ - validateEquality?: boolean; - } - ): void { - const { validateEquality } = options; - - if (state.currentItems.length == 0 && state.previousHashes.size == 0) { - state.isEqual = true; - return; - } - - if (state.resumeIndex >= state.currentItems.length) { - // No more items to compare, we are done - return; - } - - if (validateEquality && state.isEqual != null) { - return; - } - - for (; state.resumeIndex < state.currentItems.length; state.resumeIndex++) { - const item = state.currentItems[state.resumeIndex]; - - const identifier = this.identify(item); - // This item is present, it has not been removed from the first array - state.previousRemovalTracker.delete(identifier); - - if (!state.previousHashes.has(identifier)) { - state.delta.added.push(item); - if (validateEquality) { - state.isEqual = false; - return; - } else { - continue; - } - } - - const hash = this.hash(item); - if (state.previousHashes.get(identifier) !== hash) { - state.delta.updated.push(item); - if (validateEquality) { - state.isEqual = false; - return; - } else { - continue; - } - } - - // TODO, should maybe use previous objects here for reference equality - state.delta.unchanged.push(item); - } - - state.delta.removed = Array.from(state.previousRemovalTracker.values()); - state.isEqual = - state.delta.added.length === 0 && state.delta.removed.length === 0 && state.delta.updated.length === 0; - } - - compare(previous: T[], current: T[]): WatchComparableResult { - const mapEntries = previous.map((item) => [this.identify(item), this.hash(item), item]) as [string, string, T][]; - - const comparisonState: SteppedComparisonState = { - currentItems: current, - resumeIndex: 0, - delta: { - added: [], - removed: [], - updated: [], - unchanged: [] - }, - previousHashes: new Map(mapEntries.map(([id, hash]) => [id, hash])), - previousRemovalTracker: new Map(mapEntries.map(([id, _, item]) => [id, item])) - }; - - return { - delta: () => { - this.stepComparison(comparisonState, { validateEquality: false }); - return comparisonState.delta; - }, - isEqual: () => { - this.stepComparison(comparisonState, { validateEquality: true }); - return comparisonState.isEqual!; - } - } satisfies WatchComparableResult; - } -} - -export type InlineWatchComparatorOptions = { - identify: (item: T) => string; - hash: (item: T) => string; -}; - -export class InlineWatchComparator extends AbstractWatchComparator { - constructor(protected options: InlineWatchComparatorOptions) { - super(); - } - - identify(item: T): string { - return this.options.identify(item); - } - - hash(item: T): string { - return this.options.hash(item); - } -} - -export class DefaultWatchComparator extends InlineWatchComparator { - constructor() { - super({ - identify: (item: T) => item.id, - hash: (item: T) => JSON.stringify(item) - }); - } -} diff --git a/packages/react/src/hooks/useQuery.ts b/packages/react/src/hooks/useQuery.ts index 82b36f08a..13571f070 100644 --- a/packages/react/src/hooks/useQuery.ts +++ b/packages/react/src/hooks/useQuery.ts @@ -1,7 +1,6 @@ import { AbstractPowerSyncDatabase, parseQuery, - WatchedQueryState, type CompilableQuery, type ParsedQuery, type SQLWatchOptions @@ -118,6 +117,7 @@ const useSingleQuery = (options: InternalHookOptions): QueryResult(options: InternalHookOptions & { options: HookWatchOptions }): QueryResult => { const { query, parameters, powerSync, queryExecutor, queryChanged, options: hookOptions } = options; + const createWatchedQuery = React.useCallback(() => { return powerSync.incrementalWatch({ sql: query, @@ -130,17 +130,7 @@ const useWatchedQuery = (options: InternalHookOptions & { options: H const [watchedQuery, setWatchedQuery] = React.useState(createWatchedQuery); - const mapState = React.useCallback( - (state: WatchedQueryState) => ({ - isFetching: state.fetching, - isLoading: state.loading, - data: state.data.all, - error: state.error - }), - [] - ); - - const [output, setOutputState] = React.useState(mapState(watchedQuery.state)); + const [output, setOutputState] = React.useState(watchedQuery.state); React.useEffect(() => { watchedQuery.close(); @@ -149,7 +139,7 @@ const useWatchedQuery = (options: InternalHookOptions & { options: H React.useEffect(() => { watchedQuery.stream().forEach(async (val) => { - setOutputState(mapState(val)); + setOutputState(val); }); return () => { diff --git a/packages/web/tests/watch.test.ts b/packages/web/tests/watch.test.ts index 6aea2211d..0866c6932 100644 --- a/packages/web/tests/watch.test.ts +++ b/packages/web/tests/watch.test.ts @@ -323,4 +323,98 @@ describe('Watch Tests', { sequential: true }, () => { expect(receivedWithManagedOverflowCount).greaterThan(2); expect(receivedWithManagedOverflowCount).toBeLessThanOrEqual(4); }); + + it('should stream watch results', async () => { + const watch = powersync.incrementalWatch({ + sql: 'SELECT * FROM assets', + parameters: [] + }); + + expect(watch.state.isLoading).true; + expect(watch.state.isFetching).true; + + const next = await watch.stream().read(); + expect(next).toBeDefined(); + expect(next!.isFetching).false; + expect(next!.isLoading).false; + + const nextFetchPromise = watch.stream().read(); + await powersync.execute('INSERT INTO assets(id, make, customer_id) VALUES (uuid(), ?, ?)', ['test', uuid()]); + const nextFetch = await nextFetchPromise; + expect(nextFetch).toBeDefined(); + expect(nextFetch!.isFetching).true; + + const dataNext = await watch.stream().read(); + expect(dataNext).toBeDefined(); + expect(dataNext!.isFetching).false; + expect(dataNext!.data).toHaveLength(1); + }); + + it('should limit queue depth', async () => { + const watch = powersync.incrementalWatch({ + sql: 'SELECT * FROM assets', + parameters: [] + }); + + expect(watch.state.isLoading).true; + expect(watch.state.isFetching).true; + + // TODO limit interface + const stream = watch.stream(); + const anotherStream = watch.stream(); + + const next = await stream.read(); + expect(next).toBeDefined(); + expect(next!.isFetching).false; + expect(next!.isLoading).false; + + const nextFetchPromise = stream.read(); + await powersync.execute('INSERT INTO assets(id, make, customer_id) VALUES (uuid(), ?, ?)', ['test', uuid()]); + const nextFetch = await nextFetchPromise; + expect(nextFetch).toBeDefined(); + expect(nextFetch!.isFetching).true; + + const dataNext = await stream.read(); + expect(dataNext).toBeDefined(); + expect(dataNext!.isFetching).false; + expect(dataNext!.data).toHaveLength(1); + + // This should only have the latest unprocessed event + expect(anotherStream.dataQueue).toHaveLength(1); + expect(anotherStream.dataQueue[0].data).toHaveLength(1); + + watch.close(); + // TODO + await new Promise((r) => setTimeout(r, 500)); + expect(stream.closed).true; + }); + + it('should only report updates for relevant changes', async () => { + const watch = powersync.incrementalWatch({ + sql: 'SELECT * FROM assets where make = ?', + parameters: ['test'] + }); + + let notificationCount = 0; + const receivedRelevantData = new Promise((resolve) => { + watch.stream().forEach(async (update) => { + notificationCount++; + console.log('Received update', update); + if (update.data.length > 0) { + resolve(); + } + }); + }); + + await powersync.execute('INSERT INTO assets(id, make, customer_id) VALUES (uuid(), ?, ?)', ['make1', uuid()]); + await powersync.execute('INSERT INTO assets(id, make, customer_id) VALUES (uuid(), ?, ?)', ['make2', uuid()]); + await powersync.execute('INSERT INTO assets(id, make, customer_id) VALUES (uuid(), ?, ?)', ['make3', uuid()]); + await powersync.execute('INSERT INTO assets(id, make, customer_id) VALUES (uuid(), ?, ?)', ['make4', uuid()]); + // Should only trigger for this operation + await powersync.execute('INSERT INTO assets(id, make, customer_id) VALUES (uuid(), ?, ?)', ['test', uuid()]); + + await receivedRelevantData; + // Should get one notification for first loading then finished loading then received data + expect(notificationCount).equals(3); + }); }); From 7d1124cad8c504685666d05f5a6f74e0b6c2d901 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 20 May 2025 13:59:56 +0200 Subject: [PATCH 10/75] Update generic typing and APIs --- .../src/client/AbstractPowerSyncDatabase.ts | 105 +++++---- .../common/src/client/watched/WatchedQuery.ts | 80 ++++--- .../src/client/watched/WatchedQueryImpl.ts | 89 -------- .../processors/AbstractQueryProcessor.ts | 204 +++++++++--------- .../processors/OnChangeQueryProcessor.ts | 87 ++++---- packages/react/src/hooks/useQuery.ts | 53 +++-- packages/react/tests/useQuery.test.tsx | 14 +- packages/web/tests/watch.test.ts | 128 +++++------ 8 files changed, 367 insertions(+), 393 deletions(-) delete mode 100644 packages/common/src/client/watched/WatchedQueryImpl.ts diff --git a/packages/common/src/client/AbstractPowerSyncDatabase.ts b/packages/common/src/client/AbstractPowerSyncDatabase.ts index 4fee8f8ff..fed3f589f 100644 --- a/packages/common/src/client/AbstractPowerSyncDatabase.ts +++ b/packages/common/src/client/AbstractPowerSyncDatabase.ts @@ -19,7 +19,6 @@ import { throttleTrailing } from '../utils/async.js'; import { mutexRunExclusive } from '../utils/mutex.js'; import { SQLOpenFactory, SQLOpenOptions, isDBAdapter, isSQLOpenFactory, isSQLOpenOptions } from './SQLOpenFactory.js'; import { PowerSyncBackendConnector } from './connection/PowerSyncBackendConnector.js'; -import { runOnSchemaChange } from './runOnSchemaChange.js'; import { BucketStorageAdapter, PSInternalTable } from './sync/bucket/BucketStorageAdapter.js'; import { CrudBatch } from './sync/bucket/CrudBatch.js'; import { CrudEntry, CrudEntryJSON } from './sync/bucket/CrudEntry.js'; @@ -34,8 +33,7 @@ import { type RequiredAdditionalConnectionOptions } from './sync/stream/AbstractStreamingSyncImplementation.js'; import { WatchedQuery } from './watched/WatchedQuery.js'; -import { WatchedQueryImpl } from './watched/WatchedQueryImpl.js'; -import { OnChangeQueryProcessor } from './watched/processors/OnChangeQueryProcessor.js'; +import { OnChangeQueryProcessor, WatchedQueryComparator } from './watched/processors/OnChangeQueryProcessor.js'; export interface DisconnectAndClearOptions { /** When set to false, data in local-only tables is preserved. */ @@ -89,6 +87,12 @@ export interface SQLWatchOptions { * Emits an empty result set immediately */ triggerImmediate?: boolean; + + /** + * Optional comparator which will be used to compare the results of the query. + * The watched query will only yield results if the comparator returns false. + */ + comparator?: WatchedQueryComparator; } export interface WatchOnChangeEvent { @@ -868,25 +872,28 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver(options: { + incrementalWatch(options: { sql: string; parameters?: any[]; throttleMs?: number; - queryExecutor?: () => Promise; + customExecutor?: { + initialData: DataType; + execute: () => Promise; + }; reportFetching?: boolean; - }): WatchedQuery { - return new WatchedQueryImpl({ - processor: new OnChangeQueryProcessor({ - db: this, - compareBy: (item) => JSON.stringify(item), // TODO make configurable - watchedQuery: { - query: options.sql, - parameters: options.parameters, - throttleMs: options.throttleMs ?? DEFAULT_WATCH_THROTTLE_MS, - queryExecutor: options.queryExecutor, - reportFetching: options.reportFetching - } - }) + }): WatchedQuery { + return new OnChangeQueryProcessor({ + db: this, + comparator: { + checkEquality: (a, b) => JSON.stringify(a) == JSON.stringify(b) + }, + query: { + sql: options.sql, + parameters: options.parameters, + throttleMs: options.throttleMs ?? DEFAULT_WATCH_THROTTLE_MS, + customExecutor: options.customExecutor, + reportFetching: options.reportFetching + } }); } @@ -908,38 +915,42 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver { - try { - const resolvedTables = await this.resolveTables(sql, parameters, options); - // Fetch initial data - const result = await this.executeReadOnly(sql, parameters); - onResult(result); - - this.onChangeWithCallback( - { - onChange: async () => { - try { - const result = await this.executeReadOnly(sql, parameters); - onResult(result); - } catch (error) { - onError?.(error); - } - }, - onError + const watch = new OnChangeQueryProcessor({ + db: this, + // Comparisons are disabled if no comparator is provided + comparator: options?.comparator, + query: { + sql, + parameters, + throttleMs: options?.throttleMs ?? DEFAULT_WATCH_THROTTLE_MS, + reportFetching: false, + // The default watch implementation returns QueryResult as the Data type + customExecutor: { + execute: async () => { + return this.executeReadOnly(sql, parameters); }, - { - ...(options ?? {}), - tables: resolvedTables, - // Override the abort signal since we intercept it - signal: abortSignal - } - ); - } catch (error) { - onError?.(error); + initialData: null + } } - }; + }); - runOnSchemaChange(watchQuery, this, options); + const dispose = watch.subscribe({ + onData: (data) => { + if (!data) { + // This should not happen. We only use null for the initial data. + return; + } + onResult(data); + }, + onError: (error) => { + onError(error); + } + }); + + options?.signal?.addEventListener('abort', () => { + dispose(); + watch.close(); + }); } /** diff --git a/packages/common/src/client/watched/WatchedQuery.ts b/packages/common/src/client/watched/WatchedQuery.ts index 01a97d554..ef8482c4b 100644 --- a/packages/common/src/client/watched/WatchedQuery.ts +++ b/packages/common/src/client/watched/WatchedQuery.ts @@ -1,34 +1,43 @@ -import { DataStream } from '../../utils/DataStream.js'; - -export interface WatchedQueryState { +export interface WatchedQueryState { + /** + * Indicates the initial loading state (hard loading). Loading becomes false once the first set of results from the watched query is available or an error occurs. + */ isLoading: boolean; + /** + * Indicates whether the query is currently fetching data, is true during the initial load and any time when the query is re-evaluating (useful for large queries). + */ isFetching: boolean; + /** + * The last error that occurred while executing the query. + */ error: Error | null; + /** + * The last time the query was updated. + */ lastUpdated: Date | null; - data: T[]; -} - -/** - * Performs underlaying watching and yields a stream of results. - * @internal - */ -export interface WatchedQueryProcessor { - readonly state: WatchedQueryState; - - generateStream(): Promise>>; - - updateQuery(query: WatchedQueryOptions): void; + /** + * The last data returned by the query. + */ + data: Data; } /** * @internal */ -export interface WatchedQueryOptions { - query: string; +export interface WatchedQueryOptions { + sql: string; parameters?: any[]; /** The minimum interval between queries. */ throttleMs?: number; - queryExecutor?: () => Promise; + /** + * Optional query executor responsible for executing the query. + * This can be used to return query results which are mapped from the database. + * Often this is useful for ORM queries or other query builders. + */ + customExecutor?: { + execute: () => Promise; + initialData: DataType; + }; /** * If true (default) the watched query will update its state to report * on the fetching state of the query. @@ -38,9 +47,32 @@ export interface WatchedQueryOptions { reportFetching?: boolean; } -export interface WatchedQuery { - readonly state: WatchedQueryState; - stream(): DataStream>; - updateQuery(query: WatchedQueryOptions): void; - close(): void; +export interface WatchedQuerySubscription { + onData?: (data: Data) => void | Promise; + onError?: (error: Error) => void | Promise; + onStateChange?: (state: WatchedQueryState) => void | Promise; +} + +export interface WatchedQuery { + /** + * Current state of the watched query. + */ + readonly state: WatchedQueryState; + + /** + * Subscribe to watched query events. + * @returns A function to unsubscribe from the events. + */ + subscribe(subscription: WatchedQuerySubscription): () => void; + + /** + * Updates the underlaying query. + * This will trigger a re-evaluation of the query and update the state. + */ + updateQuery(query: WatchedQueryOptions): Promise; + + /** + * Close the watched query and end all subscriptions. + */ + close(): Promise; } diff --git a/packages/common/src/client/watched/WatchedQueryImpl.ts b/packages/common/src/client/watched/WatchedQueryImpl.ts deleted file mode 100644 index f672297a4..000000000 --- a/packages/common/src/client/watched/WatchedQueryImpl.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { DataStream } from '../../utils/DataStream.js'; -import { limitStreamDepth } from './processors/AbstractQueryProcessor.js'; -import { WatchedQuery, WatchedQueryOptions, WatchedQueryProcessor, WatchedQueryState } from './WatchedQuery.js'; - -export interface WatchedQueryImplOptions { - processor: WatchedQueryProcessor; -} - -export class WatchedQueryImpl implements WatchedQuery { - protected lazyStreamPromise: Promise>>; - protected _stream: DataStream> | null; - - constructor(protected options: WatchedQueryImplOptions) { - this._stream = null; - this.lazyStreamPromise = this.options.processor.generateStream().then((s) => { - this._stream = s; - return s; - }); - } - - get state() { - return this.options.processor.state; - } - - updateQuery(query: WatchedQueryOptions): void { - this.options.processor.updateQuery(query); - } - - stream(): DataStream> { - // Return a new stream which can independently be closed from the original - const stream = new DataStream>({ - closeOnError: true, - pressure: { - // limit the number of events queued in the event of slow consumers - highWaterMark: 2 - } - }); - - limitStreamDepth(stream, 1); - - // pipe the lazy stream to the new stream - this.lazyStreamPromise - .then((upstreamSource) => { - // Edge case where the stream is closed before the upstream source is created - if (stream.closed) { - return; - } - - const dispose = upstreamSource.registerListener({ - data: async (data) => { - stream.enqueueData(data); - }, - closed: () => { - stream.close(); - }, - error: (error) => { - stream.iterateListeners((l) => l.error?.(error)); - } - }); - - stream.registerListener({ - closed: () => { - dispose(); - } - }); - }) - .catch((error) => { - stream.iterateListeners((l) => l.error?.(error)); - }); - - return stream; - } - - close(): void { - if (this._stream) { - this._stream.close().catch(() => {}); - return; - } - this.lazyStreamPromise - .then(async (s) => { - await s.close(); - }) - .catch(() => { - // In rare cases where the DB might be closed before the stream is created - // this can throw an error. - // This should not affect closing - }); - } -} diff --git a/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts b/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts index 0003a4f6e..5d1af7be7 100644 --- a/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts +++ b/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts @@ -1,155 +1,149 @@ import { AbstractPowerSyncDatabase } from '../../../client/AbstractPowerSyncDatabase.js'; import { BaseListener, BaseObserver } from '../../../utils/BaseObserver.js'; -import { DataStream } from '../../../utils/DataStream.js'; -import { WatchedQueryOptions, WatchedQueryProcessor, WatchedQueryState } from '../WatchedQuery.js'; +import { WatchedQuery, WatchedQueryOptions, WatchedQueryState, WatchedQuerySubscription } from '../WatchedQuery.js'; -export interface AbstractQueryProcessorOptions { +/** + * @internal + */ +export interface AbstractQueryProcessorOptions { db: AbstractPowerSyncDatabase; - watchedQuery: WatchedQueryOptions; -} - -export interface AbstractQueryListener extends BaseListener { - queryUpdated: (query: WatchedQueryOptions) => Promise; + query: WatchedQueryOptions; } -export interface LinkQueryStreamOptions { - stream: DataStream>; +/** + * @internal + */ +export interface LinkQueryOptions { abortSignal: AbortSignal; - query: WatchedQueryOptions; + query: WatchedQueryOptions; } +type WatchedQueryProcessorListener = WatchedQuerySubscription & BaseListener; + /** - * Limits a stream on high water to only keep the latest event. + * Performs underlaying watching and yields a stream of results. + * @internal */ -export const limitStreamDepth = (stream: DataStream, limit: number) => { - const l = stream.registerListener({ - closed: () => l(), - highWater: async () => { - // Splice the queue to only keep the latest event - stream.dataQueue.splice(0, stream.dataQueue.length - limit); - } - }); - return stream; -}; - -export abstract class AbstractQueryProcessor - extends BaseObserver> - implements WatchedQueryProcessor +export abstract class AbstractQueryProcessor + extends BaseObserver> + implements WatchedQuery { - readonly state: WatchedQueryState = { - isLoading: true, - isFetching: true, - error: null, - lastUpdated: null, - data: [] - }; + readonly state: WatchedQueryState; - protected _stream: DataStream> | null; + protected abortController: AbortController; + protected initialized: Promise; - constructor(protected options: AbstractQueryProcessorOptions) { + constructor(protected options: AbstractQueryProcessorOptions) { super(); - this._stream = null; + this.abortController = new AbortController(); + this.state = { + isLoading: true, + isFetching: this.reportFetching, // Only set to true if we will report updates in future + error: null, + lastUpdated: null, + data: options.query.customExecutor?.initialData ?? ([] as Data) + }; + this.initialized = this.init(); } protected get reportFetching() { - return this.options.watchedQuery.reportFetching ?? true; + return this.options.query.reportFetching ?? true; } /** * Updates the underlaying query. */ - updateQuery(query: WatchedQueryOptions) { - this.options.watchedQuery = query; - - if (this._stream) { - this.iterateAsyncListeners(async (l) => l.queryUpdated?.(query)).catch((error) => { - this.updateState({ error }); - }); - } + async updateQuery(query: WatchedQueryOptions) { + await this.initialized; + + this.options.query = query; + this.abortController.abort(); + this.abortController = new AbortController(); + await this.linkQuery({ + abortSignal: this.abortController.signal, + query + }); } /** - * This method is called when the stream is created or the PowerSync schema has updated. - * It links the stream to the underlaying query. + * This method is used to link a query to the subscribers of this listener class. + * This method should perform actual query watching and report results via {@link updateState} method. */ - protected abstract linkStream(options: LinkQueryStreamOptions): Promise; - - protected updateState(update: Partial>) { - Object.assign(this.state, { lastUpdated: new Date() } satisfies Partial>, update); + protected abstract linkQuery(options: LinkQueryOptions): Promise; - if (this._stream?.closed) { - // Don't enqueue data in a closed stream. - // it should be safe to ignore this. - // This can be triggered if the stream is closed while data is being fetched. - return; + protected async updateState(update: Partial>) { + if (typeof update.error !== 'undefined') { + await this.iterateAsyncListenersWithError(async (l) => l.onError?.(update.error!)); } - this._stream?.enqueueData({ ...this.state }); - } - async generateStream() { - if (this._stream) { - return this._stream; + if (typeof update.data !== 'undefined') { + await this.iterateAsyncListenersWithError(async (l) => l.onData?.(update!.data!)); } - const { db } = this.options; - - const stream = new DataStream>({ - logger: db.logger, - pressure: { - highWaterMark: 2 // Trigger event when 2 events are queued - } - }); - - limitStreamDepth(stream, 1); - - this._stream = stream; - - let abortController: AbortController | null = null; + Object.assign(this.state, { lastUpdated: new Date() } satisfies Partial>, update); + await this.iterateAsyncListenersWithError(async (l) => l.onStateChange?.(this.state)); + } - const link = async (query: WatchedQueryOptions) => { - abortController?.abort(); - abortController = new AbortController(); - await this.linkStream({ - stream, - abortSignal: abortController.signal, - query - }); - }; + /** + * Configures base DB listeners and links the query to listeners. + */ + protected async init() { + const { db } = this.options; db.registerListener({ schemaChanged: async () => { - try { - await link(this.options.watchedQuery); - } catch (error) { - this.updateState({ error }); - } + await this.runWithReporting(async () => { + await this.updateQuery(this.options.query); + }); }, closing: () => { - stream.close().catch(() => {}); + this.close(); } }); - this.registerListener({ - queryUpdated: async (query) => { - try { - await link(query); - } catch (error) { - this.updateState({ error }); - } - } + // Initial setup + await this.runWithReporting(async () => { + await this.updateQuery(this.options.query); }); + } - // Cancel the underlaying query if the stream is closed - stream.registerListener({ - closed: () => abortController?.abort() - }); + subscribe(subscription: WatchedQuerySubscription): () => void { + return this.registerListener({ ...subscription }); + } + async close() { + await this.initialized; + this.abortController.abort(); + } + + /** + * Runs a callback and reports errors to the error listeners. + */ + protected async runWithReporting(callback: () => Promise): Promise { try { - await link(this.options.watchedQuery); + await callback(); } catch (error) { - this.updateState({ error }); + // This will update the error on the state and iterate error listeners + await this.updateState({ error }); } + } - return stream; + /** + * Iterate listeners and reports errors to onError handlers. + */ + protected async iterateAsyncListenersWithError( + callback: (listener: Partial>) => Promise | void + ) { + try { + await this.iterateAsyncListeners(async (l) => callback(l)); + } catch (error) { + try { + await this.iterateAsyncListeners(async (l) => l.onError?.(error)); + } catch (error) { + // Errors here are ignored + // since we are already in an error state + this.options.db.logger.error('Watched query error handler threw an Error', error); + } + } } } diff --git a/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts b/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts index 9cb2d90b9..a08948ae8 100644 --- a/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts +++ b/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts @@ -1,27 +1,24 @@ import { WatchedQueryState } from '../WatchedQuery.js'; -import { - AbstractQueryProcessor, - AbstractQueryProcessorOptions, - LinkQueryStreamOptions -} from './AbstractQueryProcessor.js'; - -export interface OnChangeQueryProcessorOptions extends AbstractQueryProcessorOptions { - compareBy?: (element: T) => string; +import { AbstractQueryProcessor, AbstractQueryProcessorOptions, LinkQueryOptions } from './AbstractQueryProcessor.js'; + +export interface WatchedQueryComparator { + checkEquality: (current: Data, previous: Data) => boolean; } /** - * Uses the PowerSync onChange event to trigger watched queries. - * Results are emitted on every change of the relevant tables. + * @internal */ -export class OnChangeQueryProcessor extends AbstractQueryProcessor { - constructor(protected options: OnChangeQueryProcessorOptions) { - super(options); - } +export interface OnChangeQueryProcessorOptions extends AbstractQueryProcessorOptions { + comparator?: WatchedQueryComparator; +} - /* - * @returns If the sets are equal - */ - protected checkEquality(current: T[], previous: T[]): boolean { +/** + * @internal + */ +export class ArrayComparator implements WatchedQueryComparator { + constructor(protected compareBy: (element: Element) => string) {} + + checkEquality(current: Element[], previous: Element[]) { if (current.length == 0 && previous.length == 0) { return true; } @@ -30,11 +27,7 @@ export class OnChangeQueryProcessor extends AbstractQueryProcessor { return false; } - const { compareBy } = this.options; - // Assume items are not equal if we can't compare them - if (!compareBy) { - return false; - } + const { compareBy } = this; // At this point the lengths are equal for (let i = 0; i < current.length; i++) { @@ -48,12 +41,31 @@ export class OnChangeQueryProcessor extends AbstractQueryProcessor { return true; } +} + +/** + * Uses the PowerSync onChange event to trigger watched queries. + * Results are emitted on every change of the relevant tables. + * @internal + */ +export class OnChangeQueryProcessor extends AbstractQueryProcessor { + constructor(protected options: OnChangeQueryProcessorOptions) { + super(options); + } + + /* + * @returns If the sets are equal + */ + protected checkEquality(current: Data, previous: Data): boolean { + // Use the provided comparator if available. Assume values are unique if not available. + return this.options.comparator?.checkEquality?.(current, previous) ?? false; + } - protected async linkStream(options: LinkQueryStreamOptions): Promise { - const { db, watchedQuery } = this.options; - const { stream, abortSignal } = options; + protected async linkQuery(options: LinkQueryOptions): Promise { + const { db, query } = this.options; + const { abortSignal } = options; - const tables = await db.resolveTables(watchedQuery.query, watchedQuery.parameters); + const tables = await db.resolveTables(query.sql, query.parameters); db.onChangeWithCallback( { @@ -61,15 +73,15 @@ export class OnChangeQueryProcessor extends AbstractQueryProcessor { // This fires for each change of the relevant tables try { if (this.reportFetching) { - this.updateState({ isFetching: true }); + await this.updateState({ isFetching: true }); } - const partialStateUpdate: Partial> = {}; + const partialStateUpdate: Partial> = {}; // Always run the query if an underlaying table has changed - const result = watchedQuery.queryExecutor - ? await watchedQuery.queryExecutor() - : await db.getAll(watchedQuery.query, watchedQuery.parameters); + const result = query.customExecutor + ? await query.customExecutor.execute() + : ((await db.getAll(query.sql, query.parameters)) as Data); if (this.reportFetching) { partialStateUpdate.isFetching = false; @@ -85,21 +97,20 @@ export class OnChangeQueryProcessor extends AbstractQueryProcessor { } if (Object.keys(partialStateUpdate).length > 0) { - this.updateState(partialStateUpdate); + await this.updateState(partialStateUpdate); } } catch (error) { - this.updateState({ error }); + await this.updateState({ error }); } }, - onError: (error) => { - this.updateState({ error }); - stream.close().catch(() => {}); + onError: async (error) => { + await this.updateState({ error }); } }, { signal: abortSignal, tables, - throttleMs: watchedQuery.throttleMs, + throttleMs: query.throttleMs, triggerImmediate: true // used to emit the initial state } ); diff --git a/packages/react/src/hooks/useQuery.ts b/packages/react/src/hooks/useQuery.ts index 13571f070..f53a426b4 100644 --- a/packages/react/src/hooks/useQuery.ts +++ b/packages/react/src/hooks/useQuery.ts @@ -16,8 +16,8 @@ export interface AdditionalOptions extends HookWatchOptions { runQueryOnce?: boolean; } -export type QueryResult = { - data: T[]; +export type QueryResult = { + data: RowType[]; /** * Indicates the initial loading state (hard loading). Loading becomes false once the first set of results from the watched query is available or an error occurs. */ @@ -33,12 +33,12 @@ export type QueryResult = { refresh?: (signal?: AbortSignal) => Promise; }; -type InternalHookOptions = { +type InternalHookOptions = { query: string; parameters: any[]; powerSync: AbstractPowerSyncDatabase; queryChanged: boolean; - queryExecutor?: () => Promise | null; + queryExecutor?: () => Promise; }; const checkQueryChanged = (sqlStatement: string, queryParameters: any[], options: AdditionalOptions) => { @@ -62,10 +62,10 @@ const checkQueryChanged = (sqlStatement: string, queryParameters: any[], opti return false; }; -const useSingleQuery = (options: InternalHookOptions): QueryResult => { +const useSingleQuery = (options: InternalHookOptions): QueryResult => { const { query, parameters, powerSync, queryExecutor, queryChanged } = options; - const [output, setOutputState] = React.useState>({ + const [output, setOutputState] = React.useState>({ isLoading: true, isFetching: true, data: [], @@ -76,7 +76,7 @@ const useSingleQuery = (options: InternalHookOptions): QueryResult { setOutputState((prev) => ({ ...prev, isLoading: true, isFetching: true, error: undefined })); try { - const result = queryExecutor ? await queryExecutor() : await powerSync.getAll(query, parameters); + const result = queryExecutor ? await queryExecutor() : await powerSync.getAll(query, parameters); if (signal.aborted) { return; } @@ -84,7 +84,7 @@ const useSingleQuery = (options: InternalHookOptions): QueryResult(options: InternalHookOptions): QueryResult(options: InternalHookOptions & { options: HookWatchOptions }): QueryResult => { +const useWatchedQuery = ( + options: InternalHookOptions & { options: HookWatchOptions } +): QueryResult => { const { query, parameters, powerSync, queryExecutor, queryChanged, options: hookOptions } = options; const createWatchedQuery = React.useCallback(() => { - return powerSync.incrementalWatch({ + return powerSync.incrementalWatch({ sql: query, parameters, - queryExecutor, + customExecutor: queryExecutor + ? { + execute: queryExecutor, + // This assumes the custom query executor will return an array of data, + // which is the requirement of CompatibleQuery. + initialData: [] + } + : undefined, throttleMs: hookOptions.throttleMs, reportFetching: hookOptions.reportFetching }); @@ -138,11 +147,14 @@ const useWatchedQuery = (options: InternalHookOptions & { options: H }, [powerSync]); React.useEffect(() => { - watchedQuery.stream().forEach(async (val) => { - setOutputState(val); + const dispose = watchedQuery.subscribe({ + onStateChange: (state) => { + setOutputState({ ...state }); + } }); return () => { + dispose(); watchedQuery.close(); }; }, [watchedQuery]); @@ -151,11 +163,12 @@ const useWatchedQuery = (options: InternalHookOptions & { options: H // Used when `isFetching` hasn't been set to true yet due to React execution. React.useEffect(() => { if (queryChanged) { + console.log('Query changed, re-fetching...'); watchedQuery.updateQuery({ - query, + sql: query, parameters: parameters, throttleMs: hookOptions.throttleMs, - queryExecutor, + customExecutor: queryExecutor ? { execute: queryExecutor, initialData: [] } : undefined, reportFetching: hookOptions.reportFetching }); } @@ -177,11 +190,11 @@ const useWatchedQuery = (options: InternalHookOptions & { options: H * * } */ -export const useQuery = ( - query: string | CompilableQuery, +export const useQuery = ( + query: string | CompilableQuery, parameters: any[] = [], options: AdditionalOptions = { runQueryOnce: false } -): QueryResult => { +): QueryResult => { const powerSync = usePowerSync(); const logger = powerSync?.logger ?? console; if (!powerSync) { @@ -203,7 +216,7 @@ export const useQuery = ( switch (options.runQueryOnce) { case true: - return useSingleQuery({ + return useSingleQuery({ query: sqlStatement, parameters: queryParameters, powerSync, @@ -211,7 +224,7 @@ export const useQuery = ( queryChanged }); default: - return useWatchedQuery({ + return useWatchedQuery({ query: sqlStatement, parameters: queryParameters, powerSync, diff --git a/packages/react/tests/useQuery.test.tsx b/packages/react/tests/useQuery.test.tsx index 0a922207a..58d0a8f98 100644 --- a/packages/react/tests/useQuery.test.tsx +++ b/packages/react/tests/useQuery.test.tsx @@ -228,14 +228,12 @@ describe('useQuery', () => { const baseGetAll = db.getAll; const getSpy = vi.spyOn(db, 'getAll').mockImplementation(async (sql, params) => { - // Allow pausing this call + // Allow pausing this call in order to test isFetching await deferred.promise; return baseGetAll.call(db, sql, params); }); // The number of calls should be incremented after we make a change - const numberOfCalls = getSpy.mock.calls.length + 1; - // This should not trigger an update await db.execute('INSERT INTO lists(id, name) VALUES (uuid(), ?)', ['anothername']); await waitFor( @@ -249,10 +247,12 @@ describe('useQuery', () => { deferred.resolve(); // We should still read the data from the DB - await waitFor(() => { - expect(getSpy).toHaveBeenCalledTimes(numberOfCalls); - expect(result.current.isFetching).toEqual(false); - }); + await waitFor( + () => { + expect(result.current.isFetching).toEqual(false); + }, + { timeout: 500, interval: 100 } + ); // The data reference should be the same as the previous time expect(data == result.current.data).toEqual(true); diff --git a/packages/web/tests/watch.test.ts b/packages/web/tests/watch.test.ts index 0866c6932..8333e9257 100644 --- a/packages/web/tests/watch.test.ts +++ b/packages/web/tests/watch.test.ts @@ -1,7 +1,7 @@ -import { AbstractPowerSyncDatabase } from '@powersync/common'; +import { AbstractPowerSyncDatabase, WatchedQueryState } from '@powersync/common'; import { PowerSyncDatabase } from '@powersync/web'; import { v4 as uuid } from 'uuid'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'; import { testSchema } from './utils/testDb'; vi.useRealTimers(); @@ -281,9 +281,10 @@ describe('Watch Tests', { sequential: true }, () => { { signal: abortController.signal, throttleMs: throttleDuration } ); }); - abortController.abort(); await receivedError; + abortController.abort(); + expect(receivedErrorCount).equals(1); }); @@ -330,91 +331,92 @@ describe('Watch Tests', { sequential: true }, () => { parameters: [] }); - expect(watch.state.isLoading).true; - expect(watch.state.isFetching).true; + const getNextState = () => + new Promise>((resolve) => { + const dispose = watch.subscribe({ + onStateChange: (state) => { + dispose(); + resolve(state); + } + }); + }); + + let state = await getNextState(); + expect(state.isFetching).true; + expect(state.isLoading).true; - const next = await watch.stream().read(); - expect(next).toBeDefined(); - expect(next!.isFetching).false; - expect(next!.isLoading).false; + state = await getNextState(); + expect(state.isFetching).false; + expect(state.isLoading).false; - const nextFetchPromise = watch.stream().read(); + const nextStatePromise = getNextState(); await powersync.execute('INSERT INTO assets(id, make, customer_id) VALUES (uuid(), ?, ?)', ['test', uuid()]); - const nextFetch = await nextFetchPromise; - expect(nextFetch).toBeDefined(); - expect(nextFetch!.isFetching).true; - - const dataNext = await watch.stream().read(); - expect(dataNext).toBeDefined(); - expect(dataNext!.isFetching).false; - expect(dataNext!.data).toHaveLength(1); + state = await nextStatePromise; + expect(state!.isFetching).true; + + state = await getNextState(); + expect(state.isFetching).false; + expect(state.data).toHaveLength(1); }); - it('should limit queue depth', async () => { + it('should only report updates for relevant changes', async () => { const watch = powersync.incrementalWatch({ - sql: 'SELECT * FROM assets', - parameters: [] + sql: 'SELECT * FROM assets where make = ?', + parameters: ['test'] }); - expect(watch.state.isLoading).true; - expect(watch.state.isFetching).true; + let notificationCount = 0; + const dispose = watch.subscribe({ + onData: () => { + notificationCount++; + } + }); + onTestFinished(dispose); - // TODO limit interface - const stream = watch.stream(); - const anotherStream = watch.stream(); + // Should only trigger for this operation + await powersync.execute('INSERT INTO assets(id, make, customer_id) VALUES (uuid(), ?, ?)', ['test', uuid()]); - const next = await stream.read(); - expect(next).toBeDefined(); - expect(next!.isFetching).false; - expect(next!.isLoading).false; + // Should not trigger for these operations + await powersync.execute('INSERT INTO assets(id, make, customer_id) VALUES (uuid(), ?, ?)', ['make1', uuid()]); + await powersync.execute('INSERT INTO assets(id, make, customer_id) VALUES (uuid(), ?, ?)', ['make2', uuid()]); + await powersync.execute('INSERT INTO assets(id, make, customer_id) VALUES (uuid(), ?, ?)', ['make3', uuid()]); + await powersync.execute('INSERT INTO assets(id, make, customer_id) VALUES (uuid(), ?, ?)', ['make4', uuid()]); - const nextFetchPromise = stream.read(); - await powersync.execute('INSERT INTO assets(id, make, customer_id) VALUES (uuid(), ?, ?)', ['test', uuid()]); - const nextFetch = await nextFetchPromise; - expect(nextFetch).toBeDefined(); - expect(nextFetch!.isFetching).true; - - const dataNext = await stream.read(); - expect(dataNext).toBeDefined(); - expect(dataNext!.isFetching).false; - expect(dataNext!.data).toHaveLength(1); - - // This should only have the latest unprocessed event - expect(anotherStream.dataQueue).toHaveLength(1); - expect(anotherStream.dataQueue[0].data).toHaveLength(1); - - watch.close(); - // TODO - await new Promise((r) => setTimeout(r, 500)); - expect(stream.closed).true; + // The initial result with no data is equal to the default state/ + // We should only receive one notification when the data is updated + expect(notificationCount).equals(1); + expect(watch.state.data).toHaveLength(1); }); - it('should only report updates for relevant changes', async () => { + it('should not report fetching status', async () => { const watch = powersync.incrementalWatch({ sql: 'SELECT * FROM assets where make = ?', - parameters: ['test'] + parameters: ['test'], + reportFetching: false }); + expect(watch.state.isFetching).false; + let notificationCount = 0; - const receivedRelevantData = new Promise((resolve) => { - watch.stream().forEach(async (update) => { + const dispose = watch.subscribe({ + onStateChange: () => { notificationCount++; - console.log('Received update', update); - if (update.data.length > 0) { - resolve(); - } - }); + } }); + onTestFinished(dispose); + // Should only a state change trigger for this operation + await powersync.execute('INSERT INTO assets(id, make, customer_id) VALUES (uuid(), ?, ?)', ['test', uuid()]); + + // Should not trigger any state change for these operations await powersync.execute('INSERT INTO assets(id, make, customer_id) VALUES (uuid(), ?, ?)', ['make1', uuid()]); await powersync.execute('INSERT INTO assets(id, make, customer_id) VALUES (uuid(), ?, ?)', ['make2', uuid()]); await powersync.execute('INSERT INTO assets(id, make, customer_id) VALUES (uuid(), ?, ?)', ['make3', uuid()]); await powersync.execute('INSERT INTO assets(id, make, customer_id) VALUES (uuid(), ?, ?)', ['make4', uuid()]); - // Should only trigger for this operation - await powersync.execute('INSERT INTO assets(id, make, customer_id) VALUES (uuid(), ?, ?)', ['test', uuid()]); - await receivedRelevantData; - // Should get one notification for first loading then finished loading then received data - expect(notificationCount).equals(3); + // The initial result with no data is equal to the default state/ + // We should only receive one notification when the data is updated + expect(notificationCount).equals(1); + expect(watch.state.data).toHaveLength(1); }); }); From 69f73943e21bb5899f92ea938c841613fcd0f058 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Wed, 21 May 2025 09:03:23 +0200 Subject: [PATCH 11/75] wip: react suspense --- .../common/src/client/watched/WatchedQuery.ts | 29 +- .../processors/AbstractQueryProcessor.ts | 42 +- packages/react/src/QueryStore.ts | 39 +- packages/react/src/WatchedQuery.ts | 367 +++++++++--------- packages/react/src/hooks/useSuspenseQuery.ts | 91 ++++- packages/react/tests/useQuery.test.tsx | 3 +- .../react/tests/useSuspenseQuery.test.tsx | 196 ++++++---- 7 files changed, 459 insertions(+), 308 deletions(-) diff --git a/packages/common/src/client/watched/WatchedQuery.ts b/packages/common/src/client/watched/WatchedQuery.ts index ef8482c4b..d6060ded5 100644 --- a/packages/common/src/client/watched/WatchedQuery.ts +++ b/packages/common/src/client/watched/WatchedQuery.ts @@ -1,3 +1,5 @@ +import { BaseListener, BaseObserverInterface } from '../../utils/BaseObserver.js'; + export interface WatchedQueryState { /** * Indicates the initial loading state (hard loading). Loading becomes false once the first set of results from the watched query is available or an error occurs. @@ -47,18 +49,37 @@ export interface WatchedQueryOptions { reportFetching?: boolean; } +export enum WatchedQuerySubscriptionEvent { + ON_DATA = 'onData', + ON_ERROR = 'onError', + ON_STATE_CHANGE = 'onStateChange' +} + export interface WatchedQuerySubscription { - onData?: (data: Data) => void | Promise; - onError?: (error: Error) => void | Promise; - onStateChange?: (state: WatchedQueryState) => void | Promise; + [WatchedQuerySubscriptionEvent.ON_DATA]?: (data: Data) => void | Promise; + [WatchedQuerySubscriptionEvent.ON_ERROR]?: (error: Error) => void | Promise; + [WatchedQuerySubscriptionEvent.ON_STATE_CHANGE]?: (state: WatchedQueryState) => void | Promise; +} + +export type SubscriptionCounts = Record & { + total: number; +}; + +export interface WatchedQueryListener extends BaseListener { + closed: () => void; + subscriptionsChanged: (counts: SubscriptionCounts) => void; } -export interface WatchedQuery { +export interface WatchedQuery extends BaseObserverInterface { /** * Current state of the watched query. */ readonly state: WatchedQueryState; + readonly closed: boolean; + + readonly subscriptionCounts: SubscriptionCounts; + /** * Subscribe to watched query events. * @returns A function to unsubscribe from the events. diff --git a/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts b/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts index 5d1af7be7..2d8674a3e 100644 --- a/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts +++ b/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts @@ -1,6 +1,14 @@ import { AbstractPowerSyncDatabase } from '../../../client/AbstractPowerSyncDatabase.js'; -import { BaseListener, BaseObserver } from '../../../utils/BaseObserver.js'; -import { WatchedQuery, WatchedQueryOptions, WatchedQueryState, WatchedQuerySubscription } from '../WatchedQuery.js'; +import { BaseObserver } from '../../../utils/BaseObserver.js'; +import { + SubscriptionCounts, + WatchedQuery, + WatchedQueryListener, + WatchedQueryOptions, + WatchedQueryState, + WatchedQuerySubscription, + WatchedQuerySubscriptionEvent +} from '../WatchedQuery.js'; /** * @internal @@ -18,7 +26,7 @@ export interface LinkQueryOptions { query: WatchedQueryOptions; } -type WatchedQueryProcessorListener = WatchedQuerySubscription & BaseListener; +type WatchedQueryProcessorListener = WatchedQuerySubscription & WatchedQueryListener; /** * Performs underlaying watching and yields a stream of results. @@ -32,10 +40,25 @@ export abstract class AbstractQueryProcessor protected abortController: AbortController; protected initialized: Promise; + protected _closed: boolean; + + get closed() { + return this._closed; + } + + get subscriptionCounts() { + const listenersArray = Array.from(this.listeners); + return Object.values(WatchedQuerySubscriptionEvent).reduce((totals: Partial, key) => { + totals[key] = listenersArray.filter((l) => !!l[key]).length; + totals.total = (totals.total ?? 0) + totals[key]; + return totals; + }, {}) as SubscriptionCounts; + } constructor(protected options: AbstractQueryProcessorOptions) { super(); this.abortController = new AbortController(); + this._closed = false; this.state = { isLoading: true, isFetching: this.reportFetching, // Only set to true if we will report updates in future @@ -108,12 +131,23 @@ export abstract class AbstractQueryProcessor } subscribe(subscription: WatchedQuerySubscription): () => void { - return this.registerListener({ ...subscription }); + // hook in to subscription events in order to report changes + const baseDispose = this.registerListener({ ...subscription }); + + const counts = this.subscriptionCounts; + this.iterateListeners((l) => l.subscriptionsChanged?.(counts)); + + return () => { + baseDispose(); + this.iterateListeners((l) => l.subscriptionsChanged?.(counts)); + }; } async close() { await this.initialized; this.abortController.abort(); + this._closed = true; + this.iterateListeners((l) => l.closed?.()); } /** diff --git a/packages/react/src/QueryStore.ts b/packages/react/src/QueryStore.ts index a1954851e..782789064 100644 --- a/packages/react/src/QueryStore.ts +++ b/packages/react/src/QueryStore.ts @@ -1,5 +1,5 @@ -import { AbstractPowerSyncDatabase } from '@powersync/common'; -import { Query, WatchedQuery } from './WatchedQuery'; +import { AbstractPowerSyncDatabase, WatchedQuery } from '@powersync/common'; +import { Query } from './WatchedQuery'; import { AdditionalOptions } from './hooks/useQuery'; export function generateQueryKey(sqlStatement: string, parameters: any[], options: AdditionalOptions): string { @@ -7,7 +7,7 @@ export function generateQueryKey(sqlStatement: string, parameters: any[], option } export class QueryStore { - cache = new Map(); + cache = new Map>(); constructor(private db: AbstractPowerSyncDatabase) {} @@ -16,17 +16,40 @@ export class QueryStore { return this.cache.get(key); } - const q = new WatchedQuery(this.db, query, options); - const disposer = q.registerListener({ - disposed: () => { + const customExecutor = typeof query.rawQuery !== 'string' ? query.rawQuery : null; + + const watchedQuery = this.db.incrementalWatch({ + sql: query.sqlStatement, + parameters: query.queryParameters, + customExecutor: customExecutor + ? { + initialData: [], + execute: () => customExecutor.execute() + } + : undefined, + throttleMs: options.throttleMs + }); + + const disposer = watchedQuery.registerListener({ + closed: () => { this.cache.delete(key); disposer?.(); } }); - this.cache.set(key, q); + watchedQuery.registerListener({ + subscriptionsChanged: (counts) => { + // Dispose this query if there are no subscribers present + if (counts.total == 0) { + watchedQuery.close(); + this.cache.delete(key); + } + } + }); + + this.cache.set(key, watchedQuery); - return q; + return watchedQuery; } } diff --git a/packages/react/src/WatchedQuery.ts b/packages/react/src/WatchedQuery.ts index 250f91d6d..c08bf45e8 100644 --- a/packages/react/src/WatchedQuery.ts +++ b/packages/react/src/WatchedQuery.ts @@ -1,12 +1,4 @@ -import { - AbstractPowerSyncDatabase, - BaseListener, - BaseObserver, - CompilableQuery, - Disposable, - runOnSchemaChange -} from '@powersync/common'; -import { AdditionalOptions } from './hooks/useQuery'; +import { CompilableQuery } from '@powersync/common'; export class Query { rawQuery: string | CompilableQuery; @@ -14,181 +6,182 @@ export class Query { queryParameters: any[]; } -export interface WatchedQueryListener extends BaseListener { - onUpdate: () => void; - disposed: () => void; -} - -export class WatchedQuery extends BaseObserver implements Disposable { - readyPromise: Promise; - isReady: boolean = false; - currentData: any[] | undefined; - currentError: any; - tables: any[] | undefined; - - private temporaryHolds = new Set(); - private controller: AbortController | undefined; - private db: AbstractPowerSyncDatabase; - - private resolveReady: undefined | (() => void); - - readonly query: Query; - readonly options: AdditionalOptions; - - constructor(db: AbstractPowerSyncDatabase, query: Query, options: AdditionalOptions) { - super(); - this.db = db; - this.query = query; - this.options = options; - - this.readyPromise = new Promise((resolve) => { - this.resolveReady = resolve; - }); - } - - get logger() { - return this.db.logger ?? console; - } - - addTemporaryHold() { - const ref = new Object(); - this.temporaryHolds.add(ref); - this.maybeListen(); - - let timeout: any; - const release = () => { - this.temporaryHolds.delete(ref); - if (timeout) { - clearTimeout(timeout); - } - this.maybeDispose(); - }; - - const timeoutRelease = () => { - if (this.isReady || this.controller == null) { - release(); - } else { - // If the query is taking long, keep the temporary hold. - timeout = setTimeout(timeoutRelease, 5_000); - } - }; - - timeout = setTimeout(timeoutRelease, 5_000); - - return release; - } - - registerListener(listener: Partial): () => void { - const disposer = super.registerListener(listener); - - this.maybeListen(); - return () => { - disposer(); - this.maybeDispose(); - }; - } - - private async fetchTables() { - try { - this.tables = await this.db.resolveTables(this.query.sqlStatement, this.query.queryParameters, this.options); - } catch (e) { - this.logger.error('Failed to fetch tables:', e); - this.setError(e); - } - } - - async fetchData() { - try { - const result = - typeof this.query.rawQuery == 'string' - ? await this.db.getAll(this.query.sqlStatement, this.query.queryParameters) - : await this.query.rawQuery.execute(); - - const data = result ?? []; - this.setData(data); - } catch (e) { - this.logger.error('Failed to fetch data:', e); - this.setError(e); - } - } - - private maybeListen() { - if (this.controller != null) { - return; - } - - if (this.onUpdateListenersCount() == 0 && this.temporaryHolds.size == 0) { - return; - } - - const controller = new AbortController(); - this.controller = controller; - - const onError = (error: Error) => { - this.setError(error); - }; - - const watchQuery = async (abortSignal: AbortSignal) => { - await this.fetchTables(); - await this.fetchData(); - - if (!this.options.runQueryOnce) { - this.db.onChangeWithCallback( - { - onChange: async () => { - await this.fetchData(); - }, - onError - }, - { - ...this.options, - signal: abortSignal, - tables: this.tables - } - ); - } - }; - runOnSchemaChange(watchQuery, this.db, { signal: this.controller.signal }); - } - - private setData(results: any[]) { - this.isReady = true; - this.currentData = results; - this.currentError = undefined; - this.resolveReady?.(); - - this.iterateListeners((l) => l.onUpdate?.()); - } - - private setError(error: any) { - this.isReady = true; - this.currentData = undefined; - this.currentError = error; - this.resolveReady?.(); - - this.iterateListeners((l) => l.onUpdate?.()); - } - - private onUpdateListenersCount(): number { - return Array.from(this.listeners).filter((listener) => listener.onUpdate !== undefined).length; - } - - private maybeDispose() { - if (this.onUpdateListenersCount() == 0 && this.temporaryHolds.size == 0) { - this.controller?.abort(); - this.controller = undefined; - this.isReady = false; - this.currentData = undefined; - this.currentError = undefined; - this.dispose(); - - this.readyPromise = new Promise((resolve, reject) => { - this.resolveReady = resolve; - }); - } - } - - async dispose() { - this.iterateAsyncListeners(async (l) => l.disposed?.()); - } -} +// export interface WatchedQueryListener extends BaseListener { +//onUpdate: () => void; +// disposed: () => void; +// } + +// export class WatchedQuery extends BaseObserver implements Disposable { +// readyPromise: Promise; +// isReady: boolean = false; +// currentData: any[] | undefined; +// currentError: any; +// tables: any[] | undefined; + +// private temporaryHolds = new Set(); +// private controller: AbortController | undefined; +// private db: AbstractPowerSyncDatabase; + +// private resolveReady: undefined | (() => void); + +// readonly query: Query; +// readonly options: AdditionalOptions; + +// constructor(db: AbstractPowerSyncDatabase, query: Query, options: AdditionalOptions) { +// super(); +// this.db = db; +// this.query = query; +// this.options = options; + +// this.readyPromise = new Promise((resolve) => { +// this.resolveReady = resolve; +// }); +// } + +// get logger() { +// return this.db.logger ?? console; +// } + +// addTemporaryHold() { +// const ref = new Object(); +// this.temporaryHolds.add(ref); +// this.maybeListen(); + +// let timeout: any; +// const release = () => { +// this.temporaryHolds.delete(ref); +// if (timeout) { +// clearTimeout(timeout); +// } +// this.maybeDispose(); +// }; + +// const timeoutRelease = () => { +// if (this.isReady || this.controller == null) { +// release(); +// } else { +// // If the query is taking long, keep the temporary hold. +// timeout = setTimeout(timeoutRelease, 5_000); +// } +// }; + +// timeout = setTimeout(timeoutRelease, 5_000); + +// return release; +// } + +// registerListener(listener: Partial): () => void { +// const disposer = super.registerListener(listener); + +// this.maybeListen(); +// return () => { +// disposer(); +// this.maybeDispose(); +// }; +// } + +// private async fetchTables() { +// try { +// this.tables = await this.db.resolveTables(this.query.sqlStatement, this.query.queryParameters, this.options); +// } catch (e) { +// this.logger.error('Failed to fetch tables:', e); +// this.setError(e); +// } +// } + +// async fetchData() { +// try { +// const result = +// typeof this.query.rawQuery == 'string' +// ? await this.db.getAll(this.query.sqlStatement, this.query.queryParameters) +// : await this.query.rawQuery.execute(); + +// const data = result ?? []; +// this.setData(data); +// } catch (e) { +// this.logger.error('Failed to fetch data:', e); +// this.setError(e); +// } +// } + +// // configures underlaying query if there are listeners +// private maybeListen() { +// if (this.controller != null) { +// return; +// } + +// if (this.onUpdateListenersCount() == 0 && this.temporaryHolds.size == 0) { +// return; +// } + +// const controller = new AbortController(); +// this.controller = controller; + +// const onError = (error: Error) => { +// this.setError(error); +// }; + +// const watchQuery = async (abortSignal: AbortSignal) => { +// await this.fetchTables(); +// await this.fetchData(); + +// if (!this.options.runQueryOnce) { +// this.db.onChangeWithCallback( +// { +// onChange: async () => { +// await this.fetchData(); +// }, +// onError +// }, +// { +// ...this.options, +// signal: abortSignal, +// tables: this.tables +// } +// ); +// } +// }; +// runOnSchemaChange(watchQuery, this.db, { signal: this.controller.signal }); +// } + +// private setData(results: any[]) { +// this.isReady = true; +// this.currentData = results; +// this.currentError = undefined; +// this.resolveReady?.(); + +// this.iterateListeners((l) => l.onUpdate?.()); +// } + +// private setError(error: any) { +// this.isReady = true; +// this.currentData = undefined; +// this.currentError = error; +// this.resolveReady?.(); + +// this.iterateListeners((l) => l.onUpdate?.()); +// } + +// private onUpdateListenersCount(): number { +// return Array.from(this.listeners).filter((listener) => listener.onUpdate !== undefined).length; +// } + +// private maybeDispose() { +// if (this.onUpdateListenersCount() == 0 && this.temporaryHolds.size == 0) { +// this.controller?.abort(); +// this.controller = undefined; +// this.isReady = false; +// this.currentData = undefined; +// this.currentError = undefined; +// this.dispose(); + +// this.readyPromise = new Promise((resolve, reject) => { +// this.resolveReady = resolve; +// }); +// } +// } + +// async dispose() { +// this.iterateAsyncListeners(async (l) => l.disposed?.()); +// } +// } diff --git a/packages/react/src/hooks/useSuspenseQuery.ts b/packages/react/src/hooks/useSuspenseQuery.ts index 87da1fbfa..0b7a7ddc5 100644 --- a/packages/react/src/hooks/useSuspenseQuery.ts +++ b/packages/react/src/hooks/useSuspenseQuery.ts @@ -1,8 +1,7 @@ +import { CompilableQuery, ParsedQuery, parseQuery, WatchedQuery } from '@powersync/common'; import React from 'react'; import { generateQueryKey, getQueryStore } from '../QueryStore'; import { usePowerSync } from './PowerSyncContext'; -import { CompilableQuery, ParsedQuery, parseQuery } from '@powersync/common'; -import { WatchedQuery } from '../WatchedQuery'; import { AdditionalOptions, QueryResult } from './useQuery'; export type SuspenseQueryResult = Pick, 'data' | 'refresh'>; @@ -51,29 +50,70 @@ export const useSuspenseQuery = ( // on the query. // Once the component "commits", we exchange that for a permanent hold. const store = getQueryStore(powerSync); - const q = store.getQuery( + const watchedQuery = store.getQuery( key, { rawQuery: query, sqlStatement: parsedQuery.sqlStatement, queryParameters: parsedQuery.parameters }, options - ); + ) as WatchedQuery; - const addedHoldTo = React.useRef(undefined); + const addedHoldTo = React.useRef | undefined>(undefined); const releaseTemporaryHold = React.useRef<(() => void) | undefined>(undefined); - if (addedHoldTo.current !== q) { + if (addedHoldTo.current !== watchedQuery) { releaseTemporaryHold.current?.(); - releaseTemporaryHold.current = q.addTemporaryHold(); - addedHoldTo.current = q; + + // The store will dispose this query if it has no subscribers attached to it. + // Creates a subscription for state change which creates a temporary hold on the query + const disposeSubscription = watchedQuery.subscribe({ + onStateChange: (state) => {} + }); + + let timeout: ReturnType; + + const disposeClosedListener = watchedQuery.registerListener({ + closed: () => { + if (timeout) { + clearTimeout(timeout); + } + disposeClosedListener(); + } + }); + + const releaseHold = () => { + disposeSubscription(); + disposeClosedListener(); + }; + releaseTemporaryHold.current = releaseHold; + + const timeoutPollMs = 5_000; + + const checkHold = () => { + if (watchedQuery.closed || !watchedQuery.state.isLoading || watchedQuery.state.error) { + // No need to keep a temporary hold on this query + releaseHold(); + } else { + // Need to keep the hold, check again after timeout + setTimeout(checkHold, timeoutPollMs); + } + }; + + // Set a timeout to conditionally remove the temporary hold + setTimeout(checkHold, timeoutPollMs); + + addedHoldTo.current = watchedQuery; } - const [_counter, setUpdateCounter] = React.useState(0); + // Force update state function + const [, setUpdateCounter] = React.useState(0); React.useEffect(() => { - const dispose = q.registerListener({ - onUpdate: () => { - setUpdateCounter((counter) => { - return counter + 1; - }); + // This runs when the component came out of suspense + // Does it run before suspending? + // This add a permanent hold since a listener has been added to the query + const dispose = watchedQuery.subscribe({ + onStateChange() { + // Trigger rerender + setUpdateCounter((prev) => prev + 1); } }); @@ -83,11 +123,24 @@ export const useSuspenseQuery = ( return dispose; }, []); - if (q.currentError != null) { - throw q.currentError; - } else if (q.currentData != null) { - return { data: q.currentData, refresh: () => q.fetchData() }; + if (watchedQuery.state.error != null) { + // Report errors - this is caught by an error boundary + throw watchedQuery.state.error; + } else if (!watchedQuery.state.isLoading) { + // Happy path data return + return { data: watchedQuery.state.data }; } else { - throw q.readyPromise; + // Notify suspense is required + throw new Promise((resolve) => { + const dispose = watchedQuery.subscribe({ + onStateChange: (state) => { + // Returns to the hook if loading is completed or if loading resulted in an error + if (!state.isLoading || state.error) { + resolve(); + dispose(); + } + } + }); + }); } }; diff --git a/packages/react/tests/useQuery.test.tsx b/packages/react/tests/useQuery.test.tsx index 58d0a8f98..3cd4b3db9 100644 --- a/packages/react/tests/useQuery.test.tsx +++ b/packages/react/tests/useQuery.test.tsx @@ -2,12 +2,11 @@ import * as commonSdk from '@powersync/common'; import { PowerSyncDatabase } from '@powersync/web'; import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; import pDefer from 'p-defer'; -import React from 'react'; import { beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'; import { PowerSyncContext } from '../src/hooks/PowerSyncContext'; import { useQuery } from '../src/hooks/useQuery'; -const openPowerSync = () => { +export const openPowerSync = () => { const db = new PowerSyncDatabase({ database: { dbFilename: 'test.db' }, schema: new commonSdk.Schema({ diff --git a/packages/react/tests/useSuspenseQuery.test.tsx b/packages/react/tests/useSuspenseQuery.test.tsx index badafe779..523cd581e 100644 --- a/packages/react/tests/useSuspenseQuery.test.tsx +++ b/packages/react/tests/useSuspenseQuery.test.tsx @@ -1,36 +1,23 @@ +import { AbstractPowerSyncDatabase, WatchedQuery } from '@powersync/common'; import { cleanup, renderHook, screen, waitFor } from '@testing-library/react'; -import React, { Suspense } from 'react'; +import React from 'react'; import { ErrorBoundary } from 'react-error-boundary'; -import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { PowerSyncContext } from '../src/hooks/PowerSyncContext'; import { useSuspenseQuery } from '../src/hooks/useSuspenseQuery'; - +import { openPowerSync } from './useQuery.test'; const defaultQueryResult = ['list1', 'list2']; -const createMockPowerSync = () => { - return { - currentStatus: { status: 'initial' }, - registerListener: vi.fn(() => {}), - resolveTables: vi.fn(() => ['table1', 'table2']), - onChangeWithCallback: vi.fn(), - getAll: vi.fn(() => Promise.resolve(defaultQueryResult)) as Mock - }; -}; - -let mockPowerSync = createMockPowerSync(); - -vi.mock('./PowerSyncContext', () => ({ - useContext: vi.fn(() => mockPowerSync) -})); - describe('useSuspenseQuery', () => { const loadingFallback = 'Loading'; const errorFallback = 'Error'; + let powersync: AbstractPowerSyncDatabase; + const wrapper = ({ children }) => ( - + - {children} + {children} ); @@ -65,7 +52,7 @@ describe('useSuspenseQuery', () => { beforeEach(() => { vi.clearAllMocks(); cleanup(); // Cleanup the DOM after each test - mockPowerSync = createMockPowerSync(); + powersync = openPowerSync(); }); it('should error when PowerSync is not set', async () => { @@ -75,81 +62,122 @@ describe('useSuspenseQuery', () => { }); it('should suspend on initial load', async () => { - mockPowerSync.getAll = vi.fn(() => { - return new Promise(() => {}); + // spy on watched query generation + const baseImplementation = powersync.incrementalWatch; + let watch: WatchedQuery> | null = null; + const spy = vi.spyOn(powersync, 'incrementalWatch').mockImplementation((options) => { + watch = baseImplementation.call(powersync, options); + return watch!; }); const wrapper = ({ children }) => ( - - {children} + + + {children} +
Not suspending
+
); - renderHook(() => useSuspenseQuery('SELECT * from lists'), { wrapper }); + await powersync.execute("INSERT INTO lists (id, name) VALUES (uuid(), 'aname')"); - await waitForSuspend(); - - expect(mockPowerSync.getAll).toHaveBeenCalledTimes(1); - }); - - it('should run the query once if runQueryOnce flag is set', async () => { - let resolvePromise: (_: string[]) => void = () => {}; - - mockPowerSync.getAll = vi.fn(() => { - return new Promise((resolve) => { - resolvePromise = resolve; - }); - }); - - const { result } = renderHook(() => useSuspenseQuery('SELECT * from lists', [], { runQueryOnce: true }), { - wrapper - }); + const { unmount } = renderHook(() => useSuspenseQuery('SELECT * from lists'), { wrapper }); + expect(screen.queryByText('Not suspending')).toBeFalsy(); await waitForSuspend(); - resolvePromise(defaultQueryResult); - - await waitForCompletedSuspend(); + // The component should render after suspending await waitFor( async () => { - const currentResult = result.current; - expect(currentResult?.data).toEqual(['list1', 'list2']); - expect(mockPowerSync.onChangeWithCallback).not.toHaveBeenCalled(); - expect(mockPowerSync.getAll).toHaveBeenCalledTimes(1); + expect(screen.queryByText('Not suspending')).toBeTruthy(); }, - { timeout: 100 } + { timeout: 500, interval: 100 } ); - }); - it('should rerun the query when refresh is used', async () => { - const { result } = renderHook(() => useSuspenseQuery('SELECT * from lists', [], { runQueryOnce: true }), { - wrapper - }); + expect(watch).toBeDefined(); + expect(watch!.closed).false; + expect(watch!.state.data.length).eq(1); + expect(watch!.subscriptionCounts.onStateChange).greaterThanOrEqual(2); // should have a temporary hold and state listener - await waitForSuspend(); + // wait for the temporary hold to elapse + await waitFor( + async () => { + expect(watch!.subscriptionCounts.onStateChange).eq(1); + }, + { timeout: 10_000, interval: 500 } + ); - let refresh; + // now unmount the hook, this should remove listeners from the watch and close the query + unmount(); + // wait for the temporary hold to elapse await waitFor( async () => { - const currentResult = result.current; - refresh = currentResult.refresh; - expect(mockPowerSync.getAll).toHaveBeenCalledTimes(1); + expect(watch!.subscriptionCounts.onStateChange).eq(0); + expect(watch?.closed).true; }, - { timeout: 100 } + { timeout: 10_000, interval: 500 } ); + }); - await waitForCompletedSuspend(); + // it('should run the query once if runQueryOnce flag is set', async () => { + // let resolvePromise: (_: string[]) => void = () => {}; - await refresh(); - expect(mockPowerSync.getAll).toHaveBeenCalledTimes(2); - }); + // mockPowerSync.getAll = vi.fn(() => { + // return new Promise((resolve) => { + // resolvePromise = resolve; + // }); + // }); + + // const { result } = renderHook(() => useSuspenseQuery('SELECT * from lists', [], { runQueryOnce: true }), { + // wrapper + // }); + + // await waitForSuspend(); + + // resolvePromise(defaultQueryResult); + + // await waitForCompletedSuspend(); + // await waitFor( + // async () => { + // const currentResult = result.current; + // expect(currentResult?.data).toEqual(['list1', 'list2']); + // expect(mockPowerSync.onChangeWithCallback).not.toHaveBeenCalled(); + // expect(mockPowerSync.getAll).toHaveBeenCalledTimes(1); + // }, + // { timeout: 100 } + // ); + // }); + + // it('should rerun the query when refresh is used', async () => { + // const { result } = renderHook(() => useSuspenseQuery('SELECT * from lists', [], { runQueryOnce: true }), { + // wrapper + // }); + + // await waitForSuspend(); + + // let refresh; + + // await waitFor( + // async () => { + // const currentResult = result.current; + // refresh = currentResult.refresh; + // expect(mockPowerSync.getAll).toHaveBeenCalledTimes(1); + // }, + // { timeout: 100 } + // ); + + // await waitForCompletedSuspend(); + + // await refresh(); + // expect(mockPowerSync.getAll).toHaveBeenCalledTimes(2); + // }); it('should set error when error occurs', async () => { let rejectPromise: (err: string) => void = () => {}; - mockPowerSync.getAll = vi.fn(() => { - return new Promise((_resolve, reject) => { + vi.spyOn(powersync, 'getAll').mockImplementation(() => { + return new Promise((_resolve, reject) => { rejectPromise = reject; }); }); @@ -163,25 +191,25 @@ describe('useSuspenseQuery', () => { await waitForError(); }); - it('should set error when error occurs and runQueryOnce flag is set', async () => { - let rejectPromise: (err: string) => void = () => {}; + // it('should set error when error occurs and runQueryOnce flag is set', async () => { + // let rejectPromise: (err: string) => void = () => {}; - mockPowerSync.getAll = vi.fn(() => { - return new Promise((_resolve, reject) => { - rejectPromise = reject; - }); - }); + // mockPowerSync.getAll = vi.fn(() => { + // return new Promise((_resolve, reject) => { + // rejectPromise = reject; + // }); + // }); - renderHook(() => useSuspenseQuery('SELECT * from lists', [], { runQueryOnce: true }), { - wrapper - }); + // renderHook(() => useSuspenseQuery('SELECT * from lists', [], { runQueryOnce: true }), { + // wrapper + // }); - await waitForSuspend(); + // await waitForSuspend(); - rejectPromise('failure'); - await waitForCompletedSuspend(); - await waitForError(); - }); + // rejectPromise('failure'); + // await waitForCompletedSuspend(); + // await waitForError(); + // }); it('should accept compilable queries', async () => { renderHook( From 83e99e2de26ca6ee7f6fef22b11babfe8d467918 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Thu, 22 May 2025 09:33:40 +0200 Subject: [PATCH 12/75] wip: query interface --- .../src/client/AbstractPowerSyncDatabase.ts | 66 +++++----- .../common/src/client/watched/WatchedQuery.ts | 39 +++--- .../processors/AbstractQueryProcessor.ts | 14 +-- .../processors/OnChangeQueryProcessor.ts | 11 +- packages/react/src/QueryStore.ts | 30 +++-- packages/react/src/WatchedQuery.ts | 12 +- packages/react/src/hooks/useQuery.ts | 114 ++++++++++-------- packages/react/src/hooks/useSuspenseQuery.ts | 23 ++-- packages/web/tests/watch.test.ts | 12 +- 9 files changed, 166 insertions(+), 155 deletions(-) diff --git a/packages/common/src/client/AbstractPowerSyncDatabase.ts b/packages/common/src/client/AbstractPowerSyncDatabase.ts index fed3f589f..fd2189198 100644 --- a/packages/common/src/client/AbstractPowerSyncDatabase.ts +++ b/packages/common/src/client/AbstractPowerSyncDatabase.ts @@ -32,7 +32,7 @@ import { type PowerSyncConnectionOptions, type RequiredAdditionalConnectionOptions } from './sync/stream/AbstractStreamingSyncImplementation.js'; -import { WatchedQuery } from './watched/WatchedQuery.js'; +import { WatchedQuery, WatchedQueryOptions } from './watched/WatchedQuery.js'; import { OnChangeQueryProcessor, WatchedQueryComparator } from './watched/processors/OnChangeQueryProcessor.js'; export interface DisconnectAndClearOptions { @@ -871,30 +871,25 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver(options: { - sql: string; - parameters?: any[]; - throttleMs?: number; - customExecutor?: { - initialData: DataType; - execute: () => Promise; - }; - reportFetching?: boolean; - }): WatchedQuery { - return new OnChangeQueryProcessor({ - db: this, - comparator: { - checkEquality: (a, b) => JSON.stringify(a) == JSON.stringify(b) - }, - query: { - sql: options.sql, - parameters: options.parameters, - throttleMs: options.throttleMs ?? DEFAULT_WATCH_THROTTLE_MS, - customExecutor: options.customExecutor, - reportFetching: options.reportFetching - } - }); + // TODO names and types + incrementalWatch( + options: { watchOptions: WatchedQueryOptions } & { + mode: 'comparison'; + comparator?: WatchedQueryComparator; + } + ): WatchedQuery { + switch (options.mode) { + case 'comparison': + return new OnChangeQueryProcessor({ + db: this, + comparator: options.comparator ?? { + checkEquality: (a, b) => JSON.stringify(a) == JSON.stringify(b) + }, + watchOptions: options.watchOptions + }); + default: + throw new Error(`Invalid mode specified ${options.mode}`); + } } /** @@ -919,18 +914,17 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver ({ + sql: sql, + parameters: parameters ?? [] + }), + execute: () => this.executeReadOnly(sql, parameters) + }, throttleMs: options?.throttleMs ?? DEFAULT_WATCH_THROTTLE_MS, - reportFetching: false, - // The default watch implementation returns QueryResult as the Data type - customExecutor: { - execute: async () => { - return this.executeReadOnly(sql, parameters); - }, - initialData: null - } + reportFetching: false } }); diff --git a/packages/common/src/client/watched/WatchedQuery.ts b/packages/common/src/client/watched/WatchedQuery.ts index d6060ded5..a6e92687e 100644 --- a/packages/common/src/client/watched/WatchedQuery.ts +++ b/packages/common/src/client/watched/WatchedQuery.ts @@ -1,12 +1,15 @@ +import { CompiledQuery } from 'src/types/types.js'; import { BaseListener, BaseObserverInterface } from '../../utils/BaseObserver.js'; export interface WatchedQueryState { /** - * Indicates the initial loading state (hard loading). Loading becomes false once the first set of results from the watched query is available or an error occurs. + * Indicates the initial loading state (hard loading). + * Loading becomes false once the first set of results from the watched query is available or an error occurs. */ isLoading: boolean; /** - * Indicates whether the query is currently fetching data, is true during the initial load and any time when the query is re-evaluating (useful for large queries). + * Indicates whether the query is currently fetching data, is true during the initial load + * and any time when the query is re-evaluating (useful for large queries). */ isFetching: boolean; /** @@ -23,23 +26,29 @@ export interface WatchedQueryState { data: Data; } +/** + * @internal + * Similar to {@link CompatibleQuery}, except the `execute` method + * does not enforce an Array result type. + */ +export interface WatchCompatibleQuery { + execute(compiled: CompiledQuery): Promise; + compile(): CompiledQuery; +} + /** * @internal */ export interface WatchedQueryOptions { - sql: string; - parameters?: any[]; - /** The minimum interval between queries. */ - throttleMs?: number; + query: WatchCompatibleQuery; + /** - * Optional query executor responsible for executing the query. - * This can be used to return query results which are mapped from the database. - * Often this is useful for ORM queries or other query builders. + * Initial result data which is presented while the initial loading is executing */ - customExecutor?: { - execute: () => Promise; - initialData: DataType; - }; + placeholderData: DataType; + + /** The minimum interval between queries. */ + throttleMs?: number; /** * If true (default) the watched query will update its state to report * on the fetching state of the query. @@ -87,10 +96,10 @@ export interface WatchedQuery extends BaseObserverInterface): () => void; /** - * Updates the underlaying query. + * Updates the underlaying query options. * This will trigger a re-evaluation of the query and update the state. */ - updateQuery(query: WatchedQueryOptions): Promise; + updateSettings(options: WatchedQueryOptions): Promise; /** * Close the watched query and end all subscriptions. diff --git a/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts b/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts index 2d8674a3e..b440ce55a 100644 --- a/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts +++ b/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts @@ -15,7 +15,7 @@ import { */ export interface AbstractQueryProcessorOptions { db: AbstractPowerSyncDatabase; - query: WatchedQueryOptions; + watchOptions: WatchedQueryOptions; } /** @@ -64,22 +64,22 @@ export abstract class AbstractQueryProcessor isFetching: this.reportFetching, // Only set to true if we will report updates in future error: null, lastUpdated: null, - data: options.query.customExecutor?.initialData ?? ([] as Data) + data: options.watchOptions.placeholderData }; this.initialized = this.init(); } protected get reportFetching() { - return this.options.query.reportFetching ?? true; + return this.options.watchOptions.reportFetching ?? true; } /** * Updates the underlaying query. */ - async updateQuery(query: WatchedQueryOptions) { + async updateSettings(query: WatchedQueryOptions) { await this.initialized; - this.options.query = query; + this.options.watchOptions = query; this.abortController.abort(); this.abortController = new AbortController(); await this.linkQuery({ @@ -116,7 +116,7 @@ export abstract class AbstractQueryProcessor db.registerListener({ schemaChanged: async () => { await this.runWithReporting(async () => { - await this.updateQuery(this.options.query); + await this.updateSettings(this.options.watchOptions); }); }, closing: () => { @@ -126,7 +126,7 @@ export abstract class AbstractQueryProcessor // Initial setup await this.runWithReporting(async () => { - await this.updateQuery(this.options.query); + await this.updateSettings(this.options.watchOptions); }); } diff --git a/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts b/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts index a08948ae8..dc4fbdf9c 100644 --- a/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts +++ b/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts @@ -62,10 +62,11 @@ export class OnChangeQueryProcessor extends AbstractQueryProcessor { } protected async linkQuery(options: LinkQueryOptions): Promise { - const { db, query } = this.options; + const { db, watchOptions } = this.options; const { abortSignal } = options; - const tables = await db.resolveTables(query.sql, query.parameters); + const compiledQuery = watchOptions.query.compile(); + const tables = await db.resolveTables(compiledQuery.sql, compiledQuery.parameters as any[]); db.onChangeWithCallback( { @@ -79,9 +80,7 @@ export class OnChangeQueryProcessor extends AbstractQueryProcessor { const partialStateUpdate: Partial> = {}; // Always run the query if an underlaying table has changed - const result = query.customExecutor - ? await query.customExecutor.execute() - : ((await db.getAll(query.sql, query.parameters)) as Data); + const result = await watchOptions.query.execute(compiledQuery); if (this.reportFetching) { partialStateUpdate.isFetching = false; @@ -110,7 +109,7 @@ export class OnChangeQueryProcessor extends AbstractQueryProcessor { { signal: abortSignal, tables, - throttleMs: query.throttleMs, + throttleMs: watchOptions.throttleMs, triggerImmediate: true // used to emit the initial state } ); diff --git a/packages/react/src/QueryStore.ts b/packages/react/src/QueryStore.ts index 782789064..39623ead2 100644 --- a/packages/react/src/QueryStore.ts +++ b/packages/react/src/QueryStore.ts @@ -1,33 +1,31 @@ -import { AbstractPowerSyncDatabase, WatchedQuery } from '@powersync/common'; -import { Query } from './WatchedQuery'; +import { AbstractPowerSyncDatabase, WatchCompatibleQuery, WatchedQuery } from '@powersync/common'; import { AdditionalOptions } from './hooks/useQuery'; -export function generateQueryKey(sqlStatement: string, parameters: any[], options: AdditionalOptions): string { +export function generateQueryKey( + sqlStatement: string, + parameters: ReadonlyArray, + options: AdditionalOptions +): string { return `${sqlStatement} -- ${JSON.stringify(parameters)} -- ${JSON.stringify(options)}`; } export class QueryStore { - cache = new Map>(); + cache = new Map>(); constructor(private db: AbstractPowerSyncDatabase) {} - getQuery(key: string, query: Query, options: AdditionalOptions) { + getQuery(key: string, query: WatchCompatibleQuery, options: AdditionalOptions) { if (this.cache.has(key)) { return this.cache.get(key); } - const customExecutor = typeof query.rawQuery !== 'string' ? query.rawQuery : null; - const watchedQuery = this.db.incrementalWatch({ - sql: query.sqlStatement, - parameters: query.queryParameters, - customExecutor: customExecutor - ? { - initialData: [], - execute: () => customExecutor.execute() - } - : undefined, - throttleMs: options.throttleMs + mode: 'comparison', + watchOptions: { + query, + placeholderData: [], + throttleMs: options.throttleMs + } }); const disposer = watchedQuery.registerListener({ diff --git a/packages/react/src/WatchedQuery.ts b/packages/react/src/WatchedQuery.ts index c08bf45e8..55683732c 100644 --- a/packages/react/src/WatchedQuery.ts +++ b/packages/react/src/WatchedQuery.ts @@ -1,10 +1,8 @@ -import { CompilableQuery } from '@powersync/common'; - -export class Query { - rawQuery: string | CompilableQuery; - sqlStatement: string; - queryParameters: any[]; -} +// export class Query { +// rawQuery: string | CompilableQuery; +// sqlStatement: string; +// queryParameters: any[]; +// } // export interface WatchedQueryListener extends BaseListener { //onUpdate: () => void; diff --git a/packages/react/src/hooks/useQuery.ts b/packages/react/src/hooks/useQuery.ts index f53a426b4..4f5bc6e7a 100644 --- a/packages/react/src/hooks/useQuery.ts +++ b/packages/react/src/hooks/useQuery.ts @@ -1,8 +1,7 @@ import { AbstractPowerSyncDatabase, - parseQuery, + WatchCompatibleQuery, type CompilableQuery, - type ParsedQuery, type SQLWatchOptions } from '@powersync/common'; import React from 'react'; @@ -33,26 +32,25 @@ export type QueryResult = { refresh?: (signal?: AbortSignal) => Promise; }; -type InternalHookOptions = { - query: string; - parameters: any[]; +type InternalHookOptions = { + query: WatchCompatibleQuery; powerSync: AbstractPowerSyncDatabase; queryChanged: boolean; - queryExecutor?: () => Promise; }; -const checkQueryChanged = (sqlStatement: string, queryParameters: any[], options: AdditionalOptions) => { - const stringifiedParams = JSON.stringify(queryParameters); +const checkQueryChanged = (query: WatchCompatibleQuery, options: AdditionalOptions) => { + const compiled = query.compile(); + const stringifiedParams = JSON.stringify(compiled.parameters); const stringifiedOptions = JSON.stringify(options); - const previousQueryRef = React.useRef({ sqlStatement, stringifiedParams, stringifiedOptions }); + const previousQueryRef = React.useRef({ sqlStatement: compiled.sql, stringifiedParams, stringifiedOptions }); if ( - previousQueryRef.current.sqlStatement !== sqlStatement || + previousQueryRef.current.sqlStatement !== compiled.sql || previousQueryRef.current.stringifiedParams != stringifiedParams || previousQueryRef.current.stringifiedOptions != stringifiedOptions ) { - previousQueryRef.current.sqlStatement = sqlStatement; + previousQueryRef.current.sqlStatement = compiled.sql; previousQueryRef.current.stringifiedParams = stringifiedParams; previousQueryRef.current.stringifiedOptions = stringifiedOptions; @@ -62,8 +60,8 @@ const checkQueryChanged = (sqlStatement: string, queryParameters: any[], opti return false; }; -const useSingleQuery = (options: InternalHookOptions): QueryResult => { - const { query, parameters, powerSync, queryExecutor, queryChanged } = options; +const useSingleQuery = (options: InternalHookOptions): QueryResult => { + const { query, powerSync, queryChanged } = options; const [output, setOutputState] = React.useState>({ isLoading: true, @@ -76,7 +74,7 @@ const useSingleQuery = (options: InternalHookOptions): Q async (signal?: AbortSignal) => { setOutputState((prev) => ({ ...prev, isLoading: true, isFetching: true, error: undefined })); try { - const result = queryExecutor ? await queryExecutor() : await powerSync.getAll(query, parameters); + const result = await query.execute(query.compile()); if (signal.aborted) { return; } @@ -97,7 +95,7 @@ const useSingleQuery = (options: InternalHookOptions): Q })); } }, - [queryChanged, queryExecutor] + [queryChanged, query] ); // Trigger initial query execution @@ -116,24 +114,20 @@ const useSingleQuery = (options: InternalHookOptions): Q }; const useWatchedQuery = ( - options: InternalHookOptions & { options: HookWatchOptions } + options: InternalHookOptions & { options: HookWatchOptions } ): QueryResult => { - const { query, parameters, powerSync, queryExecutor, queryChanged, options: hookOptions } = options; + const { query, powerSync, queryChanged, options: hookOptions } = options; const createWatchedQuery = React.useCallback(() => { return powerSync.incrementalWatch({ - sql: query, - parameters, - customExecutor: queryExecutor - ? { - execute: queryExecutor, - // This assumes the custom query executor will return an array of data, - // which is the requirement of CompatibleQuery. - initialData: [] - } - : undefined, - throttleMs: hookOptions.throttleMs, - reportFetching: hookOptions.reportFetching + // This always enables comparison. Might want to be able to disable this?? + mode: 'comparison', + watchOptions: { + placeholderData: [], + query, + throttleMs: hookOptions.throttleMs, + reportFetching: hookOptions.reportFetching + } }); }, []); @@ -164,11 +158,10 @@ const useWatchedQuery = ( React.useEffect(() => { if (queryChanged) { console.log('Query changed, re-fetching...'); - watchedQuery.updateQuery({ - sql: query, - parameters: parameters, + watchedQuery.updateSettings({ + placeholderData: [], + query, throttleMs: hookOptions.throttleMs, - customExecutor: queryExecutor ? { execute: queryExecutor, initialData: [] } : undefined, reportFetching: hookOptions.reportFetching }); } @@ -177,6 +170,39 @@ const useWatchedQuery = ( return output; }; +export const constructCompatibleQuery = ( + query: string | CompilableQuery, + parameters: any[] = [], + options: AdditionalOptions +) => { + const powerSync = usePowerSync(); + + const parsedQuery = React.useMemo>(() => { + if (typeof query == 'string') { + return { + compile: () => ({ + sql: query, + parameters: parameters + }), + execute: () => powerSync.getAll(query, parameters) + }; + } else { + return { + // Generics differ a bit but holistically this is the same + compile: () => query.compile(), + execute: () => query.execute() + }; + } + }, [query]); + + const queryChanged = checkQueryChanged(parsedQuery, options); + + return { + parsedQuery, + queryChanged + }; +}; + /** * A hook to access the results of a watched query. * @example @@ -196,39 +222,23 @@ export const useQuery = ( options: AdditionalOptions = { runQueryOnce: false } ): QueryResult => { const powerSync = usePowerSync(); - const logger = powerSync?.logger ?? console; if (!powerSync) { return { isLoading: false, isFetching: false, data: [], error: new Error('PowerSync not configured.') }; } - let parsedQuery: ParsedQuery; - try { - parsedQuery = parseQuery(query, parameters); - } catch (error) { - logger.error('Failed to parse query:', error); - return { isLoading: false, isFetching: false, data: [], error }; - } - - const { sqlStatement, parameters: queryParameters } = parsedQuery; - - const queryChanged = checkQueryChanged(sqlStatement, queryParameters, options); - const queryExecutor = typeof query == 'object' ? query.execute : undefined; + const { parsedQuery, queryChanged } = constructCompatibleQuery(query, parameters, options); switch (options.runQueryOnce) { case true: return useSingleQuery({ - query: sqlStatement, - parameters: queryParameters, + query: parsedQuery, powerSync, - queryExecutor, queryChanged }); default: return useWatchedQuery({ - query: sqlStatement, - parameters: queryParameters, + query: parsedQuery, powerSync, - queryExecutor, queryChanged, options }); diff --git a/packages/react/src/hooks/useSuspenseQuery.ts b/packages/react/src/hooks/useSuspenseQuery.ts index 0b7a7ddc5..ad2d6468a 100644 --- a/packages/react/src/hooks/useSuspenseQuery.ts +++ b/packages/react/src/hooks/useSuspenseQuery.ts @@ -1,8 +1,8 @@ -import { CompilableQuery, ParsedQuery, parseQuery, WatchedQuery } from '@powersync/common'; +import { CompilableQuery, WatchedQuery } from '@powersync/common'; import React from 'react'; import { generateQueryKey, getQueryStore } from '../QueryStore'; import { usePowerSync } from './PowerSyncContext'; -import { AdditionalOptions, QueryResult } from './useQuery'; +import { AdditionalOptions, constructCompatibleQuery, QueryResult } from './useQuery'; export type SuspenseQueryResult = Pick, 'data' | 'refresh'>; @@ -37,24 +37,19 @@ export const useSuspenseQuery = ( throw new Error('PowerSync not configured.'); } - let parsedQuery: ParsedQuery; - try { - parsedQuery = parseQuery(query, parameters); - } catch (error) { - throw new Error('Failed to parse query: ' + error.message); - } - const key = generateQueryKey(parsedQuery.sqlStatement, parsedQuery.parameters, options); + // Note, we don't need to check if the query changed since we fetch the WatchedQuery + // from the store given these query params + const { parsedQuery } = constructCompatibleQuery(query, parameters, options); + const { sql: parsedSql, parameters: parsedParameters } = parsedQuery.compile(); + + const key = generateQueryKey(parsedSql, parsedParameters, options); // When the component is suspended, all state is discarded. We don't get // any notification of that. So checkoutQuery reserves a temporary hold // on the query. // Once the component "commits", we exchange that for a permanent hold. const store = getQueryStore(powerSync); - const watchedQuery = store.getQuery( - key, - { rawQuery: query, sqlStatement: parsedQuery.sqlStatement, queryParameters: parsedQuery.parameters }, - options - ) as WatchedQuery; + const watchedQuery = store.getQuery(key, parsedQuery, options) as WatchedQuery; const addedHoldTo = React.useRef | undefined>(undefined); const releaseTemporaryHold = React.useRef<(() => void) | undefined>(undefined); diff --git a/packages/web/tests/watch.test.ts b/packages/web/tests/watch.test.ts index 8333e9257..e98cdc1b6 100644 --- a/packages/web/tests/watch.test.ts +++ b/packages/web/tests/watch.test.ts @@ -327,8 +327,16 @@ describe('Watch Tests', { sequential: true }, () => { it('should stream watch results', async () => { const watch = powersync.incrementalWatch({ - sql: 'SELECT * FROM assets', - parameters: [] + mode: 'comparison', + watchOptions: { + query: { + compile: () => ({ + sql: 'SELECT * FROM assets', + parameters: [] + }), + execute: ({ sql, parameters }) => powersync.getAll(sql, parameters) + } + } }); const getNextState = () => From 47047232641ebe1fc8876066075664d17a791aa3 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Mon, 26 May 2025 17:12:33 +0200 Subject: [PATCH 13/75] cleanup interfaces --- .../src/client/AbstractPowerSyncDatabase.ts | 28 +++++++++------- .../common/src/client/watched/WatchedQuery.ts | 10 ++++-- packages/react/src/QueryStore.ts | 3 +- packages/react/src/hooks/useQuery.ts | 13 +++++--- packages/web/tests/watch.test.ts | 32 ++++++++++++++----- 5 files changed, 58 insertions(+), 28 deletions(-) diff --git a/packages/common/src/client/AbstractPowerSyncDatabase.ts b/packages/common/src/client/AbstractPowerSyncDatabase.ts index fd2189198..7c90e155b 100644 --- a/packages/common/src/client/AbstractPowerSyncDatabase.ts +++ b/packages/common/src/client/AbstractPowerSyncDatabase.ts @@ -109,6 +109,16 @@ export interface WatchOnChangeHandler { onError?: (error: Error) => void; } +export interface ComparatorWatchOptions { + mode: 'comparison'; + comparator?: WatchedQueryComparator; +} + +export interface IncrementalWatchOptions { + watch: WatchedQueryOptions; + processor?: ComparatorWatchOptions; +} + export interface PowerSyncDBListener extends StreamingSyncImplementationListener { initialized: () => void; schemaChanged: (schema: Schema) => void; @@ -872,23 +882,19 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver( - options: { watchOptions: WatchedQueryOptions } & { - mode: 'comparison'; - comparator?: WatchedQueryComparator; - } - ): WatchedQuery { - switch (options.mode) { + incrementalWatch(options: IncrementalWatchOptions): WatchedQuery { + const { watch, processor } = options; + + switch (options.processor?.mode) { case 'comparison': + default: return new OnChangeQueryProcessor({ db: this, - comparator: options.comparator ?? { + comparator: processor?.comparator ?? { checkEquality: (a, b) => JSON.stringify(a) == JSON.stringify(b) }, - watchOptions: options.watchOptions + watchOptions: watch }); - default: - throw new Error(`Invalid mode specified ${options.mode}`); } } diff --git a/packages/common/src/client/watched/WatchedQuery.ts b/packages/common/src/client/watched/WatchedQuery.ts index a6e92687e..b71b85101 100644 --- a/packages/common/src/client/watched/WatchedQuery.ts +++ b/packages/common/src/client/watched/WatchedQuery.ts @@ -1,4 +1,3 @@ -import { CompiledQuery } from 'src/types/types.js'; import { BaseListener, BaseObserverInterface } from '../../utils/BaseObserver.js'; export interface WatchedQueryState { @@ -26,14 +25,19 @@ export interface WatchedQueryState { data: Data; } +export interface WatchCompiledQuery { + sql: string; + parameters: any[]; +} /** + * * @internal * Similar to {@link CompatibleQuery}, except the `execute` method * does not enforce an Array result type. */ export interface WatchCompatibleQuery { - execute(compiled: CompiledQuery): Promise; - compile(): CompiledQuery; + execute(compiled: WatchCompiledQuery): Promise; + compile(): WatchCompiledQuery; } /** diff --git a/packages/react/src/QueryStore.ts b/packages/react/src/QueryStore.ts index 39623ead2..b7e528e9a 100644 --- a/packages/react/src/QueryStore.ts +++ b/packages/react/src/QueryStore.ts @@ -20,8 +20,7 @@ export class QueryStore { } const watchedQuery = this.db.incrementalWatch({ - mode: 'comparison', - watchOptions: { + watch: { query, placeholderData: [], throttleMs: options.throttleMs diff --git a/packages/react/src/hooks/useQuery.ts b/packages/react/src/hooks/useQuery.ts index 4f5bc6e7a..6e6ab866b 100644 --- a/packages/react/src/hooks/useQuery.ts +++ b/packages/react/src/hooks/useQuery.ts @@ -121,8 +121,7 @@ const useWatchedQuery = ( const createWatchedQuery = React.useCallback(() => { return powerSync.incrementalWatch({ // This always enables comparison. Might want to be able to disable this?? - mode: 'comparison', - watchOptions: { + watch: { placeholderData: [], query, throttleMs: hookOptions.throttleMs, @@ -182,14 +181,20 @@ export const constructCompatibleQuery = ( return { compile: () => ({ sql: query, - parameters: parameters + parameters }), execute: () => powerSync.getAll(query, parameters) }; } else { return { // Generics differ a bit but holistically this is the same - compile: () => query.compile(), + compile: () => { + const compiled = query.compile(); + return { + sql: compiled.sql, + parameters: [...compiled.parameters] + }; + }, execute: () => query.execute() }; } diff --git a/packages/web/tests/watch.test.ts b/packages/web/tests/watch.test.ts index e98cdc1b6..1a201c8fe 100644 --- a/packages/web/tests/watch.test.ts +++ b/packages/web/tests/watch.test.ts @@ -327,15 +327,15 @@ describe('Watch Tests', { sequential: true }, () => { it('should stream watch results', async () => { const watch = powersync.incrementalWatch({ - mode: 'comparison', - watchOptions: { + watch: { query: { compile: () => ({ sql: 'SELECT * FROM assets', parameters: [] }), execute: ({ sql, parameters }) => powersync.getAll(sql, parameters) - } + }, + placeholderData: [] } }); @@ -369,8 +369,16 @@ describe('Watch Tests', { sequential: true }, () => { it('should only report updates for relevant changes', async () => { const watch = powersync.incrementalWatch({ - sql: 'SELECT * FROM assets where make = ?', - parameters: ['test'] + watch: { + query: { + compile: () => ({ + sql: 'SELECT * FROM assets where make = ?', + parameters: ['test'] + }), + execute: ({ sql, parameters }) => powersync.getAll(sql, parameters) + }, + placeholderData: [] + } }); let notificationCount = 0; @@ -398,9 +406,17 @@ describe('Watch Tests', { sequential: true }, () => { it('should not report fetching status', async () => { const watch = powersync.incrementalWatch({ - sql: 'SELECT * FROM assets where make = ?', - parameters: ['test'], - reportFetching: false + watch: { + query: { + compile: () => ({ + sql: 'SELECT * FROM assets where make = ?', + parameters: ['test'] + }), + execute: ({ sql, parameters }) => powersync.getAll(sql, parameters) + }, + placeholderData: [], + reportFetching: false + } }); expect(watch.state.isFetching).false; From 79f809eaf09e31034964d8cbaed7b22bdd620811 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 27 May 2025 09:44:31 +0200 Subject: [PATCH 14/75] cleanup suspense hooks --- packages/react/src/hooks/useSuspenseQuery.ts | 255 ++++++++++++++----- 1 file changed, 189 insertions(+), 66 deletions(-) diff --git a/packages/react/src/hooks/useSuspenseQuery.ts b/packages/react/src/hooks/useSuspenseQuery.ts index ad2d6468a..5746ddafa 100644 --- a/packages/react/src/hooks/useSuspenseQuery.ts +++ b/packages/react/src/hooks/useSuspenseQuery.ts @@ -7,58 +7,28 @@ import { AdditionalOptions, constructCompatibleQuery, QueryResult } from './useQ export type SuspenseQueryResult = Pick, 'data' | 'refresh'>; /** - * A hook to access the results of a watched query that suspends until the initial result has loaded. - * @example - * export const ContentComponent = () => { - * const { data: lists } = useSuspenseQuery('SELECT * from lists'); - * - * return - * {lists.map((l) => ( - * {JSON.stringify(l)} - * ))} - * ; - * } - * - * export const DisplayComponent = () => { - * return ( - * Loading content...}> - * - * - * ); - * } + * The store will dispose this query if it has no subscribers attached to it. + * The suspense promise adds a subscriber to the query, but the promise could resolve + * before this component is committed. The promise will release it's listener once the query is no longer loading. + * This temporary hold is used to ensure that the query is not disposed in the interim. + * Creates a subscription for state change which creates a temporary hold on the query + * @returns a function to release the hold */ -export const useSuspenseQuery = ( - query: string | CompilableQuery, - parameters: any[] = [], - options: AdditionalOptions = {} -): SuspenseQueryResult => { - const powerSync = usePowerSync(); - if (!powerSync) { - throw new Error('PowerSync not configured.'); - } - - // Note, we don't need to check if the query changed since we fetch the WatchedQuery - // from the store given these query params - const { parsedQuery } = constructCompatibleQuery(query, parameters, options); - const { sql: parsedSql, parameters: parsedParameters } = parsedQuery.compile(); - - const key = generateQueryKey(parsedSql, parsedParameters, options); - - // When the component is suspended, all state is discarded. We don't get - // any notification of that. So checkoutQuery reserves a temporary hold - // on the query. - // Once the component "commits", we exchange that for a permanent hold. - const store = getQueryStore(powerSync); - const watchedQuery = store.getQuery(key, parsedQuery, options) as WatchedQuery; - - const addedHoldTo = React.useRef | undefined>(undefined); +const useTemporaryHold = (watchedQuery?: WatchedQuery) => { const releaseTemporaryHold = React.useRef<(() => void) | undefined>(undefined); + const addedHoldTo = React.useRef | undefined>(undefined); if (addedHoldTo.current !== watchedQuery) { releaseTemporaryHold.current?.(); + addedHoldTo.current = watchedQuery; + + if (!watchedQuery || !watchedQuery.state.isLoading) { + // No query to hold or no reason to hold, return a no-op + return { + releaseHold: () => {} + }; + } - // The store will dispose this query if it has no subscribers attached to it. - // Creates a subscription for state change which creates a temporary hold on the query const disposeSubscription = watchedQuery.subscribe({ onStateChange: (state) => {} }); @@ -94,48 +64,201 @@ export const useSuspenseQuery = ( // Set a timeout to conditionally remove the temporary hold setTimeout(checkHold, timeoutPollMs); - - addedHoldTo.current = watchedQuery; } + return { + releaseHold: releaseTemporaryHold.current + }; +}; + +/** + * React suspense relies on a promise that resolves once the initial data has loaded. + * This creates a promise which registers a listener on the watched query. + * Registering a listener on the watched query will ensure that the query is not disposed + * while the component is suspended. + */ +const createSuspendingPromise = (query: WatchedQuery) => { + return new Promise((resolve) => { + // The listener here will dispose itself once the loading is done + // This decreases the number of listeners on the query + // even if the component is unmounted + const dispose = query.subscribe({ + onStateChange: (state) => { + // Returns to the hook if loading is completed or if loading resulted in an error + if (!state.isLoading || state.error) { + resolve(); + dispose(); + } + } + }); + }); +}; + +// TODO naming +export const useWatchedQuerySuspenseSubscription = (query: WatchedQuery) => { + const { releaseHold } = useTemporaryHold(query); + // Force update state function const [, setUpdateCounter] = React.useState(0); React.useEffect(() => { // This runs when the component came out of suspense - // Does it run before suspending? // This add a permanent hold since a listener has been added to the query - const dispose = watchedQuery.subscribe({ + const dispose = query.subscribe({ onStateChange() { // Trigger rerender setUpdateCounter((prev) => prev + 1); } }); - releaseTemporaryHold.current?.(); - releaseTemporaryHold.current = undefined; + // This runs on the first iteration before the component is suspended + // We should only release the hold once the component is no longer loading + if (!query.state.isLoading) { + releaseHold(); + } return dispose; }, []); - if (watchedQuery.state.error != null) { + if (query.state.error != null) { // Report errors - this is caught by an error boundary - throw watchedQuery.state.error; - } else if (!watchedQuery.state.isLoading) { + throw query.state.error; + } else if (!query.state.isLoading) { // Happy path data return - return { data: watchedQuery.state.data }; + return { data: query.state.data }; } else { // Notify suspense is required - throw new Promise((resolve) => { - const dispose = watchedQuery.subscribe({ - onStateChange: (state) => { - // Returns to the hook if loading is completed or if loading resulted in an error - if (!state.isLoading || state.error) { - resolve(); - dispose(); - } + throw createSuspendingPromise(query); + } +}; + +const useWatchedSuspenseQuery = ( + query: string | CompilableQuery, + parameters: any[] = [], + options: AdditionalOptions = {} +): SuspenseQueryResult => { + const powerSync = usePowerSync(); + if (!powerSync) { + throw new Error('PowerSync not configured.'); + } + + // Note, we don't need to check if the query changed since we fetch the WatchedQuery + // from the store given these query params + const { parsedQuery } = constructCompatibleQuery(query, parameters, options); + const { sql: parsedSql, parameters: parsedParameters } = parsedQuery.compile(); + + const key = generateQueryKey(parsedSql, parsedParameters, options); + + // When the component is suspended, all state is discarded. We don't get + // any notification of that. So checkoutQuery reserves a temporary hold + // on the query. + // Once the component "commits", we exchange that for a permanent hold. + const store = getQueryStore(powerSync); + const watchedQuery = store.getQuery(key, parsedQuery, options) as WatchedQuery; + + return useWatchedQuerySuspenseSubscription(watchedQuery); +}; + +/** + * Use a query which is not watched, but suspends until the initial result has loaded. + * Internally this uses a WatchedQuery during suspense for state management. The watched + * query is potentially disposed, if there are no subscribers attached to it, after the initial load. + * The query can be refreshed by calling the `refresh` function after initial load. + */ +const useSingleSuspenseQuery = ( + query: string | CompilableQuery, + parameters: any[] = [], + options: AdditionalOptions = {} +): SuspenseQueryResult => { + const powerSync = usePowerSync(); + if (!powerSync) { + throw new Error('PowerSync not configured.'); + } + + // Manually track data for single queries + const [data, setData] = React.useState(null); + const [error, setError] = React.useState(null); + + // Note, we don't need to check if the query changed since we fetch the WatchedQuery + // from the store given these query params + const { parsedQuery } = constructCompatibleQuery(query, parameters, options); + const { sql: parsedSql, parameters: parsedParameters } = parsedQuery.compile(); + + const key = generateQueryKey(parsedSql, parsedParameters, options); + const store = getQueryStore(powerSync); + + // Only use a temporary watched query if we don't have data yet. + const watchedQuery = data ? null : (store.getQuery(key, parsedQuery, options) as WatchedQuery); + const { releaseHold } = useTemporaryHold(watchedQuery); + + React.useEffect(() => { + // Set the initial yielded data + // it should be available once we commit the component + if (watchedQuery?.state.error) { + setError(watchedQuery.state.error); + } else if (watchedQuery?.state.data) { + setData(watchedQuery.state.data); + setError(null); + } + + if (!watchedQuery?.state.isLoading) { + releaseHold(); + } + }, []); + + if (error != null) { + // Report errors - this is caught by an error boundary + throw error; + } else if (data || watchedQuery?.state.data) { + // Happy path data return + return { + data: data ?? watchedQuery?.state.data ?? [], + refresh: async () => { + try { + const result = await parsedQuery.execute(parsedQuery.compile()); + setData(result); + setError(null); + } catch (e) { + setError(e); } - }); - }); + } + }; + } else { + // Notify suspense is required + throw createSuspendingPromise(watchedQuery!); + } +}; + +/** + * A hook to access the results of a watched query that suspends until the initial result has loaded. + * @example + * export const ContentComponent = () => { + * const { data: lists } = useSuspenseQuery('SELECT * from lists'); + * + * return + * {lists.map((l) => ( + * {JSON.stringify(l)} + * ))} + * ; + * } + * + * export const DisplayComponent = () => { + * return ( + * Loading content...}> + * + * + * ); + * } + */ +export const useSuspenseQuery = ( + query: string | CompilableQuery, + parameters: any[] = [], + options: AdditionalOptions = {} +): SuspenseQueryResult => { + switch (options.runQueryOnce) { + case true: + return useSingleSuspenseQuery(query, parameters, options); + default: + return useWatchedSuspenseQuery(query, parameters, options); } }; From b11c24e364d344063553d0a98e39f34d2a679495 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 27 May 2025 09:50:03 +0200 Subject: [PATCH 15/75] wip: split suspense hooks --- .../src/hooks/suspense/suspense-utils.ts | 90 +++++++ .../hooks/suspense/useSingleSuspenseQuery.ts | 77 ++++++ .../useWatchedQuerySuspenseSubscription.ts | 41 ++++ .../hooks/suspense/useWatchedSuspenseQuery.ts | 32 +++ packages/react/src/hooks/useSuspenseQuery.ts | 230 +----------------- 5 files changed, 242 insertions(+), 228 deletions(-) create mode 100644 packages/react/src/hooks/suspense/suspense-utils.ts create mode 100644 packages/react/src/hooks/suspense/useSingleSuspenseQuery.ts create mode 100644 packages/react/src/hooks/suspense/useWatchedQuerySuspenseSubscription.ts create mode 100644 packages/react/src/hooks/suspense/useWatchedSuspenseQuery.ts diff --git a/packages/react/src/hooks/suspense/suspense-utils.ts b/packages/react/src/hooks/suspense/suspense-utils.ts new file mode 100644 index 000000000..3d1d71be0 --- /dev/null +++ b/packages/react/src/hooks/suspense/suspense-utils.ts @@ -0,0 +1,90 @@ +import { WatchedQuery } from '@powersync/common'; +import React from 'react'; + +/** + * The store will dispose this query if it has no subscribers attached to it. + * The suspense promise adds a subscriber to the query, but the promise could resolve + * before this component is committed. The promise will release it's listener once the query is no longer loading. + * This temporary hold is used to ensure that the query is not disposed in the interim. + * Creates a subscription for state change which creates a temporary hold on the query + * @returns a function to release the hold + */ +export const useTemporaryHold = (watchedQuery?: WatchedQuery) => { + const releaseTemporaryHold = React.useRef<(() => void) | undefined>(undefined); + const addedHoldTo = React.useRef | undefined>(undefined); + + if (addedHoldTo.current !== watchedQuery) { + releaseTemporaryHold.current?.(); + addedHoldTo.current = watchedQuery; + + if (!watchedQuery || !watchedQuery.state.isLoading) { + // No query to hold or no reason to hold, return a no-op + return { + releaseHold: () => {} + }; + } + + const disposeSubscription = watchedQuery.subscribe({ + onStateChange: (state) => {} + }); + + let timeout: ReturnType; + + const disposeClosedListener = watchedQuery.registerListener({ + closed: () => { + if (timeout) { + clearTimeout(timeout); + } + disposeClosedListener(); + } + }); + + const releaseHold = () => { + disposeSubscription(); + disposeClosedListener(); + }; + releaseTemporaryHold.current = releaseHold; + + const timeoutPollMs = 5_000; + + const checkHold = () => { + if (watchedQuery.closed || !watchedQuery.state.isLoading || watchedQuery.state.error) { + // No need to keep a temporary hold on this query + releaseHold(); + } else { + // Need to keep the hold, check again after timeout + setTimeout(checkHold, timeoutPollMs); + } + }; + + // Set a timeout to conditionally remove the temporary hold + setTimeout(checkHold, timeoutPollMs); + } + + return { + releaseHold: releaseTemporaryHold.current + }; +}; + +/** + * React suspense relies on a promise that resolves once the initial data has loaded. + * This creates a promise which registers a listener on the watched query. + * Registering a listener on the watched query will ensure that the query is not disposed + * while the component is suspended. + */ +export const createSuspendingPromise = (query: WatchedQuery) => { + return new Promise((resolve) => { + // The listener here will dispose itself once the loading is done + // This decreases the number of listeners on the query + // even if the component is unmounted + const dispose = query.subscribe({ + onStateChange: (state) => { + // Returns to the hook if loading is completed or if loading resulted in an error + if (!state.isLoading || state.error) { + resolve(); + dispose(); + } + } + }); + }); +}; diff --git a/packages/react/src/hooks/suspense/useSingleSuspenseQuery.ts b/packages/react/src/hooks/suspense/useSingleSuspenseQuery.ts new file mode 100644 index 000000000..7f24c9a9c --- /dev/null +++ b/packages/react/src/hooks/suspense/useSingleSuspenseQuery.ts @@ -0,0 +1,77 @@ +import { CompilableQuery } from '@powersync/common'; +import React from 'react'; +import { generateQueryKey, getQueryStore } from '../../QueryStore'; +import { usePowerSync } from '../PowerSyncContext'; +import { AdditionalOptions, constructCompatibleQuery } from '../useQuery'; +import { SuspenseQueryResult } from '../useSuspenseQuery'; +import { createSuspendingPromise, useTemporaryHold } from './suspense-utils'; + +/** + * Use a query which is not watched, but suspends until the initial result has loaded. + * Internally this uses a WatchedQuery during suspense for state management. The watched + * query is potentially disposed, if there are no subscribers attached to it, after the initial load. + * The query can be refreshed by calling the `refresh` function after initial load. + */ +export const useSingleSuspenseQuery = ( + query: string | CompilableQuery, + parameters: any[] = [], + options: AdditionalOptions = {} +): SuspenseQueryResult => { + const powerSync = usePowerSync(); + if (!powerSync) { + throw new Error('PowerSync not configured.'); + } + + // Manually track data for single queries + const [data, setData] = React.useState(null); + const [error, setError] = React.useState(null); + + // Note, we don't need to check if the query changed since we fetch the WatchedQuery + // from the store given these query params + const { parsedQuery } = constructCompatibleQuery(query, parameters, options); + const { sql: parsedSql, parameters: parsedParameters } = parsedQuery.compile(); + + const key = generateQueryKey(parsedSql, parsedParameters, options); + const store = getQueryStore(powerSync); + + // Only use a temporary watched query if we don't have data yet. + const watchedQuery = data ? null : (store.getQuery(key, parsedQuery, options) as WatchedQuery); + const { releaseHold } = useTemporaryHold(watchedQuery); + + React.useEffect(() => { + // Set the initial yielded data + // it should be available once we commit the component + if (watchedQuery?.state.error) { + setError(watchedQuery.state.error); + } else if (watchedQuery?.state.data) { + setData(watchedQuery.state.data); + setError(null); + } + + if (!watchedQuery?.state.isLoading) { + releaseHold(); + } + }, []); + + if (error != null) { + // Report errors - this is caught by an error boundary + throw error; + } else if (data || watchedQuery?.state.data) { + // Happy path data return + return { + data: data ?? watchedQuery?.state.data ?? [], + refresh: async () => { + try { + const result = await parsedQuery.execute(parsedQuery.compile()); + setData(result); + setError(null); + } catch (e) { + setError(e); + } + } + }; + } else { + // Notify suspense is required + throw createSuspendingPromise(watchedQuery!); + } +}; diff --git a/packages/react/src/hooks/suspense/useWatchedQuerySuspenseSubscription.ts b/packages/react/src/hooks/suspense/useWatchedQuerySuspenseSubscription.ts new file mode 100644 index 000000000..a25994f26 --- /dev/null +++ b/packages/react/src/hooks/suspense/useWatchedQuerySuspenseSubscription.ts @@ -0,0 +1,41 @@ +import { WatchedQuery } from '@powersync/common'; +import React from 'react'; +import { createSuspendingPromise, useTemporaryHold } from './suspense-utils'; + +// TODO naming +export const useWatchedQuerySuspenseSubscription = (query: WatchedQuery) => { + const { releaseHold } = useTemporaryHold(query); + + // Force update state function + const [, setUpdateCounter] = React.useState(0); + + React.useEffect(() => { + // This runs when the component came out of suspense + // This add a permanent hold since a listener has been added to the query + const dispose = query.subscribe({ + onStateChange() { + // Trigger rerender + setUpdateCounter((prev) => prev + 1); + } + }); + + // This runs on the first iteration before the component is suspended + // We should only release the hold once the component is no longer loading + if (!query.state.isLoading) { + releaseHold(); + } + + return dispose; + }, []); + + if (query.state.error != null) { + // Report errors - this is caught by an error boundary + throw query.state.error; + } else if (!query.state.isLoading) { + // Happy path data return + return { data: query.state.data }; + } else { + // Notify suspense is required + throw createSuspendingPromise(query); + } +}; diff --git a/packages/react/src/hooks/suspense/useWatchedSuspenseQuery.ts b/packages/react/src/hooks/suspense/useWatchedSuspenseQuery.ts new file mode 100644 index 000000000..2f6de4298 --- /dev/null +++ b/packages/react/src/hooks/suspense/useWatchedSuspenseQuery.ts @@ -0,0 +1,32 @@ +import { CompilableQuery, WatchedQuery } from '@powersync/common'; +import { generateQueryKey, getQueryStore } from '../../QueryStore'; +import { usePowerSync } from '../PowerSyncContext'; +import { AdditionalOptions, constructCompatibleQuery } from '../useQuery'; +import { useWatchedQuerySuspenseSubscription } from './useWatchedQuerySuspenseSubscription'; + +const useWatchedSuspenseQuery = ( + query: string | CompilableQuery, + parameters: any[] = [], + options: AdditionalOptions = {} +): SuspenseQueryResult => { + const powerSync = usePowerSync(); + if (!powerSync) { + throw new Error('PowerSync not configured.'); + } + + // Note, we don't need to check if the query changed since we fetch the WatchedQuery + // from the store given these query params + const { parsedQuery } = constructCompatibleQuery(query, parameters, options); + const { sql: parsedSql, parameters: parsedParameters } = parsedQuery.compile(); + + const key = generateQueryKey(parsedSql, parsedParameters, options); + + // When the component is suspended, all state is discarded. We don't get + // any notification of that. So checkoutQuery reserves a temporary hold + // on the query. + // Once the component "commits", we exchange that for a permanent hold. + const store = getQueryStore(powerSync); + const watchedQuery = store.getQuery(key, parsedQuery, options) as WatchedQuery; + + return useWatchedQuerySuspenseSubscription(watchedQuery); +}; diff --git a/packages/react/src/hooks/useSuspenseQuery.ts b/packages/react/src/hooks/useSuspenseQuery.ts index 5746ddafa..f89586d98 100644 --- a/packages/react/src/hooks/useSuspenseQuery.ts +++ b/packages/react/src/hooks/useSuspenseQuery.ts @@ -1,234 +1,8 @@ -import { CompilableQuery, WatchedQuery } from '@powersync/common'; -import React from 'react'; -import { generateQueryKey, getQueryStore } from '../QueryStore'; -import { usePowerSync } from './PowerSyncContext'; -import { AdditionalOptions, constructCompatibleQuery, QueryResult } from './useQuery'; +import { CompilableQuery } from '@powersync/common'; +import { AdditionalOptions, QueryResult } from './useQuery'; export type SuspenseQueryResult = Pick, 'data' | 'refresh'>; -/** - * The store will dispose this query if it has no subscribers attached to it. - * The suspense promise adds a subscriber to the query, but the promise could resolve - * before this component is committed. The promise will release it's listener once the query is no longer loading. - * This temporary hold is used to ensure that the query is not disposed in the interim. - * Creates a subscription for state change which creates a temporary hold on the query - * @returns a function to release the hold - */ -const useTemporaryHold = (watchedQuery?: WatchedQuery) => { - const releaseTemporaryHold = React.useRef<(() => void) | undefined>(undefined); - const addedHoldTo = React.useRef | undefined>(undefined); - - if (addedHoldTo.current !== watchedQuery) { - releaseTemporaryHold.current?.(); - addedHoldTo.current = watchedQuery; - - if (!watchedQuery || !watchedQuery.state.isLoading) { - // No query to hold or no reason to hold, return a no-op - return { - releaseHold: () => {} - }; - } - - const disposeSubscription = watchedQuery.subscribe({ - onStateChange: (state) => {} - }); - - let timeout: ReturnType; - - const disposeClosedListener = watchedQuery.registerListener({ - closed: () => { - if (timeout) { - clearTimeout(timeout); - } - disposeClosedListener(); - } - }); - - const releaseHold = () => { - disposeSubscription(); - disposeClosedListener(); - }; - releaseTemporaryHold.current = releaseHold; - - const timeoutPollMs = 5_000; - - const checkHold = () => { - if (watchedQuery.closed || !watchedQuery.state.isLoading || watchedQuery.state.error) { - // No need to keep a temporary hold on this query - releaseHold(); - } else { - // Need to keep the hold, check again after timeout - setTimeout(checkHold, timeoutPollMs); - } - }; - - // Set a timeout to conditionally remove the temporary hold - setTimeout(checkHold, timeoutPollMs); - } - - return { - releaseHold: releaseTemporaryHold.current - }; -}; - -/** - * React suspense relies on a promise that resolves once the initial data has loaded. - * This creates a promise which registers a listener on the watched query. - * Registering a listener on the watched query will ensure that the query is not disposed - * while the component is suspended. - */ -const createSuspendingPromise = (query: WatchedQuery) => { - return new Promise((resolve) => { - // The listener here will dispose itself once the loading is done - // This decreases the number of listeners on the query - // even if the component is unmounted - const dispose = query.subscribe({ - onStateChange: (state) => { - // Returns to the hook if loading is completed or if loading resulted in an error - if (!state.isLoading || state.error) { - resolve(); - dispose(); - } - } - }); - }); -}; - -// TODO naming -export const useWatchedQuerySuspenseSubscription = (query: WatchedQuery) => { - const { releaseHold } = useTemporaryHold(query); - - // Force update state function - const [, setUpdateCounter] = React.useState(0); - - React.useEffect(() => { - // This runs when the component came out of suspense - // This add a permanent hold since a listener has been added to the query - const dispose = query.subscribe({ - onStateChange() { - // Trigger rerender - setUpdateCounter((prev) => prev + 1); - } - }); - - // This runs on the first iteration before the component is suspended - // We should only release the hold once the component is no longer loading - if (!query.state.isLoading) { - releaseHold(); - } - - return dispose; - }, []); - - if (query.state.error != null) { - // Report errors - this is caught by an error boundary - throw query.state.error; - } else if (!query.state.isLoading) { - // Happy path data return - return { data: query.state.data }; - } else { - // Notify suspense is required - throw createSuspendingPromise(query); - } -}; - -const useWatchedSuspenseQuery = ( - query: string | CompilableQuery, - parameters: any[] = [], - options: AdditionalOptions = {} -): SuspenseQueryResult => { - const powerSync = usePowerSync(); - if (!powerSync) { - throw new Error('PowerSync not configured.'); - } - - // Note, we don't need to check if the query changed since we fetch the WatchedQuery - // from the store given these query params - const { parsedQuery } = constructCompatibleQuery(query, parameters, options); - const { sql: parsedSql, parameters: parsedParameters } = parsedQuery.compile(); - - const key = generateQueryKey(parsedSql, parsedParameters, options); - - // When the component is suspended, all state is discarded. We don't get - // any notification of that. So checkoutQuery reserves a temporary hold - // on the query. - // Once the component "commits", we exchange that for a permanent hold. - const store = getQueryStore(powerSync); - const watchedQuery = store.getQuery(key, parsedQuery, options) as WatchedQuery; - - return useWatchedQuerySuspenseSubscription(watchedQuery); -}; - -/** - * Use a query which is not watched, but suspends until the initial result has loaded. - * Internally this uses a WatchedQuery during suspense for state management. The watched - * query is potentially disposed, if there are no subscribers attached to it, after the initial load. - * The query can be refreshed by calling the `refresh` function after initial load. - */ -const useSingleSuspenseQuery = ( - query: string | CompilableQuery, - parameters: any[] = [], - options: AdditionalOptions = {} -): SuspenseQueryResult => { - const powerSync = usePowerSync(); - if (!powerSync) { - throw new Error('PowerSync not configured.'); - } - - // Manually track data for single queries - const [data, setData] = React.useState(null); - const [error, setError] = React.useState(null); - - // Note, we don't need to check if the query changed since we fetch the WatchedQuery - // from the store given these query params - const { parsedQuery } = constructCompatibleQuery(query, parameters, options); - const { sql: parsedSql, parameters: parsedParameters } = parsedQuery.compile(); - - const key = generateQueryKey(parsedSql, parsedParameters, options); - const store = getQueryStore(powerSync); - - // Only use a temporary watched query if we don't have data yet. - const watchedQuery = data ? null : (store.getQuery(key, parsedQuery, options) as WatchedQuery); - const { releaseHold } = useTemporaryHold(watchedQuery); - - React.useEffect(() => { - // Set the initial yielded data - // it should be available once we commit the component - if (watchedQuery?.state.error) { - setError(watchedQuery.state.error); - } else if (watchedQuery?.state.data) { - setData(watchedQuery.state.data); - setError(null); - } - - if (!watchedQuery?.state.isLoading) { - releaseHold(); - } - }, []); - - if (error != null) { - // Report errors - this is caught by an error boundary - throw error; - } else if (data || watchedQuery?.state.data) { - // Happy path data return - return { - data: data ?? watchedQuery?.state.data ?? [], - refresh: async () => { - try { - const result = await parsedQuery.execute(parsedQuery.compile()); - setData(result); - setError(null); - } catch (e) { - setError(e); - } - } - }; - } else { - // Notify suspense is required - throw createSuspendingPromise(watchedQuery!); - } -}; - /** * A hook to access the results of a watched query that suspends until the initial result has loaded. * @example From 9b86a6369ad1ff009e88620722876070a5c3c9ac Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 27 May 2025 09:50:36 +0200 Subject: [PATCH 16/75] git mv --- packages/react/src/hooks/{ => suspense}/useSuspenseQuery.ts | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/react/src/hooks/{ => suspense}/useSuspenseQuery.ts (100%) diff --git a/packages/react/src/hooks/useSuspenseQuery.ts b/packages/react/src/hooks/suspense/useSuspenseQuery.ts similarity index 100% rename from packages/react/src/hooks/useSuspenseQuery.ts rename to packages/react/src/hooks/suspense/useSuspenseQuery.ts From e98e43f43b638b9ffd514c48299c48f46974e372 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 27 May 2025 09:54:25 +0200 Subject: [PATCH 17/75] cleanup hook folder structure --- .../react/src/hooks/suspense/useSingleSuspenseQuery.ts | 7 ++++--- packages/react/src/hooks/suspense/useSuspenseQuery.ts | 6 +++--- .../react/src/hooks/suspense/useWatchedSuspenseQuery.ts | 3 ++- packages/react/src/index.ts | 9 +++++---- 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/packages/react/src/hooks/suspense/useSingleSuspenseQuery.ts b/packages/react/src/hooks/suspense/useSingleSuspenseQuery.ts index 7f24c9a9c..9a1d851ef 100644 --- a/packages/react/src/hooks/suspense/useSingleSuspenseQuery.ts +++ b/packages/react/src/hooks/suspense/useSingleSuspenseQuery.ts @@ -1,11 +1,12 @@ -import { CompilableQuery } from '@powersync/common'; +import { CompilableQuery, WatchedQuery } from '@powersync/common'; import React from 'react'; import { generateQueryKey, getQueryStore } from '../../QueryStore'; import { usePowerSync } from '../PowerSyncContext'; -import { AdditionalOptions, constructCompatibleQuery } from '../useQuery'; -import { SuspenseQueryResult } from '../useSuspenseQuery'; +import { AdditionalOptions, constructCompatibleQuery, QueryResult } from '../useQuery'; import { createSuspendingPromise, useTemporaryHold } from './suspense-utils'; +export type SuspenseQueryResult = Pick, 'data' | 'refresh'>; + /** * Use a query which is not watched, but suspends until the initial result has loaded. * Internally this uses a WatchedQuery during suspense for state management. The watched diff --git a/packages/react/src/hooks/suspense/useSuspenseQuery.ts b/packages/react/src/hooks/suspense/useSuspenseQuery.ts index f89586d98..c7980bd99 100644 --- a/packages/react/src/hooks/suspense/useSuspenseQuery.ts +++ b/packages/react/src/hooks/suspense/useSuspenseQuery.ts @@ -1,7 +1,7 @@ import { CompilableQuery } from '@powersync/common'; -import { AdditionalOptions, QueryResult } from './useQuery'; - -export type SuspenseQueryResult = Pick, 'data' | 'refresh'>; +import { AdditionalOptions } from '../useQuery'; +import { SuspenseQueryResult, useSingleSuspenseQuery } from './useSingleSuspenseQuery'; +import { useWatchedSuspenseQuery } from './useWatchedSuspenseQuery'; /** * A hook to access the results of a watched query that suspends until the initial result has loaded. diff --git a/packages/react/src/hooks/suspense/useWatchedSuspenseQuery.ts b/packages/react/src/hooks/suspense/useWatchedSuspenseQuery.ts index 2f6de4298..c721d928f 100644 --- a/packages/react/src/hooks/suspense/useWatchedSuspenseQuery.ts +++ b/packages/react/src/hooks/suspense/useWatchedSuspenseQuery.ts @@ -2,9 +2,10 @@ import { CompilableQuery, WatchedQuery } from '@powersync/common'; import { generateQueryKey, getQueryStore } from '../../QueryStore'; import { usePowerSync } from '../PowerSyncContext'; import { AdditionalOptions, constructCompatibleQuery } from '../useQuery'; +import { SuspenseQueryResult } from './useSingleSuspenseQuery'; import { useWatchedQuerySuspenseSubscription } from './useWatchedQuerySuspenseSubscription'; -const useWatchedSuspenseQuery = ( +export const useWatchedSuspenseQuery = ( query: string | CompilableQuery, parameters: any[] = [], options: AdditionalOptions = {} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 6199b10ea..75ccd2f46 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,7 +1,8 @@ export * from './hooks/PowerSyncContext'; +export { useSuspenseQuery } from './hooks/suspense/useSuspenseQuery'; +export { useWatchedQuerySuspenseSubscription } from './hooks/suspense/useWatchedQuerySuspenseSubscription'; export { usePowerSyncQuery } from './hooks/usePowerSyncQuery'; -export { useStatus } from './hooks/useStatus'; -export { useQuery } from './hooks/useQuery'; -export { useSuspenseQuery } from './hooks/useSuspenseQuery'; -export { usePowerSyncWatchedQuery } from './hooks/usePowerSyncWatchedQuery'; export { usePowerSyncStatus } from './hooks/usePowerSyncStatus'; +export { usePowerSyncWatchedQuery } from './hooks/usePowerSyncWatchedQuery'; +export { useQuery } from './hooks/useQuery'; +export { useStatus } from './hooks/useStatus'; From fccbd69782a91a3fbecd333378b8362582be620d Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 27 May 2025 09:58:19 +0200 Subject: [PATCH 18/75] more hook cleanup --- .../src/hooks/suspense/SuspenseQueryResult.ts | 3 +++ .../hooks/suspense/useSingleSuspenseQuery.ts | 5 ++--- .../src/hooks/suspense/useSuspenseQuery.ts | 3 ++- .../useWatchedQuerySuspenseSubscription.ts | 22 ++++++++++++++++++- .../hooks/suspense/useWatchedSuspenseQuery.ts | 2 +- packages/react/src/index.ts | 1 + 6 files changed, 30 insertions(+), 6 deletions(-) create mode 100644 packages/react/src/hooks/suspense/SuspenseQueryResult.ts diff --git a/packages/react/src/hooks/suspense/SuspenseQueryResult.ts b/packages/react/src/hooks/suspense/SuspenseQueryResult.ts new file mode 100644 index 000000000..055c7c18b --- /dev/null +++ b/packages/react/src/hooks/suspense/SuspenseQueryResult.ts @@ -0,0 +1,3 @@ +import { QueryResult } from '../useQuery'; + +export type SuspenseQueryResult = Pick, 'data' | 'refresh'>; diff --git a/packages/react/src/hooks/suspense/useSingleSuspenseQuery.ts b/packages/react/src/hooks/suspense/useSingleSuspenseQuery.ts index 9a1d851ef..3da8d9601 100644 --- a/packages/react/src/hooks/suspense/useSingleSuspenseQuery.ts +++ b/packages/react/src/hooks/suspense/useSingleSuspenseQuery.ts @@ -2,10 +2,9 @@ import { CompilableQuery, WatchedQuery } from '@powersync/common'; import React from 'react'; import { generateQueryKey, getQueryStore } from '../../QueryStore'; import { usePowerSync } from '../PowerSyncContext'; -import { AdditionalOptions, constructCompatibleQuery, QueryResult } from '../useQuery'; +import { AdditionalOptions, constructCompatibleQuery } from '../useQuery'; import { createSuspendingPromise, useTemporaryHold } from './suspense-utils'; - -export type SuspenseQueryResult = Pick, 'data' | 'refresh'>; +import { SuspenseQueryResult } from './SuspenseQueryResult'; /** * Use a query which is not watched, but suspends until the initial result has loaded. diff --git a/packages/react/src/hooks/suspense/useSuspenseQuery.ts b/packages/react/src/hooks/suspense/useSuspenseQuery.ts index c7980bd99..85afa66b8 100644 --- a/packages/react/src/hooks/suspense/useSuspenseQuery.ts +++ b/packages/react/src/hooks/suspense/useSuspenseQuery.ts @@ -1,6 +1,7 @@ import { CompilableQuery } from '@powersync/common'; import { AdditionalOptions } from '../useQuery'; -import { SuspenseQueryResult, useSingleSuspenseQuery } from './useSingleSuspenseQuery'; +import { SuspenseQueryResult } from './SuspenseQueryResult'; +import { useSingleSuspenseQuery } from './useSingleSuspenseQuery'; import { useWatchedSuspenseQuery } from './useWatchedSuspenseQuery'; /** diff --git a/packages/react/src/hooks/suspense/useWatchedQuerySuspenseSubscription.ts b/packages/react/src/hooks/suspense/useWatchedQuerySuspenseSubscription.ts index a25994f26..b55a6abe7 100644 --- a/packages/react/src/hooks/suspense/useWatchedQuerySuspenseSubscription.ts +++ b/packages/react/src/hooks/suspense/useWatchedQuerySuspenseSubscription.ts @@ -2,7 +2,27 @@ import { WatchedQuery } from '@powersync/common'; import React from 'react'; import { createSuspendingPromise, useTemporaryHold } from './suspense-utils'; -// TODO naming +/** + * A hook to access and subscribe to the results of an existing {@link WatchedQuery}. + * @example + * export const ContentComponent = () => { + * const { data: lists } = useWatchedQuerySuspenseSubscription(listsQuery); + * + * return + * {lists.map((l) => ( + * {JSON.stringify(l)} + * ))} + * ; + * } + * + * export const DisplayComponent = () => { + * return ( + * Loading content...}> + * + * + * ); + * } + */ export const useWatchedQuerySuspenseSubscription = (query: WatchedQuery) => { const { releaseHold } = useTemporaryHold(query); diff --git a/packages/react/src/hooks/suspense/useWatchedSuspenseQuery.ts b/packages/react/src/hooks/suspense/useWatchedSuspenseQuery.ts index c721d928f..6ffccf4ea 100644 --- a/packages/react/src/hooks/suspense/useWatchedSuspenseQuery.ts +++ b/packages/react/src/hooks/suspense/useWatchedSuspenseQuery.ts @@ -2,7 +2,7 @@ import { CompilableQuery, WatchedQuery } from '@powersync/common'; import { generateQueryKey, getQueryStore } from '../../QueryStore'; import { usePowerSync } from '../PowerSyncContext'; import { AdditionalOptions, constructCompatibleQuery } from '../useQuery'; -import { SuspenseQueryResult } from './useSingleSuspenseQuery'; +import { SuspenseQueryResult } from './SuspenseQueryResult'; import { useWatchedQuerySuspenseSubscription } from './useWatchedQuerySuspenseSubscription'; export const useWatchedSuspenseQuery = ( diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 75ccd2f46..d8fb30f64 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,4 +1,5 @@ export * from './hooks/PowerSyncContext'; +export { SuspenseQueryResult } from './hooks/suspense/SuspenseQueryResult'; export { useSuspenseQuery } from './hooks/suspense/useSuspenseQuery'; export { useWatchedQuerySuspenseSubscription } from './hooks/suspense/useWatchedQuerySuspenseSubscription'; export { usePowerSyncQuery } from './hooks/usePowerSyncQuery'; From 0ce4ddf1a1833fb02c3cd29ba7bf48f8ac9bacfc Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 27 May 2025 10:06:32 +0200 Subject: [PATCH 19/75] git mv --- packages/react/src/hooks/{ => deprecated}/usePowerSyncQuery.ts | 0 .../react/src/hooks/{ => deprecated}/usePowerSyncWatchedQuery.ts | 0 .../{usePowerSyncStatus.ts => deprecated/usePowersyncStatus.ts} | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename packages/react/src/hooks/{ => deprecated}/usePowerSyncQuery.ts (100%) rename packages/react/src/hooks/{ => deprecated}/usePowerSyncWatchedQuery.ts (100%) rename packages/react/src/hooks/{usePowerSyncStatus.ts => deprecated/usePowersyncStatus.ts} (100%) diff --git a/packages/react/src/hooks/usePowerSyncQuery.ts b/packages/react/src/hooks/deprecated/usePowerSyncQuery.ts similarity index 100% rename from packages/react/src/hooks/usePowerSyncQuery.ts rename to packages/react/src/hooks/deprecated/usePowerSyncQuery.ts diff --git a/packages/react/src/hooks/usePowerSyncWatchedQuery.ts b/packages/react/src/hooks/deprecated/usePowerSyncWatchedQuery.ts similarity index 100% rename from packages/react/src/hooks/usePowerSyncWatchedQuery.ts rename to packages/react/src/hooks/deprecated/usePowerSyncWatchedQuery.ts diff --git a/packages/react/src/hooks/usePowerSyncStatus.ts b/packages/react/src/hooks/deprecated/usePowersyncStatus.ts similarity index 100% rename from packages/react/src/hooks/usePowerSyncStatus.ts rename to packages/react/src/hooks/deprecated/usePowersyncStatus.ts From 3009ac79f78bc3b71ab5d8673cacc3dd87c5fc1b Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 27 May 2025 10:06:36 +0200 Subject: [PATCH 20/75] cleanup files --- packages/react/src/hooks/deprecated/usePowerSyncQuery.ts | 2 +- .../react/src/hooks/deprecated/usePowerSyncWatchedQuery.ts | 2 +- packages/react/src/hooks/deprecated/usePowersyncStatus.ts | 2 +- packages/react/src/hooks/suspense/useSingleSuspenseQuery.ts | 5 ++++- packages/react/src/hooks/useStatus.ts | 4 ++-- packages/react/src/index.ts | 6 +++--- 6 files changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/react/src/hooks/deprecated/usePowerSyncQuery.ts b/packages/react/src/hooks/deprecated/usePowerSyncQuery.ts index 59feb1d65..9f490274c 100644 --- a/packages/react/src/hooks/deprecated/usePowerSyncQuery.ts +++ b/packages/react/src/hooks/deprecated/usePowerSyncQuery.ts @@ -1,5 +1,5 @@ import React from 'react'; -import { usePowerSync } from './PowerSyncContext'; +import { usePowerSync } from '../PowerSyncContext'; /** * @deprecated use {@link useQuery} instead. diff --git a/packages/react/src/hooks/deprecated/usePowerSyncWatchedQuery.ts b/packages/react/src/hooks/deprecated/usePowerSyncWatchedQuery.ts index 7521f6b8a..581823afc 100644 --- a/packages/react/src/hooks/deprecated/usePowerSyncWatchedQuery.ts +++ b/packages/react/src/hooks/deprecated/usePowerSyncWatchedQuery.ts @@ -1,6 +1,6 @@ import { SQLWatchOptions } from '@powersync/common'; import React from 'react'; -import { usePowerSync } from './PowerSyncContext'; +import { usePowerSync } from '../PowerSyncContext'; /** * @deprecated use {@link useQuery} instead. diff --git a/packages/react/src/hooks/deprecated/usePowersyncStatus.ts b/packages/react/src/hooks/deprecated/usePowersyncStatus.ts index 0798db66a..34a3ee91e 100644 --- a/packages/react/src/hooks/deprecated/usePowersyncStatus.ts +++ b/packages/react/src/hooks/deprecated/usePowersyncStatus.ts @@ -1,5 +1,5 @@ import { useContext, useEffect, useState } from 'react'; -import { PowerSyncContext } from './PowerSyncContext'; +import { PowerSyncContext } from '../PowerSyncContext'; /** * @deprecated Use {@link useStatus} instead. diff --git a/packages/react/src/hooks/suspense/useSingleSuspenseQuery.ts b/packages/react/src/hooks/suspense/useSingleSuspenseQuery.ts index 3da8d9601..c26f62080 100644 --- a/packages/react/src/hooks/suspense/useSingleSuspenseQuery.ts +++ b/packages/react/src/hooks/suspense/useSingleSuspenseQuery.ts @@ -60,9 +60,12 @@ export const useSingleSuspenseQuery = ( // Happy path data return return { data: data ?? watchedQuery?.state.data ?? [], - refresh: async () => { + refresh: async (signal) => { try { const result = await parsedQuery.execute(parsedQuery.compile()); + if (signal.aborted) { + return; // Abort if the signal is already aborted + } setData(result); setError(null); } catch (e) { diff --git a/packages/react/src/hooks/useStatus.ts b/packages/react/src/hooks/useStatus.ts index 150767942..90624a173 100644 --- a/packages/react/src/hooks/useStatus.ts +++ b/packages/react/src/hooks/useStatus.ts @@ -1,4 +1,4 @@ -import { usePowerSyncStatus } from './usePowerSyncStatus'; +import { usePowerSyncStatus } from './deprecated/usePowerSyncStatus'; /** * Custom hook that provides access to the current status of PowerSync. @@ -14,4 +14,4 @@ import { usePowerSyncStatus } from './usePowerSyncStatus'; * * }; */ -export const useStatus = usePowerSyncStatus; +export const useStatus = () => usePowerSyncStatus(); diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index d8fb30f64..df11a32e2 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,9 +1,9 @@ +export { usePowerSyncQuery } from './hooks/deprecated/usePowerSyncQuery'; +export { usePowerSyncStatus } from './hooks/deprecated/usePowerSyncStatus'; +export { usePowerSyncWatchedQuery } from './hooks/deprecated/usePowerSyncWatchedQuery'; export * from './hooks/PowerSyncContext'; export { SuspenseQueryResult } from './hooks/suspense/SuspenseQueryResult'; export { useSuspenseQuery } from './hooks/suspense/useSuspenseQuery'; export { useWatchedQuerySuspenseSubscription } from './hooks/suspense/useWatchedQuerySuspenseSubscription'; -export { usePowerSyncQuery } from './hooks/usePowerSyncQuery'; -export { usePowerSyncStatus } from './hooks/usePowerSyncStatus'; -export { usePowerSyncWatchedQuery } from './hooks/usePowerSyncWatchedQuery'; export { useQuery } from './hooks/useQuery'; export { useStatus } from './hooks/useStatus'; From 151dc0abd5001c1e5b0941e1c77c6546dc4b3292 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 27 May 2025 10:20:20 +0200 Subject: [PATCH 21/75] temp --- .../react/src/hooks/deprecated/{usePowersyncStatus.ts => temp.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/react/src/hooks/deprecated/{usePowersyncStatus.ts => temp.ts} (100%) diff --git a/packages/react/src/hooks/deprecated/usePowersyncStatus.ts b/packages/react/src/hooks/deprecated/temp.ts similarity index 100% rename from packages/react/src/hooks/deprecated/usePowersyncStatus.ts rename to packages/react/src/hooks/deprecated/temp.ts From 0b4926e34ea41bdb895a1e91c591cb4b7c4af326 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 27 May 2025 10:20:41 +0200 Subject: [PATCH 22/75] rename --- .../react/src/hooks/deprecated/{temp.ts => usePowerSyncStatus.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/react/src/hooks/deprecated/{temp.ts => usePowerSyncStatus.ts} (100%) diff --git a/packages/react/src/hooks/deprecated/temp.ts b/packages/react/src/hooks/deprecated/usePowerSyncStatus.ts similarity index 100% rename from packages/react/src/hooks/deprecated/temp.ts rename to packages/react/src/hooks/deprecated/usePowerSyncStatus.ts From e4a8a2d7bb58513b07a0c0073daab72a1379ffcb Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 27 May 2025 10:30:12 +0200 Subject: [PATCH 23/75] mv --- packages/react/src/hooks/{ => watched}/useQuery.ts | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/react/src/hooks/{ => watched}/useQuery.ts (100%) diff --git a/packages/react/src/hooks/useQuery.ts b/packages/react/src/hooks/watched/useQuery.ts similarity index 100% rename from packages/react/src/hooks/useQuery.ts rename to packages/react/src/hooks/watched/useQuery.ts From f273312fed3be5d3027e8420bc1e08a0cca19313 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 27 May 2025 10:30:18 +0200 Subject: [PATCH 24/75] more react cleanup --- .../hooks/suspense/useSingleSuspenseQuery.ts | 3 +- .../src/hooks/suspense/useSuspenseQuery.ts | 2 +- .../useWatchedQuerySuspenseSubscription.ts | 7 +- .../hooks/suspense/useWatchedSuspenseQuery.ts | 3 +- packages/react/src/hooks/watched/useQuery.ts | 215 +----------------- .../react/src/hooks/watched/useSingleQuery.ts | 56 +++++ .../src/hooks/watched/useWatchedQuery.ts | 59 +++++ .../react/src/hooks/watched/watch-types.ts | 26 +++ .../react/src/hooks/watched/watch-utils.ts | 71 ++++++ 9 files changed, 229 insertions(+), 213 deletions(-) create mode 100644 packages/react/src/hooks/watched/useSingleQuery.ts create mode 100644 packages/react/src/hooks/watched/useWatchedQuery.ts create mode 100644 packages/react/src/hooks/watched/watch-types.ts create mode 100644 packages/react/src/hooks/watched/watch-utils.ts diff --git a/packages/react/src/hooks/suspense/useSingleSuspenseQuery.ts b/packages/react/src/hooks/suspense/useSingleSuspenseQuery.ts index c26f62080..0e427efb5 100644 --- a/packages/react/src/hooks/suspense/useSingleSuspenseQuery.ts +++ b/packages/react/src/hooks/suspense/useSingleSuspenseQuery.ts @@ -2,7 +2,8 @@ import { CompilableQuery, WatchedQuery } from '@powersync/common'; import React from 'react'; import { generateQueryKey, getQueryStore } from '../../QueryStore'; import { usePowerSync } from '../PowerSyncContext'; -import { AdditionalOptions, constructCompatibleQuery } from '../useQuery'; +import { AdditionalOptions } from '../watched/watch-types'; +import { constructCompatibleQuery } from '../watched/watch-utils'; import { createSuspendingPromise, useTemporaryHold } from './suspense-utils'; import { SuspenseQueryResult } from './SuspenseQueryResult'; diff --git a/packages/react/src/hooks/suspense/useSuspenseQuery.ts b/packages/react/src/hooks/suspense/useSuspenseQuery.ts index 85afa66b8..bea3f701f 100644 --- a/packages/react/src/hooks/suspense/useSuspenseQuery.ts +++ b/packages/react/src/hooks/suspense/useSuspenseQuery.ts @@ -1,5 +1,5 @@ import { CompilableQuery } from '@powersync/common'; -import { AdditionalOptions } from '../useQuery'; +import { AdditionalOptions } from '../watched/watch-types'; import { SuspenseQueryResult } from './SuspenseQueryResult'; import { useSingleSuspenseQuery } from './useSingleSuspenseQuery'; import { useWatchedSuspenseQuery } from './useWatchedSuspenseQuery'; diff --git a/packages/react/src/hooks/suspense/useWatchedQuerySuspenseSubscription.ts b/packages/react/src/hooks/suspense/useWatchedQuerySuspenseSubscription.ts index b55a6abe7..bd1be3786 100644 --- a/packages/react/src/hooks/suspense/useWatchedQuerySuspenseSubscription.ts +++ b/packages/react/src/hooks/suspense/useWatchedQuerySuspenseSubscription.ts @@ -53,7 +53,12 @@ export const useWatchedQuerySuspenseSubscription = (query: WatchedQu throw query.state.error; } else if (!query.state.isLoading) { // Happy path data return - return { data: query.state.data }; + return { + data: query.state.data, + refresh: () => { + // no-op for watched queries + } + }; } else { // Notify suspense is required throw createSuspendingPromise(query); diff --git a/packages/react/src/hooks/suspense/useWatchedSuspenseQuery.ts b/packages/react/src/hooks/suspense/useWatchedSuspenseQuery.ts index 6ffccf4ea..ec8aec57d 100644 --- a/packages/react/src/hooks/suspense/useWatchedSuspenseQuery.ts +++ b/packages/react/src/hooks/suspense/useWatchedSuspenseQuery.ts @@ -1,7 +1,8 @@ import { CompilableQuery, WatchedQuery } from '@powersync/common'; import { generateQueryKey, getQueryStore } from '../../QueryStore'; import { usePowerSync } from '../PowerSyncContext'; -import { AdditionalOptions, constructCompatibleQuery } from '../useQuery'; +import { AdditionalOptions } from '../watched/watch-types'; +import { constructCompatibleQuery } from '../watched/watch-utils'; import { SuspenseQueryResult } from './SuspenseQueryResult'; import { useWatchedQuerySuspenseSubscription } from './useWatchedQuerySuspenseSubscription'; diff --git a/packages/react/src/hooks/watched/useQuery.ts b/packages/react/src/hooks/watched/useQuery.ts index 6e6ab866b..975ca7472 100644 --- a/packages/react/src/hooks/watched/useQuery.ts +++ b/packages/react/src/hooks/watched/useQuery.ts @@ -1,212 +1,9 @@ -import { - AbstractPowerSyncDatabase, - WatchCompatibleQuery, - type CompilableQuery, - type SQLWatchOptions -} from '@powersync/common'; -import React from 'react'; -import { usePowerSync } from './PowerSyncContext'; - -interface HookWatchOptions extends Omit { - reportFetching?: boolean; -} - -export interface AdditionalOptions extends HookWatchOptions { - runQueryOnce?: boolean; -} - -export type QueryResult = { - data: RowType[]; - /** - * Indicates the initial loading state (hard loading). Loading becomes false once the first set of results from the watched query is available or an error occurs. - */ - isLoading: boolean; - /** - * Indicates whether the query is currently fetching data, is true during the initial load and any time when the query is re-evaluating (useful for large queries). - */ - isFetching: boolean; - error: Error | undefined; - /** - * Function used to run the query again. - */ - refresh?: (signal?: AbortSignal) => Promise; -}; - -type InternalHookOptions = { - query: WatchCompatibleQuery; - powerSync: AbstractPowerSyncDatabase; - queryChanged: boolean; -}; - -const checkQueryChanged = (query: WatchCompatibleQuery, options: AdditionalOptions) => { - const compiled = query.compile(); - const stringifiedParams = JSON.stringify(compiled.parameters); - const stringifiedOptions = JSON.stringify(options); - - const previousQueryRef = React.useRef({ sqlStatement: compiled.sql, stringifiedParams, stringifiedOptions }); - - if ( - previousQueryRef.current.sqlStatement !== compiled.sql || - previousQueryRef.current.stringifiedParams != stringifiedParams || - previousQueryRef.current.stringifiedOptions != stringifiedOptions - ) { - previousQueryRef.current.sqlStatement = compiled.sql; - previousQueryRef.current.stringifiedParams = stringifiedParams; - previousQueryRef.current.stringifiedOptions = stringifiedOptions; - - return true; - } - - return false; -}; - -const useSingleQuery = (options: InternalHookOptions): QueryResult => { - const { query, powerSync, queryChanged } = options; - - const [output, setOutputState] = React.useState>({ - isLoading: true, - isFetching: true, - data: [], - error: undefined - }); - - const runQuery = React.useCallback( - async (signal?: AbortSignal) => { - setOutputState((prev) => ({ ...prev, isLoading: true, isFetching: true, error: undefined })); - try { - const result = await query.execute(query.compile()); - if (signal.aborted) { - return; - } - setOutputState((prev) => ({ - ...prev, - isLoading: false, - isFetching: false, - data: result, - error: undefined - })); - } catch (error) { - setOutputState((prev) => ({ - ...prev, - isLoading: false, - isFetching: false, - data: [], - error - })); - } - }, - [queryChanged, query] - ); - - // Trigger initial query execution - React.useEffect(() => { - const abortController = new AbortController(); - runQuery(abortController.signal); - return () => { - abortController.abort(); - }; - }, [powerSync, queryChanged]); - - return { - ...output, - refresh: runQuery - }; -}; - -const useWatchedQuery = ( - options: InternalHookOptions & { options: HookWatchOptions } -): QueryResult => { - const { query, powerSync, queryChanged, options: hookOptions } = options; - - const createWatchedQuery = React.useCallback(() => { - return powerSync.incrementalWatch({ - // This always enables comparison. Might want to be able to disable this?? - watch: { - placeholderData: [], - query, - throttleMs: hookOptions.throttleMs, - reportFetching: hookOptions.reportFetching - } - }); - }, []); - - const [watchedQuery, setWatchedQuery] = React.useState(createWatchedQuery); - - const [output, setOutputState] = React.useState(watchedQuery.state); - - React.useEffect(() => { - watchedQuery.close(); - setWatchedQuery(createWatchedQuery); - }, [powerSync]); - - React.useEffect(() => { - const dispose = watchedQuery.subscribe({ - onStateChange: (state) => { - setOutputState({ ...state }); - } - }); - - return () => { - dispose(); - watchedQuery.close(); - }; - }, [watchedQuery]); - - // Indicates that the query will be re-fetched due to a change in the query. - // Used when `isFetching` hasn't been set to true yet due to React execution. - React.useEffect(() => { - if (queryChanged) { - console.log('Query changed, re-fetching...'); - watchedQuery.updateSettings({ - placeholderData: [], - query, - throttleMs: hookOptions.throttleMs, - reportFetching: hookOptions.reportFetching - }); - } - }, [queryChanged]); - - return output; -}; - -export const constructCompatibleQuery = ( - query: string | CompilableQuery, - parameters: any[] = [], - options: AdditionalOptions -) => { - const powerSync = usePowerSync(); - - const parsedQuery = React.useMemo>(() => { - if (typeof query == 'string') { - return { - compile: () => ({ - sql: query, - parameters - }), - execute: () => powerSync.getAll(query, parameters) - }; - } else { - return { - // Generics differ a bit but holistically this is the same - compile: () => { - const compiled = query.compile(); - return { - sql: compiled.sql, - parameters: [...compiled.parameters] - }; - }, - execute: () => query.execute() - }; - } - }, [query]); - - const queryChanged = checkQueryChanged(parsedQuery, options); - - return { - parsedQuery, - queryChanged - }; -}; +import { type CompilableQuery } from '@powersync/common'; +import { usePowerSync } from '../PowerSyncContext'; +import { useSingleQuery } from './useSingleQuery'; +import { useWatchedQuery } from './useWatchedQuery'; +import { AdditionalOptions, QueryResult } from './watch-types'; +import { constructCompatibleQuery } from './watch-utils'; /** * A hook to access the results of a watched query. diff --git a/packages/react/src/hooks/watched/useSingleQuery.ts b/packages/react/src/hooks/watched/useSingleQuery.ts new file mode 100644 index 000000000..82bd64aee --- /dev/null +++ b/packages/react/src/hooks/watched/useSingleQuery.ts @@ -0,0 +1,56 @@ +import React from 'react'; +import { QueryResult } from './watch-types'; +import { InternalHookOptions } from './watch-utils'; + +export const useSingleQuery = (options: InternalHookOptions): QueryResult => { + const { query, powerSync, queryChanged } = options; + + const [output, setOutputState] = React.useState>({ + isLoading: true, + isFetching: true, + data: [], + error: undefined + }); + + const runQuery = React.useCallback( + async (signal?: AbortSignal) => { + setOutputState((prev) => ({ ...prev, isLoading: true, isFetching: true, error: undefined })); + try { + const result = await query.execute(query.compile()); + if (signal.aborted) { + return; + } + setOutputState((prev) => ({ + ...prev, + isLoading: false, + isFetching: false, + data: result, + error: undefined + })); + } catch (error) { + setOutputState((prev) => ({ + ...prev, + isLoading: false, + isFetching: false, + data: [], + error + })); + } + }, + [queryChanged, query] + ); + + // Trigger initial query execution + React.useEffect(() => { + const abortController = new AbortController(); + runQuery(abortController.signal); + return () => { + abortController.abort(); + }; + }, [powerSync, queryChanged]); + + return { + ...output, + refresh: runQuery + }; +}; diff --git a/packages/react/src/hooks/watched/useWatchedQuery.ts b/packages/react/src/hooks/watched/useWatchedQuery.ts new file mode 100644 index 000000000..c0c0c2fd6 --- /dev/null +++ b/packages/react/src/hooks/watched/useWatchedQuery.ts @@ -0,0 +1,59 @@ +import React from 'react'; +import { HookWatchOptions, QueryResult } from './watch-types'; +import { InternalHookOptions } from './watch-utils'; + +export const useWatchedQuery = ( + options: InternalHookOptions & { options: HookWatchOptions } +): QueryResult => { + const { query, powerSync, queryChanged, options: hookOptions } = options; + + const createWatchedQuery = React.useCallback(() => { + return powerSync.incrementalWatch({ + // This always enables comparison. Might want to be able to disable this?? + watch: { + placeholderData: [], + query, + throttleMs: hookOptions.throttleMs, + reportFetching: hookOptions.reportFetching + } + }); + }, []); + + const [watchedQuery, setWatchedQuery] = React.useState(createWatchedQuery); + + const [output, setOutputState] = React.useState(watchedQuery.state); + + React.useEffect(() => { + watchedQuery.close(); + setWatchedQuery(createWatchedQuery); + }, [powerSync]); + + React.useEffect(() => { + const dispose = watchedQuery.subscribe({ + onStateChange: (state) => { + setOutputState({ ...state }); + } + }); + + return () => { + dispose(); + watchedQuery.close(); + }; + }, [watchedQuery]); + + // Indicates that the query will be re-fetched due to a change in the query. + // Used when `isFetching` hasn't been set to true yet due to React execution. + React.useEffect(() => { + if (queryChanged) { + console.log('Query changed, re-fetching...'); + watchedQuery.updateSettings({ + placeholderData: [], + query, + throttleMs: hookOptions.throttleMs, + reportFetching: hookOptions.reportFetching + }); + } + }, [queryChanged]); + + return output; +}; diff --git a/packages/react/src/hooks/watched/watch-types.ts b/packages/react/src/hooks/watched/watch-types.ts new file mode 100644 index 000000000..961463d73 --- /dev/null +++ b/packages/react/src/hooks/watched/watch-types.ts @@ -0,0 +1,26 @@ +import { type SQLWatchOptions } from '@powersync/common'; + +export interface HookWatchOptions extends Omit { + reportFetching?: boolean; +} + +export interface AdditionalOptions extends HookWatchOptions { + runQueryOnce?: boolean; +} + +export type QueryResult = { + data: RowType[]; + /** + * Indicates the initial loading state (hard loading). Loading becomes false once the first set of results from the watched query is available or an error occurs. + */ + isLoading: boolean; + /** + * Indicates whether the query is currently fetching data, is true during the initial load and any time when the query is re-evaluating (useful for large queries). + */ + isFetching: boolean; + error: Error | undefined; + /** + * Function used to run the query again. + */ + refresh?: (signal?: AbortSignal) => Promise; +}; diff --git a/packages/react/src/hooks/watched/watch-utils.ts b/packages/react/src/hooks/watched/watch-utils.ts new file mode 100644 index 000000000..e0b00b94b --- /dev/null +++ b/packages/react/src/hooks/watched/watch-utils.ts @@ -0,0 +1,71 @@ +import { AbstractPowerSyncDatabase, CompilableQuery, WatchCompatibleQuery } from '@powersync/common'; +import React from 'react'; +import { usePowerSync } from '../PowerSyncContext'; +import { AdditionalOptions } from './watch-types'; + +export type InternalHookOptions = { + query: WatchCompatibleQuery; + powerSync: AbstractPowerSyncDatabase; + queryChanged: boolean; +}; + +export const checkQueryChanged = (query: WatchCompatibleQuery, options: AdditionalOptions) => { + const compiled = query.compile(); + const stringifiedParams = JSON.stringify(compiled.parameters); + const stringifiedOptions = JSON.stringify(options); + + const previousQueryRef = React.useRef({ sqlStatement: compiled.sql, stringifiedParams, stringifiedOptions }); + + if ( + previousQueryRef.current.sqlStatement !== compiled.sql || + previousQueryRef.current.stringifiedParams != stringifiedParams || + previousQueryRef.current.stringifiedOptions != stringifiedOptions + ) { + previousQueryRef.current.sqlStatement = compiled.sql; + previousQueryRef.current.stringifiedParams = stringifiedParams; + previousQueryRef.current.stringifiedOptions = stringifiedOptions; + + return true; + } + + return false; +}; + +export const constructCompatibleQuery = ( + query: string | CompilableQuery, + parameters: any[] = [], + options: AdditionalOptions +) => { + const powerSync = usePowerSync(); + + const parsedQuery = React.useMemo>(() => { + if (typeof query == 'string') { + return { + compile: () => ({ + sql: query, + parameters + }), + execute: () => powerSync.getAll(query, parameters) + }; + } else { + return { + // Generics differ a bit but holistically this is the same + compile: () => { + const compiled = query.compile(); + return { + sql: compiled.sql, + parameters: [...compiled.parameters] + }; + }, + execute: () => query.execute() + }; + } + }, [query]); + + const queryChanged = checkQueryChanged(parsedQuery, options); + + return { + parsedQuery, + queryChanged + }; +}; From 83b128992159936845eea13854ce3cf9deee3f0b Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 27 May 2025 12:03:03 +0200 Subject: [PATCH 25/75] update unit tests --- packages/common/rollup.config.mjs | 12 +- .../processors/AbstractQueryProcessor.ts | 32 +++-- packages/react/src/QueryStore.ts | 2 +- .../src/hooks/suspense/SuspenseQueryResult.ts | 2 +- .../hooks/suspense/useSingleSuspenseQuery.ts | 8 +- .../useWatchedQuerySuspenseSubscription.ts | 2 +- .../react/src/hooks/watched/watch-utils.ts | 16 ++- packages/react/src/index.ts | 3 +- packages/react/tests/QueryStore.test.tsx | 5 +- packages/react/tests/useQuery.test.tsx | 6 +- .../react/tests/useSuspenseQuery.test.tsx | 133 ++++++++---------- 11 files changed, 121 insertions(+), 100 deletions(-) diff --git a/packages/common/rollup.config.mjs b/packages/common/rollup.config.mjs index 4e64e9212..1fc8fedd9 100644 --- a/packages/common/rollup.config.mjs +++ b/packages/common/rollup.config.mjs @@ -2,9 +2,13 @@ import commonjs from '@rollup/plugin-commonjs'; import inject from '@rollup/plugin-inject'; import json from '@rollup/plugin-json'; import nodeResolve from '@rollup/plugin-node-resolve'; +import terser from '@rollup/plugin-terser'; +/** + * @returns {import('rollup').RollupOptions} + */ export default (commandLineArgs) => { - const sourcemap = (commandLineArgs.sourceMap || 'true') == 'true'; + const sourceMap = (commandLineArgs.sourceMap || 'true') == 'true'; // Clears rollup CLI warning https://github.com/rollup/rollup/issues/2694 delete commandLineArgs.sourceMap; @@ -14,7 +18,7 @@ export default (commandLineArgs) => { output: { file: 'dist/bundle.mjs', format: 'esm', - sourcemap: sourcemap + sourcemap: sourceMap }, plugins: [ json(), @@ -25,8 +29,8 @@ export default (commandLineArgs) => { ReadableStream: ['web-streams-polyfill/ponyfill', 'ReadableStream'], // Used by can-ndjson-stream TextDecoder: ['text-encoding', 'TextDecoder'] - }) - // terser() + }), + terser({ sourceMap }) ], // This makes life easier external: [ diff --git a/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts b/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts index b440ce55a..066caaef6 100644 --- a/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts +++ b/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts @@ -41,6 +41,7 @@ export abstract class AbstractQueryProcessor protected abortController: AbortController; protected initialized: Promise; protected _closed: boolean; + protected disposeListeners: (() => void) | null; get closed() { return this._closed; @@ -66,6 +67,7 @@ export abstract class AbstractQueryProcessor lastUpdated: null, data: options.watchOptions.placeholderData }; + this.disposeListeners = null; this.initialized = this.init(); } @@ -97,6 +99,9 @@ export abstract class AbstractQueryProcessor protected async updateState(update: Partial>) { if (typeof update.error !== 'undefined') { await this.iterateAsyncListenersWithError(async (l) => l.onError?.(update.error!)); + // An error always stops for the current fetching state + update.isFetching = false; + update.isLoading = false; } if (typeof update.data !== 'undefined') { @@ -113,19 +118,29 @@ export abstract class AbstractQueryProcessor protected async init() { const { db } = this.options; - db.registerListener({ + const disposeCloseListener = db.registerListener({ + closed: async () => { + this.close(); + } + }); + + // Wait for the schema to be set before listening to changes + await db.waitForReady(); + const disposeSchemaListener = db.registerListener({ schemaChanged: async () => { await this.runWithReporting(async () => { await this.updateSettings(this.options.watchOptions); }); - }, - closing: () => { - this.close(); } }); + this.disposeListeners = () => { + disposeCloseListener(); + disposeSchemaListener(); + }; + // Initial setup - await this.runWithReporting(async () => { + this.runWithReporting(async () => { await this.updateSettings(this.options.watchOptions); }); } @@ -134,18 +149,19 @@ export abstract class AbstractQueryProcessor // hook in to subscription events in order to report changes const baseDispose = this.registerListener({ ...subscription }); - const counts = this.subscriptionCounts; - this.iterateListeners((l) => l.subscriptionsChanged?.(counts)); + this.iterateListeners((l) => l.subscriptionsChanged?.(this.subscriptionCounts)); return () => { baseDispose(); - this.iterateListeners((l) => l.subscriptionsChanged?.(counts)); + this.iterateListeners((l) => l.subscriptionsChanged?.(this.subscriptionCounts)); }; } async close() { await this.initialized; this.abortController.abort(); + this.disposeListeners?.(); + this.disposeListeners = null; this._closed = true; this.iterateListeners((l) => l.closed?.()); } diff --git a/packages/react/src/QueryStore.ts b/packages/react/src/QueryStore.ts index b7e528e9a..f409a706b 100644 --- a/packages/react/src/QueryStore.ts +++ b/packages/react/src/QueryStore.ts @@ -1,5 +1,5 @@ import { AbstractPowerSyncDatabase, WatchCompatibleQuery, WatchedQuery } from '@powersync/common'; -import { AdditionalOptions } from './hooks/useQuery'; +import { AdditionalOptions } from './hooks/watched/watch-types'; export function generateQueryKey( sqlStatement: string, diff --git a/packages/react/src/hooks/suspense/SuspenseQueryResult.ts b/packages/react/src/hooks/suspense/SuspenseQueryResult.ts index 055c7c18b..adc9dc2c8 100644 --- a/packages/react/src/hooks/suspense/SuspenseQueryResult.ts +++ b/packages/react/src/hooks/suspense/SuspenseQueryResult.ts @@ -1,3 +1,3 @@ -import { QueryResult } from '../useQuery'; +import { QueryResult } from '../watched/watch-types'; export type SuspenseQueryResult = Pick, 'data' | 'refresh'>; diff --git a/packages/react/src/hooks/suspense/useSingleSuspenseQuery.ts b/packages/react/src/hooks/suspense/useSingleSuspenseQuery.ts index 0e427efb5..d2c1fc413 100644 --- a/packages/react/src/hooks/suspense/useSingleSuspenseQuery.ts +++ b/packages/react/src/hooks/suspense/useSingleSuspenseQuery.ts @@ -38,13 +38,13 @@ export const useSingleSuspenseQuery = ( // Only use a temporary watched query if we don't have data yet. const watchedQuery = data ? null : (store.getQuery(key, parsedQuery, options) as WatchedQuery); const { releaseHold } = useTemporaryHold(watchedQuery); - + console.log('single watched query', !!watchedQuery); React.useEffect(() => { // Set the initial yielded data // it should be available once we commit the component if (watchedQuery?.state.error) { setError(watchedQuery.state.error); - } else if (watchedQuery?.state.data) { + } else if (watchedQuery?.state.isLoading === false) { setData(watchedQuery.state.data); setError(null); } @@ -57,16 +57,18 @@ export const useSingleSuspenseQuery = ( if (error != null) { // Report errors - this is caught by an error boundary throw error; - } else if (data || watchedQuery?.state.data) { + } else if (data || watchedQuery?.state.isLoading === false) { // Happy path data return return { data: data ?? watchedQuery?.state.data ?? [], refresh: async (signal) => { try { + console.log('calling refresh for single query', key); const result = await parsedQuery.execute(parsedQuery.compile()); if (signal.aborted) { return; // Abort if the signal is already aborted } + console.log('done with query refresh'); setData(result); setError(null); } catch (e) { diff --git a/packages/react/src/hooks/suspense/useWatchedQuerySuspenseSubscription.ts b/packages/react/src/hooks/suspense/useWatchedQuerySuspenseSubscription.ts index bd1be3786..21867a1c9 100644 --- a/packages/react/src/hooks/suspense/useWatchedQuerySuspenseSubscription.ts +++ b/packages/react/src/hooks/suspense/useWatchedQuerySuspenseSubscription.ts @@ -55,7 +55,7 @@ export const useWatchedQuerySuspenseSubscription = (query: WatchedQu // Happy path data return return { data: query.state.data, - refresh: () => { + refresh: async () => { // no-op for watched queries } }; diff --git a/packages/react/src/hooks/watched/watch-utils.ts b/packages/react/src/hooks/watched/watch-utils.ts index e0b00b94b..9dd1d2b58 100644 --- a/packages/react/src/hooks/watched/watch-utils.ts +++ b/packages/react/src/hooks/watched/watch-utils.ts @@ -1,4 +1,9 @@ -import { AbstractPowerSyncDatabase, CompilableQuery, WatchCompatibleQuery } from '@powersync/common'; +import { + AbstractPowerSyncDatabase, + CompilableQuery, + WatchCompatibleQuery, + WatchCompiledQuery +} from '@powersync/common'; import React from 'react'; import { usePowerSync } from '../PowerSyncContext'; import { AdditionalOptions } from './watch-types'; @@ -10,7 +15,14 @@ export type InternalHookOptions = { }; export const checkQueryChanged = (query: WatchCompatibleQuery, options: AdditionalOptions) => { - const compiled = query.compile(); + let _compiled: WatchCompiledQuery; + try { + _compiled = query.compile(); + } catch (error) { + return false; // If compilation fails, we assume the query has changed + } + const compiled = _compiled!; + const stringifiedParams = JSON.stringify(compiled.parameters); const stringifiedOptions = JSON.stringify(options); diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index df11a32e2..7f9716c97 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -5,5 +5,6 @@ export * from './hooks/PowerSyncContext'; export { SuspenseQueryResult } from './hooks/suspense/SuspenseQueryResult'; export { useSuspenseQuery } from './hooks/suspense/useSuspenseQuery'; export { useWatchedQuerySuspenseSubscription } from './hooks/suspense/useWatchedQuerySuspenseSubscription'; -export { useQuery } from './hooks/useQuery'; export { useStatus } from './hooks/useStatus'; +export { useQuery } from './hooks/watched/useQuery'; +export { AdditionalOptions } from './hooks/watched/watch-types'; diff --git a/packages/react/tests/QueryStore.test.tsx b/packages/react/tests/QueryStore.test.tsx index 201d458e1..b02e65e7e 100644 --- a/packages/react/tests/QueryStore.test.tsx +++ b/packages/react/tests/QueryStore.test.tsx @@ -1,6 +1,7 @@ +import { AbstractPowerSyncDatabase, SQLWatchOptions } from '@powersync/common'; import { beforeEach, describe, expect, it } from 'vitest'; import { generateQueryKey, getQueryStore, QueryStore } from '../src/QueryStore'; -import { AbstractPowerSyncDatabase, SQLWatchOptions } from '@powersync/common'; +import { openPowerSync } from './useQuery.test'; describe('QueryStore', () => { describe('generateQueryKey', () => { @@ -46,7 +47,7 @@ describe('QueryStore', () => { let options: SQLWatchOptions; beforeEach(() => { - db = createMockDatabase(); + db = openPowerSync(); store = new QueryStore(db); query = {}; options = {}; diff --git a/packages/react/tests/useQuery.test.tsx b/packages/react/tests/useQuery.test.tsx index 3cd4b3db9..659c6afd7 100644 --- a/packages/react/tests/useQuery.test.tsx +++ b/packages/react/tests/useQuery.test.tsx @@ -1,11 +1,11 @@ +import React from 'react'; import * as commonSdk from '@powersync/common'; import { PowerSyncDatabase } from '@powersync/web'; import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; import pDefer from 'p-defer'; import { beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'; import { PowerSyncContext } from '../src/hooks/PowerSyncContext'; -import { useQuery } from '../src/hooks/useQuery'; - +import { useQuery } from '../src/hooks/watched/useQuery'; export const openPowerSync = () => { const db = new PowerSyncDatabase({ database: { dbFilename: 'test.db' }, @@ -128,8 +128,6 @@ describe('useQuery', () => { }, { timeout: 500, interval: 100 } ); - - console.log('got to this point'); }); it('should accept compilable queries', async () => { diff --git a/packages/react/tests/useSuspenseQuery.test.tsx b/packages/react/tests/useSuspenseQuery.test.tsx index 523cd581e..7b52b4cb2 100644 --- a/packages/react/tests/useSuspenseQuery.test.tsx +++ b/packages/react/tests/useSuspenseQuery.test.tsx @@ -4,9 +4,8 @@ import React from 'react'; import { ErrorBoundary } from 'react-error-boundary'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { PowerSyncContext } from '../src/hooks/PowerSyncContext'; -import { useSuspenseQuery } from '../src/hooks/useSuspenseQuery'; +import { useSuspenseQuery } from '../src/hooks/suspense/useSuspenseQuery'; import { openPowerSync } from './useQuery.test'; -const defaultQueryResult = ['list1', 'list2']; describe('useSuspenseQuery', () => { const loadingFallback = 'Loading'; @@ -120,96 +119,84 @@ describe('useSuspenseQuery', () => { ); }); - // it('should run the query once if runQueryOnce flag is set', async () => { - // let resolvePromise: (_: string[]) => void = () => {}; + it('should run the query once if runQueryOnce flag is set', async () => { + await powersync.execute("INSERT INTO lists (id, name) VALUES (uuid(), 'list1')"); - // mockPowerSync.getAll = vi.fn(() => { - // return new Promise((resolve) => { - // resolvePromise = resolve; - // }); - // }); + const { result } = renderHook( + () => useSuspenseQuery<{ name: string }>('SELECT * from lists', [], { runQueryOnce: true }), + { + wrapper + } + ); - // const { result } = renderHook(() => useSuspenseQuery('SELECT * from lists', [], { runQueryOnce: true }), { - // wrapper - // }); + // Wait for the data to be presented + let lastData; + await waitFor( + async () => { + const currentResult = result.current; + lastData = currentResult?.data; + expect(lastData?.[0]).toBeDefined(); + expect(lastData?.[0].name).toBe('list1'); + }, + { timeout: 1000 } + ); - // await waitForSuspend(); + await waitForCompletedSuspend(); - // resolvePromise(defaultQueryResult); + // Do another insert, this should not trigger a re-render + await powersync.execute("INSERT INTO lists (id, name) VALUES (uuid(), 'list2')"); - // await waitForCompletedSuspend(); - // await waitFor( - // async () => { - // const currentResult = result.current; - // expect(currentResult?.data).toEqual(['list1', 'list2']); - // expect(mockPowerSync.onChangeWithCallback).not.toHaveBeenCalled(); - // expect(mockPowerSync.getAll).toHaveBeenCalledTimes(1); - // }, - // { timeout: 100 } - // ); - // }); + // Wait a bit, it's difficult to test that something did not happen, so we just wait a bit + await new Promise((r) => setTimeout(r, 1000)); - // it('should rerun the query when refresh is used', async () => { - // const { result } = renderHook(() => useSuspenseQuery('SELECT * from lists', [], { runQueryOnce: true }), { - // wrapper - // }); + expect(result.current.data).toEqual(lastData); + // sanity + expect(result.current.data?.length).toBe(1); + }); - // await waitForSuspend(); + it('should rerun the query when refresh is used', async () => { + const { result } = renderHook(() => useSuspenseQuery('SELECT * from lists', [], { runQueryOnce: true }), { + wrapper + }); - // let refresh; + // First ensure we do suspend, then wait for suspending to complete + await waitForSuspend(); - // await waitFor( - // async () => { - // const currentResult = result.current; - // refresh = currentResult.refresh; - // expect(mockPowerSync.getAll).toHaveBeenCalledTimes(1); - // }, - // { timeout: 100 } - // ); + let refresh; + await waitFor( + async () => { + const currentResult = result.current; + console.log(currentResult); + refresh = currentResult?.refresh; + expect(refresh).toBeDefined(); + }, + { timeout: 1000 } + ); - // await waitForCompletedSuspend(); + await waitForCompletedSuspend(); + expect(refresh).toBeDefined(); - // await refresh(); - // expect(mockPowerSync.getAll).toHaveBeenCalledTimes(2); - // }); + const spy = vi.spyOn(powersync, 'getAll'); + const callCount = spy.mock.calls.length; + await refresh(); + expect(spy).toHaveBeenCalledTimes(callCount + 1); + }); it('should set error when error occurs', async () => { - let rejectPromise: (err: string) => void = () => {}; - - vi.spyOn(powersync, 'getAll').mockImplementation(() => { - return new Promise((_resolve, reject) => { - rejectPromise = reject; - }); - }); - - renderHook(() => useSuspenseQuery('SELECT * from lists', []), { wrapper }); - - await waitForSuspend(); + renderHook(() => useSuspenseQuery('SELECT * from fakelists', []), { wrapper }); - rejectPromise('failure'); await waitForCompletedSuspend(); await waitForError(); }); - // it('should set error when error occurs and runQueryOnce flag is set', async () => { - // let rejectPromise: (err: string) => void = () => {}; - - // mockPowerSync.getAll = vi.fn(() => { - // return new Promise((_resolve, reject) => { - // rejectPromise = reject; - // }); - // }); - - // renderHook(() => useSuspenseQuery('SELECT * from lists', [], { runQueryOnce: true }), { - // wrapper - // }); - - // await waitForSuspend(); + it('should set error when error occurs and runQueryOnce flag is set', async () => { + renderHook(() => useSuspenseQuery('SELECT * from fakelists', [], { runQueryOnce: true }), { + wrapper + }); - // rejectPromise('failure'); - // await waitForCompletedSuspend(); - // await waitForError(); - // }); + await waitForCompletedSuspend(); + await waitForError(); + }); it('should accept compilable queries', async () => { renderHook( From 4c5b136db2cc4d89ccbd74c6bf94771eba06cf18 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 27 May 2025 14:03:21 +0200 Subject: [PATCH 26/75] Add tests for shared queries --- packages/react-native/rollup.config.mjs | 6 +-- .../src/hooks/watched/useWatchedQuery.ts | 1 - .../watched/useWatchedQuerySubscription.ts | 36 +++++++++++++ packages/react/src/index.ts | 1 + packages/react/tests/useQuery.test.tsx | 54 ++++++++++++++++++- .../react/tests/useSuspenseQuery.test.tsx | 54 +++++++++++++++++++ 6 files changed, 147 insertions(+), 5 deletions(-) create mode 100644 packages/react/src/hooks/watched/useWatchedQuerySubscription.ts diff --git a/packages/react-native/rollup.config.mjs b/packages/react-native/rollup.config.mjs index 9a180747c..08699165b 100644 --- a/packages/react-native/rollup.config.mjs +++ b/packages/react-native/rollup.config.mjs @@ -12,7 +12,7 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); export default (commandLineArgs) => { - const sourcemap = (commandLineArgs.sourceMap || 'true') == 'true'; + const sourceMap = (commandLineArgs.sourceMap || 'true') == 'true'; // Clears rollup CLI warning https://github.com/rollup/rollup/issues/2694 delete commandLineArgs.sourceMap; @@ -22,7 +22,7 @@ export default (commandLineArgs) => { output: { file: 'dist/index.js', format: 'cjs', - sourcemap: sourcemap + sourcemap: sourceMap }, plugins: [ // We do this so that we can inject on BSON's crypto usage. @@ -54,7 +54,7 @@ export default (commandLineArgs) => { } ] }), - terser() + terser({ sourceMap }) ], external: [ '@journeyapps/react-native-quick-sqlite', diff --git a/packages/react/src/hooks/watched/useWatchedQuery.ts b/packages/react/src/hooks/watched/useWatchedQuery.ts index c0c0c2fd6..76b70f204 100644 --- a/packages/react/src/hooks/watched/useWatchedQuery.ts +++ b/packages/react/src/hooks/watched/useWatchedQuery.ts @@ -45,7 +45,6 @@ export const useWatchedQuery = ( // Used when `isFetching` hasn't been set to true yet due to React execution. React.useEffect(() => { if (queryChanged) { - console.log('Query changed, re-fetching...'); watchedQuery.updateSettings({ placeholderData: [], query, diff --git a/packages/react/src/hooks/watched/useWatchedQuerySubscription.ts b/packages/react/src/hooks/watched/useWatchedQuerySubscription.ts new file mode 100644 index 000000000..721fcc984 --- /dev/null +++ b/packages/react/src/hooks/watched/useWatchedQuerySubscription.ts @@ -0,0 +1,36 @@ +import { WatchedQuery, WatchedQueryState } from '@powersync/common'; +import React from 'react'; + +/** + * A hook to access and subscribe to the results of an existing {@link WatchedQuery} instance. + * @example + * export const ContentComponent = () => { + * const { data: lists } = useWatchedQuerySuspenseSubscription(listsQuery); + * + * return + * {lists.map((l) => ( + * {JSON.stringify(l)} + * ))} + * ; + * } + * + */ +export const useWatchedQuerySubscription = ( + query: WatchedQuery +): WatchedQueryState => { + const [output, setOutputState] = React.useState(query.state); + + React.useEffect(() => { + const dispose = query.subscribe({ + onStateChange: (state) => { + setOutputState({ ...state }); + } + }); + + return () => { + dispose(); + }; + }, [query]); + + return output; +}; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 7f9716c97..6bdc019aa 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -7,4 +7,5 @@ export { useSuspenseQuery } from './hooks/suspense/useSuspenseQuery'; export { useWatchedQuerySuspenseSubscription } from './hooks/suspense/useWatchedQuerySuspenseSubscription'; export { useStatus } from './hooks/useStatus'; export { useQuery } from './hooks/watched/useQuery'; +export { useWatchedQuerySubscription } from './hooks/watched/useWatchedQuerySubscription'; export { AdditionalOptions } from './hooks/watched/watch-types'; diff --git a/packages/react/tests/useQuery.test.tsx b/packages/react/tests/useQuery.test.tsx index 659c6afd7..52aeaf7a7 100644 --- a/packages/react/tests/useQuery.test.tsx +++ b/packages/react/tests/useQuery.test.tsx @@ -6,6 +6,7 @@ import pDefer from 'p-defer'; import { beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'; import { PowerSyncContext } from '../src/hooks/PowerSyncContext'; import { useQuery } from '../src/hooks/watched/useQuery'; +import { useWatchedQuerySubscription } from '../src/hooks/watched/useWatchedQuerySubscription'; export const openPowerSync = () => { const db = new PowerSyncDatabase({ database: { dbFilename: 'test.db' }, @@ -224,7 +225,7 @@ describe('useQuery', () => { const deferred = pDefer(); const baseGetAll = db.getAll; - const getSpy = vi.spyOn(db, 'getAll').mockImplementation(async (sql, params) => { + vi.spyOn(db, 'getAll').mockImplementation(async (sql, params) => { // Allow pausing this call in order to test isFetching await deferred.promise; return baseGetAll.call(db, sql, params); @@ -255,5 +256,56 @@ describe('useQuery', () => { expect(data == result.current.data).toEqual(true); }); + it('should use an existing WatchedQuery instance', async () => { + const db = openPowerSync(); + + // This query can be instantiated once and reused. + // The query retains it's state and will not re-fetch the data unless the result changes. + // This is useful for queries that are used in multiple components. + const listsQuery = db.incrementalWatch({ + watch: { + placeholderData: [], + query: { + compile: () => ({ + sql: `SELECT * FROM lists`, + parameters: [] + }), + execute: ({ sql, parameters }) => db.getAll(sql, parameters) + } + } + }); + + const wrapper = ({ children }) => {children}; + const { result } = renderHook(() => useWatchedQuerySubscription(listsQuery), { + wrapper + }); + + expect(result.current.isLoading).toEqual(true); + + await waitFor( + async () => { + const { current } = result; + expect(current.isLoading).toEqual(false); + }, + { timeout: 500, interval: 100 } + ); + + // This should trigger an update + await db.execute('INSERT INTO lists(id, name) VALUES (uuid(), ?)', ['aname']); + + await waitFor( + async () => { + const { current } = result; + expect(current.data.length).toEqual(1); + }, + { timeout: 500, interval: 100 } + ); + + // now use the same query again, the result should be available immediately + const { result: newResult } = renderHook(() => useWatchedQuerySubscription(listsQuery), { wrapper }); + expect(newResult.current.isLoading).toEqual(false); + expect(newResult.current.data.length).toEqual(1); + }); + // TODO: Add tests for powersync.onChangeWithCallback path }); diff --git a/packages/react/tests/useSuspenseQuery.test.tsx b/packages/react/tests/useSuspenseQuery.test.tsx index 7b52b4cb2..20a6cb861 100644 --- a/packages/react/tests/useSuspenseQuery.test.tsx +++ b/packages/react/tests/useSuspenseQuery.test.tsx @@ -5,6 +5,7 @@ import { ErrorBoundary } from 'react-error-boundary'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { PowerSyncContext } from '../src/hooks/PowerSyncContext'; import { useSuspenseQuery } from '../src/hooks/suspense/useSuspenseQuery'; +import { useWatchedQuerySuspenseSubscription } from '../src/hooks/suspense/useWatchedQuerySuspenseSubscription'; import { openPowerSync } from './useQuery.test'; describe('useSuspenseQuery', () => { @@ -242,4 +243,57 @@ describe('useSuspenseQuery', () => { await waitForCompletedSuspend(); await waitForError(); }); + + it('should use an existing WatchedQuery instance', async () => { + const db = openPowerSync(); + + // This query can be instantiated once and reused. + // The query retains it's state and will not re-fetch the data unless the result changes. + // This is useful for queries that are used in multiple components. + const listsQuery = db.incrementalWatch({ + watch: { + placeholderData: [], + query: { + compile: () => ({ + sql: `SELECT * FROM lists`, + parameters: [] + }), + execute: ({ sql, parameters }) => db.getAll(sql, parameters) + } + } + }); + + const wrapper = ({ children }) => {children}; + const { result } = renderHook(() => useWatchedQuerySuspenseSubscription(listsQuery), { + wrapper + }); + + // Initially, the query should be loading/suspended + expect(result.current).toEqual(null); + + await waitFor( + async () => { + expect(result.current).not.null; + }, + { timeout: 500, interval: 100 } + ); + + expect(result.current.data.length).toEqual(0); + + // This should trigger an update + await db.execute('INSERT INTO lists(id, name) VALUES (uuid(), ?)', ['aname']); + + await waitFor( + async () => { + const { current } = result; + expect(current.data.length).toEqual(1); + }, + { timeout: 500, interval: 100 } + ); + + // now use the same query again, the result should be available immediately + const { result: newResult } = renderHook(() => useWatchedQuerySuspenseSubscription(listsQuery), { wrapper }); + expect(newResult.current).not.null; + expect(newResult.current.data.length).toEqual(1); + }); }); From 40b849cec790c9fc808c4f257db57a01d8a062be Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Mon, 2 Jun 2025 11:30:25 +0200 Subject: [PATCH 27/75] cleanup compatible queries --- .../common/src/client/watched/WatchedQuery.ts | 9 ++++- .../processors/OnChangeQueryProcessor.ts | 7 +++- .../kysely-driver/tests/sqlite/watch.test.ts | 34 +++++++++++++++++++ .../hooks/suspense/useSingleSuspenseQuery.ts | 6 +++- .../react/src/hooks/watched/useSingleQuery.ts | 6 +++- .../react/src/hooks/watched/watch-utils.ts | 9 ++--- 6 files changed, 60 insertions(+), 11 deletions(-) diff --git a/packages/common/src/client/watched/WatchedQuery.ts b/packages/common/src/client/watched/WatchedQuery.ts index b71b85101..d22df85f5 100644 --- a/packages/common/src/client/watched/WatchedQuery.ts +++ b/packages/common/src/client/watched/WatchedQuery.ts @@ -1,3 +1,4 @@ +import { CompiledQuery } from '../../types/types.js'; import { BaseListener, BaseObserverInterface } from '../../utils/BaseObserver.js'; export interface WatchedQueryState { @@ -25,10 +26,16 @@ export interface WatchedQueryState { data: Data; } +/** + * Similar to {@link CompiledQuery}, but used for watched queries. + * The parameters are not read-only, as they can be modified. This is useful for compatibility with + * PowerSync queries. + */ export interface WatchCompiledQuery { sql: string; parameters: any[]; } + /** * * @internal @@ -37,7 +44,7 @@ export interface WatchCompiledQuery { */ export interface WatchCompatibleQuery { execute(compiled: WatchCompiledQuery): Promise; - compile(): WatchCompiledQuery; + compile(): CompiledQuery; } /** diff --git a/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts b/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts index dc4fbdf9c..6d6607d33 100644 --- a/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts +++ b/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts @@ -80,7 +80,12 @@ export class OnChangeQueryProcessor extends AbstractQueryProcessor { const partialStateUpdate: Partial> = {}; // Always run the query if an underlaying table has changed - const result = await watchOptions.query.execute(compiledQuery); + const result = await watchOptions.query.execute({ + sql: compiledQuery.sql, + // Allows casting from ReadOnlyArray[unknown] to Array + // This allows simpler compatibility with PowerSync queries + parameters: [...compiledQuery.parameters] + }); if (this.reportFetching) { partialStateUpdate.isFetching = false; diff --git a/packages/kysely-driver/tests/sqlite/watch.test.ts b/packages/kysely-driver/tests/sqlite/watch.test.ts index 1c39a32e3..1d18fc0eb 100644 --- a/packages/kysely-driver/tests/sqlite/watch.test.ts +++ b/packages/kysely-driver/tests/sqlite/watch.test.ts @@ -261,4 +261,38 @@ describe('Watch Tests', () => { expect(receivedWithManagedOverflowCount).greaterThan(2); expect(receivedWithManagedOverflowCount).toBeLessThanOrEqual(4); }); + + it('incremental watch should accept queries', async () => { + const query = db.selectFrom('assets').select(db.fn.count('assets.id').as('count')); + + const watch = powerSyncDb.incrementalWatch({ + watch: { + query, + placeholderData: [] + } + }); + + const latestDataPromise = new Promise>>((resolve) => { + const dispose = watch.subscribe({ + onData: (data) => { + if (data.length > 0) { + resolve(data); + dispose(); + } + } + }); + }); + + await db + .insertInto('assets') + .values({ + id: sql`uuid()`, + make: 'test', + customer_id: sql`uuid()` + }) + .execute(); + + const data = await latestDataPromise; + expect(data.length).equals(1); + }); }); diff --git a/packages/react/src/hooks/suspense/useSingleSuspenseQuery.ts b/packages/react/src/hooks/suspense/useSingleSuspenseQuery.ts index d2c1fc413..efb2ba33e 100644 --- a/packages/react/src/hooks/suspense/useSingleSuspenseQuery.ts +++ b/packages/react/src/hooks/suspense/useSingleSuspenseQuery.ts @@ -64,7 +64,11 @@ export const useSingleSuspenseQuery = ( refresh: async (signal) => { try { console.log('calling refresh for single query', key); - const result = await parsedQuery.execute(parsedQuery.compile()); + const compiledQuery = parsedQuery.compile(); + const result = await parsedQuery.execute({ + sql: compiledQuery.sql, + parameters: [...compiledQuery.parameters] + }); if (signal.aborted) { return; // Abort if the signal is already aborted } diff --git a/packages/react/src/hooks/watched/useSingleQuery.ts b/packages/react/src/hooks/watched/useSingleQuery.ts index 82bd64aee..f02afa8ac 100644 --- a/packages/react/src/hooks/watched/useSingleQuery.ts +++ b/packages/react/src/hooks/watched/useSingleQuery.ts @@ -16,7 +16,11 @@ export const useSingleQuery = (options: InternalHookOptions { setOutputState((prev) => ({ ...prev, isLoading: true, isFetching: true, error: undefined })); try { - const result = await query.execute(query.compile()); + const compiledQuery = query.compile(); + const result = await query.execute({ + sql: compiledQuery.sql, + parameters: [...compiledQuery.parameters] + }); if (signal.aborted) { return; } diff --git a/packages/react/src/hooks/watched/watch-utils.ts b/packages/react/src/hooks/watched/watch-utils.ts index 9dd1d2b58..b7272fa6f 100644 --- a/packages/react/src/hooks/watched/watch-utils.ts +++ b/packages/react/src/hooks/watched/watch-utils.ts @@ -1,9 +1,4 @@ -import { - AbstractPowerSyncDatabase, - CompilableQuery, - WatchCompatibleQuery, - WatchCompiledQuery -} from '@powersync/common'; +import { AbstractPowerSyncDatabase, CompilableQuery, CompiledQuery, WatchCompatibleQuery } from '@powersync/common'; import React from 'react'; import { usePowerSync } from '../PowerSyncContext'; import { AdditionalOptions } from './watch-types'; @@ -15,7 +10,7 @@ export type InternalHookOptions = { }; export const checkQueryChanged = (query: WatchCompatibleQuery, options: AdditionalOptions) => { - let _compiled: WatchCompiledQuery; + let _compiled: CompiledQuery; try { _compiled = query.compile(); } catch (error) { From 81d68e38689cf2a5d0a47edac920c00b8e912706 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Mon, 2 Jun 2025 12:15:49 +0200 Subject: [PATCH 28/75] maintain backwards compatibility --- .../src/client/AbstractPowerSyncDatabase.ts | 47 ++++++++-------- .../client/watched/processors/comparators.ts | 43 +++++++++++++++ packages/common/src/index.ts | 3 +- packages/react/src/QueryStore.ts | 3 +- .../src/hooks/suspense/useSuspenseQuery.ts | 12 +++-- packages/react/src/hooks/watched/useQuery.ts | 15 ++++-- .../src/hooks/watched/useWatchedQuery.ts | 3 +- .../react/src/hooks/watched/watch-types.ts | 7 +-- packages/react/tests/useQuery.test.tsx | 54 ++++++++++++++++++- 9 files changed, 152 insertions(+), 35 deletions(-) create mode 100644 packages/common/src/client/watched/processors/comparators.ts diff --git a/packages/common/src/client/AbstractPowerSyncDatabase.ts b/packages/common/src/client/AbstractPowerSyncDatabase.ts index 7c90e155b..11b3b1a09 100644 --- a/packages/common/src/client/AbstractPowerSyncDatabase.ts +++ b/packages/common/src/client/AbstractPowerSyncDatabase.ts @@ -34,6 +34,7 @@ import { } from './sync/stream/AbstractStreamingSyncImplementation.js'; import { WatchedQuery, WatchedQueryOptions } from './watched/WatchedQuery.js'; import { OnChangeQueryProcessor, WatchedQueryComparator } from './watched/processors/OnChangeQueryProcessor.js'; +import { FalsyComparator } from './watched/processors/comparators.js'; export interface DisconnectAndClearOptions { /** When set to false, data in local-only tables is preserved. */ @@ -71,6 +72,18 @@ export interface PowerSyncDatabaseOptionsWithSettings extends BasePowerSyncDatab database: SQLOpenOptions; } +export interface WatchComparatorOptions { + mode: 'comparison'; + comparator?: WatchedQueryComparator; +} + +export type WatchProcessorOptions = WatchComparatorOptions; + +export interface IncrementalWatchOptions { + watch: WatchedQueryOptions; + processor?: WatchProcessorOptions; +} + export interface SQLWatchOptions { signal?: AbortSignal; tables?: string[]; @@ -92,7 +105,7 @@ export interface SQLWatchOptions { * Optional comparator which will be used to compare the results of the query. * The watched query will only yield results if the comparator returns false. */ - comparator?: WatchedQueryComparator; + processor?: WatchProcessorOptions; } export interface WatchOnChangeEvent { @@ -109,16 +122,6 @@ export interface WatchOnChangeHandler { onError?: (error: Error) => void; } -export interface ComparatorWatchOptions { - mode: 'comparison'; - comparator?: WatchedQueryComparator; -} - -export interface IncrementalWatchOptions { - watch: WatchedQueryOptions; - processor?: ComparatorWatchOptions; -} - export interface PowerSyncDBListener extends StreamingSyncImplementationListener { initialized: () => void; schemaChanged: (schema: Schema) => void; @@ -916,12 +919,9 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver ({ sql: sql, @@ -929,12 +929,17 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver this.executeReadOnly(sql, parameters) }, - throttleMs: options?.throttleMs ?? DEFAULT_WATCH_THROTTLE_MS, - reportFetching: false + placeholderData: null, + reportFetching: false, + throttleMs: options?.throttleMs ?? DEFAULT_WATCH_THROTTLE_MS + }, + processor: options?.processor ?? { + mode: 'comparison', + comparator: FalsyComparator } }); - const dispose = watch.subscribe({ + const dispose = watchedQuery.subscribe({ onData: (data) => { if (!data) { // This should not happen. We only use null for the initial data. @@ -949,7 +954,7 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver { dispose(); - watch.close(); + watchedQuery.close(); }); } diff --git a/packages/common/src/client/watched/processors/comparators.ts b/packages/common/src/client/watched/processors/comparators.ts new file mode 100644 index 000000000..041339d93 --- /dev/null +++ b/packages/common/src/client/watched/processors/comparators.ts @@ -0,0 +1,43 @@ +import { WatchedQueryComparator } from './OnChangeQueryProcessor.js'; + +export type ArrayComparatorOptions = { + compareBy: (item: ItemType) => string; +}; + +/** + * Compares array results of watched queries. + */ +export class ArrayComparator implements WatchedQueryComparator { + constructor(protected options: ArrayComparatorOptions) {} + + checkEquality(current: ItemType[], previous: ItemType[]) { + if (current.length === 0 && previous.length === 0) { + return true; + } + + if (current.length !== previous.length) { + return false; + } + + const { compareBy } = this.options; + + // At this point the lengths are equal + for (let i = 0; i < current.length; i++) { + const currentItem = compareBy(current[i]); + const previousItem = compareBy(previous[i]); + + if (currentItem !== previousItem) { + return false; + } + } + + return true; + } +} + +/** + * Watched query comparator that always reports changed result sets. + */ +export const FalsyComparator: WatchedQueryComparator = { + checkEquality: () => false // Default comparator that always returns false +}; diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index ba1415636..e36fb60fa 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -30,7 +30,8 @@ export * from './db/schema/Schema.js'; export * from './db/schema/Table.js'; export * from './db/schema/TableV2.js'; -// TODO other exports +export * from './client/watched/processors/AbstractQueryProcessor.js'; +export * from './client/watched/processors/comparators.js'; export * from './client/watched/WatchedQuery.js'; export * from './utils/AbortOperation.js'; diff --git a/packages/react/src/QueryStore.ts b/packages/react/src/QueryStore.ts index f409a706b..467bfee08 100644 --- a/packages/react/src/QueryStore.ts +++ b/packages/react/src/QueryStore.ts @@ -24,7 +24,8 @@ export class QueryStore { query, placeholderData: [], throttleMs: options.throttleMs - } + }, + processor: options.processor }); const disposer = watchedQuery.registerListener({ diff --git a/packages/react/src/hooks/suspense/useSuspenseQuery.ts b/packages/react/src/hooks/suspense/useSuspenseQuery.ts index bea3f701f..1795cec7e 100644 --- a/packages/react/src/hooks/suspense/useSuspenseQuery.ts +++ b/packages/react/src/hooks/suspense/useSuspenseQuery.ts @@ -1,4 +1,4 @@ -import { CompilableQuery } from '@powersync/common'; +import { CompilableQuery, FalsyComparator } from '@powersync/common'; import { AdditionalOptions } from '../watched/watch-types'; import { SuspenseQueryResult } from './SuspenseQueryResult'; import { useSingleSuspenseQuery } from './useSingleSuspenseQuery'; @@ -28,12 +28,18 @@ import { useWatchedSuspenseQuery } from './useWatchedSuspenseQuery'; export const useSuspenseQuery = ( query: string | CompilableQuery, parameters: any[] = [], - options: AdditionalOptions = {} + options: AdditionalOptions = {} ): SuspenseQueryResult => { switch (options.runQueryOnce) { case true: return useSingleSuspenseQuery(query, parameters, options); default: - return useWatchedSuspenseQuery(query, parameters, options); + return useWatchedSuspenseQuery(query, parameters, { + ...options, + processor: options.processor ?? { + mode: 'comparison', + comparator: FalsyComparator // Default comparator that always reports changed result sets + } + }); } }; diff --git a/packages/react/src/hooks/watched/useQuery.ts b/packages/react/src/hooks/watched/useQuery.ts index 975ca7472..c6288711e 100644 --- a/packages/react/src/hooks/watched/useQuery.ts +++ b/packages/react/src/hooks/watched/useQuery.ts @@ -1,4 +1,4 @@ -import { type CompilableQuery } from '@powersync/common'; +import { FalsyComparator, type CompilableQuery } from '@powersync/common'; import { usePowerSync } from '../PowerSyncContext'; import { useSingleQuery } from './useSingleQuery'; import { useWatchedQuery } from './useWatchedQuery'; @@ -21,7 +21,7 @@ import { constructCompatibleQuery } from './watch-utils'; export const useQuery = ( query: string | CompilableQuery, parameters: any[] = [], - options: AdditionalOptions = { runQueryOnce: false } + options: AdditionalOptions = { runQueryOnce: false } ): QueryResult => { const powerSync = usePowerSync(); if (!powerSync) { @@ -42,7 +42,16 @@ export const useQuery = ( query: parsedQuery, powerSync, queryChanged, - options + options: { + ...options, + processor: options.processor ?? { + // Maintains backwards compatibility with previous versions + // Comparisons are opt-in by default + // We emit new data for each table change by default. + mode: 'comparison', + comparator: FalsyComparator + } + } }); } }; diff --git a/packages/react/src/hooks/watched/useWatchedQuery.ts b/packages/react/src/hooks/watched/useWatchedQuery.ts index 76b70f204..afe573030 100644 --- a/packages/react/src/hooks/watched/useWatchedQuery.ts +++ b/packages/react/src/hooks/watched/useWatchedQuery.ts @@ -15,7 +15,8 @@ export const useWatchedQuery = ( query, throttleMs: hookOptions.throttleMs, reportFetching: hookOptions.reportFetching - } + }, + processor: hookOptions.processor }); }, []); diff --git a/packages/react/src/hooks/watched/watch-types.ts b/packages/react/src/hooks/watched/watch-types.ts index 961463d73..258f43257 100644 --- a/packages/react/src/hooks/watched/watch-types.ts +++ b/packages/react/src/hooks/watched/watch-types.ts @@ -1,10 +1,11 @@ -import { type SQLWatchOptions } from '@powersync/common'; +import { WatchProcessorOptions, type SQLWatchOptions } from '@powersync/common'; -export interface HookWatchOptions extends Omit { +export interface HookWatchOptions extends Omit { reportFetching?: boolean; + processor?: WatchProcessorOptions; } -export interface AdditionalOptions extends HookWatchOptions { +export interface AdditionalOptions extends HookWatchOptions { runQueryOnce?: boolean; } diff --git a/packages/react/tests/useQuery.test.tsx b/packages/react/tests/useQuery.test.tsx index 52aeaf7a7..88066dd5a 100644 --- a/packages/react/tests/useQuery.test.tsx +++ b/packages/react/tests/useQuery.test.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import * as commonSdk from '@powersync/common'; import { PowerSyncDatabase } from '@powersync/web'; import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; @@ -195,7 +194,18 @@ describe('useQuery', () => { it('should emit result data when query changes', async () => { const db = openPowerSync(); const wrapper = ({ children }) => {children}; - const { result } = renderHook(() => useQuery('SELECT * FROM lists WHERE name = ?', ['aname']), { wrapper }); + const { result } = renderHook( + () => + useQuery('SELECT * FROM lists WHERE name = ?', ['aname'], { + processor: { + mode: 'comparison', + comparator: new commonSdk.ArrayComparator({ + compareBy: (item) => JSON.stringify(item) + }) + } + }), + { wrapper } + ); expect(result.current.isLoading).toEqual(true); @@ -256,6 +266,46 @@ describe('useQuery', () => { expect(data == result.current.data).toEqual(true); }); + // Verifies backwards compatibility with the previous implementation (no comparison) + it('should emit result data when data changes when not using comparator', async () => { + const db = openPowerSync(); + const wrapper = ({ children }) => {children}; + const { result } = renderHook(() => useQuery('SELECT * FROM lists WHERE name = ?', ['aname']), { wrapper }); + + expect(result.current.isLoading).toEqual(true); + + await waitFor( + async () => { + const { current } = result; + expect(current.isLoading).toEqual(false); + }, + { timeout: 500, interval: 100 } + ); + + // This should trigger an update + await db.execute('INSERT INTO lists(id, name) VALUES (uuid(), ?)', ['aname']); + + // Keep track of the previous data reference + let previousData = result.current.data; + await waitFor( + async () => { + const { current } = result; + expect(current.data.length).toEqual(1); + previousData = current.data; + }, + { timeout: 500, interval: 100 } + ); + + // This should still trigger an update since the underlaying tables changed. + await db.execute('INSERT INTO lists(id, name) VALUES (uuid(), ?)', ['noname']); + + // It's difficult to assert no update happened, but we can wait a bit + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // It should be the same data array reference, no update should have happened + expect(result.current.data == previousData).false; + }); + it('should use an existing WatchedQuery instance', async () => { const db = openPowerSync(); From 65a0ea31906fbaad51946e6412b593c68705c029 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Mon, 2 Jun 2025 12:43:14 +0200 Subject: [PATCH 29/75] Simplify query options for watched queries --- .../common/src/client/watched/GetAllQuery.ts | 29 +++ .../common/src/client/watched/WatchedQuery.ts | 10 +- .../processors/OnChangeQueryProcessor.ts | 3 +- packages/common/src/index.ts | 1 + packages/react/src/WatchedQuery.ts | 185 ------------------ .../hooks/suspense/useSingleSuspenseQuery.ts | 3 +- .../react/src/hooks/watched/useSingleQuery.ts | 3 +- packages/web/tests/watch.test.ts | 13 +- packages/web/vitest.config.ts | 2 +- 9 files changed, 47 insertions(+), 202 deletions(-) create mode 100644 packages/common/src/client/watched/GetAllQuery.ts delete mode 100644 packages/react/src/WatchedQuery.ts diff --git a/packages/common/src/client/watched/GetAllQuery.ts b/packages/common/src/client/watched/GetAllQuery.ts new file mode 100644 index 000000000..137406a3c --- /dev/null +++ b/packages/common/src/client/watched/GetAllQuery.ts @@ -0,0 +1,29 @@ +import { CompiledQuery } from '../../types/types.js'; +import { WatchCompatibleQuery, WatchExecuteOptions } from './WatchedQuery.js'; + +/** + * Options for {@link GetAllQuery}. + */ +export type GetAllQueryOptions = { + sql: string; + parameters?: ReadonlyArray; +}; + +/** + * Performs a {@link AbstractPowerSyncDatabase.getAll} operation for a watched query. + */ +export class GetAllQuery implements WatchCompatibleQuery { + constructor(protected options: GetAllQueryOptions) {} + + compile(): CompiledQuery { + return { + sql: this.options.sql, + parameters: this.options.parameters ?? [] + }; + } + + execute(options: WatchExecuteOptions): Promise { + const { db, sql, parameters } = options; + return db.getAll(sql, parameters); + } +} diff --git a/packages/common/src/client/watched/WatchedQuery.ts b/packages/common/src/client/watched/WatchedQuery.ts index d22df85f5..fc5858955 100644 --- a/packages/common/src/client/watched/WatchedQuery.ts +++ b/packages/common/src/client/watched/WatchedQuery.ts @@ -1,5 +1,6 @@ import { CompiledQuery } from '../../types/types.js'; import { BaseListener, BaseObserverInterface } from '../../utils/BaseObserver.js'; +import { AbstractPowerSyncDatabase } from '../AbstractPowerSyncDatabase.js'; export interface WatchedQueryState { /** @@ -27,13 +28,12 @@ export interface WatchedQueryState { } /** - * Similar to {@link CompiledQuery}, but used for watched queries. - * The parameters are not read-only, as they can be modified. This is useful for compatibility with - * PowerSync queries. + * Options provided to the `execute` method of a {@link WatchCompatibleQuery}. */ -export interface WatchCompiledQuery { +export interface WatchExecuteOptions { sql: string; parameters: any[]; + db: AbstractPowerSyncDatabase; } /** @@ -43,7 +43,7 @@ export interface WatchCompiledQuery { * does not enforce an Array result type. */ export interface WatchCompatibleQuery { - execute(compiled: WatchCompiledQuery): Promise; + execute(options: WatchExecuteOptions): Promise; compile(): CompiledQuery; } diff --git a/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts b/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts index 6d6607d33..f13ed32df 100644 --- a/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts +++ b/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts @@ -84,7 +84,8 @@ export class OnChangeQueryProcessor extends AbstractQueryProcessor { sql: compiledQuery.sql, // Allows casting from ReadOnlyArray[unknown] to Array // This allows simpler compatibility with PowerSync queries - parameters: [...compiledQuery.parameters] + parameters: [...compiledQuery.parameters], + db: this.options.db }); if (this.reportFetching) { diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index e36fb60fa..3ef29f48e 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -30,6 +30,7 @@ export * from './db/schema/Schema.js'; export * from './db/schema/Table.js'; export * from './db/schema/TableV2.js'; +export * from './client/watched/GetAllQuery.js'; export * from './client/watched/processors/AbstractQueryProcessor.js'; export * from './client/watched/processors/comparators.js'; export * from './client/watched/WatchedQuery.js'; diff --git a/packages/react/src/WatchedQuery.ts b/packages/react/src/WatchedQuery.ts deleted file mode 100644 index 55683732c..000000000 --- a/packages/react/src/WatchedQuery.ts +++ /dev/null @@ -1,185 +0,0 @@ -// export class Query { -// rawQuery: string | CompilableQuery; -// sqlStatement: string; -// queryParameters: any[]; -// } - -// export interface WatchedQueryListener extends BaseListener { -//onUpdate: () => void; -// disposed: () => void; -// } - -// export class WatchedQuery extends BaseObserver implements Disposable { -// readyPromise: Promise; -// isReady: boolean = false; -// currentData: any[] | undefined; -// currentError: any; -// tables: any[] | undefined; - -// private temporaryHolds = new Set(); -// private controller: AbortController | undefined; -// private db: AbstractPowerSyncDatabase; - -// private resolveReady: undefined | (() => void); - -// readonly query: Query; -// readonly options: AdditionalOptions; - -// constructor(db: AbstractPowerSyncDatabase, query: Query, options: AdditionalOptions) { -// super(); -// this.db = db; -// this.query = query; -// this.options = options; - -// this.readyPromise = new Promise((resolve) => { -// this.resolveReady = resolve; -// }); -// } - -// get logger() { -// return this.db.logger ?? console; -// } - -// addTemporaryHold() { -// const ref = new Object(); -// this.temporaryHolds.add(ref); -// this.maybeListen(); - -// let timeout: any; -// const release = () => { -// this.temporaryHolds.delete(ref); -// if (timeout) { -// clearTimeout(timeout); -// } -// this.maybeDispose(); -// }; - -// const timeoutRelease = () => { -// if (this.isReady || this.controller == null) { -// release(); -// } else { -// // If the query is taking long, keep the temporary hold. -// timeout = setTimeout(timeoutRelease, 5_000); -// } -// }; - -// timeout = setTimeout(timeoutRelease, 5_000); - -// return release; -// } - -// registerListener(listener: Partial): () => void { -// const disposer = super.registerListener(listener); - -// this.maybeListen(); -// return () => { -// disposer(); -// this.maybeDispose(); -// }; -// } - -// private async fetchTables() { -// try { -// this.tables = await this.db.resolveTables(this.query.sqlStatement, this.query.queryParameters, this.options); -// } catch (e) { -// this.logger.error('Failed to fetch tables:', e); -// this.setError(e); -// } -// } - -// async fetchData() { -// try { -// const result = -// typeof this.query.rawQuery == 'string' -// ? await this.db.getAll(this.query.sqlStatement, this.query.queryParameters) -// : await this.query.rawQuery.execute(); - -// const data = result ?? []; -// this.setData(data); -// } catch (e) { -// this.logger.error('Failed to fetch data:', e); -// this.setError(e); -// } -// } - -// // configures underlaying query if there are listeners -// private maybeListen() { -// if (this.controller != null) { -// return; -// } - -// if (this.onUpdateListenersCount() == 0 && this.temporaryHolds.size == 0) { -// return; -// } - -// const controller = new AbortController(); -// this.controller = controller; - -// const onError = (error: Error) => { -// this.setError(error); -// }; - -// const watchQuery = async (abortSignal: AbortSignal) => { -// await this.fetchTables(); -// await this.fetchData(); - -// if (!this.options.runQueryOnce) { -// this.db.onChangeWithCallback( -// { -// onChange: async () => { -// await this.fetchData(); -// }, -// onError -// }, -// { -// ...this.options, -// signal: abortSignal, -// tables: this.tables -// } -// ); -// } -// }; -// runOnSchemaChange(watchQuery, this.db, { signal: this.controller.signal }); -// } - -// private setData(results: any[]) { -// this.isReady = true; -// this.currentData = results; -// this.currentError = undefined; -// this.resolveReady?.(); - -// this.iterateListeners((l) => l.onUpdate?.()); -// } - -// private setError(error: any) { -// this.isReady = true; -// this.currentData = undefined; -// this.currentError = error; -// this.resolveReady?.(); - -// this.iterateListeners((l) => l.onUpdate?.()); -// } - -// private onUpdateListenersCount(): number { -// return Array.from(this.listeners).filter((listener) => listener.onUpdate !== undefined).length; -// } - -// private maybeDispose() { -// if (this.onUpdateListenersCount() == 0 && this.temporaryHolds.size == 0) { -// this.controller?.abort(); -// this.controller = undefined; -// this.isReady = false; -// this.currentData = undefined; -// this.currentError = undefined; -// this.dispose(); - -// this.readyPromise = new Promise((resolve, reject) => { -// this.resolveReady = resolve; -// }); -// } -// } - -// async dispose() { -// this.iterateAsyncListeners(async (l) => l.disposed?.()); -// } -// } diff --git a/packages/react/src/hooks/suspense/useSingleSuspenseQuery.ts b/packages/react/src/hooks/suspense/useSingleSuspenseQuery.ts index efb2ba33e..3137f2c51 100644 --- a/packages/react/src/hooks/suspense/useSingleSuspenseQuery.ts +++ b/packages/react/src/hooks/suspense/useSingleSuspenseQuery.ts @@ -67,7 +67,8 @@ export const useSingleSuspenseQuery = ( const compiledQuery = parsedQuery.compile(); const result = await parsedQuery.execute({ sql: compiledQuery.sql, - parameters: [...compiledQuery.parameters] + parameters: [...compiledQuery.parameters], + db: powerSync }); if (signal.aborted) { return; // Abort if the signal is already aborted diff --git a/packages/react/src/hooks/watched/useSingleQuery.ts b/packages/react/src/hooks/watched/useSingleQuery.ts index f02afa8ac..f56ec8d15 100644 --- a/packages/react/src/hooks/watched/useSingleQuery.ts +++ b/packages/react/src/hooks/watched/useSingleQuery.ts @@ -19,7 +19,8 @@ export const useSingleQuery = (options: InternalHookOptions { it('should stream watch results', async () => { const watch = powersync.incrementalWatch({ watch: { - query: { - compile: () => ({ - sql: 'SELECT * FROM assets', - parameters: [] - }), - execute: ({ sql, parameters }) => powersync.getAll(sql, parameters) - }, + query: new GetAllQuery({ + sql: 'SELECT * FROM assets', + parameters: [] + }), placeholderData: [] } }); diff --git a/packages/web/vitest.config.ts b/packages/web/vitest.config.ts index 42f7d5e3c..6c125b660 100644 --- a/packages/web/vitest.config.ts +++ b/packages/web/vitest.config.ts @@ -50,7 +50,7 @@ const config: UserConfigExport = { */ isolate: true, provider: 'playwright', - headless: true, + headless: false, instances: [ { browser: 'chromium' From 5ba28b4f6417643d34bf1f64046b31443e787ae4 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Mon, 2 Jun 2025 13:59:06 +0200 Subject: [PATCH 30/75] update React README --- packages/react/README.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/react/README.md b/packages/react/README.md index 89a49fb02..84900c584 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -343,7 +343,12 @@ function MyWidget() { // Note that isFetching is set (by default) whenever the query is being fetched/checked. // This will result in `MyWidget` re-rendering for any change to the `cats` table. const { data, isLoading, isFetching } = useQuery(`SELECT * FROM cats WHERE breed = 'tabby'`, [], { - processor: WatchedQueryProcessor.COMPARISON // TODO + processor: { + mode: 'comparison', + comparator: new ArrayComparator({ + compareBy: (cat) => JSON.stringify(cat) + }) + }, }) // ... Widget code @@ -368,7 +373,12 @@ function MyWidget() { // When reportFetching == false the object returned from useQuery will only be changed when the data, isLoading or error state changes. // This method performs a comparison in memory in order to determine changes. const { data, isLoading, isFetching } = useQuery(`SELECT * FROM cats WHERE breed = 'tabby'`, [], { - processor: WatchedQueryProcessor.COMPARISON // TODO + processor: { + mode: 'comparison', + comparator: new ArrayComparator({ + compareBy: (cat) => JSON.stringify(cat) + }) + }, reportFetching: false }) From 9832417169a47df7f3d783e9ae2f8fc99648510e Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Mon, 2 Jun 2025 17:14:14 +0200 Subject: [PATCH 31/75] cleanup React demo --- .prettierrc | 3 +- .../src/app/views/sql-console/page.tsx | 34 +++-- .../src/app/views/todo-lists/edit/page.tsx | 85 +++++------- .../src/app/views/todo-lists/page.tsx | 25 ++-- .../src/components/widgets/ListItemWidget.tsx | 66 ++++++--- .../src/components/widgets/TodoItemWidget.tsx | 83 ++++++++--- .../components/widgets/TodoListsWidget.tsx | 51 +++---- package.json | 2 + .../common/src/client/watched/WatchedQuery.ts | 7 +- .../processors/AbstractQueryProcessor.ts | 4 +- .../processors/OnChangeQueryProcessor.ts | 2 +- packages/react/README.md | 2 +- packages/react/tests/useQuery.test.tsx | 2 +- pnpm-lock.yaml | 131 +++++++++++++++++- 14 files changed, 341 insertions(+), 156 deletions(-) diff --git a/.prettierrc b/.prettierrc index 85bab2c6a..b92dbc1be 100644 --- a/.prettierrc +++ b/.prettierrc @@ -5,5 +5,6 @@ "jsxBracketSameLine": true, "useTabs": false, "printWidth": 120, - "trailingComma": "none" + "trailingComma": "none", + "plugins": ["prettier-plugin-embed", "prettier-plugin-sql"] } diff --git a/demos/react-supabase-todolist/src/app/views/sql-console/page.tsx b/demos/react-supabase-todolist/src/app/views/sql-console/page.tsx index a7e8f8e16..7cc8b8212 100644 --- a/demos/react-supabase-todolist/src/app/views/sql-console/page.tsx +++ b/demos/react-supabase-todolist/src/app/views/sql-console/page.tsx @@ -2,6 +2,7 @@ import { NavigationPage } from '@/components/navigation/NavigationPage'; import { Box, Button, Grid, TextField, styled } from '@mui/material'; import { DataGrid } from '@mui/x-data-grid'; import { useQuery } from '@powersync/react'; +import { ArrayComparator } from '@powersync/web'; import React from 'react'; export type LoginFormParams = { @@ -9,10 +10,14 @@ export type LoginFormParams = { password: string; }; -const DEFAULT_QUERY = 'SELECT * FROM lists'; +const DEFAULT_QUERY = /* sql */ ` + SELECT + * + FROM + lists +`; -const TableDisplay = ({ data }: { data: any[] }) => { - console.log('Rendering table display', data); +const TableDisplay = React.memo(({ data }: { data: any[] }) => { const queryDataGridResult = React.useMemo(() => { const firstItem = data?.[0]; return { @@ -44,15 +49,28 @@ const TableDisplay = ({ data }: { data: any[] }) => { /> ); -}; +}); + export default function SQLConsolePage() { const inputRef = React.useRef(); const [query, setQuery] = React.useState(DEFAULT_QUERY); - const { data } = useQuery(query, [], { reportFetching: false }); - React.useEffect(() => { - console.log('Query result changed', data); - }, [data]); + const { data } = useQuery(query, [], { + /** + * We don't use the isFetching status here, we can avoid re-renders if we don't report on it. + */ + reportFetching: false, + /** + * The query here will only emit results when the query data set changes. + * Result sets are compared by serializing each item to JSON and comparing the strings. + */ + processor: { + mode: 'comparison', + comparator: new ArrayComparator({ + compareBy: (item) => JSON.stringify(item) + }) + } + }); return ( diff --git a/demos/react-supabase-todolist/src/app/views/todo-lists/edit/page.tsx b/demos/react-supabase-todolist/src/app/views/todo-lists/edit/page.tsx index 0648a5ca5..7a488792f 100644 --- a/demos/react-supabase-todolist/src/app/views/todo-lists/edit/page.tsx +++ b/demos/react-supabase-todolist/src/app/views/todo-lists/edit/page.tsx @@ -1,4 +1,7 @@ -import { usePowerSync, useQuery } from '@powersync/react'; +import { NavigationPage } from '@/components/navigation/NavigationPage'; +import { useSupabase } from '@/components/providers/SystemProvider'; +import { TodoItemWidget } from '@/components/widgets/TodoItemWidget'; +import { LISTS_TABLE, TODOS_TABLE, TodoRecord } from '@/library/powersync/AppSchema'; import AddIcon from '@mui/icons-material/Add'; import { Box, @@ -15,12 +18,9 @@ import { styled } from '@mui/material'; import Fab from '@mui/material/Fab'; +import { usePowerSync, useQuery } from '@powersync/react'; import React, { Suspense } from 'react'; import { useParams } from 'react-router-dom'; -import { useSupabase } from '@/components/providers/SystemProvider'; -import { LISTS_TABLE, TODOS_TABLE, TodoRecord } from '@/library/powersync/AppSchema'; -import { NavigationPage } from '@/components/navigation/NavigationPage'; -import { TodoItemWidget } from '@/components/widgets/TodoItemWidget'; /** * useSearchParams causes the entire element to fall back to client side rendering @@ -34,39 +34,36 @@ const TodoEditSection = () => { const { data: [listRecord] - } = useQuery<{ name: string }>(`SELECT name FROM ${LISTS_TABLE} WHERE id = ?`, [listID]); + } = useQuery<{ name: string }>( + /* sql */ ` + SELECT + name + FROM + ${LISTS_TABLE} + WHERE + id = ? + `, + [listID] + ); const { data: todos } = useQuery( - `SELECT * FROM ${TODOS_TABLE} WHERE list_id=? ORDER BY created_at DESC, id`, + /* sql */ ` + SELECT + * + FROM + ${TODOS_TABLE} + WHERE + list_id = ? + ORDER BY + created_at DESC, + id + `, [listID] ); const [showPrompt, setShowPrompt] = React.useState(false); const nameInputRef = React.createRef(); - const toggleCompletion = async (record: TodoRecord, completed: boolean) => { - const updatedRecord = { ...record, completed: completed }; - if (completed) { - const userID = supabase?.currentSession?.user.id; - if (!userID) { - throw new Error(`Could not get user ID.`); - } - updatedRecord.completed_at = new Date().toISOString(); - updatedRecord.completed_by = userID; - } else { - updatedRecord.completed_at = null; - updatedRecord.completed_by = null; - } - await powerSync.execute( - `UPDATE ${TODOS_TABLE} - SET completed = ?, - completed_at = ?, - completed_by = ? - WHERE id = ?`, - [completed, updatedRecord.completed_at, updatedRecord.completed_by, record.id] - ); - }; - const createNewTodo = async (description: string) => { const userID = supabase?.currentSession?.user.id; if (!userID) { @@ -74,21 +71,16 @@ const TodoEditSection = () => { } await powerSync.execute( - `INSERT INTO - ${TODOS_TABLE} - (id, created_at, created_by, description, list_id) - VALUES - (uuid(), datetime(), ?, ?, ?)`, + /* sql */ ` + INSERT INTO + ${TODOS_TABLE} (id, created_at, created_by, description, list_id) + VALUES + (uuid (), datetime (), ?, ?, ?) + `, [userID, description, listID!] ); }; - const deleteTodo = async (id: string) => { - await powerSync.writeTransaction(async (tx) => { - await tx.execute(`DELETE FROM ${TODOS_TABLE} WHERE id = ?`, [id]); - }); - }; - if (!listRecord) { return ( @@ -106,13 +98,7 @@ const TodoEditSection = () => { {todos.map((r) => ( - deleteTodo(r.id)} - isComplete={r.completed == 1} - toggleCompletion={() => toggleCompletion(r, !r.completed)} - /> + ))} @@ -129,8 +115,7 @@ const TodoEditSection = () => { await createNewTodo(nameInputRef.current!.value); setShowPrompt(false); } - }} - > + }}> {'Create Todo Item'} Enter a description for a new todo item diff --git a/demos/react-supabase-todolist/src/app/views/todo-lists/page.tsx b/demos/react-supabase-todolist/src/app/views/todo-lists/page.tsx index 1f1a686b7..edb168a2b 100644 --- a/demos/react-supabase-todolist/src/app/views/todo-lists/page.tsx +++ b/demos/react-supabase-todolist/src/app/views/todo-lists/page.tsx @@ -1,4 +1,9 @@ -import { usePowerSync, useStatus } from '@powersync/react'; +import { NavigationPage } from '@/components/navigation/NavigationPage'; +import { useSupabase } from '@/components/providers/SystemProvider'; +import { GuardBySync } from '@/components/widgets/GuardBySync'; +import { SearchBarWidget } from '@/components/widgets/SearchBarWidget'; +import { TodoListsWidget } from '@/components/widgets/TodoListsWidget'; +import { LISTS_TABLE } from '@/library/powersync/AppSchema'; import AddIcon from '@mui/icons-material/Add'; import { Box, @@ -12,18 +17,12 @@ import { styled } from '@mui/material'; import Fab from '@mui/material/Fab'; +import { usePowerSync } from '@powersync/react'; import React from 'react'; -import { useSupabase } from '@/components/providers/SystemProvider'; -import { LISTS_TABLE } from '@/library/powersync/AppSchema'; -import { NavigationPage } from '@/components/navigation/NavigationPage'; -import { SearchBarWidget } from '@/components/widgets/SearchBarWidget'; -import { TodoListsWidget } from '@/components/widgets/TodoListsWidget'; -import { GuardBySync } from '@/components/widgets/GuardBySync'; export default function TodoListsPage() { const powerSync = usePowerSync(); const supabase = useSupabase(); - const status = useStatus(); const [showPrompt, setShowPrompt] = React.useState(false); const nameInputRef = React.createRef(); @@ -36,7 +35,12 @@ export default function TodoListsPage() { } const res = await powerSync.execute( - `INSERT INTO ${LISTS_TABLE} (id, created_at, name, owner_id) VALUES (uuid(), datetime(), ?, ?) RETURNING *`, + /* sql */ ` + INSERT INTO + ${LISTS_TABLE} (id, created_at, name, owner_id) + VALUES + (uuid (), datetime (), ?, ?) RETURNING * + `, [name, userID] ); @@ -71,8 +75,7 @@ export default function TodoListsPage() { } }} aria-labelledby="alert-dialog-title" - aria-describedby="alert-dialog-description" - > + aria-describedby="alert-dialog-description"> {'Create Todo List'} Enter a name for a new todo list diff --git a/demos/react-supabase-todolist/src/components/widgets/ListItemWidget.tsx b/demos/react-supabase-todolist/src/components/widgets/ListItemWidget.tsx index dac08705a..e2ac183ef 100644 --- a/demos/react-supabase-todolist/src/components/widgets/ListItemWidget.tsx +++ b/demos/react-supabase-todolist/src/components/widgets/ListItemWidget.tsx @@ -11,60 +11,80 @@ import { } from '@mui/material'; import React from 'react'; +import { TODO_LISTS_ROUTE } from '@/app/router'; +import { LISTS_TABLE, TODOS_TABLE } from '@/library/powersync/AppSchema'; import RightIcon from '@mui/icons-material/ArrowRightAlt'; import DeleteIcon from '@mui/icons-material/DeleteOutline'; import ListIcon from '@mui/icons-material/ListAltOutlined'; +import { usePowerSync } from '@powersync/react'; +import { useNavigate } from 'react-router-dom'; export type ListItemWidgetProps = { + id: string; title: string; description: string; selected?: boolean; - onDelete: () => void; - onPress: () => void; }; -export const ListItemWidget: React.FC = (props) => { - console.log('ListItemWidget', props); +export const ListItemWidget: React.FC = React.memo((props) => { + const { id, title, description, selected } = props; + + const powerSync = usePowerSync(); + const navigate = useNavigate(); + + const deleteList = React.useCallback(async () => { + await powerSync.writeTransaction(async (tx) => { + // Delete associated todos + await tx.execute( + /* sql */ ` + DELETE FROM ${TODOS_TABLE} + WHERE + list_id = ? + `, + [id] + ); + // Delete list record + await tx.execute( + /* sql */ ` + DELETE FROM ${LISTS_TABLE} + WHERE + id = ? + `, + [id] + ); + }); + }, [id]); + + const openList = React.useCallback(() => { + navigate(TODO_LISTS_ROUTE + '/' + id); + }, [id]); + return ( - { - props.onDelete(); - }}> + - { - props.onPress(); - }}> + }> - { - props.onPress(); - }} - selected={props.selected}> + - +
); -}; +}); export namespace S { export const MainPaper = styled(Paper)` diff --git a/demos/react-supabase-todolist/src/components/widgets/TodoItemWidget.tsx b/demos/react-supabase-todolist/src/components/widgets/TodoItemWidget.tsx index 8fac060de..d44418c4a 100644 --- a/demos/react-supabase-todolist/src/components/widgets/TodoItemWidget.tsx +++ b/demos/react-supabase-todolist/src/components/widgets/TodoItemWidget.tsx @@ -1,51 +1,88 @@ -import React from 'react'; -import { ListItem, IconButton, ListItemAvatar, ListItemText, Box, styled, Paper, ListItemButton } from '@mui/material'; -import DeleteIcon from '@mui/icons-material/DeleteOutline'; -import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank'; +import { TODOS_TABLE } from '@/library/powersync/AppSchema'; import CheckBoxIcon from '@mui/icons-material/CheckBox'; +import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank'; +import DeleteIcon from '@mui/icons-material/DeleteOutline'; +import { Box, IconButton, ListItem, ListItemAvatar, ListItemButton, ListItemText, Paper, styled } from '@mui/material'; +import { usePowerSync } from '@powersync/react'; +import React from 'react'; +import { useSupabase } from '../providers/SystemProvider'; export type TodoItemWidgetProps = { + id: string; description: string | null; isComplete: boolean; - onDelete: () => void; - toggleCompletion: () => void; }; -export const TodoItemWidget: React.FC = (props) => { +export const TodoItemWidget: React.FC = React.memo((props) => { + const { id, description, isComplete } = props; + + const powerSync = usePowerSync(); + const supabase = useSupabase(); + + const deleteTodo = React.useCallback(async () => { + await powerSync.writeTransaction(async (tx) => { + await tx.execute( + /* sql */ ` + DELETE FROM ${TODOS_TABLE} + WHERE + id = ? + `, + [id] + ); + }); + }, [id]); + + const toggleCompletion = React.useCallback(async () => { + let completedAt: String | null = null; + let completedBy: String | null = null; + + if (!isComplete) { + // Need to set to Completed. This requires a session. + const userID = supabase?.currentSession?.user.id; + if (!userID) { + throw new Error(`Could not get user ID.`); + } + completedAt = new Date().toISOString(); + completedBy = userID; + } + + await powerSync.execute( + /* sql */ ` + UPDATE ${TODOS_TABLE} + SET + completed = ?, + completed_at = ?, + completed_by = ? + WHERE + id = ? + `, + [!isComplete, completedAt, completedBy, id] + ); + }, [id, isComplete]); + return ( - { - props.onDelete(); - }} - > + - } - > - { - props.toggleCompletion(); - }} - > + }> + {props.isComplete ? : } - + ); -}; +}); namespace S { export const MainPaper = styled(Paper)` diff --git a/demos/react-supabase-todolist/src/components/widgets/TodoListsWidget.tsx b/demos/react-supabase-todolist/src/components/widgets/TodoListsWidget.tsx index 9352115dc..9db0450b7 100644 --- a/demos/react-supabase-todolist/src/components/widgets/TodoListsWidget.tsx +++ b/demos/react-supabase-todolist/src/components/widgets/TodoListsWidget.tsx @@ -1,9 +1,8 @@ -import { usePowerSync, useQuery } from '@powersync/react'; +import { LISTS_TABLE, ListRecord, TODOS_TABLE } from '@/library/powersync/AppSchema'; import { List } from '@mui/material'; -import { useNavigate } from 'react-router-dom'; +import { useQuery } from '@powersync/react'; +import { ArrayComparator } from '@powersync/web'; import { ListItemWidget } from './ListItemWidget'; -import { LISTS_TABLE, ListRecord, TODOS_TABLE } from '@/library/powersync/AppSchema'; -import { TODO_LISTS_ROUTE } from '@/app/router'; export type TodoListsWidgetProps = { selectedId?: string; @@ -14,28 +13,33 @@ const description = (total: number, completed: number = 0) => { }; export function TodoListsWidget(props: TodoListsWidgetProps) { - const powerSync = usePowerSync(); - const navigate = useNavigate(); - - const { data: listRecords, isLoading } = useQuery(` + const { data: listRecords, isLoading } = useQuery( + /* sql */ ` SELECT - ${LISTS_TABLE}.*, COUNT(${TODOS_TABLE}.id) AS total_tasks, SUM(CASE WHEN ${TODOS_TABLE}.completed = true THEN 1 ELSE 0 END) as completed_tasks + ${LISTS_TABLE}.*, + COUNT(${TODOS_TABLE}.id) AS total_tasks, + SUM( + CASE + WHEN ${TODOS_TABLE}.completed = true THEN 1 + ELSE 0 + END + ) as completed_tasks FROM ${LISTS_TABLE} - LEFT JOIN ${TODOS_TABLE} - ON ${LISTS_TABLE}.id = ${TODOS_TABLE}.list_id + LEFT JOIN ${TODOS_TABLE} ON ${LISTS_TABLE}.id = ${TODOS_TABLE}.list_id GROUP BY ${LISTS_TABLE}.id; - `); - - const deleteList = async (id: string) => { - await powerSync.writeTransaction(async (tx) => { - // Delete associated todos - await tx.execute(`DELETE FROM ${TODOS_TABLE} WHERE list_id = ?`, [id]); - // Delete list record - await tx.execute(`DELETE FROM ${LISTS_TABLE} WHERE id = ?`, [id]); - }); - }; + `, + [], + { + processor: { + mode: 'comparison', + comparator: new ArrayComparator({ + compareBy: (item) => JSON.stringify(item) + }) + } + } + ); if (isLoading) { return
Loading...
; @@ -46,13 +50,10 @@ export function TodoListsWidget(props: TodoListsWidgetProps) { {listRecords.map((r) => ( deleteList(r.id)} - onPress={() => { - navigate(TODO_LISTS_ROUTE + '/' + r.id); - }} /> ))} diff --git a/package.json b/package.json index 5ae008c68..ced658407 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,8 @@ "lint-staged": "^15.2.2", "playwright": "^1.51.0", "prettier": "^3.2.5", + "prettier-plugin-embed": "^0.4.15", + "prettier-plugin-sql": "^0.18.1", "typescript": "^5.7.2", "vitest": "^3.0.8" } diff --git a/packages/common/src/client/watched/WatchedQuery.ts b/packages/common/src/client/watched/WatchedQuery.ts index fc5858955..399186aae 100644 --- a/packages/common/src/client/watched/WatchedQuery.ts +++ b/packages/common/src/client/watched/WatchedQuery.ts @@ -37,8 +37,6 @@ export interface WatchExecuteOptions { } /** - * - * @internal * Similar to {@link CompatibleQuery}, except the `execute` method * does not enforce an Array result type. */ @@ -47,9 +45,6 @@ export interface WatchCompatibleQuery { compile(): CompiledQuery; } -/** - * @internal - */ export interface WatchedQueryOptions { query: WatchCompatibleQuery; @@ -107,7 +102,7 @@ export interface WatchedQuery extends BaseObserverInterface): () => void; /** - * Updates the underlaying query options. + * Updates the underlying query options. * This will trigger a re-evaluation of the query and update the state. */ updateSettings(options: WatchedQueryOptions): Promise; diff --git a/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts b/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts index 066caaef6..3eee41157 100644 --- a/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts +++ b/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts @@ -29,7 +29,7 @@ export interface LinkQueryOptions { type WatchedQueryProcessorListener = WatchedQuerySubscription & WatchedQueryListener; /** - * Performs underlaying watching and yields a stream of results. + * Performs underlying watching and yields a stream of results. * @internal */ export abstract class AbstractQueryProcessor @@ -76,7 +76,7 @@ export abstract class AbstractQueryProcessor } /** - * Updates the underlaying query. + * Updates the underlying query. */ async updateSettings(query: WatchedQueryOptions) { await this.initialized; diff --git a/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts b/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts index f13ed32df..500164aeb 100644 --- a/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts +++ b/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts @@ -79,7 +79,7 @@ export class OnChangeQueryProcessor extends AbstractQueryProcessor { const partialStateUpdate: Partial> = {}; - // Always run the query if an underlaying table has changed + // Always run the query if an underlying table has changed const result = await watchOptions.query.execute({ sql: compiledQuery.sql, // Allows casting from ReadOnlyArray[unknown] to Array diff --git a/packages/react/README.md b/packages/react/README.md index 84900c584..4cf105655 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -311,7 +311,7 @@ The above example is incomplete, but is required for the optimizations below. ### Incremental Queries -By default watched queries are queried whenever a change to the underlaying tables has been detected. These changes might not be relevant to the actual query, but will still trigger a query and `data` update. +By default watched queries are queried whenever a change to the underlying tables has been detected. These changes might not be relevant to the actual query, but will still trigger a query and `data` update. ```tsx function MyWidget() { diff --git a/packages/react/tests/useQuery.test.tsx b/packages/react/tests/useQuery.test.tsx index 88066dd5a..8b6dac146 100644 --- a/packages/react/tests/useQuery.test.tsx +++ b/packages/react/tests/useQuery.test.tsx @@ -296,7 +296,7 @@ describe('useQuery', () => { { timeout: 500, interval: 100 } ); - // This should still trigger an update since the underlaying tables changed. + // This should still trigger an update since the underlying tables changed. await db.execute('INSERT INTO lists(id, name) VALUES (uuid(), ?)', ['noname']); // It's difficult to assert no update happened, but we can wait a bit diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 03206b7fe..effdf8d1b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,12 @@ importers: prettier: specifier: ^3.2.5 version: 3.3.3 + prettier-plugin-embed: + specifier: ^0.4.15 + version: 0.4.15(babel-plugin-macros@3.1.0) + prettier-plugin-sql: + specifier: ^0.18.1 + version: 0.18.1(prettier@3.3.3) typescript: specifier: ^5.7.2 version: 5.8.2 @@ -522,7 +528,7 @@ importers: version: 0.15.0 next: specifier: 14.2.3 - version: 14.2.3(@babel/core@7.26.10)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.85.0) + version: 14.2.3(@babel/core@7.26.10)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.85.0) react: specifier: 18.2.0 version: 18.2.0 @@ -11336,6 +11342,9 @@ packages: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} + discontinuous-range@1.0.0: + resolution: {integrity: sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==} + dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} @@ -12524,6 +12533,10 @@ packages: find-root@1.1.0: resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==} + find-up-simple@1.0.1: + resolution: {integrity: sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==} + engines: {node: '>=18'} + find-up@2.1.0: resolution: {integrity: sha512-NWzkk0jSJtTt08+FBFMvXoeZnOJD+jTtsRmBYbAIzJdX6l7dLgR7CTubCM5/eDdPUBvLCeVasP1brfVR/9/EZQ==} engines: {node: '>=4'} @@ -14206,6 +14219,10 @@ packages: resolution: {integrity: sha512-tuGfYXxkQGDPnLJ7SibiQgVgeDgfbPq2k2ICcbgqW8WxWLBAxKQM/ZCu/IT8SOSwmaYl4dpTFCW5xZv7YbbWUw==} engines: {node: '>=12', npm: '>=6'} + jsox@1.2.123: + resolution: {integrity: sha512-LYordXJ/0Q4G8pUE1Pvh4fkfGvZY7lRe4WIJKl0wr0rtFDVw9lcdNW95GH0DceJ6E9xh41zJNW0vreEz7xOxCw==} + hasBin: true + jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -15057,6 +15074,9 @@ packages: engines: {node: '>=18.18'} hasBin: true + micro-memoize@4.1.3: + resolution: {integrity: sha512-DzRMi8smUZXT7rCGikRwldEh6eO6qzKiPPopcr1+2EY3AYKpy5fu159PKWwIS9A6IWnrvPKDMcuFtyrroZa8Bw==} + micromark-core-commonmark@2.0.1: resolution: {integrity: sha512-CUQyKr1e///ZODyD1U3xit6zXwy1a8q2a1S1HKtIlmgvurrEpaw/Y9y6KSIbF8P59cn/NjzHyO+Q2fAyYLQrAA==} @@ -15388,6 +15408,9 @@ packages: moment@2.30.1: resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==} + moo@0.5.2: + resolution: {integrity: sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==} + moti@0.25.4: resolution: {integrity: sha512-UiH0WcWzUYlUmo8Y1F+iyVW+qVVZ3YkXO3Q/gQUZzOhje6+Q0MdllYfqKW2m5IhFs+Vxt2i+osjvWBxXKK2yOw==} peerDependencies: @@ -15499,6 +15522,10 @@ packages: engines: {node: '>=10'} hasBin: true + nearley@2.20.1: + resolution: {integrity: sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==} + hasBin: true + needle@3.3.1: resolution: {integrity: sha512-6k0YULvhpw+RoLNiQCRKOl09Rv1dPLr8hHnVjHqdolKwDrdNyk+Hmrthi4lIGPPz3r39dLx0hsF5s40sZ3Us4Q==} engines: {node: '>= 4.4.x'} @@ -15651,6 +15678,10 @@ packages: node-rsa@1.1.1: resolution: {integrity: sha512-Jd4cvbJMryN21r5HgxQOpMEqv+ooke/korixNNK3mGqfGJmy0M77WDDzo/05969+OkMy3XW1UuZsSmW9KQm7Fw==} + node-sql-parser@4.18.0: + resolution: {integrity: sha512-2YEOR5qlI1zUFbGMLKNfsrR5JUvFg9LxIRVE+xJe962pfVLH0rnItqLzv96XVs1Y1UIR8FxsXAuvX/lYAWZ2BQ==} + engines: {node: '>=8'} + node-stream-zip@1.15.0: resolution: {integrity: sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw==} engines: {node: '>=0.12.0'} @@ -16041,6 +16072,10 @@ packages: resolution: {integrity: sha512-cbH9IAIJHNj9uXi196JVsRlt7cHKak6u/e6AkL/bkRelZ7rlL3X1YKxsZwa36xipOEKAsdtmaG6aAJoM1fx2zA==} engines: {node: '>=14.16'} + package-up@5.0.0: + resolution: {integrity: sha512-MQEgDUvXCa3sGvqHg3pzHO8e9gqTCMPVrWUko3vPQGntwegmFo52mZb2abIVTjFnUcW0BcPz0D93jV5Cas1DWA==} + engines: {node: '>=18'} + pacote@20.0.0: resolution: {integrity: sha512-pRjC5UFwZCgx9kUFDVM9YEahv4guZ1nSLqwmWiLUnDbGsjs+U5w7z6Uc8HNR1a6x8qnu5y9xtGE6D1uAuYz+0A==} engines: {node: ^18.17.0 || >=20.5.0} @@ -16785,6 +16820,15 @@ packages: resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==} engines: {node: '>=6.0.0'} + prettier-plugin-embed@0.4.15: + resolution: {integrity: sha512-9pZVIp3bw2jw+Ge+iAMZ4j+sIVC9cPruZ93H2tj5Wa/3YDFDJ/uYyVWdUGfcFUnv28drhW2Bmome9xSGXsPKOw==} + + prettier-plugin-sql@0.18.1: + resolution: {integrity: sha512-2+Nob2sg7hzLAKJoE6sfgtkhBZCqOzrWHZPvE4Kee/e80oOyI4qwy9vypeltqNBJwTtq3uiKPrCxlT03bBpOaw==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + prettier: ^3.0.3 + prettier@2.8.8: resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} engines: {node: '>=10.13.0'} @@ -17047,6 +17091,13 @@ packages: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} + railroad-diagrams@1.0.0: + resolution: {integrity: sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==} + + randexp@0.4.6: + resolution: {integrity: sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==} + engines: {node: '>=0.12'} + randombytes@2.1.0: resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} @@ -17844,6 +17895,10 @@ packages: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} + ret@0.1.15: + resolution: {integrity: sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==} + engines: {node: '>=0.12'} + retry@0.12.0: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} @@ -18436,6 +18491,10 @@ packages: sprintf-js@1.1.3: resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + sql-formatter@15.6.2: + resolution: {integrity: sha512-ZjqOfJGuB97UeHzTJoTbadlM0h9ynehtSTHNUbGfXR4HZ4rCIoD2oIW91W+A5oE76k8hl0Uz5GD8Sx3Pt9Xa3w==} + hasBin: true + srcset@4.0.0: resolution: {integrity: sha512-wvLeHgcVHKO8Sc/H/5lkGreJQVeYMm9rlmt8PuR1xE31rIuXhuzznUUqAt8MqLhB3MqJdFzlNAfpcWnxiFUcPw==} engines: {node: '>=12'} @@ -18948,6 +19007,9 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tiny-jsonc@1.0.2: + resolution: {integrity: sha512-f5QDAfLq6zIVSyCZQZhhyl0QS6MvAyTxgz4X4x3+EoCktNWEYJ6PeoEA97fyb98njpBNNi88ybpD7m+BDFXaCw==} + tiny-warning@1.0.3: resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} @@ -34151,6 +34213,8 @@ snapshots: dependencies: path-type: 4.0.0 + discontinuous-range@1.0.0: {} + dlv@1.1.3: {} dns-packet@5.6.1: @@ -36009,6 +36073,8 @@ snapshots: find-root@1.1.0: {} + find-up-simple@1.0.1: {} + find-up@2.1.0: dependencies: locate-path: 2.0.0 @@ -38416,6 +38482,8 @@ snapshots: ms: 2.1.3 semver: 7.7.1 + jsox@1.2.123: {} + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.8 @@ -39870,6 +39938,8 @@ snapshots: - supports-color - utf-8-validate + micro-memoize@4.1.3: {} + micromark-core-commonmark@2.0.1: dependencies: decode-named-character-reference: 1.0.2 @@ -40358,6 +40428,8 @@ snapshots: moment@2.30.1: optional: true + moo@0.5.2: {} + moti@0.25.4(react-dom@18.3.1(react@18.3.1))(react-native-reanimated@3.16.7(@babel/core@7.26.10)(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react@18.3.1): dependencies: framer-motion: 6.5.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -40494,6 +40566,13 @@ snapshots: split2: 3.2.2 through2: 4.0.2 + nearley@2.20.1: + dependencies: + commander: 2.20.3 + moo: 0.5.2 + railroad-diagrams: 1.0.0 + randexp: 0.4.6 + needle@3.3.1: dependencies: iconv-lite: 0.6.3 @@ -40510,7 +40589,7 @@ snapshots: netmask@2.0.2: {} - next@14.2.3(@babel/core@7.26.10)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.85.0): + next@14.2.3(@babel/core@7.26.10)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.85.0): dependencies: '@next/env': 14.2.3 '@swc/helpers': 0.5.5 @@ -40520,7 +40599,7 @@ snapshots: postcss: 8.4.31 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - styled-jsx: 5.1.1(@babel/core@7.26.10)(react@18.2.0) + styled-jsx: 5.1.1(@babel/core@7.26.10)(babel-plugin-macros@3.1.0)(react@18.2.0) optionalDependencies: '@next/swc-darwin-arm64': 14.2.3 '@next/swc-darwin-x64': 14.2.3 @@ -40660,6 +40739,10 @@ snapshots: dependencies: asn1: 0.2.6 + node-sql-parser@4.18.0: + dependencies: + big-integer: 1.6.52 + node-stream-zip@1.15.0: {} nopt@6.0.0: @@ -41074,6 +41157,10 @@ snapshots: registry-url: 6.0.1 semver: 7.7.1 + package-up@5.0.0: + dependencies: + find-up-simple: 1.0.1 + pacote@20.0.0: dependencies: '@npmcli/git': 6.0.3 @@ -41904,6 +41991,25 @@ snapshots: dependencies: fast-diff: 1.3.0 + prettier-plugin-embed@0.4.15(babel-plugin-macros@3.1.0): + dependencies: + '@types/estree': 1.0.6 + dedent: 1.5.3(babel-plugin-macros@3.1.0) + micro-memoize: 4.1.3 + package-up: 5.0.0 + tiny-jsonc: 1.0.2 + type-fest: 4.41.0 + transitivePeerDependencies: + - babel-plugin-macros + + prettier-plugin-sql@0.18.1(prettier@3.3.3): + dependencies: + jsox: 1.2.123 + node-sql-parser: 4.18.0 + prettier: 3.3.3 + sql-formatter: 15.6.2 + tslib: 2.8.1 + prettier@2.8.8: {} prettier@3.3.3: {} @@ -42201,6 +42307,13 @@ snapshots: quick-lru@5.1.1: {} + railroad-diagrams@1.0.0: {} + + randexp@0.4.6: + dependencies: + discontinuous-range: 1.0.0 + ret: 0.1.15 + randombytes@2.1.0: dependencies: safe-buffer: 5.2.1 @@ -43589,6 +43702,8 @@ snapshots: onetime: 7.0.0 signal-exit: 4.1.0 + ret@0.1.15: {} + retry@0.12.0: {} retry@0.13.1: {} @@ -44300,6 +44415,11 @@ snapshots: sprintf-js@1.1.3: {} + sql-formatter@15.6.2: + dependencies: + argparse: 2.0.1 + nearley: 2.20.1 + srcset@4.0.0: {} ssri@10.0.6: @@ -44554,12 +44674,13 @@ snapshots: hey-listen: 1.0.8 tslib: 2.8.1 - styled-jsx@5.1.1(@babel/core@7.26.10)(react@18.2.0): + styled-jsx@5.1.1(@babel/core@7.26.10)(babel-plugin-macros@3.1.0)(react@18.2.0): dependencies: client-only: 0.0.1 react: 18.2.0 optionalDependencies: '@babel/core': 7.26.10 + babel-plugin-macros: 3.1.0 stylehacks@6.1.1(postcss@8.5.3): dependencies: @@ -45008,6 +45129,8 @@ snapshots: tiny-invariant@1.3.3: {} + tiny-jsonc@1.0.2: {} + tiny-warning@1.0.3: {} tinybench@2.9.0: {} From 603665229421caa9e52f6cc93a656ceaf44d2d6e Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 3 Jun 2025 09:39:09 +0200 Subject: [PATCH 32/75] update vue unit tests to use an actual DB --- .../src/client/AbstractPowerSyncDatabase.ts | 2 +- packages/react/tests/useQuery.test.tsx | 1 + packages/vue/package.json | 1 + packages/vue/src/composables/useQuery.ts | 132 +------------ .../vue/src/composables/useSingleQuery.ts | 118 +++++++++++ .../vue/src/composables/useWatchedQuery.ts | 99 ++++++++++ packages/vue/tests/useQuery.test.ts | 187 +++++++++++------- packages/vue/tests/useStatus.test.ts | 56 +++--- packages/vue/tests/utils.ts | 3 +- packages/vue/vitest.config.ts | 38 +++- pnpm-lock.yaml | 12 +- 11 files changed, 426 insertions(+), 223 deletions(-) create mode 100644 packages/vue/src/composables/useSingleQuery.ts create mode 100644 packages/vue/src/composables/useWatchedQuery.ts diff --git a/packages/common/src/client/AbstractPowerSyncDatabase.ts b/packages/common/src/client/AbstractPowerSyncDatabase.ts index 11b3b1a09..5863cbe9f 100644 --- a/packages/common/src/client/AbstractPowerSyncDatabase.ts +++ b/packages/common/src/client/AbstractPowerSyncDatabase.ts @@ -884,7 +884,7 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver(options: IncrementalWatchOptions): WatchedQuery { const { watch, processor } = options; diff --git a/packages/react/tests/useQuery.test.tsx b/packages/react/tests/useQuery.test.tsx index 8b6dac146..377064e4a 100644 --- a/packages/react/tests/useQuery.test.tsx +++ b/packages/react/tests/useQuery.test.tsx @@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'; import { PowerSyncContext } from '../src/hooks/PowerSyncContext'; import { useQuery } from '../src/hooks/watched/useQuery'; import { useWatchedQuerySubscription } from '../src/hooks/watched/useWatchedQuerySubscription'; + export const openPowerSync = () => { const db = new PowerSyncDatabase({ database: { dbFilename: 'test.db' }, diff --git a/packages/vue/package.json b/packages/vue/package.json index 82cd941c5..7f4047665 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -34,6 +34,7 @@ }, "devDependencies": { "@powersync/common": "workspace:*", + "@powersync/web": "workspace:*", "flush-promises": "^1.0.2", "jsdom": "^24.0.0", "vue": "3.4.21" diff --git a/packages/vue/src/composables/useQuery.ts b/packages/vue/src/composables/useQuery.ts index 2a5d32f25..cbf5353b6 100644 --- a/packages/vue/src/composables/useQuery.ts +++ b/packages/vue/src/composables/useQuery.ts @@ -1,16 +1,7 @@ -import { - type CompilableQuery, - ParsedQuery, - type SQLWatchOptions, - parseQuery, - runOnSchemaChange -} from '@powersync/common'; -import { type MaybeRef, type Ref, ref, toValue, watchEffect } from 'vue'; -import { usePowerSync } from './powerSync'; - -interface AdditionalOptions extends Omit { - runQueryOnce?: boolean; -} +import { type CompilableQuery } from '@powersync/common'; +import { type MaybeRef, type Ref } from 'vue'; +import { AdditionalOptions, useSingleQuery } from './useSingleQuery'; +import { useWatchedQuery } from './useWatchedQuery'; export type WatchedQueryResult = { data: Ref; @@ -54,115 +45,12 @@ export type WatchedQueryResult = { export const useQuery = ( query: MaybeRef>, sqlParameters: MaybeRef = [], - options: AdditionalOptions = {} + options: AdditionalOptions = {} ): WatchedQueryResult => { - const data = ref([]) as Ref; - const error = ref(undefined); - const isLoading = ref(true); - const isFetching = ref(true); - - // Only defined when the query and parameters are successfully parsed and tables are resolved - let fetchData: () => Promise | undefined; - - const powerSync = usePowerSync(); - const logger = powerSync?.value?.logger ?? console; - - const finishLoading = () => { - isLoading.value = false; - isFetching.value = false; - }; - - if (!powerSync) { - finishLoading(); - error.value = new Error('PowerSync not configured.'); - return { data, isLoading, isFetching, error }; + switch (true) { + case options.runQueryOnce: + return useSingleQuery(query, sqlParameters, options); + default: + return useWatchedQuery(query, sqlParameters, options); } - - const handleResult = (result: T[]) => { - finishLoading(); - data.value = result; - error.value = undefined; - }; - - const handleError = (e: Error) => { - fetchData = undefined; - finishLoading(); - data.value = []; - - const wrappedError = new Error('PowerSync failed to fetch data: ' + e.message); - wrappedError.cause = e; - error.value = wrappedError; - }; - - const _fetchData = async (executor: () => Promise) => { - isFetching.value = true; - try { - const result = await executor(); - handleResult(result); - } catch (e) { - logger.error('Failed to fetch data:', e); - handleError(e); - } - }; - - watchEffect(async (onCleanup) => { - const abortController = new AbortController(); - // Abort any previous watches when the effect triggers again, or when the component is unmounted - onCleanup(() => abortController.abort()); - - let parsedQuery: ParsedQuery; - const queryValue = toValue(query); - try { - parsedQuery = parseQuery(queryValue, toValue(sqlParameters).map(toValue)); - } catch (e) { - logger.error('Failed to parse query:', e); - handleError(e); - return; - } - - const { sqlStatement: sql, parameters } = parsedQuery; - const watchQuery = async (abortSignal: AbortSignal) => { - let resolvedTables = []; - try { - resolvedTables = await powerSync.value.resolveTables(sql, parameters, options); - } catch (e) { - logger.error('Failed to fetch tables:', e); - handleError(e); - return; - } - // Fetch initial data - const executor = - typeof queryValue == 'string' ? () => powerSync.value.getAll(sql, parameters) : () => queryValue.execute(); - fetchData = () => _fetchData(executor); - await fetchData(); - - if (options.runQueryOnce) { - return; - } - - powerSync.value.onChangeWithCallback( - { - onChange: async () => { - await fetchData(); - }, - onError: handleError - }, - { - ...options, - signal: abortSignal, - tables: resolvedTables - } - ); - }; - - runOnSchemaChange(watchQuery, powerSync.value, { signal: abortController.signal }); - }); - - return { - data, - isLoading, - isFetching, - error, - refresh: () => fetchData?.() - }; }; diff --git a/packages/vue/src/composables/useSingleQuery.ts b/packages/vue/src/composables/useSingleQuery.ts new file mode 100644 index 000000000..a80f310fe --- /dev/null +++ b/packages/vue/src/composables/useSingleQuery.ts @@ -0,0 +1,118 @@ +import { + type CompilableQuery, + ParsedQuery, + type SQLWatchOptions, + WatchProcessorOptions, + parseQuery +} from '@powersync/common'; +import { type MaybeRef, type Ref, ref, toValue, watchEffect } from 'vue'; +import { usePowerSync } from './powerSync'; + +export interface AdditionalOptions extends Omit { + runQueryOnce?: boolean; + processor?: WatchProcessorOptions; +} + +export type WatchedQueryResult = { + data: Ref; + /** + * Indicates the initial loading state (hard loading). Loading becomes false once the first set of results from the watched query is available or an error occurs. + */ + isLoading: Ref; + /** + * Indicates whether the query is currently fetching data, is true during the initial load and any time when the query is re-evaluating (useful for large queries). + */ + isFetching: Ref; + error: Ref; + /** + * Function used to run the query again. + */ + refresh?: () => Promise; +}; + +export const useSingleQuery = ( + query: MaybeRef>, + sqlParameters: MaybeRef = [], + options: AdditionalOptions = {} +): WatchedQueryResult => { + const data = ref([]) as Ref; + const error = ref(undefined); + const isLoading = ref(true); + const isFetching = ref(true); + + // Only defined when the query and parameters are successfully parsed and tables are resolved + let fetchData: () => Promise | undefined; + + const powerSync = usePowerSync(); + const logger = powerSync?.value?.logger ?? console; + + const finishLoading = () => { + isLoading.value = false; + isFetching.value = false; + }; + + if (!powerSync || !powerSync.value) { + finishLoading(); + error.value = new Error('PowerSync not configured.'); + return { data, isLoading, isFetching, error }; + } + + const handleResult = (result: T[]) => { + finishLoading(); + data.value = result; + error.value = undefined; + }; + + const handleError = (e: Error) => { + fetchData = undefined; + finishLoading(); + data.value = []; + + const wrappedError = new Error('PowerSync failed to fetch data: ' + e.message); + wrappedError.cause = e; + error.value = wrappedError; + }; + + watchEffect(async (onCleanup) => { + const abortController = new AbortController(); + // Abort any previous watches when the effect triggers again, or when the component is unmounted + onCleanup(() => abortController.abort()); + + let parsedQuery: ParsedQuery; + const queryValue = toValue(query); + try { + parsedQuery = parseQuery(queryValue, toValue(sqlParameters).map(toValue)); + } catch (e) { + logger.error('Failed to parse query:', e); + handleError(e); + return; + } + + const { sqlStatement: sql, parameters } = parsedQuery; + // Fetch initial data + const executor = + typeof queryValue == 'string' ? () => powerSync.value.getAll(sql, parameters) : () => queryValue.execute(); + + fetchData = async () => { + isFetching.value = true; + try { + const result = await executor(); + handleResult(result); + } catch (e) { + logger.error('Failed to fetch data:', e); + handleError(e); + } + }; + + // fetch initial data + await fetchData(); + }); + + return { + data, + isLoading, + isFetching, + error, + refresh: () => fetchData?.() + }; +}; diff --git a/packages/vue/src/composables/useWatchedQuery.ts b/packages/vue/src/composables/useWatchedQuery.ts new file mode 100644 index 000000000..b2a4e7218 --- /dev/null +++ b/packages/vue/src/composables/useWatchedQuery.ts @@ -0,0 +1,99 @@ +import { type CompilableQuery, FalsyComparator, ParsedQuery, parseQuery } from '@powersync/common'; +import { type MaybeRef, type Ref, ref, toValue, watchEffect } from 'vue'; +import { usePowerSync } from './powerSync'; +import { AdditionalOptions, WatchedQueryResult } from './useSingleQuery'; + +export const useWatchedQuery = ( + query: MaybeRef>, + sqlParameters: MaybeRef = [], + options: AdditionalOptions = {} +): WatchedQueryResult => { + const data = ref([]) as Ref; + const error = ref(undefined); + const isLoading = ref(true); + const isFetching = ref(true); + + const powerSync = usePowerSync(); + const logger = powerSync?.value?.logger ?? console; + + const finishLoading = () => { + isLoading.value = false; + isFetching.value = false; + }; + + if (!powerSync || !powerSync.value) { + finishLoading(); + error.value = new Error('PowerSync not configured.'); + return { data, isLoading, isFetching, error }; + } + + const handleError = (e: Error) => { + finishLoading(); + data.value = []; + + const wrappedError = new Error('PowerSync failed to fetch data: ' + e.message); + wrappedError.cause = e; + error.value = wrappedError; + }; + + watchEffect(async (onCleanup) => { + let parsedQuery: ParsedQuery; + const queryValue = toValue(query); + try { + parsedQuery = parseQuery(queryValue, toValue(sqlParameters).map(toValue)); + } catch (e) { + logger.error('Failed to parse query:', e); + handleError(e); + return; + } + + const { sqlStatement: sql, parameters } = parsedQuery; + + const watchedQuery = powerSync.value.incrementalWatch({ + watch: { + placeholderData: [], + query: { + compile: () => ({ sql, parameters }), + execute: async ({ db, sql, parameters }) => { + if (typeof queryValue === 'string') { + return db.getAll(sql, parameters); + } + return queryValue.execute(); + } + } + }, + processor: options.processor ?? { + mode: 'comparison', + // Defaults to no comparison if no processor is provided + comparator: FalsyComparator + } + }); + + const disposer = watchedQuery.subscribe({ + onStateChange: (state) => { + isLoading.value = state.isLoading; + isFetching.value = state.isFetching; + data.value = state.data; + if (state.error) { + const wrappedError = new Error('PowerSync failed to fetch data: ' + state.error.message); + wrappedError.cause = state.error; + error.value = wrappedError; + } else { + error.value = undefined; + } + } + }); + + onCleanup(() => { + disposer(); + watchedQuery.close(); + }); + }); + + return { + data, + isLoading, + isFetching, + error + }; +}; diff --git a/packages/vue/tests/useQuery.test.ts b/packages/vue/tests/useQuery.test.ts index 2a33605c1..74b0aa005 100644 --- a/packages/vue/tests/useQuery.test.ts +++ b/packages/vue/tests/useQuery.test.ts @@ -1,28 +1,51 @@ +import * as commonSdk from '@powersync/common'; +import { PowerSyncDatabase } from '@powersync/web'; import flushPromises from 'flush-promises'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, onTestFinished, vi } from 'vitest'; import { isProxy, isRef, ref } from 'vue'; -import * as PowerSync from '../src/composables/powerSync'; +import { createPowerSyncPlugin } from '../src/composables/powerSync'; import { useQuery } from '../src/composables/useQuery'; import { withSetup } from './utils'; -const mockPowerSync = { - currentStatus: { status: 'initial' }, - registerListener: vi.fn(() => {}), - resolveTables: vi.fn(), - watch: vi.fn(), - onChangeWithCallback: vi.fn(), - getAll: vi.fn(() => ['list1', 'list2']) +export const openPowerSync = () => { + const db = new PowerSyncDatabase({ + database: { dbFilename: 'test.db' }, + schema: new commonSdk.Schema({ + lists: new commonSdk.Table({ + name: commonSdk.column.text + }) + }) + }); + + onTestFinished(async () => { + await db.disconnectAndClear(); + await db.close(); + }); + + return db; }; describe('useQuery', () => { + let powersync: commonSdk.AbstractPowerSyncDatabase | null; + + beforeEach(() => { + powersync = openPowerSync(); + }); + afterEach(() => { - vi.clearAllMocks(); + vi.resetAllMocks(); }); - it('should error when PowerSync is not set', () => { - vi.spyOn(PowerSync, 'usePowerSync').mockReturnValue(undefined); + const withPowerSyncSetup = (callback: () => Result) => { + return withSetup(callback, (app) => { + const { install } = createPowerSyncPlugin({ database: powersync! }); + install(app); + }); + }; - const [{ data, isLoading, isFetching, error }] = withSetup(() => useQuery('SELECT * from lists')); + it('should error when PowerSync is not set', () => { + powersync = null; + const [{ data, isLoading, isFetching, error }] = withPowerSyncSetup(() => useQuery('SELECT * from lists')); expect(error.value?.message).toEqual('PowerSync not configured.'); expect(isFetching.value).toEqual(false); @@ -31,9 +54,9 @@ describe('useQuery', () => { }); it('should handle error in watchEffect', async () => { - vi.spyOn(PowerSync, 'usePowerSync').mockReturnValue(undefined); + powersync = null; - const [{ data, isLoading, isFetching, error }] = withSetup(() => useQuery('SELECT * from lists')); + const [{ data, isLoading, isFetching, error }] = withPowerSyncSetup(() => useQuery('SELECT * from lists')); expect(error.value).toEqual(Error('PowerSync not configured.')); expect(isFetching.value).toEqual(false); @@ -42,51 +65,65 @@ describe('useQuery', () => { }); it('should run the query once when runQueryOnce flag is set', async () => { - vi.spyOn(PowerSync, 'usePowerSync').mockReturnValue(ref(mockPowerSync) as any); - const getAllSpy = mockPowerSync.getAll; - - const [{ data, isLoading, isFetching, error }] = withSetup(() => + await powersync!.execute(/* sql */ ` + INSERT INTO + lists (id, name) + VALUES + (uuid (), 'list1'); + `); + + const [{ data, isLoading, isFetching, error }] = withPowerSyncSetup(() => useQuery('SELECT * from lists', [], { runQueryOnce: true }) ); - await flushPromises(); - expect(getAllSpy).toHaveBeenCalledTimes(1); - expect(data.value).toEqual(['list1', 'list2']); - expect(isLoading.value).toEqual(false); - expect(isFetching.value).toEqual(false); - expect(error.value).toEqual(undefined); + await vi.waitFor( + () => { + expect(data.value.map((item) => item.name)).toEqual(['list1']); + expect(isLoading.value).toEqual(false); + expect(isFetching.value).toEqual(false); + expect(error.value).toEqual(undefined); + }, + { timeout: 1000 } + ); }); // ensure that Proxy wrapper object is stripped it('should propagate raw reactive sql parameters', async () => { - vi.spyOn(PowerSync, 'usePowerSync').mockReturnValue(ref(mockPowerSync) as any); - const getAllSpy = mockPowerSync.getAll; + const getAllSpy = vi.spyOn(powersync!, 'getAll'); - const [{ data, isLoading, isFetching, error }] = withSetup(() => + const [{ data, isLoading, isFetching, error }] = withPowerSyncSetup(() => useQuery('SELECT * from lists where id = $1', ref([ref('test')])) ); - await flushPromises(); - expect(getAllSpy).toHaveBeenCalledTimes(1); - const sqlParam = (getAllSpy.mock.calls[0] as Array)[1]; - expect(isRef(sqlParam)).toEqual(false); - expect(isProxy(sqlParam)).toEqual(false); + + await vi.waitFor( + () => { + expect(getAllSpy).toHaveBeenCalledTimes(3); + const sqlParam = (getAllSpy.mock.calls[2] as Array)[1]; + expect(isRef(sqlParam)).toEqual(false); + expect(isProxy(sqlParam)).toEqual(false); + }, + { timeout: 1000 } + ); }); it('should rerun the query when refresh is used', async () => { - vi.spyOn(PowerSync, 'usePowerSync').mockReturnValue(ref(mockPowerSync) as any); - const getAllSpy = mockPowerSync.getAll; + const getAllSpy = vi.spyOn(powersync!, 'getAll'); - const [{ isLoading, isFetching, refresh }] = withSetup(() => + const [{ isLoading, isFetching, refresh }] = withPowerSyncSetup(() => useQuery('SELECT * from lists', [], { runQueryOnce: true }) ); expect(isFetching.value).toEqual(true); expect(isLoading.value).toEqual(true); - await flushPromises(); - expect(isFetching.value).toEqual(false); - expect(isLoading.value).toEqual(false); + await vi.waitFor( + () => { + expect(isFetching.value).toEqual(false); + expect(isLoading.value).toEqual(false); + }, + { timeout: 1000 } + ); - expect(getAllSpy).toHaveBeenCalledTimes(1); + const callCount = getAllSpy.mock.calls.length; const refreshPromise = refresh?.(); expect(isFetching.value).toEqual(true); @@ -95,71 +132,71 @@ describe('useQuery', () => { await refreshPromise; expect(isFetching.value).toEqual(false); - expect(getAllSpy).toHaveBeenCalledTimes(2); + expect(getAllSpy).toHaveBeenCalledTimes(callCount + 1); }); it('should set error when error occurs and runQueryOnce flag is set', async () => { - const mockPowerSyncError = { - ...mockPowerSync, - getAll: vi.fn(() => { - throw new Error('some error'); - }) - }; - vi.spyOn(PowerSync, 'usePowerSync').mockReturnValue(ref(mockPowerSyncError) as any); + vi.spyOn(powersync!, 'getAll').mockImplementation(() => { + throw new Error('some error'); + }); - const [{ error }] = withSetup(() => useQuery('SELECT * from lists', [], { runQueryOnce: true })); + const [{ error }] = withPowerSyncSetup(() => useQuery('SELECT * from lists', [], { runQueryOnce: true })); await flushPromises(); expect(error.value?.message).toEqual('PowerSync failed to fetch data: some error'); }); it('should set error when error occurs', async () => { - const mockPowerSyncError = { - ...mockPowerSync, - getAll: vi.fn(() => { - throw new Error('some error'); - }) - }; - vi.spyOn(PowerSync, 'usePowerSync').mockReturnValue(ref(mockPowerSyncError) as any); - - const [{ error }] = withSetup(() => useQuery('SELECT * from lists', [])); - await flushPromises(); - - expect(error.value?.message).toEqual('PowerSync failed to fetch data: some error'); + vi.spyOn(powersync!, 'getAll').mockImplementation(() => { + throw new Error('some error'); + }); + + const [{ error }] = withPowerSyncSetup(() => useQuery('SELECT * from lists', [])); + await vi.waitFor( + () => { + expect(error.value?.message).toEqual('PowerSync failed to fetch data: some error'); + }, + { timeout: 1000 } + ); }); it('should accept compilable queries', async () => { - vi.spyOn(PowerSync, 'usePowerSync').mockReturnValue(ref(mockPowerSync) as any); - - const [{ isLoading }] = withSetup(() => + const [{ isLoading }] = withPowerSyncSetup(() => useQuery({ execute: () => [] as any, compile: () => ({ sql: 'SELECT * from lists', parameters: [] }) }) ); expect(isLoading.value).toEqual(true); - await flushPromises(); - expect(isLoading.value).toEqual(false); + await vi.waitFor( + () => { + expect(isLoading.value).toEqual(false); + }, + { timeout: 1000 } + ); }); it('should execute compilable queries', async () => { - vi.spyOn(PowerSync, 'usePowerSync').mockReturnValue(ref(mockPowerSync) as any); - - const [{ isLoading, data }] = withSetup(() => + const [result] = withPowerSyncSetup(() => useQuery({ execute: () => [{ test: 'custom' }] as any, compile: () => ({ sql: 'SELECT * from lists', parameters: [] }) }) ); + const { isLoading, data } = result; + expect(isLoading.value).toEqual(true); - await flushPromises(); - expect(isLoading.value).toEqual(false); - expect(data.value[0].test).toEqual('custom'); + + await vi.waitFor( + () => { + expect(isLoading.value).toEqual(false); + expect(data.value[0].test).toEqual('custom'); + }, + { timeout: 1000 } + ); }); it('should set error for compilable query on useQuery parameters', async () => { - vi.spyOn(PowerSync, 'usePowerSync').mockReturnValue(ref(mockPowerSync) as any); - - const [{ error }] = withSetup(() => + const [{ error }] = withPowerSyncSetup(() => useQuery({ execute: () => [] as any, compile: () => ({ sql: 'SELECT * from lists', parameters: [] }) }, ['x']) ); diff --git a/packages/vue/tests/useStatus.test.ts b/packages/vue/tests/useStatus.test.ts index 8a0912da6..1516fc7fd 100644 --- a/packages/vue/tests/useStatus.test.ts +++ b/packages/vue/tests/useStatus.test.ts @@ -1,36 +1,48 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; +import * as commonSdk from '@powersync/common'; +import { PowerSyncDatabase } from '@powersync/web'; +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'; +import { createPowerSyncPlugin } from '../src/composables/powerSync'; import { useStatus } from '../src/composables/useStatus'; import { withSetup } from './utils'; -import * as PowerSync from '../src/composables/powerSync'; -import { ref } from 'vue'; -const cleanupListener = vi.fn(); +export const openPowerSync = () => { + const db = new PowerSyncDatabase({ + database: { dbFilename: 'test.db' }, + schema: new commonSdk.Schema({ + lists: new commonSdk.Table({ + name: commonSdk.column.text + }) + }) + }); + + onTestFinished(async () => { + await db.disconnectAndClear(); + await db.close(); + }); -const mockPowerSync = { - currentStatus: { connected: true }, - registerListener: () => cleanupListener + return db; }; describe('useStatus', () => { - afterEach(() => { - vi.resetAllMocks(); - }); + let powersync: commonSdk.AbstractPowerSyncDatabase | null; - it('should load the status of PowerSync', async () => { - vi.spyOn(PowerSync, 'usePowerSync').mockReturnValue(ref(mockPowerSync) as any); - - const [status] = withSetup(() => useStatus()); - expect(status.value.connected).toBe(true); + beforeEach(() => { + powersync = openPowerSync(); }); - it('should run the listener cleanup on unmount', () => { - vi.spyOn(PowerSync, 'usePowerSync').mockReturnValue(ref(mockPowerSync as any)); - - const [, app] = withSetup(() => useStatus()); - const listenerUnsubscribe = cleanupListener; + afterEach(() => { + vi.resetAllMocks(); + }); - app.unmount(); + const withPowerSyncSetup = (callback: () => Result) => { + return withSetup(callback, (app) => { + const { install } = createPowerSyncPlugin({ database: powersync! }); + install(app); + }); + }; - expect(listenerUnsubscribe).toHaveBeenCalled(); + it('should load the status of PowerSync', async () => { + const [status] = withPowerSyncSetup(() => useStatus()); + expect(status.value.connected).toBe(false); }); }); diff --git a/packages/vue/tests/utils.ts b/packages/vue/tests/utils.ts index 5e3c20bf2..7761fc5b7 100644 --- a/packages/vue/tests/utils.ts +++ b/packages/vue/tests/utils.ts @@ -1,10 +1,11 @@ import type { App } from 'vue'; import { createApp } from 'vue'; -export function withSetup(composable: () => T): [T, App] { +export function withSetup(composable: () => T, provide?: (app: App) => void): [T, App] { let result: T; const app = createApp({ setup() { + provide?.(app); result = composable(); return () => {}; } diff --git a/packages/vue/vitest.config.ts b/packages/vue/vitest.config.ts index f96ac5630..b8841cf03 100644 --- a/packages/vue/vitest.config.ts +++ b/packages/vue/vitest.config.ts @@ -1,8 +1,44 @@ import { defineConfig, UserConfigExport } from 'vitest/config'; +import topLevelAwait from 'vite-plugin-top-level-await'; +import wasm from 'vite-plugin-wasm'; + const config: UserConfigExport = { + // This is only needed for local tests to resolve the package name correctly + worker: { + format: 'es', + plugins: () => [wasm(), topLevelAwait()] + }, + optimizeDeps: { + // Don't optimise these packages as they contain web workers and WASM files. + // https://github.com/vitejs/vite/issues/11672#issuecomment-1415820673 + exclude: ['@journeyapps/wa-sqlite', '@powersync/web'], + include: ['bson'] + }, + plugins: [wasm(), topLevelAwait()], test: { - environment: 'jsdom' + globals: true, + include: ['tests/**/*.test.ts'], + maxConcurrency: 1, + // This doesn't currently seem to work in browser mode, but setting this for one day when it does + sequence: { + shuffle: false, // Disable shuffling of test files + concurrent: false // Run test files sequentially + }, + browser: { + enabled: true, + /** + * Starts each test in a new iFrame + */ + isolate: true, + provider: 'playwright', + headless: false, + instances: [ + { + browser: 'chromium' + } + ] + } } }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index effdf8d1b..a9c2170d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -398,7 +398,7 @@ importers: version: 10.4.20(postcss@8.5.3) babel-loader: specifier: ^9.1.3 - version: 9.2.1(@babel/core@7.26.10)(webpack@5.98.0(@swc/core@1.6.13(@swc/helpers@0.5.5))) + version: 9.2.1(@babel/core@7.26.10)(webpack@5.95.0(@swc/core@1.6.13(@swc/helpers@0.5.5))) electron: specifier: 30.0.2 version: 30.0.2 @@ -1982,6 +1982,9 @@ importers: '@powersync/common': specifier: workspace:* version: link:../common + '@powersync/web': + specifier: workspace:* + version: link:../web flush-promises: specifier: ^1.0.2 version: 1.0.2 @@ -32284,6 +32287,13 @@ snapshots: transitivePeerDependencies: - supports-color + babel-loader@9.2.1(@babel/core@7.26.10)(webpack@5.95.0(@swc/core@1.6.13(@swc/helpers@0.5.5))): + dependencies: + '@babel/core': 7.26.10 + find-cache-dir: 4.0.0 + schema-utils: 4.2.0 + webpack: 5.95.0(@swc/core@1.6.13(@swc/helpers@0.5.5)) + babel-loader@9.2.1(@babel/core@7.26.10)(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))): dependencies: '@babel/core': 7.26.10 From c930e0e126b04d6a6feb2decb084a8c3a96749d2 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 3 Jun 2025 11:03:28 +0200 Subject: [PATCH 33/75] fix unit tests. Improve closing behaviour. --- .changeset/little-bananas-fetch.md | 5 ++ .changeset/stale-dots-jog.md | 5 ++ .../src/client/AbstractPowerSyncDatabase.ts | 6 ++- .../processors/AbstractQueryProcessor.ts | 4 +- .../processors/OnChangeQueryProcessor.ts | 36 ++----------- packages/react/vitest.config.ts | 2 +- packages/vue/vitest.config.ts | 2 +- .../db/adapters/LockedAsyncDatabaseAdapter.ts | 52 ++++++++++++++++--- packages/web/tests/watch.test.ts | 2 +- packages/web/vitest.config.ts | 2 +- 10 files changed, 70 insertions(+), 46 deletions(-) create mode 100644 .changeset/little-bananas-fetch.md create mode 100644 .changeset/stale-dots-jog.md diff --git a/.changeset/little-bananas-fetch.md b/.changeset/little-bananas-fetch.md new file mode 100644 index 000000000..e6d33e211 --- /dev/null +++ b/.changeset/little-bananas-fetch.md @@ -0,0 +1,5 @@ +--- +'@powersync/common': minor +--- + +Added additional listeners for `closing` and `closed` events in `AbstractPowerSyncDatabase`. diff --git a/.changeset/stale-dots-jog.md b/.changeset/stale-dots-jog.md new file mode 100644 index 000000000..e1ed00c4b --- /dev/null +++ b/.changeset/stale-dots-jog.md @@ -0,0 +1,5 @@ +--- +'@powersync/web': minor +--- + +Improved query behaviour when client is closed. Pending requests will be aborted, future requests will be rejected with an Error. Fixed read and write lock requests not respecting timeout parameter. diff --git a/packages/common/src/client/AbstractPowerSyncDatabase.ts b/packages/common/src/client/AbstractPowerSyncDatabase.ts index 5863cbe9f..0b086c5cd 100644 --- a/packages/common/src/client/AbstractPowerSyncDatabase.ts +++ b/packages/common/src/client/AbstractPowerSyncDatabase.ts @@ -125,7 +125,8 @@ export interface WatchOnChangeHandler { export interface PowerSyncDBListener extends StreamingSyncImplementationListener { initialized: () => void; schemaChanged: (schema: Schema) => void; - closing: () => void; + closing: () => Promise | void; + closed: () => Promise | void; } export interface PowerSyncCloseOptions { @@ -532,7 +533,7 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver cb.closing?.()); + await this.iterateAsyncListeners(async (cb) => cb.closing?.()); const { disconnect } = options; if (disconnect) { @@ -542,6 +543,7 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver cb.closed?.()); } /** diff --git a/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts b/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts index 3eee41157..9455af53e 100644 --- a/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts +++ b/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts @@ -119,8 +119,8 @@ export abstract class AbstractQueryProcessor const { db } = this.options; const disposeCloseListener = db.registerListener({ - closed: async () => { - this.close(); + closing: async () => { + await this.close(); } }); diff --git a/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts b/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts index 500164aeb..520f58808 100644 --- a/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts +++ b/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts @@ -12,37 +12,6 @@ export interface OnChangeQueryProcessorOptions extends AbstractQueryProces comparator?: WatchedQueryComparator; } -/** - * @internal - */ -export class ArrayComparator implements WatchedQueryComparator { - constructor(protected compareBy: (element: Element) => string) {} - - checkEquality(current: Element[], previous: Element[]) { - if (current.length == 0 && previous.length == 0) { - return true; - } - - if (current.length !== previous.length) { - return false; - } - - const { compareBy } = this; - - // At this point the lengths are equal - for (let i = 0; i < current.length; i++) { - const currentItem = compareBy(current[i]); - const previousItem = compareBy(previous[i]); - - if (currentItem !== previousItem) { - return false; - } - } - - return true; - } -} - /** * Uses the PowerSync onChange event to trigger watched queries. * Results are emitted on every change of the relevant tables. @@ -71,9 +40,12 @@ export class OnChangeQueryProcessor extends AbstractQueryProcessor { db.onChangeWithCallback( { onChange: async () => { + if (this.closed) { + return; + } // This fires for each change of the relevant tables try { - if (this.reportFetching) { + if (this.reportFetching && !this.state.isFetching) { await this.updateState({ isFetching: true }); } diff --git a/packages/react/vitest.config.ts b/packages/react/vitest.config.ts index f734d8f1f..bf0bd4de1 100644 --- a/packages/react/vitest.config.ts +++ b/packages/react/vitest.config.ts @@ -32,7 +32,7 @@ const config: UserConfigExport = { */ isolate: true, provider: 'playwright', - headless: false, + headless: true, instances: [ { browser: 'chromium' diff --git a/packages/vue/vitest.config.ts b/packages/vue/vitest.config.ts index b8841cf03..80323a05c 100644 --- a/packages/vue/vitest.config.ts +++ b/packages/vue/vitest.config.ts @@ -32,7 +32,7 @@ const config: UserConfigExport = { */ isolate: true, provider: 'playwright', - headless: false, + headless: true, instances: [ { browser: 'chromium' diff --git a/packages/web/src/db/adapters/LockedAsyncDatabaseAdapter.ts b/packages/web/src/db/adapters/LockedAsyncDatabaseAdapter.ts index bd41da199..bcdbfadde 100644 --- a/packages/web/src/db/adapters/LockedAsyncDatabaseAdapter.ts +++ b/packages/web/src/db/adapters/LockedAsyncDatabaseAdapter.ts @@ -47,11 +47,18 @@ export class LockedAsyncDatabaseAdapter private _db: AsyncDatabaseConnection | null = null; protected _disposeTableChangeListener: (() => void) | null = null; private _config: ResolvedWebSQLOpenOptions | null = null; + protected pendingAbortControllers: Set; + + closing: boolean; + closed: boolean; constructor(protected options: LockedAsyncDatabaseAdapterOptions) { super(); this._dbIdentifier = options.name; this.logger = options.logger ?? createLogger(`LockedAsyncDatabaseAdapter - ${this._dbIdentifier}`); + this.pendingAbortControllers = new Set(); + this.closed = false; + this.closing = false; // Set the name if provided. We can query for the name if not available yet this.debugMode = options.debugMode ?? false; if (this.debugMode) { @@ -154,8 +161,11 @@ export class LockedAsyncDatabaseAdapter * tabs are still using it. */ async close() { + this.closing = true; this._disposeTableChangeListener?.(); + this.pendingAbortControllers.forEach((controller) => controller.abort('Closed')); await this.baseDB?.close?.(); + this.closed = true; } async getAll(sql: string, parameters?: any[] | undefined): Promise { @@ -175,20 +185,49 @@ export class LockedAsyncDatabaseAdapter async readLock(fn: (tx: LockContext) => Promise, options?: DBLockOptions | undefined): Promise { await this.waitForInitialized(); - return this.acquireLock(async () => - fn(this.generateDBHelpers({ execute: this._execute, executeRaw: this._executeRaw })) + return this.acquireLock( + async () => fn(this.generateDBHelpers({ execute: this._execute, executeRaw: this._executeRaw })), + { + timeoutMs: options?.timeoutMs + } ); } async writeLock(fn: (tx: LockContext) => Promise, options?: DBLockOptions | undefined): Promise { await this.waitForInitialized(); - return this.acquireLock(async () => - fn(this.generateDBHelpers({ execute: this._execute, executeRaw: this._executeRaw })) + return this.acquireLock( + async () => fn(this.generateDBHelpers({ execute: this._execute, executeRaw: this._executeRaw })), + { + timeoutMs: options?.timeoutMs + } ); } - protected acquireLock(callback: () => Promise): Promise { - return getNavigatorLocks().request(`db-lock-${this._dbIdentifier}`, callback); + protected async acquireLock(callback: () => Promise, options?: { timeoutMs?: number }): Promise { + await this.waitForInitialized(); + + if (this.closing) { + throw new Error(`Cannot acquire lock, the database is closing`); + } + + const abortController = new AbortController(); + this.pendingAbortControllers.add(abortController); + const { timeoutMs } = options ?? {}; + + const timoutId = timeoutMs + ? setTimeout(() => { + abortController.abort(`Timeout after ${timeoutMs}ms`); + this.pendingAbortControllers.delete(abortController); + }, timeoutMs) + : null; + + return getNavigatorLocks().request(`db-lock-${this._dbIdentifier}`, { signal: abortController.signal }, () => { + this.pendingAbortControllers.delete(abortController); + if (timoutId) { + clearTimeout(timoutId); + } + return callback(); + }); } async readTransaction(fn: (tx: Transaction) => Promise, options?: DBLockOptions | undefined): Promise { @@ -286,6 +325,7 @@ export class LockedAsyncDatabaseAdapter */ private _execute = async (sql: string, bindings?: any[]): Promise => { await this.waitForInitialized(); + const result = await this.baseDB.execute(sql, bindings); return { ...result, diff --git a/packages/web/tests/watch.test.ts b/packages/web/tests/watch.test.ts index 742faf738..4777ced30 100644 --- a/packages/web/tests/watch.test.ts +++ b/packages/web/tests/watch.test.ts @@ -346,7 +346,7 @@ describe('Watch Tests', { sequential: true }, () => { }); }); - let state = await getNextState(); + let state = watch.state; expect(state.isFetching).true; expect(state.isLoading).true; diff --git a/packages/web/vitest.config.ts b/packages/web/vitest.config.ts index 6c125b660..42f7d5e3c 100644 --- a/packages/web/vitest.config.ts +++ b/packages/web/vitest.config.ts @@ -50,7 +50,7 @@ const config: UserConfigExport = { */ isolate: true, provider: 'playwright', - headless: false, + headless: true, instances: [ { browser: 'chromium' From 328aaa864d2555dce25e337c1ab51d3e0964958e Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 3 Jun 2025 11:14:07 +0200 Subject: [PATCH 34/75] fix React tests --- packages/react/tests/useQuery.test.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/react/tests/useQuery.test.tsx b/packages/react/tests/useQuery.test.tsx index 377064e4a..2f6a1911b 100644 --- a/packages/react/tests/useQuery.test.tsx +++ b/packages/react/tests/useQuery.test.tsx @@ -2,6 +2,7 @@ import * as commonSdk from '@powersync/common'; import { PowerSyncDatabase } from '@powersync/web'; import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; import pDefer from 'p-defer'; +import React from 'react'; import { beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'; import { PowerSyncContext } from '../src/hooks/PowerSyncContext'; import { useQuery } from '../src/hooks/watched/useQuery'; @@ -41,7 +42,10 @@ describe('useQuery', () => { it('should set isLoading to true on initial load', async () => { const wrapper = ({ children }) => ( - {children} + // Placeholder use for `React` to prevent import cleanup from removing the React import + + {children} + ); const { result } = renderHook(() => useQuery('SELECT * from lists'), { wrapper }); From 642c11a295e933c9c737593d9408dc4d386e6458 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 3 Jun 2025 11:15:02 +0200 Subject: [PATCH 35/75] remove log --- packages/react/src/hooks/suspense/useSingleSuspenseQuery.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/react/src/hooks/suspense/useSingleSuspenseQuery.ts b/packages/react/src/hooks/suspense/useSingleSuspenseQuery.ts index 3137f2c51..444f94fa7 100644 --- a/packages/react/src/hooks/suspense/useSingleSuspenseQuery.ts +++ b/packages/react/src/hooks/suspense/useSingleSuspenseQuery.ts @@ -38,7 +38,6 @@ export const useSingleSuspenseQuery = ( // Only use a temporary watched query if we don't have data yet. const watchedQuery = data ? null : (store.getQuery(key, parsedQuery, options) as WatchedQuery); const { releaseHold } = useTemporaryHold(watchedQuery); - console.log('single watched query', !!watchedQuery); React.useEffect(() => { // Set the initial yielded data // it should be available once we commit the component From 4f54dab943a6465025b0b9e5f00f0fc8c4ca6110 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 3 Jun 2025 12:00:19 +0200 Subject: [PATCH 36/75] cleanup --- packages/react/README.md | 21 +++++++++++---------- packages/react/tests/useQuery.test.tsx | 2 -- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/packages/react/README.md b/packages/react/README.md index 4cf105655..a97c24e6a 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -272,7 +272,7 @@ export const TodoListDisplaySuspense = () => { }; ``` -## Preventing unecessary renders +## Preventing Unnecessary Renders The `useQuery` hook returns a stateful object which contains query fetching/loading state values and the query result set data. @@ -294,21 +294,23 @@ The returned object is a new JS object reference whenever the internal state cha function MyWidget() { // ... Widget code // result is an object which contains `isLoading`, `isFetching`, `data` members. - const result = useQuery(...) + const {data, error, isLoading} = useQuery(...) // ... Widget code return ( - // Other components - // MyWatchedWidget will rerender whenever the watched query state changes - // (MyWatchedWidget will also rerender if the result object is unchanged if it is not memoized) - + // ... Other components + + // If MyWatchedWidget is not memoized + // - It will rerender on any state change of the watched query. E.g. if isFetching alternates + // If MyWatchedWidget is memoized + // - It will re-render if the data reference changes. By default the data reference changes after any + // change to the query's dependant tables. This can be optimized by using Incremental queries. + ) } ``` -The above example is incomplete, but is required for the optimizations below. - ### Incremental Queries By default watched queries are queried whenever a change to the underlying tables has been detected. These changes might not be relevant to the actual query, but will still trigger a query and `data` update. @@ -372,7 +374,7 @@ function MyWidget() { // The `data` reference will only be changed if there have been changes since the previous value. // When reportFetching == false the object returned from useQuery will only be changed when the data, isLoading or error state changes. // This method performs a comparison in memory in order to determine changes. - const { data, isLoading, isFetching } = useQuery(`SELECT * FROM cats WHERE breed = 'tabby'`, [], { + const { data, isLoading } = useQuery(`SELECT * FROM cats WHERE breed = 'tabby'`, [], { processor: { mode: 'comparison', comparator: new ArrayComparator({ @@ -388,7 +390,6 @@ function MyWidget() { // Other components // The data array is the same reference if no changes have occurred between fetches // Note: The array is a new reference is there are any changes in the result set (individual row object references are not preserved) - // Note: CatCollection requires memoization in order to prevent re-rendering (due to the parent re-rendering on fetch) ) } diff --git a/packages/react/tests/useQuery.test.tsx b/packages/react/tests/useQuery.test.tsx index 2f6a1911b..6f59f1fde 100644 --- a/packages/react/tests/useQuery.test.tsx +++ b/packages/react/tests/useQuery.test.tsx @@ -361,6 +361,4 @@ describe('useQuery', () => { expect(newResult.current.isLoading).toEqual(false); expect(newResult.current.data.length).toEqual(1); }); - - // TODO: Add tests for powersync.onChangeWithCallback path }); From 3a6520ecdd7ee59bed6f840cbbf05aeffb00ab59 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 3 Jun 2025 12:06:16 +0200 Subject: [PATCH 37/75] use enums --- .../common/src/client/AbstractPowerSyncDatabase.ts | 10 +++++++--- packages/react/README.md | 2 +- packages/react/src/hooks/suspense/useSuspenseQuery.ts | 4 ++-- packages/react/src/hooks/watched/useQuery.ts | 4 ++-- packages/vue/src/composables/useWatchedQuery.ts | 10 ++++++++-- 5 files changed, 20 insertions(+), 10 deletions(-) diff --git a/packages/common/src/client/AbstractPowerSyncDatabase.ts b/packages/common/src/client/AbstractPowerSyncDatabase.ts index 0b086c5cd..43a969a98 100644 --- a/packages/common/src/client/AbstractPowerSyncDatabase.ts +++ b/packages/common/src/client/AbstractPowerSyncDatabase.ts @@ -72,8 +72,12 @@ export interface PowerSyncDatabaseOptionsWithSettings extends BasePowerSyncDatab database: SQLOpenOptions; } +export enum IncrementalWatchMode { + COMPARISON = 'comparison' +} + export interface WatchComparatorOptions { - mode: 'comparison'; + mode: IncrementalWatchMode.COMPARISON; comparator?: WatchedQueryComparator; } @@ -891,7 +895,7 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver JSON.stringify(cat) }) diff --git a/packages/react/src/hooks/suspense/useSuspenseQuery.ts b/packages/react/src/hooks/suspense/useSuspenseQuery.ts index 1795cec7e..bdea85693 100644 --- a/packages/react/src/hooks/suspense/useSuspenseQuery.ts +++ b/packages/react/src/hooks/suspense/useSuspenseQuery.ts @@ -1,4 +1,4 @@ -import { CompilableQuery, FalsyComparator } from '@powersync/common'; +import { CompilableQuery, FalsyComparator, IncrementalWatchMode } from '@powersync/common'; import { AdditionalOptions } from '../watched/watch-types'; import { SuspenseQueryResult } from './SuspenseQueryResult'; import { useSingleSuspenseQuery } from './useSingleSuspenseQuery'; @@ -37,7 +37,7 @@ export const useSuspenseQuery = ( return useWatchedSuspenseQuery(query, parameters, { ...options, processor: options.processor ?? { - mode: 'comparison', + mode: IncrementalWatchMode.COMPARISON, comparator: FalsyComparator // Default comparator that always reports changed result sets } }); diff --git a/packages/react/src/hooks/watched/useQuery.ts b/packages/react/src/hooks/watched/useQuery.ts index c6288711e..c01e01d7a 100644 --- a/packages/react/src/hooks/watched/useQuery.ts +++ b/packages/react/src/hooks/watched/useQuery.ts @@ -1,4 +1,4 @@ -import { FalsyComparator, type CompilableQuery } from '@powersync/common'; +import { FalsyComparator, IncrementalWatchMode, type CompilableQuery } from '@powersync/common'; import { usePowerSync } from '../PowerSyncContext'; import { useSingleQuery } from './useSingleQuery'; import { useWatchedQuery } from './useWatchedQuery'; @@ -48,7 +48,7 @@ export const useQuery = ( // Maintains backwards compatibility with previous versions // Comparisons are opt-in by default // We emit new data for each table change by default. - mode: 'comparison', + mode: IncrementalWatchMode.COMPARISON, comparator: FalsyComparator } } diff --git a/packages/vue/src/composables/useWatchedQuery.ts b/packages/vue/src/composables/useWatchedQuery.ts index b2a4e7218..184659ae5 100644 --- a/packages/vue/src/composables/useWatchedQuery.ts +++ b/packages/vue/src/composables/useWatchedQuery.ts @@ -1,4 +1,10 @@ -import { type CompilableQuery, FalsyComparator, ParsedQuery, parseQuery } from '@powersync/common'; +import { + type CompilableQuery, + FalsyComparator, + IncrementalWatchMode, + ParsedQuery, + parseQuery +} from '@powersync/common'; import { type MaybeRef, type Ref, ref, toValue, watchEffect } from 'vue'; import { usePowerSync } from './powerSync'; import { AdditionalOptions, WatchedQueryResult } from './useSingleQuery'; @@ -63,7 +69,7 @@ export const useWatchedQuery = ( } }, processor: options.processor ?? { - mode: 'comparison', + mode: IncrementalWatchMode.COMPARISON, // Maintains backwards compatibility with previous versions // Defaults to no comparison if no processor is provided comparator: FalsyComparator } From aab75f3df32f8abe23a78f82bc024e9713c0da63 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 3 Jun 2025 12:10:51 +0200 Subject: [PATCH 38/75] cleanup --- packages/react/src/hooks/watched/useWatchedQuery.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/react/src/hooks/watched/useWatchedQuery.ts b/packages/react/src/hooks/watched/useWatchedQuery.ts index afe573030..e8abcdff5 100644 --- a/packages/react/src/hooks/watched/useWatchedQuery.ts +++ b/packages/react/src/hooks/watched/useWatchedQuery.ts @@ -9,7 +9,6 @@ export const useWatchedQuery = ( const createWatchedQuery = React.useCallback(() => { return powerSync.incrementalWatch({ - // This always enables comparison. Might want to be able to disable this?? watch: { placeholderData: [], query, From c3e4709fcf55ce844685f063905f5cc8885cf9e0 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 3 Jun 2025 16:28:09 +0200 Subject: [PATCH 39/75] use a builder pattern to separate incremental watched query types. --- .../src/client/AbstractPowerSyncDatabase.ts | 55 ++++--------- .../src/client/watched/WatchedQueryBuilder.ts | 12 +++ .../client/watched/WatchedQueryBuilderMap.ts | 17 ++++ .../ComparisonWatchedQueryBuilder.ts | 24 ++++++ .../processors/OnChangeQueryProcessor.ts | 5 +- .../client/watched/processors/comparators.ts | 4 +- packages/common/src/index.ts | 1 + packages/react/README.md | 11 +-- packages/react/src/QueryStore.ts | 22 +++-- .../src/hooks/suspense/useSuspenseQuery.ts | 8 +- packages/react/src/hooks/watched/useQuery.ts | 14 ++-- .../src/hooks/watched/useWatchedQuery.ts | 5 +- .../react/src/hooks/watched/watch-types.ts | 6 +- packages/react/tests/useQuery.test.tsx | 11 +-- .../react/tests/useSuspenseQuery.test.tsx | 22 +++-- packages/react/vitest.config.ts | 2 +- .../vue/src/composables/useSingleQuery.ts | 6 +- .../vue/src/composables/useWatchedQuery.ts | 35 ++++---- packages/web/tests/watch.test.ts | 82 +++++++++++-------- 19 files changed, 193 insertions(+), 149 deletions(-) create mode 100644 packages/common/src/client/watched/WatchedQueryBuilder.ts create mode 100644 packages/common/src/client/watched/WatchedQueryBuilderMap.ts create mode 100644 packages/common/src/client/watched/processors/ComparisonWatchedQueryBuilder.ts diff --git a/packages/common/src/client/AbstractPowerSyncDatabase.ts b/packages/common/src/client/AbstractPowerSyncDatabase.ts index 43a969a98..f5823bf9e 100644 --- a/packages/common/src/client/AbstractPowerSyncDatabase.ts +++ b/packages/common/src/client/AbstractPowerSyncDatabase.ts @@ -32,9 +32,9 @@ import { type PowerSyncConnectionOptions, type RequiredAdditionalConnectionOptions } from './sync/stream/AbstractStreamingSyncImplementation.js'; -import { WatchedQuery, WatchedQueryOptions } from './watched/WatchedQuery.js'; -import { OnChangeQueryProcessor, WatchedQueryComparator } from './watched/processors/OnChangeQueryProcessor.js'; -import { FalsyComparator } from './watched/processors/comparators.js'; +import { IncrementalWatchMode } from './watched/WatchedQueryBuilder.js'; +import { WatchedQueryBuilderMap } from './watched/WatchedQueryBuilderMap.js'; +import { WatchedQueryComparator } from './watched/processors/comparators.js'; export interface DisconnectAndClearOptions { /** When set to false, data in local-only tables is preserved. */ @@ -72,22 +72,6 @@ export interface PowerSyncDatabaseOptionsWithSettings extends BasePowerSyncDatab database: SQLOpenOptions; } -export enum IncrementalWatchMode { - COMPARISON = 'comparison' -} - -export interface WatchComparatorOptions { - mode: IncrementalWatchMode.COMPARISON; - comparator?: WatchedQueryComparator; -} - -export type WatchProcessorOptions = WatchComparatorOptions; - -export interface IncrementalWatchOptions { - watch: WatchedQueryOptions; - processor?: WatchProcessorOptions; -} - export interface SQLWatchOptions { signal?: AbortSignal; tables?: string[]; @@ -109,7 +93,7 @@ export interface SQLWatchOptions { * Optional comparator which will be used to compare the results of the query. * The watched query will only yield results if the comparator returns false. */ - processor?: WatchProcessorOptions; + comparator?: WatchedQueryComparator; } export interface WatchOnChangeEvent { @@ -891,20 +875,14 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver(options: IncrementalWatchOptions): WatchedQuery { - const { watch, processor } = options; - - switch (options.processor?.mode) { - case IncrementalWatchMode.COMPARISON: - default: - return new OnChangeQueryProcessor({ - db: this, - comparator: processor?.comparator ?? { - checkEquality: (a, b) => JSON.stringify(a) == JSON.stringify(b) - }, - watchOptions: watch - }); + incrementalWatch(options: { mode: Mode }): WatchedQueryBuilderMap[Mode] { + const { mode } = options; + const builderFactory = WatchedQueryBuilderMap[mode]; + if (!builderFactory) { + debugger; + throw new Error(`Unsupported watch mode: ${mode}`); } + return builderFactory(this); } /** @@ -925,8 +903,11 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver ({ @@ -938,10 +919,6 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver(options: {}): WatchedQuery; +} diff --git a/packages/common/src/client/watched/WatchedQueryBuilderMap.ts b/packages/common/src/client/watched/WatchedQueryBuilderMap.ts new file mode 100644 index 000000000..1691970a8 --- /dev/null +++ b/packages/common/src/client/watched/WatchedQueryBuilderMap.ts @@ -0,0 +1,17 @@ +import { AbstractPowerSyncDatabase } from '../AbstractPowerSyncDatabase.js'; +import { ComparisonWatchedQueryBuilder } from './processors/ComparisonWatchedQueryBuilder.js'; +import { IncrementalWatchMode } from './WatchedQueryBuilder.js'; + +/** + * @internal + */ +export const WatchedQueryBuilderMap = { + [IncrementalWatchMode.COMPARISON]: (db: AbstractPowerSyncDatabase) => new ComparisonWatchedQueryBuilder(db) +}; + +/** + * @internal + */ +export type WatchedQueryBuilderMap = { + [key in IncrementalWatchMode]: ReturnType<(typeof WatchedQueryBuilderMap)[key]>; +}; diff --git a/packages/common/src/client/watched/processors/ComparisonWatchedQueryBuilder.ts b/packages/common/src/client/watched/processors/ComparisonWatchedQueryBuilder.ts new file mode 100644 index 000000000..f5e87a918 --- /dev/null +++ b/packages/common/src/client/watched/processors/ComparisonWatchedQueryBuilder.ts @@ -0,0 +1,24 @@ +import { AbstractPowerSyncDatabase } from '../../../client/AbstractPowerSyncDatabase.js'; +import { WatchedQuery, WatchedQueryOptions } from '../WatchedQuery.js'; +import { WatchedQueryBuilder } from '../WatchedQueryBuilder.js'; +import { WatchedQueryComparator } from './comparators.js'; +import { OnChangeQueryProcessor } from './OnChangeQueryProcessor.js'; + +export interface ComparisonWatchProcessorOptions { + comparator?: WatchedQueryComparator; + watch: WatchedQueryOptions; +} + +export class ComparisonWatchedQueryBuilder implements WatchedQueryBuilder { + constructor(protected db: AbstractPowerSyncDatabase) {} + + build(options: ComparisonWatchProcessorOptions): WatchedQuery { + return new OnChangeQueryProcessor({ + db: this.db, + comparator: options.comparator ?? { + checkEquality: (a, b) => JSON.stringify(a) == JSON.stringify(b) + }, + watchOptions: options.watch + }); + } +} diff --git a/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts b/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts index 520f58808..2204db35f 100644 --- a/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts +++ b/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts @@ -1,9 +1,6 @@ import { WatchedQueryState } from '../WatchedQuery.js'; import { AbstractQueryProcessor, AbstractQueryProcessorOptions, LinkQueryOptions } from './AbstractQueryProcessor.js'; - -export interface WatchedQueryComparator { - checkEquality: (current: Data, previous: Data) => boolean; -} +import { WatchedQueryComparator } from './comparators.js'; /** * @internal diff --git a/packages/common/src/client/watched/processors/comparators.ts b/packages/common/src/client/watched/processors/comparators.ts index 041339d93..e66496fe2 100644 --- a/packages/common/src/client/watched/processors/comparators.ts +++ b/packages/common/src/client/watched/processors/comparators.ts @@ -1,4 +1,6 @@ -import { WatchedQueryComparator } from './OnChangeQueryProcessor.js'; +export interface WatchedQueryComparator { + checkEquality: (current: Data, previous: Data) => boolean; +} export type ArrayComparatorOptions = { compareBy: (item: ItemType) => string; diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 3ef29f48e..b1ab2cba5 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -34,6 +34,7 @@ export * from './client/watched/GetAllQuery.js'; export * from './client/watched/processors/AbstractQueryProcessor.js'; export * from './client/watched/processors/comparators.js'; export * from './client/watched/WatchedQuery.js'; +export * from './client/watched/WatchedQueryBuilder.js'; export * from './utils/AbortOperation.js'; export * from './utils/BaseObserver.js'; diff --git a/packages/react/README.md b/packages/react/README.md index 24396ee55..65e6c2443 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -345,12 +345,9 @@ function MyWidget() { // Note that isFetching is set (by default) whenever the query is being fetched/checked. // This will result in `MyWidget` re-rendering for any change to the `cats` table. const { data, isLoading, isFetching } = useQuery(`SELECT * FROM cats WHERE breed = 'tabby'`, [], { - processor: { - mode: IncrementalWatchMode.COMPARISON, comparator: new ArrayComparator({ compareBy: (cat) => JSON.stringify(cat) }) - }, }) // ... Widget code @@ -375,12 +372,8 @@ function MyWidget() { // When reportFetching == false the object returned from useQuery will only be changed when the data, isLoading or error state changes. // This method performs a comparison in memory in order to determine changes. const { data, isLoading } = useQuery(`SELECT * FROM cats WHERE breed = 'tabby'`, [], { - processor: { - mode: 'comparison', - comparator: new ArrayComparator({ - compareBy: (cat) => JSON.stringify(cat) - }) - }, + comparator: new ArrayComparator({ + compareBy: (cat) => JSON.stringify(cat) reportFetching: false }) diff --git a/packages/react/src/QueryStore.ts b/packages/react/src/QueryStore.ts index 467bfee08..1ec2e741f 100644 --- a/packages/react/src/QueryStore.ts +++ b/packages/react/src/QueryStore.ts @@ -1,4 +1,4 @@ -import { AbstractPowerSyncDatabase, WatchCompatibleQuery, WatchedQuery } from '@powersync/common'; +import { AbstractPowerSyncDatabase, IncrementalWatchMode, WatchCompatibleQuery, WatchedQuery } from '@powersync/common'; import { AdditionalOptions } from './hooks/watched/watch-types'; export function generateQueryKey( @@ -19,14 +19,18 @@ export class QueryStore { return this.cache.get(key); } - const watchedQuery = this.db.incrementalWatch({ - watch: { - query, - placeholderData: [], - throttleMs: options.throttleMs - }, - processor: options.processor - }); + const watchedQuery = this.db + .incrementalWatch({ + mode: IncrementalWatchMode.COMPARISON + }) + .build({ + watch: { + query, + placeholderData: [], + throttleMs: options.throttleMs + }, + comparator: options.comparator + }); const disposer = watchedQuery.registerListener({ closed: () => { diff --git a/packages/react/src/hooks/suspense/useSuspenseQuery.ts b/packages/react/src/hooks/suspense/useSuspenseQuery.ts index bdea85693..c8c6a1671 100644 --- a/packages/react/src/hooks/suspense/useSuspenseQuery.ts +++ b/packages/react/src/hooks/suspense/useSuspenseQuery.ts @@ -1,4 +1,4 @@ -import { CompilableQuery, FalsyComparator, IncrementalWatchMode } from '@powersync/common'; +import { CompilableQuery, FalsyComparator } from '@powersync/common'; import { AdditionalOptions } from '../watched/watch-types'; import { SuspenseQueryResult } from './SuspenseQueryResult'; import { useSingleSuspenseQuery } from './useSingleSuspenseQuery'; @@ -36,10 +36,8 @@ export const useSuspenseQuery = ( default: return useWatchedSuspenseQuery(query, parameters, { ...options, - processor: options.processor ?? { - mode: IncrementalWatchMode.COMPARISON, - comparator: FalsyComparator // Default comparator that always reports changed result sets - } + // Default comparator that always reports changed result sets + comparator: options.comparator ?? FalsyComparator }); } }; diff --git a/packages/react/src/hooks/watched/useQuery.ts b/packages/react/src/hooks/watched/useQuery.ts index c01e01d7a..e50f030cd 100644 --- a/packages/react/src/hooks/watched/useQuery.ts +++ b/packages/react/src/hooks/watched/useQuery.ts @@ -1,4 +1,4 @@ -import { FalsyComparator, IncrementalWatchMode, type CompilableQuery } from '@powersync/common'; +import { FalsyComparator, type CompilableQuery } from '@powersync/common'; import { usePowerSync } from '../PowerSyncContext'; import { useSingleQuery } from './useSingleQuery'; import { useWatchedQuery } from './useWatchedQuery'; @@ -43,14 +43,10 @@ export const useQuery = ( powerSync, queryChanged, options: { - ...options, - processor: options.processor ?? { - // Maintains backwards compatibility with previous versions - // Comparisons are opt-in by default - // We emit new data for each table change by default. - mode: IncrementalWatchMode.COMPARISON, - comparator: FalsyComparator - } + // Maintains backwards compatibility with previous versions + // Comparisons are opt-in by default + // We emit new data for each table change by default. + comparator: options.comparator ?? FalsyComparator } }); } diff --git a/packages/react/src/hooks/watched/useWatchedQuery.ts b/packages/react/src/hooks/watched/useWatchedQuery.ts index e8abcdff5..56182cda3 100644 --- a/packages/react/src/hooks/watched/useWatchedQuery.ts +++ b/packages/react/src/hooks/watched/useWatchedQuery.ts @@ -1,3 +1,4 @@ +import { FalsyComparator, IncrementalWatchMode } from '@powersync/common'; import React from 'react'; import { HookWatchOptions, QueryResult } from './watch-types'; import { InternalHookOptions } from './watch-utils'; @@ -8,14 +9,14 @@ export const useWatchedQuery = ( const { query, powerSync, queryChanged, options: hookOptions } = options; const createWatchedQuery = React.useCallback(() => { - return powerSync.incrementalWatch({ + return powerSync.incrementalWatch({ mode: IncrementalWatchMode.COMPARISON }).build({ watch: { placeholderData: [], query, throttleMs: hookOptions.throttleMs, reportFetching: hookOptions.reportFetching }, - processor: hookOptions.processor + comparator: hookOptions.comparator ?? FalsyComparator }); }, []); diff --git a/packages/react/src/hooks/watched/watch-types.ts b/packages/react/src/hooks/watched/watch-types.ts index 258f43257..0365025a8 100644 --- a/packages/react/src/hooks/watched/watch-types.ts +++ b/packages/react/src/hooks/watched/watch-types.ts @@ -1,8 +1,8 @@ -import { WatchProcessorOptions, type SQLWatchOptions } from '@powersync/common'; +import { WatchedQueryComparator, type SQLWatchOptions } from '@powersync/common'; -export interface HookWatchOptions extends Omit { +export interface HookWatchOptions extends Omit { reportFetching?: boolean; - processor?: WatchProcessorOptions; + comparator?: WatchedQueryComparator; } export interface AdditionalOptions extends HookWatchOptions { diff --git a/packages/react/tests/useQuery.test.tsx b/packages/react/tests/useQuery.test.tsx index 6f59f1fde..a24cf37e6 100644 --- a/packages/react/tests/useQuery.test.tsx +++ b/packages/react/tests/useQuery.test.tsx @@ -202,12 +202,9 @@ describe('useQuery', () => { const { result } = renderHook( () => useQuery('SELECT * FROM lists WHERE name = ?', ['aname'], { - processor: { - mode: 'comparison', - comparator: new commonSdk.ArrayComparator({ - compareBy: (item) => JSON.stringify(item) - }) - } + comparator: new commonSdk.ArrayComparator({ + compareBy: (item) => JSON.stringify(item) + }) }), { wrapper } ); @@ -317,7 +314,7 @@ describe('useQuery', () => { // This query can be instantiated once and reused. // The query retains it's state and will not re-fetch the data unless the result changes. // This is useful for queries that are used in multiple components. - const listsQuery = db.incrementalWatch({ + const listsQuery = db.incrementalWatch({ mode: commonSdk.IncrementalWatchMode.COMPARISON }).build({ watch: { placeholderData: [], query: { diff --git a/packages/react/tests/useSuspenseQuery.test.tsx b/packages/react/tests/useSuspenseQuery.test.tsx index 20a6cb861..488ea1062 100644 --- a/packages/react/tests/useSuspenseQuery.test.tsx +++ b/packages/react/tests/useSuspenseQuery.test.tsx @@ -1,4 +1,4 @@ -import { AbstractPowerSyncDatabase, WatchedQuery } from '@powersync/common'; +import { AbstractPowerSyncDatabase, IncrementalWatchMode, WatchedQuery } from '@powersync/common'; import { cleanup, renderHook, screen, waitFor } from '@testing-library/react'; import React from 'react'; import { ErrorBoundary } from 'react-error-boundary'; @@ -64,10 +64,22 @@ describe('useSuspenseQuery', () => { it('should suspend on initial load', async () => { // spy on watched query generation const baseImplementation = powersync.incrementalWatch; - let watch: WatchedQuery> | null = null; + let watch: WatchedQuery | null = null; + const spy = vi.spyOn(powersync, 'incrementalWatch').mockImplementation((options) => { - watch = baseImplementation.call(powersync, options); - return watch!; + if (options.mode !== IncrementalWatchMode.COMPARISON) { + return baseImplementation.call(powersync, options); + } + + const builder = baseImplementation.call(powersync, options); + const baseBuild = builder.build; + + vi.spyOn(builder, 'build').mockImplementation((buildOptions) => { + watch = baseBuild.call(builder, buildOptions); + return watch!; + }); + + return builder!; }); const wrapper = ({ children }) => ( @@ -250,7 +262,7 @@ describe('useSuspenseQuery', () => { // This query can be instantiated once and reused. // The query retains it's state and will not re-fetch the data unless the result changes. // This is useful for queries that are used in multiple components. - const listsQuery = db.incrementalWatch({ + const listsQuery = db.incrementalWatch({ mode: IncrementalWatchMode.COMPARISON }).build({ watch: { placeholderData: [], query: { diff --git a/packages/react/vitest.config.ts b/packages/react/vitest.config.ts index bf0bd4de1..f734d8f1f 100644 --- a/packages/react/vitest.config.ts +++ b/packages/react/vitest.config.ts @@ -32,7 +32,7 @@ const config: UserConfigExport = { */ isolate: true, provider: 'playwright', - headless: true, + headless: false, instances: [ { browser: 'chromium' diff --git a/packages/vue/src/composables/useSingleQuery.ts b/packages/vue/src/composables/useSingleQuery.ts index a80f310fe..f1ec69992 100644 --- a/packages/vue/src/composables/useSingleQuery.ts +++ b/packages/vue/src/composables/useSingleQuery.ts @@ -2,15 +2,15 @@ import { type CompilableQuery, ParsedQuery, type SQLWatchOptions, - WatchProcessorOptions, + WatchedQueryComparator, parseQuery } from '@powersync/common'; import { type MaybeRef, type Ref, ref, toValue, watchEffect } from 'vue'; import { usePowerSync } from './powerSync'; -export interface AdditionalOptions extends Omit { +export interface AdditionalOptions extends Omit { runQueryOnce?: boolean; - processor?: WatchProcessorOptions; + comparator?: WatchedQueryComparator; } export type WatchedQueryResult = { diff --git a/packages/vue/src/composables/useWatchedQuery.ts b/packages/vue/src/composables/useWatchedQuery.ts index 184659ae5..c70e9b7d0 100644 --- a/packages/vue/src/composables/useWatchedQuery.ts +++ b/packages/vue/src/composables/useWatchedQuery.ts @@ -55,25 +55,26 @@ export const useWatchedQuery = ( const { sqlStatement: sql, parameters } = parsedQuery; - const watchedQuery = powerSync.value.incrementalWatch({ - watch: { - placeholderData: [], - query: { - compile: () => ({ sql, parameters }), - execute: async ({ db, sql, parameters }) => { - if (typeof queryValue === 'string') { - return db.getAll(sql, parameters); + const watchedQuery = powerSync.value + .incrementalWatch({ + mode: IncrementalWatchMode.COMPARISON + }) + .build({ + watch: { + placeholderData: [], + query: { + compile: () => ({ sql, parameters }), + execute: async ({ db, sql, parameters }) => { + if (typeof queryValue === 'string') { + return db.getAll(sql, parameters); + } + return queryValue.execute(); } - return queryValue.execute(); } - } - }, - processor: options.processor ?? { - mode: IncrementalWatchMode.COMPARISON, // Maintains backwards compatibility with previous versions - // Defaults to no comparison if no processor is provided - comparator: FalsyComparator - } - }); + }, + // Maintains backwards compatibility with previous versions + comparator: options.comparator ?? FalsyComparator + }); const disposer = watchedQuery.subscribe({ onStateChange: (state) => { diff --git a/packages/web/tests/watch.test.ts b/packages/web/tests/watch.test.ts index 4777ced30..825aa641e 100644 --- a/packages/web/tests/watch.test.ts +++ b/packages/web/tests/watch.test.ts @@ -1,4 +1,4 @@ -import { AbstractPowerSyncDatabase, GetAllQuery, WatchedQueryState } from '@powersync/common'; +import { AbstractPowerSyncDatabase, GetAllQuery, IncrementalWatchMode, WatchedQueryState } from '@powersync/common'; import { PowerSyncDatabase } from '@powersync/web'; import { v4 as uuid } from 'uuid'; import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'; @@ -326,15 +326,19 @@ describe('Watch Tests', { sequential: true }, () => { }); it('should stream watch results', async () => { - const watch = powersync.incrementalWatch({ - watch: { - query: new GetAllQuery({ - sql: 'SELECT * FROM assets', - parameters: [] - }), - placeholderData: [] - } - }); + const watch = powersync + .incrementalWatch({ + mode: IncrementalWatchMode.COMPARISON + }) + .build({ + watch: { + query: new GetAllQuery({ + sql: 'SELECT * FROM assets', + parameters: [] + }), + placeholderData: [] + } + }); const getNextState = () => new Promise>((resolve) => { @@ -365,18 +369,22 @@ describe('Watch Tests', { sequential: true }, () => { }); it('should only report updates for relevant changes', async () => { - const watch = powersync.incrementalWatch({ - watch: { - query: { - compile: () => ({ - sql: 'SELECT * FROM assets where make = ?', - parameters: ['test'] - }), - execute: ({ sql, parameters }) => powersync.getAll(sql, parameters) - }, - placeholderData: [] - } - }); + const watch = powersync + .incrementalWatch({ + mode: IncrementalWatchMode.COMPARISON + }) + .build({ + watch: { + query: { + compile: () => ({ + sql: 'SELECT * FROM assets where make = ?', + parameters: ['test'] + }), + execute: ({ sql, parameters }) => powersync.getAll(sql, parameters) + }, + placeholderData: [] + } + }); let notificationCount = 0; const dispose = watch.subscribe({ @@ -402,19 +410,23 @@ describe('Watch Tests', { sequential: true }, () => { }); it('should not report fetching status', async () => { - const watch = powersync.incrementalWatch({ - watch: { - query: { - compile: () => ({ - sql: 'SELECT * FROM assets where make = ?', - parameters: ['test'] - }), - execute: ({ sql, parameters }) => powersync.getAll(sql, parameters) - }, - placeholderData: [], - reportFetching: false - } - }); + const watch = powersync + .incrementalWatch({ + mode: IncrementalWatchMode.COMPARISON + }) + .build({ + watch: { + query: { + compile: () => ({ + sql: 'SELECT * FROM assets where make = ?', + parameters: ['test'] + }), + execute: ({ sql, parameters }) => powersync.getAll(sql, parameters) + }, + placeholderData: [], + reportFetching: false + } + }); expect(watch.state.isFetching).false; From c1080949e8632cc38ae2c9e696af853a466ac63d Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 3 Jun 2025 16:42:05 +0200 Subject: [PATCH 40/75] fix web tests --- packages/common/src/client/AbstractPowerSyncDatabase.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/common/src/client/AbstractPowerSyncDatabase.ts b/packages/common/src/client/AbstractPowerSyncDatabase.ts index f5823bf9e..2983a39f3 100644 --- a/packages/common/src/client/AbstractPowerSyncDatabase.ts +++ b/packages/common/src/client/AbstractPowerSyncDatabase.ts @@ -34,7 +34,7 @@ import { } from './sync/stream/AbstractStreamingSyncImplementation.js'; import { IncrementalWatchMode } from './watched/WatchedQueryBuilder.js'; import { WatchedQueryBuilderMap } from './watched/WatchedQueryBuilderMap.js'; -import { WatchedQueryComparator } from './watched/processors/comparators.js'; +import { FalsyComparator, WatchedQueryComparator } from './watched/processors/comparators.js'; export interface DisconnectAndClearOptions { /** When set to false, data in local-only tables is preserved. */ @@ -903,10 +903,10 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver({ comparator, watch: { query: { From a0e0c700cb8a371ac7ac0433ac36ec28f5127acf Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 3 Jun 2025 17:13:18 +0200 Subject: [PATCH 41/75] improve generics --- .../src/client/AbstractPowerSyncDatabase.ts | 3 +- .../common/src/client/watched/WatchedQuery.ts | 7 ++- .../processors/AbstractQueryProcessor.ts | 32 +++++++---- .../ComparisonWatchedQueryBuilder.ts | 10 ++-- .../processors/OnChangeQueryProcessor.ts | 11 ++-- packages/web/tests/watch.test.ts | 53 +++++++++++++++++++ 6 files changed, 94 insertions(+), 22 deletions(-) diff --git a/packages/common/src/client/AbstractPowerSyncDatabase.ts b/packages/common/src/client/AbstractPowerSyncDatabase.ts index 2983a39f3..a71f3470b 100644 --- a/packages/common/src/client/AbstractPowerSyncDatabase.ts +++ b/packages/common/src/client/AbstractPowerSyncDatabase.ts @@ -879,10 +879,9 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver { } export interface WatchedQueryOptions { - query: WatchCompatibleQuery; - /** * Initial result data which is presented while the initial loading is executing */ @@ -85,7 +83,8 @@ export interface WatchedQueryListener extends BaseListener { subscriptionsChanged: (counts: SubscriptionCounts) => void; } -export interface WatchedQuery extends BaseObserverInterface { +export interface WatchedQuery = WatchedQueryOptions> + extends BaseObserverInterface { /** * Current state of the watched query. */ @@ -105,7 +104,7 @@ export interface WatchedQuery extends BaseObserverInterface): Promise; + updateSettings(options: Settings): Promise; /** * Close the watched query and end all subscriptions. diff --git a/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts b/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts index 9455af53e..224fdb971 100644 --- a/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts +++ b/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts @@ -13,17 +13,20 @@ import { /** * @internal */ -export interface AbstractQueryProcessorOptions { +export interface AbstractQueryProcessorOptions< + Data, + Settings extends WatchedQueryOptions = WatchedQueryOptions +> { db: AbstractPowerSyncDatabase; - watchOptions: WatchedQueryOptions; + watchOptions: Settings; } /** * @internal */ -export interface LinkQueryOptions { +export interface LinkQueryOptions = WatchedQueryOptions> { abortSignal: AbortSignal; - query: WatchedQueryOptions; + settings: Settings; } type WatchedQueryProcessorListener = WatchedQuerySubscription & WatchedQueryListener; @@ -32,7 +35,10 @@ type WatchedQueryProcessorListener = WatchedQuerySubscription & Watc * Performs underlying watching and yields a stream of results. * @internal */ -export abstract class AbstractQueryProcessor +export abstract class AbstractQueryProcessor< + Data = unknown[], + Settings extends WatchedQueryOptions = WatchedQueryOptions + > extends BaseObserver> implements WatchedQuery { @@ -56,7 +62,7 @@ export abstract class AbstractQueryProcessor }, {}) as SubscriptionCounts; } - constructor(protected options: AbstractQueryProcessorOptions) { + constructor(protected options: AbstractQueryProcessorOptions) { super(); this.abortController = new AbortController(); this._closed = false; @@ -78,15 +84,23 @@ export abstract class AbstractQueryProcessor /** * Updates the underlying query. */ - async updateSettings(query: WatchedQueryOptions) { + async updateSettings(settings: Settings) { await this.initialized; - this.options.watchOptions = query; + if (!this.state.isLoading) { + await this.updateState({ + isLoading: true, + isFetching: this.reportFetching ? true : false, + data: settings.placeholderData + }); + } + + this.options.watchOptions = settings; this.abortController.abort(); this.abortController = new AbortController(); await this.linkQuery({ abortSignal: this.abortController.signal, - query + settings }); } diff --git a/packages/common/src/client/watched/processors/ComparisonWatchedQueryBuilder.ts b/packages/common/src/client/watched/processors/ComparisonWatchedQueryBuilder.ts index f5e87a918..e6225ab4a 100644 --- a/packages/common/src/client/watched/processors/ComparisonWatchedQueryBuilder.ts +++ b/packages/common/src/client/watched/processors/ComparisonWatchedQueryBuilder.ts @@ -1,18 +1,20 @@ import { AbstractPowerSyncDatabase } from '../../../client/AbstractPowerSyncDatabase.js'; -import { WatchedQuery, WatchedQueryOptions } from '../WatchedQuery.js'; +import { WatchedQuery } from '../WatchedQuery.js'; import { WatchedQueryBuilder } from '../WatchedQueryBuilder.js'; import { WatchedQueryComparator } from './comparators.js'; -import { OnChangeQueryProcessor } from './OnChangeQueryProcessor.js'; +import { ComparisonWatchedQuerySettings, OnChangeQueryProcessor } from './OnChangeQueryProcessor.js'; export interface ComparisonWatchProcessorOptions { comparator?: WatchedQueryComparator; - watch: WatchedQueryOptions; + watch: ComparisonWatchedQuerySettings; } export class ComparisonWatchedQueryBuilder implements WatchedQueryBuilder { constructor(protected db: AbstractPowerSyncDatabase) {} - build(options: ComparisonWatchProcessorOptions): WatchedQuery { + build( + options: ComparisonWatchProcessorOptions + ): WatchedQuery> { return new OnChangeQueryProcessor({ db: this.db, comparator: options.comparator ?? { diff --git a/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts b/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts index 2204db35f..d078f24ce 100644 --- a/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts +++ b/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts @@ -1,11 +1,16 @@ -import { WatchedQueryState } from '../WatchedQuery.js'; +import { WatchCompatibleQuery, WatchedQueryOptions, WatchedQueryState } from '../WatchedQuery.js'; import { AbstractQueryProcessor, AbstractQueryProcessorOptions, LinkQueryOptions } from './AbstractQueryProcessor.js'; import { WatchedQueryComparator } from './comparators.js'; +export interface ComparisonWatchedQuerySettings extends WatchedQueryOptions { + query: WatchCompatibleQuery; +} + /** * @internal */ -export interface OnChangeQueryProcessorOptions extends AbstractQueryProcessorOptions { +export interface OnChangeQueryProcessorOptions + extends AbstractQueryProcessorOptions> { comparator?: WatchedQueryComparator; } @@ -14,7 +19,7 @@ export interface OnChangeQueryProcessorOptions extends AbstractQueryProces * Results are emitted on every change of the relevant tables. * @internal */ -export class OnChangeQueryProcessor extends AbstractQueryProcessor { +export class OnChangeQueryProcessor extends AbstractQueryProcessor> { constructor(protected options: OnChangeQueryProcessorOptions) { super(options); } diff --git a/packages/web/tests/watch.test.ts b/packages/web/tests/watch.test.ts index 825aa641e..dae08a473 100644 --- a/packages/web/tests/watch.test.ts +++ b/packages/web/tests/watch.test.ts @@ -452,4 +452,57 @@ describe('Watch Tests', { sequential: true }, () => { expect(notificationCount).equals(1); expect(watch.state.data).toHaveLength(1); }); + + it('should allow updating queries', async () => { + // Create sample data + await powersync.execute('INSERT INTO assets(id, make, customer_id) VALUES (uuid(), ?, ?)', ['test', uuid()]); + await powersync.execute('INSERT INTO assets(id, make, customer_id) VALUES (uuid(), ?, ?)', ['nottest', uuid()]); + + const watch = powersync + .incrementalWatch({ + mode: IncrementalWatchMode.COMPARISON + }) + .build({ + watch: { + query: new GetAllQuery<{ make: string }>({ + sql: 'SELECT * FROM assets where make = ?', + parameters: ['test'] + }), + placeholderData: [], + reportFetching: false + } + }); + + expect(watch.state.isFetching).false; + + await vi.waitFor( + () => { + expect(watch.state.isLoading).false; + }, + { timeout: 1000 } + ); + + expect(watch.state.data).toHaveLength(1); + expect(watch.state.data[0].make).equals('test'); + + await watch.updateSettings({ + placeholderData: [], + query: new GetAllQuery<{ make: string }>({ + sql: 'SELECT * FROM assets where make = ?', + parameters: ['nottest'] + }) + }); + + expect(watch.state.isLoading).true; + + await vi.waitFor( + () => { + expect(watch.state.isLoading).false; + }, + { timeout: 1000 } + ); + + expect(watch.state.data).toHaveLength(1); + expect(watch.state.data[0].make).equals('nottest'); + }); }); From 05f50f1f35fcea7591b71ffc6dc8f6f9bec3fc87 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 3 Jun 2025 17:38:32 +0200 Subject: [PATCH 42/75] Add demo for instant query caching with localStorage --- .../components/providers/SystemProvider.tsx | 83 +++++++++++++++++-- .../components/widgets/TodoListsWidget.tsx | 36 ++------ 2 files changed, 79 insertions(+), 40 deletions(-) diff --git a/demos/react-supabase-todolist/src/components/providers/SystemProvider.tsx b/demos/react-supabase-todolist/src/components/providers/SystemProvider.tsx index 9bbbb4f11..911a4f66c 100644 --- a/demos/react-supabase-todolist/src/components/providers/SystemProvider.tsx +++ b/demos/react-supabase-todolist/src/components/providers/SystemProvider.tsx @@ -1,9 +1,17 @@ import { configureFts } from '@/app/utils/fts_setup'; -import { AppSchema } from '@/library/powersync/AppSchema'; +import { AppSchema, ListRecord, LISTS_TABLE, TODOS_TABLE } from '@/library/powersync/AppSchema'; import { SupabaseConnector } from '@/library/powersync/SupabaseConnector'; import { CircularProgress } from '@mui/material'; import { PowerSyncContext } from '@powersync/react'; -import { createBaseLogger, LogLevel, PowerSyncDatabase } from '@powersync/web'; +import { + ArrayComparator, + createBaseLogger, + GetAllQuery, + IncrementalWatchMode, + LogLevel, + PowerSyncDatabase, + WatchedQuery +} from '@powersync/web'; import React, { Suspense } from 'react'; import { NavigationPanelContextProvider } from '../navigation/NavigationPanelContext'; @@ -17,10 +25,65 @@ export const db = new PowerSyncDatabase({ } }); +export type EnhancedListRecord = ListRecord & { total_tasks: number; completed_tasks: number }; + +export type QueryStore = { + lists: WatchedQuery; +}; + +const QueryStore = React.createContext(null); +export const useQueryStore = () => React.useContext(QueryStore); + export const SystemProvider = ({ children }: { children: React.ReactNode }) => { - const [connector] = React.useState(new SupabaseConnector()); + const [connector] = React.useState(() => new SupabaseConnector()); const [powerSync] = React.useState(db); + const [queryStore] = React.useState(() => { + const listsQuery = db.incrementalWatch({ mode: IncrementalWatchMode.COMPARISON }).build({ + comparator: new ArrayComparator({ + compareBy: (item) => JSON.stringify(item) + }), + watch: { + // This provides instant caching of the query results. + // SQLite calls are asynchronous - therefore on page refresh the placeholder data will be used until the query is resolved. + // This uses localStorage to synchronously display a cached version while loading. + // Note that the TodoListsWidget is wraped by a GuardBySync component, which will prevent rendering until the query is resolved. + // Disable GuardBySync to see the placeholder data in action. + placeholderData: JSON.parse(localStorage.getItem('listscache') ?? '[]') as EnhancedListRecord[], + query: new GetAllQuery({ + sql: /* sql */ ` + SELECT + ${LISTS_TABLE}.*, + COUNT(${TODOS_TABLE}.id) AS total_tasks, + SUM( + CASE + WHEN ${TODOS_TABLE}.completed = true THEN 1 + ELSE 0 + END + ) as completed_tasks + FROM + ${LISTS_TABLE} + LEFT JOIN ${TODOS_TABLE} ON ${LISTS_TABLE}.id = ${TODOS_TABLE}.list_id + GROUP BY + ${LISTS_TABLE}.id; + ` + }) + } + }); + + // This updates a cache in order to display results instantly on page load. + listsQuery.subscribe({ + onData: (data) => { + // Store the data in localStorage for instant caching + localStorage.setItem('listscache', JSON.stringify(data)); + } + }); + + return { + lists: listsQuery + }; + }); + React.useEffect(() => { const logger = createBaseLogger(); logger.useDefaults(); // eslint-disable-line @@ -30,7 +93,7 @@ export const SystemProvider = ({ children }: { children: React.ReactNode }) => { powerSync.init(); const l = connector.registerListener({ - initialized: () => { }, + initialized: () => {}, sessionStarted: () => { powerSync.connect(connector); } @@ -47,11 +110,13 @@ export const SystemProvider = ({ children }: { children: React.ReactNode }) => { return ( }> - - - {children} - - + + + + {children} + + + ); }; diff --git a/demos/react-supabase-todolist/src/components/widgets/TodoListsWidget.tsx b/demos/react-supabase-todolist/src/components/widgets/TodoListsWidget.tsx index 9db0450b7..d2f12b9fa 100644 --- a/demos/react-supabase-todolist/src/components/widgets/TodoListsWidget.tsx +++ b/demos/react-supabase-todolist/src/components/widgets/TodoListsWidget.tsx @@ -1,7 +1,6 @@ -import { LISTS_TABLE, ListRecord, TODOS_TABLE } from '@/library/powersync/AppSchema'; import { List } from '@mui/material'; -import { useQuery } from '@powersync/react'; -import { ArrayComparator } from '@powersync/web'; +import { useWatchedQuerySubscription } from '@powersync/react'; +import { useQueryStore } from '../providers/SystemProvider'; import { ListItemWidget } from './ListItemWidget'; export type TodoListsWidgetProps = { @@ -13,35 +12,10 @@ const description = (total: number, completed: number = 0) => { }; export function TodoListsWidget(props: TodoListsWidgetProps) { - const { data: listRecords, isLoading } = useQuery( - /* sql */ ` - SELECT - ${LISTS_TABLE}.*, - COUNT(${TODOS_TABLE}.id) AS total_tasks, - SUM( - CASE - WHEN ${TODOS_TABLE}.completed = true THEN 1 - ELSE 0 - END - ) as completed_tasks - FROM - ${LISTS_TABLE} - LEFT JOIN ${TODOS_TABLE} ON ${LISTS_TABLE}.id = ${TODOS_TABLE}.list_id - GROUP BY - ${LISTS_TABLE}.id; - `, - [], - { - processor: { - mode: 'comparison', - comparator: new ArrayComparator({ - compareBy: (item) => JSON.stringify(item) - }) - } - } - ); + const queries = useQueryStore(); + const { data: listRecords, isLoading } = useWatchedQuerySubscription(queries!.lists); - if (isLoading) { + if (isLoading && listRecords.length == 0) { return
Loading...
; } From 0d01b9762cf5abb3aea2f4943ec5506778459fbb Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 3 Jun 2025 17:48:07 +0200 Subject: [PATCH 43/75] revert headless setting --- packages/react/vitest.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/vitest.config.ts b/packages/react/vitest.config.ts index f734d8f1f..bf0bd4de1 100644 --- a/packages/react/vitest.config.ts +++ b/packages/react/vitest.config.ts @@ -32,7 +32,7 @@ const config: UserConfigExport = { */ isolate: true, provider: 'playwright', - headless: false, + headless: true, instances: [ { browser: 'chromium' From 12b6aa8d6bdc1cc38ced08ae458340e17bd4969f Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Wed, 4 Jun 2025 10:41:14 +0200 Subject: [PATCH 44/75] fix kysely test --- .../kysely-driver/tests/sqlite/watch.test.ts | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/kysely-driver/tests/sqlite/watch.test.ts b/packages/kysely-driver/tests/sqlite/watch.test.ts index 1d18fc0eb..abf4ef1bd 100644 --- a/packages/kysely-driver/tests/sqlite/watch.test.ts +++ b/packages/kysely-driver/tests/sqlite/watch.test.ts @@ -1,4 +1,4 @@ -import { AbstractPowerSyncDatabase, column, Schema, Table } from '@powersync/common'; +import { AbstractPowerSyncDatabase, column, IncrementalWatchMode, Schema, Table } from '@powersync/common'; import { PowerSyncDatabase } from '@powersync/web'; import { sql } from 'kysely'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -90,9 +90,9 @@ describe('Watch Tests', () => { await db .insertInto('assets') .values({ - id: sql`uuid()`, + id: sql`uuid ()`, make: 'test', - customer_id: sql`uuid()` + customer_id: sql`uuid ()` }) .execute(); @@ -126,9 +126,9 @@ describe('Watch Tests', () => { await db .insertInto('assets') .values({ - id: sql`uuid()`, + id: sql`uuid ()`, make: 'test', - customer_id: sql`uuid()` + customer_id: sql`uuid ()` }) .execute(); } @@ -180,9 +180,9 @@ describe('Watch Tests', () => { await db .insertInto('assets') .values({ - id: sql`uuid()`, + id: sql`uuid ()`, make: 'test', - customer_id: sql`uuid()` + customer_id: sql`uuid ()` }) .execute(); @@ -210,7 +210,7 @@ describe('Watch Tests', () => { const query = db.selectFrom('assets').select([ () => { - const fullName = sql`fakeFunction()`; // Simulate an error with invalid function + const fullName = sql`fakeFunction ()`; // Simulate an error with invalid function return fullName.as('full_name'); } ]); @@ -246,9 +246,9 @@ describe('Watch Tests', () => { for (let i = 0; i < updatesCount; i++) { db.insertInto('assets') .values({ - id: sql`uuid()`, + id: sql`uuid ()`, make: 'test', - customer_id: sql`uuid()` + customer_id: sql`uuid ()` }) .execute(); } @@ -265,7 +265,7 @@ describe('Watch Tests', () => { it('incremental watch should accept queries', async () => { const query = db.selectFrom('assets').select(db.fn.count('assets.id').as('count')); - const watch = powerSyncDb.incrementalWatch({ + const watch = powerSyncDb.incrementalWatch({ mode: IncrementalWatchMode.COMPARISON }).build({ watch: { query, placeholderData: [] @@ -286,9 +286,9 @@ describe('Watch Tests', () => { await db .insertInto('assets') .values({ - id: sql`uuid()`, + id: sql`uuid ()`, make: 'test', - customer_id: sql`uuid()` + customer_id: sql`uuid ()` }) .execute(); From 8208ac03f927f1a0794966d7ffeb882a80752ee1 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Wed, 4 Jun 2025 11:00:19 +0200 Subject: [PATCH 45/75] cleanup example JSDoc comments --- .../src/app/views/sql-console/page.tsx | 9 ++---- packages/common/package.json | 1 - .../src/client/AbstractPowerSyncDatabase.ts | 32 +++++++++++++++++-- .../processors/AbstractQueryProcessor.ts | 1 + pnpm-lock.yaml | 3 -- 5 files changed, 34 insertions(+), 12 deletions(-) diff --git a/demos/react-supabase-todolist/src/app/views/sql-console/page.tsx b/demos/react-supabase-todolist/src/app/views/sql-console/page.tsx index 7cc8b8212..e3a9e43e5 100644 --- a/demos/react-supabase-todolist/src/app/views/sql-console/page.tsx +++ b/demos/react-supabase-todolist/src/app/views/sql-console/page.tsx @@ -64,12 +64,9 @@ export default function SQLConsolePage() { * The query here will only emit results when the query data set changes. * Result sets are compared by serializing each item to JSON and comparing the strings. */ - processor: { - mode: 'comparison', - comparator: new ArrayComparator({ - compareBy: (item) => JSON.stringify(item) - }) - } + comparator: new ArrayComparator({ + compareBy: (item) => JSON.stringify(item) + }) }); return ( diff --git a/packages/common/package.json b/packages/common/package.json index 1fa223432..1c9fd7d98 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -54,7 +54,6 @@ "can-ndjson-stream": "^1.0.2", "cross-fetch": "^4.0.0", "event-iterator": "^2.0.0", - "p-defer": "^4.0.1", "rollup": "4.14.3", "rsocket-core": "1.0.0-alpha.3", "rsocket-websocket-client": "1.0.0-alpha.3", diff --git a/packages/common/src/client/AbstractPowerSyncDatabase.ts b/packages/common/src/client/AbstractPowerSyncDatabase.ts index a71f3470b..582720eaf 100644 --- a/packages/common/src/client/AbstractPowerSyncDatabase.ts +++ b/packages/common/src/client/AbstractPowerSyncDatabase.ts @@ -874,12 +874,40 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver JSON.stringify(item) + * }) + * }); + * ``` + */ incrementalWatch(options: { mode: Mode }): WatchedQueryBuilderMap[Mode] { const { mode } = options; const builderFactory = WatchedQueryBuilderMap[mode]; if (!builderFactory) { - throw new Error(`Unsupported watch mode: ${mode}`); + throw new Error( + `Unsupported watch mode: ${mode}. Please specify on of [${Object.values(IncrementalWatchMode).join(', ')}]` + ); } return builderFactory(this) as WatchedQueryBuilderMap[Mode]; } diff --git a/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts b/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts index 224fdb971..a0bb13c28 100644 --- a/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts +++ b/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts @@ -178,6 +178,7 @@ export abstract class AbstractQueryProcessor< this.disposeListeners = null; this._closed = true; this.iterateListeners((l) => l.closed?.()); + this.listeners.clear(); } /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c21a30937..1ae95fd4a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1694,9 +1694,6 @@ importers: event-iterator: specifier: ^2.0.0 version: 2.0.0 - p-defer: - specifier: ^4.0.1 - version: 4.0.1 rollup: specifier: 4.14.3 version: 4.14.3 From 8a42cdae35a16c993d61644385533caa9ee43b9d Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Wed, 4 Jun 2025 13:48:40 +0200 Subject: [PATCH 46/75] cleanup --- packages/common/src/client/watched/WatchedQuery.ts | 2 +- packages/common/src/client/watched/WatchedQueryBuilder.ts | 2 +- .../common/src/client/watched/processors/comparators.ts | 6 ++++++ packages/react/README.md | 1 + 4 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/common/src/client/watched/WatchedQuery.ts b/packages/common/src/client/watched/WatchedQuery.ts index 4e0435358..27c5f1a76 100644 --- a/packages/common/src/client/watched/WatchedQuery.ts +++ b/packages/common/src/client/watched/WatchedQuery.ts @@ -83,7 +83,7 @@ export interface WatchedQueryListener extends BaseListener { subscriptionsChanged: (counts: SubscriptionCounts) => void; } -export interface WatchedQuery = WatchedQueryOptions> +export interface WatchedQuery = WatchedQueryOptions> extends BaseObserverInterface { /** * Current state of the watched query. diff --git a/packages/common/src/client/watched/WatchedQueryBuilder.ts b/packages/common/src/client/watched/WatchedQueryBuilder.ts index 76cc462d5..3593d2df3 100644 --- a/packages/common/src/client/watched/WatchedQueryBuilder.ts +++ b/packages/common/src/client/watched/WatchedQueryBuilder.ts @@ -8,5 +8,5 @@ export enum IncrementalWatchMode { * Builds a {@link WatchedQuery} instance given a set of options. */ export interface WatchedQueryBuilder { - build(options: {}): WatchedQuery; + build(options: {}): WatchedQuery; } diff --git a/packages/common/src/client/watched/processors/comparators.ts b/packages/common/src/client/watched/processors/comparators.ts index e66496fe2..9d8552f8a 100644 --- a/packages/common/src/client/watched/processors/comparators.ts +++ b/packages/common/src/client/watched/processors/comparators.ts @@ -2,7 +2,13 @@ export interface WatchedQueryComparator { checkEquality: (current: Data, previous: Data) => boolean; } +/** + * Options for {@link ArrayComparator} + */ export type ArrayComparatorOptions = { + /** + * Returns a string to uniquely identify an item in the array. + */ compareBy: (item: ItemType) => string; }; diff --git a/packages/react/README.md b/packages/react/README.md index 65e6c2443..13fb4691a 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -374,6 +374,7 @@ function MyWidget() { const { data, isLoading } = useQuery(`SELECT * FROM cats WHERE breed = 'tabby'`, [], { comparator: new ArrayComparator({ compareBy: (cat) => JSON.stringify(cat) + }, reportFetching: false }) From cf8034f7e6056488ba9a8ba5a1c101503d3274a4 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Wed, 4 Jun 2025 13:49:13 +0200 Subject: [PATCH 47/75] wip add differential --- .../src/client/watched/WatchedQueryBuilder.ts | 3 +- .../client/watched/WatchedQueryBuilderMap.ts | 4 +- .../processors/AbstractQueryProcessor.ts | 2 +- .../processors/DifferentialQueryProcessor.ts | 192 ++++++++++++++++++ .../DifferentialWatchedQueryBuilder.ts | 36 ++++ 5 files changed, 234 insertions(+), 3 deletions(-) create mode 100644 packages/common/src/client/watched/processors/DifferentialQueryProcessor.ts create mode 100644 packages/common/src/client/watched/processors/DifferentialWatchedQueryBuilder.ts diff --git a/packages/common/src/client/watched/WatchedQueryBuilder.ts b/packages/common/src/client/watched/WatchedQueryBuilder.ts index 3593d2df3..a8daa7f5c 100644 --- a/packages/common/src/client/watched/WatchedQueryBuilder.ts +++ b/packages/common/src/client/watched/WatchedQueryBuilder.ts @@ -1,7 +1,8 @@ import { WatchedQuery } from './WatchedQuery.js'; export enum IncrementalWatchMode { - COMPARISON = 'comparison' + COMPARISON = 'comparison', + DIFFERENTIAL = 'differential' } /** diff --git a/packages/common/src/client/watched/WatchedQueryBuilderMap.ts b/packages/common/src/client/watched/WatchedQueryBuilderMap.ts index 1691970a8..db8ae9f68 100644 --- a/packages/common/src/client/watched/WatchedQueryBuilderMap.ts +++ b/packages/common/src/client/watched/WatchedQueryBuilderMap.ts @@ -1,12 +1,14 @@ import { AbstractPowerSyncDatabase } from '../AbstractPowerSyncDatabase.js'; import { ComparisonWatchedQueryBuilder } from './processors/ComparisonWatchedQueryBuilder.js'; +import { DifferentialWatchedQueryBuilder } from './processors/DifferentialWatchedQueryBuilder.js'; import { IncrementalWatchMode } from './WatchedQueryBuilder.js'; /** * @internal */ export const WatchedQueryBuilderMap = { - [IncrementalWatchMode.COMPARISON]: (db: AbstractPowerSyncDatabase) => new ComparisonWatchedQueryBuilder(db) + [IncrementalWatchMode.COMPARISON]: (db: AbstractPowerSyncDatabase) => new ComparisonWatchedQueryBuilder(db), + [IncrementalWatchMode.DIFFERENTIAL]: (db: AbstractPowerSyncDatabase) => new DifferentialWatchedQueryBuilder(db) }; /** diff --git a/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts b/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts index a0bb13c28..eb4d4af5e 100644 --- a/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts +++ b/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts @@ -40,7 +40,7 @@ export abstract class AbstractQueryProcessor< Settings extends WatchedQueryOptions = WatchedQueryOptions > extends BaseObserver> - implements WatchedQuery + implements WatchedQuery { readonly state: WatchedQueryState; diff --git a/packages/common/src/client/watched/processors/DifferentialQueryProcessor.ts b/packages/common/src/client/watched/processors/DifferentialQueryProcessor.ts new file mode 100644 index 000000000..e89b956a8 --- /dev/null +++ b/packages/common/src/client/watched/processors/DifferentialQueryProcessor.ts @@ -0,0 +1,192 @@ +import { WatchCompatibleQuery, WatchedQuery, WatchedQueryOptions, WatchedQueryState } from '../WatchedQuery.js'; +import { AbstractQueryProcessor, AbstractQueryProcessorOptions, LinkQueryOptions } from './AbstractQueryProcessor.js'; + +export interface Differential { + current: RowType; + previous: RowType; +} + +export interface WatchedQueryDifferential { + added: RowType[]; + all: RowType[]; + removed: RowType[]; + updated: Differential[]; + unchanged: RowType[]; +} + +export interface Differentiator { + identify: (item: RowType) => string; + compareBy: (item: RowType) => string; +} + +export interface DifferentialWatchedQuerySettings + extends WatchedQueryOptions> { + query: WatchCompatibleQuery; +} + +/** + * @internal + */ +export interface DifferentialQueryProcessorOptions + extends AbstractQueryProcessorOptions, DifferentialWatchedQuerySettings> { + differentiator: Differentiator; +} + +type DataHashMap = Map; + +export const EMPTY_DIFFERENTIAL = { + added: [], + all: [], + removed: [], + updated: [], + unchanged: [] +}; + +/** + * Uses the PowerSync onChange event to trigger watched queries. + * Results are emitted on every change of the relevant tables. + * @internal + */ +export class DifferentialQueryProcessor + extends AbstractQueryProcessor, DifferentialWatchedQuerySettings> + implements WatchedQuery, DifferentialWatchedQuerySettings> +{ + constructor(protected options: DifferentialQueryProcessorOptions) { + super(options); + } + + /* + * @returns If the sets are equal + */ + protected differentiate( + current: RowType[], + previousMap: DataHashMap + ): { diff: WatchedQueryDifferential; map: DataHashMap; hasChanged: boolean } { + const { identify, compareBy } = this.options.differentiator; + + let hasChanged = false; + const currentMap = new Map(); + current.forEach((item) => { + currentMap.set(identify(item), { + hash: compareBy(item), + item + }); + }); + + const removedTracker = new Set(previousMap.keys()); + + const diff: WatchedQueryDifferential = { + all: current, + added: [], + removed: [], + updated: [], + unchanged: [] + }; + + for (const [key, { hash, item }] of currentMap) { + const previousItem = previousMap.get(key); + if (!previousItem) { + // New item + hasChanged = true; + diff.added.push(item); + } else { + // Existing item + if (hash == previousItem.hash) { + diff.unchanged.push(item); + } else { + hasChanged = true; + diff.updated.push({ current: item, previous: previousItem.item }); + } + } + // The item is present, we don't consider it removed + removedTracker.delete(key); + } + + diff.removed = Array.from(removedTracker).map((key) => previousMap.get(key)!.item); + hasChanged = hasChanged || diff.removed.length > 0; + + return { + diff, + hasChanged, + map: currentMap + }; + } + + protected async linkQuery(options: LinkQueryOptions>): Promise { + const { db, watchOptions } = this.options; + const { abortSignal } = options; + + const compiledQuery = watchOptions.query.compile(); + const tables = await db.resolveTables(compiledQuery.sql, compiledQuery.parameters as any[]); + + let currentMap: DataHashMap = new Map(); + + // populate the currentMap from the placeholder data + if (this.state.data) { + this.state.data.all.forEach((item) => { + currentMap.set(this.options.differentiator.identify(item), { + hash: this.options.differentiator.compareBy(item), + item + }); + }); + } + + db.onChangeWithCallback( + { + onChange: async () => { + if (this.closed) { + return; + } + // This fires for each change of the relevant tables + try { + if (this.reportFetching && !this.state.isFetching) { + await this.updateState({ isFetching: true }); + } + + const partialStateUpdate: Partial>> = {}; + + // Always run the query if an underlying table has changed + const result = await watchOptions.query.execute({ + sql: compiledQuery.sql, + // Allows casting from ReadOnlyArray[unknown] to Array + // This allows simpler compatibility with PowerSync queries + parameters: [...compiledQuery.parameters], + db: this.options.db + }); + + if (this.reportFetching) { + partialStateUpdate.isFetching = false; + } + + if (this.state.isLoading) { + partialStateUpdate.isLoading = false; + } + + const { diff, hasChanged, map } = this.differentiate(result, currentMap); + // Update for future comparisons + currentMap = map; + + if (hasChanged) { + partialStateUpdate.data = diff; + } + + if (Object.keys(partialStateUpdate).length > 0) { + await this.updateState(partialStateUpdate); + } + } catch (error) { + await this.updateState({ error }); + } + }, + onError: async (error) => { + await this.updateState({ error }); + } + }, + { + signal: abortSignal, + tables, + throttleMs: watchOptions.throttleMs, + triggerImmediate: true // used to emit the initial state + } + ); + } +} diff --git a/packages/common/src/client/watched/processors/DifferentialWatchedQueryBuilder.ts b/packages/common/src/client/watched/processors/DifferentialWatchedQueryBuilder.ts new file mode 100644 index 000000000..55b74d62c --- /dev/null +++ b/packages/common/src/client/watched/processors/DifferentialWatchedQueryBuilder.ts @@ -0,0 +1,36 @@ +import { AbstractPowerSyncDatabase } from '../../../client/AbstractPowerSyncDatabase.js'; +import { WatchedQuery } from '../WatchedQuery.js'; +import { WatchedQueryBuilder } from '../WatchedQueryBuilder.js'; +import { + DifferentialQueryProcessor, + DifferentialWatchedQuerySettings, + Differentiator, + WatchedQueryDifferential +} from './DifferentialQueryProcessor.js'; + +export type DifferentialWatchedQueryBuilderOptions = { + differentiator?: Differentiator; + watchOptions: DifferentialWatchedQuerySettings; +}; + +export class DifferentialWatchedQueryBuilder implements WatchedQueryBuilder { + constructor(protected db: AbstractPowerSyncDatabase) {} + + build( + options: DifferentialWatchedQueryBuilderOptions + ): WatchedQuery, DifferentialWatchedQuerySettings> { + return new DifferentialQueryProcessor({ + db: this.db, + differentiator: options.differentiator ?? { + identify: (item: RowType) => { + if (item && typeof item == 'object' && typeof item['id'] == 'string') { + return item['id']; + } + return JSON.stringify(item); + }, + compareBy: (item: RowType) => JSON.stringify(item) + }, + watchOptions: options.watchOptions + }); + } +} From 4edb977bfa947496ddff78924f8b59bf3b4ecd07 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Wed, 4 Jun 2025 13:56:06 +0200 Subject: [PATCH 48/75] cleanup interfaces --- .../common/src/client/AbstractPowerSyncDatabase.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/common/src/client/AbstractPowerSyncDatabase.ts b/packages/common/src/client/AbstractPowerSyncDatabase.ts index 582720eaf..53ac09981 100644 --- a/packages/common/src/client/AbstractPowerSyncDatabase.ts +++ b/packages/common/src/client/AbstractPowerSyncDatabase.ts @@ -72,7 +72,7 @@ export interface PowerSyncDatabaseOptionsWithSettings extends BasePowerSyncDatab database: SQLOpenOptions; } -export interface SQLWatchOptions { +export interface SQLOnChangeOptions { signal?: AbortSignal; tables?: string[]; /** The minimum interval between queries. */ @@ -88,7 +88,9 @@ export interface SQLWatchOptions { * Emits an empty result set immediately */ triggerImmediate?: boolean; +} +export interface SQLWatchOptions extends SQLOnChangeOptions { /** * Optional comparator which will be used to compare the results of the query. * The watched query will only yield results if the comparator returns false. @@ -1039,7 +1041,7 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver; + onChange(options?: SQLOnChangeOptions): AsyncIterable; /** * See {@link onChangeWithCallback}. * @@ -1054,11 +1056,11 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver void; + onChange(handler?: WatchOnChangeHandler, options?: SQLOnChangeOptions): () => void; onChange( - handlerOrOptions?: WatchOnChangeHandler | SQLWatchOptions, - maybeOptions?: SQLWatchOptions + handlerOrOptions?: WatchOnChangeHandler | SQLOnChangeOptions, + maybeOptions?: SQLOnChangeOptions ): (() => void) | AsyncIterable { if (handlerOrOptions && typeof handlerOrOptions === 'object' && 'onChange' in handlerOrOptions) { const handler = handlerOrOptions as WatchOnChangeHandler; @@ -1083,7 +1085,7 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver void { + onChangeWithCallback(handler?: WatchOnChangeHandler, options?: SQLOnChangeOptions): () => void { const { onChange, onError = (e: Error) => this.options.logger?.error(e) } = handler ?? {}; if (!onChange) { throw new Error('onChange is required'); From 5603bc5ece7baa80e571c32e445a748ec839c457 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 17 Jun 2025 16:56:45 +0200 Subject: [PATCH 49/75] Add unit tests for differential watch. Add transformer option to getAllQuery --- .../common/src/client/watched/GetAllQuery.ts | 23 ++++- packages/common/src/index.ts | 1 + packages/web/tests/watch.test.ts | 97 ++++++++++++++++++- 3 files changed, 116 insertions(+), 5 deletions(-) diff --git a/packages/common/src/client/watched/GetAllQuery.ts b/packages/common/src/client/watched/GetAllQuery.ts index 137406a3c..645873e8c 100644 --- a/packages/common/src/client/watched/GetAllQuery.ts +++ b/packages/common/src/client/watched/GetAllQuery.ts @@ -4,16 +4,27 @@ import { WatchCompatibleQuery, WatchExecuteOptions } from './WatchedQuery.js'; /** * Options for {@link GetAllQuery}. */ -export type GetAllQueryOptions = { +export type GetAllQueryOptions = { sql: string; parameters?: ReadonlyArray; + /** + * Optional transformer function to convert raw rows into the desired RowType. + * @example + * ```typescript + * (rawRow: Record) => ({ + * id: rawRow.id as string, + * created_at: new Date(rawRow.created_at), + * }) + * ``` + */ + transformer?: (rawRow: Record) => RowType; }; /** * Performs a {@link AbstractPowerSyncDatabase.getAll} operation for a watched query. */ export class GetAllQuery implements WatchCompatibleQuery { - constructor(protected options: GetAllQueryOptions) {} + constructor(protected options: GetAllQueryOptions) {} compile(): CompiledQuery { return { @@ -22,8 +33,12 @@ export class GetAllQuery implements WatchCompatibleQuery { + async execute(options: WatchExecuteOptions): Promise { const { db, sql, parameters } = options; - return db.getAll(sql, parameters); + const rawResult = await db.getAll(sql, parameters); + if (this.options.transformer) { + return rawResult.map(this.options.transformer); + } + return rawResult as RowType[]; } } diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index b1ab2cba5..7de5b1a49 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -33,6 +33,7 @@ export * from './db/schema/TableV2.js'; export * from './client/watched/GetAllQuery.js'; export * from './client/watched/processors/AbstractQueryProcessor.js'; export * from './client/watched/processors/comparators.js'; +export * from './client/watched/processors/DifferentialQueryProcessor.js'; export * from './client/watched/WatchedQuery.js'; export * from './client/watched/WatchedQueryBuilder.js'; diff --git a/packages/web/tests/watch.test.ts b/packages/web/tests/watch.test.ts index dae08a473..b201efdd8 100644 --- a/packages/web/tests/watch.test.ts +++ b/packages/web/tests/watch.test.ts @@ -1,4 +1,10 @@ -import { AbstractPowerSyncDatabase, GetAllQuery, IncrementalWatchMode, WatchedQueryState } from '@powersync/common'; +import { + AbstractPowerSyncDatabase, + EMPTY_DIFFERENTIAL, + GetAllQuery, + IncrementalWatchMode, + WatchedQueryState +} from '@powersync/common'; import { PowerSyncDatabase } from '@powersync/web'; import { v4 as uuid } from 'uuid'; import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'; @@ -505,4 +511,93 @@ describe('Watch Tests', { sequential: true }, () => { expect(watch.state.data).toHaveLength(1); expect(watch.state.data[0].make).equals('nottest'); }); + + it('should report differential query results', async () => { + const watch = powersync + .incrementalWatch({ + mode: IncrementalWatchMode.DIFFERENTIAL + }) + .build({ + watchOptions: { + query: new GetAllQuery({ + sql: /* sql */ ` + SELECT + * + FROM + assets + `, + transformer: (raw) => { + return { + id: raw.id as string, + make: raw.make as string + }; + } + }), + // TODO make this optional + placeholderData: EMPTY_DIFFERENTIAL + } + }); + + // Create sample data + await powersync.execute( + /* sql */ ` + INSERT INTO + assets (id, make, customer_id) + VALUES + (uuid (), ?, ?) + `, + ['test1', uuid()] + ); + + await vi.waitFor( + () => { + expect(watch.state.data.added[0]?.make).equals('test1'); + }, + { timeout: 1000 } + ); + + await powersync.execute( + /* sql */ ` + INSERT INTO + assets (id, make, customer_id) + VALUES + (uuid (), ?, ?) + `, + ['test2', uuid()] + ); + + await vi.waitFor( + () => { + // This should now reflect that we had one change since the last event + expect(watch.state.data.added).toHaveLength(1); + expect(watch.state.data.added[0]?.make).equals('test2'); + + expect(watch.state.data.removed).toHaveLength(0); + expect(watch.state.data.all).toHaveLength(2); + }, + { timeout: 1000 } + ); + + await powersync.execute( + /* sql */ ` + DELETE FROM assets + WHERE + make = ? + `, + ['test2'] + ); + + await vi.waitFor( + () => { + expect(watch.state.data.added).toHaveLength(0); + expect(watch.state.data.all).toHaveLength(1); + expect(watch.state.data.unchanged).toHaveLength(1); + expect(watch.state.data.unchanged[0]?.make).equals('test1'); + + expect(watch.state.data.removed).toHaveLength(1); + expect(watch.state.data.removed[0]?.make).equals('test2'); + }, + { timeout: 1000 } + ); + }); }); From 540d841d400e823a3ccdcf430b73380f141e4221 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Wed, 18 Jun 2025 11:37:20 +0200 Subject: [PATCH 50/75] Add differential query function. --- .../src/client/AbstractPowerSyncDatabase.ts | 32 +-- .../common/src/client/watched/GetAllQuery.ts | 10 +- .../common/src/client/watched/WatchedQuery.ts | 9 +- .../processors/AbstractQueryProcessor.ts | 15 +- .../ComparisonWatchedQueryBuilder.ts | 27 +- .../processors/DifferentialQueryProcessor.ts | 12 +- .../DifferentialWatchedQueryBuilder.ts | 39 ++- .../processors/OnChangeQueryProcessor.ts | 7 +- packages/common/src/index.ts | 3 + packages/web/tests/watch.test.ts | 245 +++++++++++++++++- 10 files changed, 353 insertions(+), 46 deletions(-) diff --git a/packages/common/src/client/AbstractPowerSyncDatabase.ts b/packages/common/src/client/AbstractPowerSyncDatabase.ts index 582720eaf..3649775cf 100644 --- a/packages/common/src/client/AbstractPowerSyncDatabase.ts +++ b/packages/common/src/client/AbstractPowerSyncDatabase.ts @@ -878,27 +878,27 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver JSON.stringify(item) - * }) - * }); + * // ... Options + * }) + * ``` + * + * For a differential based watch , use {@link IncrementalWatchMode.DIFFERENTIAL}. + * See {@link DifferentialWatchedQueryBuilder} for more details. + * @example + * ```javascript + * const watchedQuery = powerSync + * .incrementalWatch({ mode: IncrementalWatchMode.DIFFERENTIAL }) + * .build({ + * // ... Options + * }) * ``` */ incrementalWatch(options: { mode: Mode }): WatchedQueryBuilderMap[Mode] { diff --git a/packages/common/src/client/watched/GetAllQuery.ts b/packages/common/src/client/watched/GetAllQuery.ts index 645873e8c..b93fecda8 100644 --- a/packages/common/src/client/watched/GetAllQuery.ts +++ b/packages/common/src/client/watched/GetAllQuery.ts @@ -1,5 +1,6 @@ import { CompiledQuery } from '../../types/types.js'; -import { WatchCompatibleQuery, WatchExecuteOptions } from './WatchedQuery.js'; +import { AbstractPowerSyncDatabase } from '../AbstractPowerSyncDatabase.js'; +import { WatchCompatibleQuery } from './WatchedQuery.js'; /** * Options for {@link GetAllQuery}. @@ -33,9 +34,10 @@ export class GetAllQuery implements WatchCompatibleQuery { - const { db, sql, parameters } = options; - const rawResult = await db.getAll(sql, parameters); + async execute(options: { db: AbstractPowerSyncDatabase }): Promise { + const { db } = options; + const { sql, parameters = [] } = this.compile(); + const rawResult = await db.getAll(sql, [...parameters]); if (this.options.transformer) { return rawResult.map(this.options.transformer); } diff --git a/packages/common/src/client/watched/WatchedQuery.ts b/packages/common/src/client/watched/WatchedQuery.ts index 27c5f1a76..db361c0dd 100644 --- a/packages/common/src/client/watched/WatchedQuery.ts +++ b/packages/common/src/client/watched/WatchedQuery.ts @@ -45,12 +45,7 @@ export interface WatchCompatibleQuery { compile(): CompiledQuery; } -export interface WatchedQueryOptions { - /** - * Initial result data which is presented while the initial loading is executing - */ - placeholderData: DataType; - +export interface WatchedQueryOptions { /** The minimum interval between queries. */ throttleMs?: number; /** @@ -83,7 +78,7 @@ export interface WatchedQueryListener extends BaseListener { subscriptionsChanged: (counts: SubscriptionCounts) => void; } -export interface WatchedQuery = WatchedQueryOptions> +export interface WatchedQuery extends BaseObserverInterface { /** * Current state of the watched query. diff --git a/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts b/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts index eb4d4af5e..8a165e35f 100644 --- a/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts +++ b/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts @@ -13,18 +13,16 @@ import { /** * @internal */ -export interface AbstractQueryProcessorOptions< - Data, - Settings extends WatchedQueryOptions = WatchedQueryOptions -> { +export interface AbstractQueryProcessorOptions { db: AbstractPowerSyncDatabase; watchOptions: Settings; + placeholderData: Data; } /** * @internal */ -export interface LinkQueryOptions = WatchedQueryOptions> { +export interface LinkQueryOptions { abortSignal: AbortSignal; settings: Settings; } @@ -37,7 +35,7 @@ type WatchedQueryProcessorListener = WatchedQuerySubscription & Watc */ export abstract class AbstractQueryProcessor< Data = unknown[], - Settings extends WatchedQueryOptions = WatchedQueryOptions + Settings extends WatchedQueryOptions = WatchedQueryOptions > extends BaseObserver> implements WatchedQuery @@ -71,7 +69,7 @@ export abstract class AbstractQueryProcessor< isFetching: this.reportFetching, // Only set to true if we will report updates in future error: null, lastUpdated: null, - data: options.watchOptions.placeholderData + data: options.placeholderData }; this.disposeListeners = null; this.initialized = this.init(); @@ -90,8 +88,7 @@ export abstract class AbstractQueryProcessor< if (!this.state.isLoading) { await this.updateState({ isLoading: true, - isFetching: this.reportFetching ? true : false, - data: settings.placeholderData + isFetching: this.reportFetching ? true : false }); } diff --git a/packages/common/src/client/watched/processors/ComparisonWatchedQueryBuilder.ts b/packages/common/src/client/watched/processors/ComparisonWatchedQueryBuilder.ts index e6225ab4a..6b6b4466e 100644 --- a/packages/common/src/client/watched/processors/ComparisonWatchedQueryBuilder.ts +++ b/packages/common/src/client/watched/processors/ComparisonWatchedQueryBuilder.ts @@ -12,6 +12,30 @@ export interface ComparisonWatchProcessorOptions { export class ComparisonWatchedQueryBuilder implements WatchedQueryBuilder { constructor(protected db: AbstractPowerSyncDatabase) {} + /** + * Builds a watched query which emits results after comparing the result set. Results are only emitted if the result set changed. + * @example + * ``` javascript + * .build({ + * watch: { + * placeholderData: [], + * query: new GetAllQuery({ + * sql: `SELECT photo_id as id FROM todos WHERE photo_id IS NOT NULL`, + * parameters: [] + * }), + * throttleMs: 1000 + * }, + * // Optional comparator, defaults to JSON stringification of the entire result set. + * comparator: new ArrayComparator({ + * // By default the entire result set is stringified and compared. + * // Comparing the array items individual can be more efficient. + * // Alternatively a unique field can be used to compare items. + * // For example, if the items are objects with an `updated_at` field: + * compareBy: (item) => JSON.stringify(item) + * }) + * }) + * ``` + */ build( options: ComparisonWatchProcessorOptions ): WatchedQuery> { @@ -20,7 +44,8 @@ export class ComparisonWatchedQueryBuilder implements WatchedQueryBuilder { comparator: options.comparator ?? { checkEquality: (a, b) => JSON.stringify(a) == JSON.stringify(b) }, - watchOptions: options.watch + watchOptions: options.watch, + placeholderData: options.watch.placeholderData }); } } diff --git a/packages/common/src/client/watched/processors/DifferentialQueryProcessor.ts b/packages/common/src/client/watched/processors/DifferentialQueryProcessor.ts index e89b956a8..6215425f8 100644 --- a/packages/common/src/client/watched/processors/DifferentialQueryProcessor.ts +++ b/packages/common/src/client/watched/processors/DifferentialQueryProcessor.ts @@ -19,9 +19,17 @@ export interface Differentiator { compareBy: (item: RowType) => string; } -export interface DifferentialWatchedQuerySettings - extends WatchedQueryOptions> { +export interface DifferentialWatchedQuerySettings extends WatchedQueryOptions { + /** + * The query here must return an array of items that can be differentiated. + */ query: WatchCompatibleQuery; + + /** + * Initial result data which is presented while the initial loading is executing. + * Defaults to an empty differential. + */ + placeholderData?: WatchedQueryDifferential; } /** diff --git a/packages/common/src/client/watched/processors/DifferentialWatchedQueryBuilder.ts b/packages/common/src/client/watched/processors/DifferentialWatchedQueryBuilder.ts index 55b74d62c..803449d20 100644 --- a/packages/common/src/client/watched/processors/DifferentialWatchedQueryBuilder.ts +++ b/packages/common/src/client/watched/processors/DifferentialWatchedQueryBuilder.ts @@ -5,17 +5,51 @@ import { DifferentialQueryProcessor, DifferentialWatchedQuerySettings, Differentiator, + EMPTY_DIFFERENTIAL, WatchedQueryDifferential } from './DifferentialQueryProcessor.js'; export type DifferentialWatchedQueryBuilderOptions = { differentiator?: Differentiator; - watchOptions: DifferentialWatchedQuerySettings; + watch: DifferentialWatchedQuerySettings; }; export class DifferentialWatchedQueryBuilder implements WatchedQueryBuilder { constructor(protected db: AbstractPowerSyncDatabase) {} + /** + * Builds a watched query which emits differential results based on the provided differentiator. + * The {@link WatchedQueryState.data} is of the {@link WatchedQueryDifferential} form. + * The data delta relates to the difference between the query result set since the last change to the dataset. + * + * @example + * ```javascript + * .build({ + * // Optional differentiator, defaults to using the `id` field of the items if available, + * // otherwise it uses JSON stringification of the entire item. + * differentiator: { + * identify: (item) => item.id, + * compareBy: (item) => JSON.stringify(item) + * }, + * watch: { + * query: new GetAllQuery({ + * sql: ' + * SELECT + * * + * FROM + * assets + * ', + * transformer: (raw) => { + * return { + * id: raw.id as string, + * make: raw.make as string + * }; + * } + * }) + * }, + * }); + * ``` + */ build( options: DifferentialWatchedQueryBuilderOptions ): WatchedQuery, DifferentialWatchedQuerySettings> { @@ -30,7 +64,8 @@ export class DifferentialWatchedQueryBuilder implements WatchedQueryBuilder { }, compareBy: (item: RowType) => JSON.stringify(item) }, - watchOptions: options.watchOptions + watchOptions: options.watch, + placeholderData: options.watch.placeholderData ?? EMPTY_DIFFERENTIAL }); } } diff --git a/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts b/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts index d078f24ce..7130c594a 100644 --- a/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts +++ b/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts @@ -2,8 +2,13 @@ import { WatchCompatibleQuery, WatchedQueryOptions, WatchedQueryState } from '.. import { AbstractQueryProcessor, AbstractQueryProcessorOptions, LinkQueryOptions } from './AbstractQueryProcessor.js'; import { WatchedQueryComparator } from './comparators.js'; -export interface ComparisonWatchedQuerySettings extends WatchedQueryOptions { +export interface ComparisonWatchedQuerySettings extends WatchedQueryOptions { query: WatchCompatibleQuery; + /** + * Initial result data which is presented while the initial loading is executing. + * Defaults to an empty differential. + */ + placeholderData: DataType; } /** diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 7de5b1a49..48ecbce8a 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -33,7 +33,10 @@ export * from './db/schema/TableV2.js'; export * from './client/watched/GetAllQuery.js'; export * from './client/watched/processors/AbstractQueryProcessor.js'; export * from './client/watched/processors/comparators.js'; +export * from './client/watched/processors/ComparisonWatchedQueryBuilder.js'; export * from './client/watched/processors/DifferentialQueryProcessor.js'; +export * from './client/watched/processors/DifferentialWatchedQueryBuilder.js'; +export * from './client/watched/processors/OnChangeQueryProcessor.js'; export * from './client/watched/WatchedQuery.js'; export * from './client/watched/WatchedQueryBuilder.js'; diff --git a/packages/web/tests/watch.test.ts b/packages/web/tests/watch.test.ts index b201efdd8..bd1075c11 100644 --- a/packages/web/tests/watch.test.ts +++ b/packages/web/tests/watch.test.ts @@ -518,7 +518,7 @@ describe('Watch Tests', { sequential: true }, () => { mode: IncrementalWatchMode.DIFFERENTIAL }) .build({ - watchOptions: { + watch: { query: new GetAllQuery({ sql: /* sql */ ` SELECT @@ -532,9 +532,7 @@ describe('Watch Tests', { sequential: true }, () => { make: raw.make as string }; } - }), - // TODO make this optional - placeholderData: EMPTY_DIFFERENTIAL + }) } }); @@ -600,4 +598,243 @@ describe('Watch Tests', { sequential: true }, () => { { timeout: 1000 } ); }); + + it('should report differential query results with a custom differentiator', async () => { + const watch = powersync + .incrementalWatch({ + mode: IncrementalWatchMode.DIFFERENTIAL + }) + .build({ + differentiator: { + identify: (item) => item.id, + compareBy: (item) => JSON.stringify(item) + }, + watch: { + query: new GetAllQuery({ + sql: /* sql */ ` + SELECT + * + FROM + assets + `, + transformer: (raw) => { + return { + id: raw.id as string, + make: raw.make as string + }; + } + }) + } + }); + + // Create sample data + await powersync.execute( + /* sql */ ` + INSERT INTO + assets (id, make, customer_id) + VALUES + (uuid (), ?, ?) + `, + ['test1', uuid()] + ); + + await vi.waitFor( + () => { + expect(watch.state.data.added[0]?.make).equals('test1'); + }, + { timeout: 1000 } + ); + + await powersync.execute( + /* sql */ ` + INSERT INTO + assets (id, make, customer_id) + VALUES + (uuid (), ?, ?) + `, + ['test2', uuid()] + ); + + await vi.waitFor( + () => { + // This should now reflect that we had one change since the last event + expect(watch.state.data.added).toHaveLength(1); + expect(watch.state.data.added[0]?.make).equals('test2'); + + expect(watch.state.data.removed).toHaveLength(0); + expect(watch.state.data.all).toHaveLength(2); + }, + { timeout: 1000 } + ); + }); + + it('should report differential query results from initial state', async () => { + /** + * Differential queries start with a placeholder data. We run a watched query under the hood + * which triggers initially and for each change to underlying tables. + * Changes are calculated based on the initial state and the current state. + * The default empty differential state will result in the initial watch query reporting + * all results as added. + * We can perform relative differential queries by providing a placeholder data + * which is the initial state of the query. + */ + + // Store the query for reuse + const query = new GetAllQuery({ + sql: /* sql */ ` + SELECT + * + FROM + assets + `, + transformer: (raw) => { + return { + id: raw.id as string, + make: raw.make as string + }; + } + }); + + // Create sample data + await powersync.execute( + /* sql */ ` + INSERT INTO + assets (id, make, customer_id) + VALUES + (uuid (), ?, ?) + `, + ['test1', uuid()] + ); + + const watch = powersync + .incrementalWatch({ + mode: IncrementalWatchMode.DIFFERENTIAL + }) + .build({ + watch: { + query, + placeholderData: { + ...EMPTY_DIFFERENTIAL, + // Fetch the initial state as a baseline before creating the watch. + // Any changes after this state will be reported as changes. + all: await query.execute({ db: powersync }) + } + } + }); + + // It should have the initial value + expect(watch.state.data.all).toHaveLength(1); + + await powersync.execute( + /* sql */ ` + INSERT INTO + assets (id, make, customer_id) + VALUES + (uuid (), ?, ?) + `, + ['test2', uuid()] + ); + + await vi.waitFor( + () => { + // This should now reflect that we had one change since the last event + expect(watch.state.data.added).toHaveLength(1); + expect(watch.state.data.added[0]?.make).equals('test2'); + + expect(watch.state.data.removed).toHaveLength(0); + expect(watch.state.data.all).toHaveLength(2); + }, + { timeout: 1000 } + ); + + await powersync.execute( + /* sql */ ` + DELETE FROM assets + WHERE + make = ? + `, + ['test2'] + ); + + await vi.waitFor( + () => { + expect(watch.state.data.added).toHaveLength(0); + expect(watch.state.data.all).toHaveLength(1); + expect(watch.state.data.unchanged).toHaveLength(1); + expect(watch.state.data.unchanged[0]?.make).equals('test1'); + + expect(watch.state.data.removed).toHaveLength(1); + expect(watch.state.data.removed[0]?.make).equals('test2'); + }, + { timeout: 1000 } + ); + }); + + it('should report differential query results changed rows', async () => { + // Create sample data + await powersync.execute( + /* sql */ ` + INSERT INTO + assets (id, make, customer_id) + VALUES + (uuid (), ?, ?) + `, + ['test1', uuid()] + ); + + const watch = powersync + .incrementalWatch({ + mode: IncrementalWatchMode.DIFFERENTIAL + }) + .build({ + watch: { + query: new GetAllQuery({ + sql: /* sql */ ` + SELECT + * + FROM + assets + `, + transformer: (raw) => { + return { + id: raw.id as string, + make: raw.make as string + }; + } + }) + } + }); + + await vi.waitFor(() => { + // Wait for the data to be loaded + expect(watch.state.data.all[0]?.make).equals('test1'); + }); + + await powersync.execute( + /* sql */ ` + UPDATE assets + SET + make = ? + WHERE + make = ? + `, + ['test2', 'test1'] + ); + + await vi.waitFor( + () => { + expect(watch.state.data.added).toHaveLength(0); + const updated = watch.state.data.updated[0]; + + // The update should contain previous and current values of changed rows + expect(updated).toBeDefined(); + expect(updated.previous.make).equals('test1'); + expect(updated.current.make).equals('test2'); + + expect(watch.state.data.removed).toHaveLength(0); + expect(watch.state.data.all).toHaveLength(1); + }, + { timeout: 1000 } + ); + }); }); From 23eb215c085ac0cbf6da82f79ced5fa553b71ed1 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Wed, 18 Jun 2025 15:24:25 +0200 Subject: [PATCH 51/75] fix conflict --- packages/react-native/rollup.config.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native/rollup.config.mjs b/packages/react-native/rollup.config.mjs index 944ffb6e6..08699165b 100644 --- a/packages/react-native/rollup.config.mjs +++ b/packages/react-native/rollup.config.mjs @@ -54,7 +54,7 @@ export default (commandLineArgs) => { } ] }), - terser({ sourceMap: sourcemap }) + terser({ sourceMap }) ], external: [ '@journeyapps/react-native-quick-sqlite', From cd5a5c8b108300874cb8e29066cd4a403615c09d Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Wed, 18 Jun 2025 17:17:20 +0200 Subject: [PATCH 52/75] Add local development for YJS demo --- .../.env.local.template | 6 ++- .../yjs-react-supabase-text-collab/README.md | 34 +++++++++++- .../powersync.yaml | 52 +++++++++++++++++++ .../supabase/config.toml | 2 + .../20250618064101_configure_powersync.sql} | 0 .../sync-rules.yaml | 2 + 6 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 demos/yjs-react-supabase-text-collab/powersync.yaml rename demos/yjs-react-supabase-text-collab/{database.sql => supabase/migrations/20250618064101_configure_powersync.sql} (100%) diff --git a/demos/yjs-react-supabase-text-collab/.env.local.template b/demos/yjs-react-supabase-text-collab/.env.local.template index 1282709c8..bcdc041a8 100644 --- a/demos/yjs-react-supabase-text-collab/.env.local.template +++ b/demos/yjs-react-supabase-text-collab/.env.local.template @@ -1,3 +1,5 @@ -VITE_SUPABASE_URL= +VITE_SUPABASE_URL=http://localhost:54321 VITE_SUPABASE_ANON_KEY= -VITE_POWERSYNC_URL= +VITE_POWERSYNC_URL=http://localhost:8080 +# Only required for development with a local Supabase instance +PS_SUPABASE_JWT_SECRET= \ No newline at end of file diff --git a/demos/yjs-react-supabase-text-collab/README.md b/demos/yjs-react-supabase-text-collab/README.md index 7d6e916b7..48e04dda8 100644 --- a/demos/yjs-react-supabase-text-collab/README.md +++ b/demos/yjs-react-supabase-text-collab/README.md @@ -17,12 +17,44 @@ pnpm install pnpm build:packages ``` +### Quick Start: Local Development + +This demo can be started with local PowerSync and Supabase services. + +Follow the [instructions](https://supabase.com/docs/guides/cli/getting-started) for configuring Supabase locally. + +Copy the environment variables template file + +```bash +cp .env.template .env.local +``` + +Start the Supabase project + +```bash +supabase start +``` + +Copy the `anon key` and `JWT secret` into the `.env` file. + +Run the PowerSync service with + +```bash +docker run \ +-p 8080:8080 \ +-e POWERSYNC_CONFIG_B64=$(base64 -i ./powersync.yaml) \ +-e POWERSYNC_SYNC_RULES_B64=$(base64 -i ./sync-rules.yaml) \ +--env-file ./.env \ +--network supabase_network_yjs-react-supabase-text-collab \ +--name my-powersync journeyapps/powersync-service:latest +``` + ### 2. Create project on Supabase and set up Postgres This demo app uses Supabase as its Postgres database and backend: 1. [Create a new project on the Supabase dashboard](https://supabase.com/dashboard/projects). -2. Go to the Supabase SQL Editor for your new project and execute the SQL statements in [`database.sql`](database.sql) to create the database schema, database functions, and publication needed for PowerSync. +2. Go to the Supabase SQL Editor for your new project and execute the SQL statements in [`database.sql`](./supabase/migrations/20250618064101_configure_powersync.sql) to create the database schema, database functions, and publication needed for PowerSync. 3. Enable "anonymous sign-ins" for the project [here](https://supabase.com/dashboard/project/_/auth/providers). ### 3. Create new project on PowerSync and connect to Supabase/Postgres diff --git a/demos/yjs-react-supabase-text-collab/powersync.yaml b/demos/yjs-react-supabase-text-collab/powersync.yaml new file mode 100644 index 000000000..089596618 --- /dev/null +++ b/demos/yjs-react-supabase-text-collab/powersync.yaml @@ -0,0 +1,52 @@ +# yaml-language-server: $schema=https://unpkg.com/@powersync/service-schema@latest/json-schema/powersync-config.json + +# This is a local development configuration file for PowerSync. + +# Note that this example uses YAML custom tags for environment variable substitution. +# Using `!env [variable name]` will substitute the value of the environment variable named +# [variable name]. +# +# Only environment variables with names starting with `PS_` can be substituted. +# +# If using VS Code see the `.vscode/settings.json` definitions which define custom tags. + +# Settings for telemetry reporting +# See https://docs.powersync.com/self-hosting/telemetry +telemetry: + # Opt out of reporting anonymized usage metrics to PowerSync telemetry service + disable_telemetry_sharing: false + +# Settings for source database replication +replication: + # Specify database connection details + # Note only 1 connection is currently supported + # Multiple connection support is on the roadmap + connections: + - type: postgresql + # The PowerSync server container can access the Postgres DB via the DB's service name. + # In this case the hostname is pg-db + + # The connection URI or individual parameters can be specified. + # Individual params take precedence over URI params + uri: postgresql://postgres:postgres@supabase_db_yjs-react-supabase-text-collab:5432/postgres + + # SSL settings + sslmode: disable # 'verify-full' (default) or 'verify-ca' or 'disable' + +# This is valid if using the `mongo` service defined in `ps-mongo.yaml` + +# Connection settings for sync bucket storage +storage: + # This uses Postgres bucket storage for simplicity + type: postgresql + uri: postgresql://postgres:postgres@supabase_db_yjs-react-supabase-text-collab:5432/postgres + # SSL settings + sslmode: disable # 'verify-full' (default) or 'verify-ca' or 'disable' + +# The port which the PowerSync API server will listen on +port: 8080 + +# Client (application end user) authentication settings +client_auth: + supabase: true + supabase_jwt_secret: !env PS_SUPABASE_JWT_SECRET diff --git a/demos/yjs-react-supabase-text-collab/supabase/config.toml b/demos/yjs-react-supabase-text-collab/supabase/config.toml index b3bdbbad6..62883d2db 100644 --- a/demos/yjs-react-supabase-text-collab/supabase/config.toml +++ b/demos/yjs-react-supabase-text-collab/supabase/config.toml @@ -79,6 +79,8 @@ enable_refresh_token_rotation = true refresh_token_reuse_interval = 10 # Allow/disallow new user signups to your project. enable_signup = true +enable_anonymous_sign_ins = true + [auth.email] # Allow/disallow new user signups via email to your project. diff --git a/demos/yjs-react-supabase-text-collab/database.sql b/demos/yjs-react-supabase-text-collab/supabase/migrations/20250618064101_configure_powersync.sql similarity index 100% rename from demos/yjs-react-supabase-text-collab/database.sql rename to demos/yjs-react-supabase-text-collab/supabase/migrations/20250618064101_configure_powersync.sql diff --git a/demos/yjs-react-supabase-text-collab/sync-rules.yaml b/demos/yjs-react-supabase-text-collab/sync-rules.yaml index d8b77a6e1..bbf450355 100644 --- a/demos/yjs-react-supabase-text-collab/sync-rules.yaml +++ b/demos/yjs-react-supabase-text-collab/sync-rules.yaml @@ -1,3 +1,5 @@ +# yaml-language-server: $schema=https://unpkg.com/@powersync/service-sync-rules@latest/schema/sync_rules.json + # Sync-rule docs: https://docs.powersync.com/usage/sync-rules bucket_definitions: documents: From 7afab68324654591ad95dbef779b312e1f8a0f09 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Wed, 18 Jun 2025 19:15:42 +0200 Subject: [PATCH 53/75] Update YJS demo to use differential queries --- .../CHANGELOG.md | 8 ++ .../package.json | 2 +- .../src/app/editor/page.tsx | 17 +++- .../components/providers/SystemProvider.tsx | 3 +- .../src/library/powersync/AppSchema.ts | 5 +- .../library/powersync/PowerSyncYjsProvider.ts | 94 ++++++++++++------- .../functions/merge-document-updates/index.ts | 3 +- .../20250618064101_configure_powersync.sql | 8 +- .../sync-rules.yaml | 8 +- 9 files changed, 96 insertions(+), 52 deletions(-) diff --git a/demos/yjs-react-supabase-text-collab/CHANGELOG.md b/demos/yjs-react-supabase-text-collab/CHANGELOG.md index 908563a2a..2b726a157 100644 --- a/demos/yjs-react-supabase-text-collab/CHANGELOG.md +++ b/demos/yjs-react-supabase-text-collab/CHANGELOG.md @@ -1,5 +1,13 @@ # yjs-react-supabase-text-collab +## 0.2.0 + +- Added a local development option with local Supabase and PowerSync services. +- Updated Sync rules to use client parameters. Each client now only syncs `document` and `document_updates` for the document being edited. +- Updated `PowerSyncYjsProvider` to use an incremental watched query for `document_updates`. + - Added a `editor_id` column to the `document_updates` table. This tracks which editor created the update and avoids reapplying updates in the source editor. + - The incremental watched query now applies updates from external editors. + ## 0.1.16 ### Patch Changes diff --git a/demos/yjs-react-supabase-text-collab/package.json b/demos/yjs-react-supabase-text-collab/package.json index 64605392c..1f8c469d1 100644 --- a/demos/yjs-react-supabase-text-collab/package.json +++ b/demos/yjs-react-supabase-text-collab/package.json @@ -1,6 +1,6 @@ { "name": "yjs-react-supabase-text-collab", - "version": "0.1.16", + "version": "0.2.0", "private": true, "scripts": { "dev": "vite", diff --git a/demos/yjs-react-supabase-text-collab/src/app/editor/page.tsx b/demos/yjs-react-supabase-text-collab/src/app/editor/page.tsx index c7cf44920..c5b808608 100644 --- a/demos/yjs-react-supabase-text-collab/src/app/editor/page.tsx +++ b/demos/yjs-react-supabase-text-collab/src/app/editor/page.tsx @@ -1,22 +1,23 @@ -import { usePowerSync, useQuery, useStatus } from '@powersync/react'; -import { Box, Container, FormControlLabel, Switch, Typography } from '@mui/material'; -import { useEffect, useMemo, useState } from 'react'; +import { connector, useSupabase } from '@/components/providers/SystemProvider'; import MenuBar from '@/components/widgets/MenuBar'; import { PowerSyncYjsProvider } from '@/library/powersync/PowerSyncYjsProvider'; +import { Box, Container, FormControlLabel, Switch, Typography } from '@mui/material'; +import { usePowerSync, useQuery, useStatus } from '@powersync/react'; import Collaboration from '@tiptap/extension-collaboration'; import Highlight from '@tiptap/extension-highlight'; import TaskItem from '@tiptap/extension-task-item'; import TaskList from '@tiptap/extension-task-list'; import { EditorContent, useEditor } from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; +import { useEffect, useMemo, useState } from 'react'; +import { useParams } from 'react-router-dom'; import * as Y from 'yjs'; import './tiptap-styles.scss'; -import { useParams } from 'react-router-dom'; -import { connector } from '@/components/providers/SystemProvider'; export default function EditorPage() { const powerSync = usePowerSync(); const status = useStatus(); + const supabase = useSupabase(); const { id: documentId } = useParams(); // cache the last edited document ID in local storage @@ -33,6 +34,12 @@ export default function EditorPage() { useEffect(() => { const provider = new PowerSyncYjsProvider(ydoc, powerSync, documentId!); + // Only sync changes for this document + powerSync.connect(supabase!, { + params: { + document_id: documentId! + } + }); return () => { provider.destroy(); }; diff --git a/demos/yjs-react-supabase-text-collab/src/components/providers/SystemProvider.tsx b/demos/yjs-react-supabase-text-collab/src/components/providers/SystemProvider.tsx index a939d8b40..7cbd439d8 100644 --- a/demos/yjs-react-supabase-text-collab/src/components/providers/SystemProvider.tsx +++ b/demos/yjs-react-supabase-text-collab/src/components/providers/SystemProvider.tsx @@ -1,8 +1,8 @@ import { AppSchema } from '@/library/powersync/AppSchema'; import { SupabaseConnector } from '@/library/powersync/SupabaseConnector'; +import { CircularProgress } from '@mui/material'; import { PowerSyncContext } from '@powersync/react'; import { createBaseLogger, LogLevel, PowerSyncDatabase } from '@powersync/web'; -import { CircularProgress } from '@mui/material'; import React, { Suspense } from 'react'; const SupabaseContext = React.createContext(null); @@ -13,7 +13,6 @@ export const powerSync = new PowerSyncDatabase({ schema: AppSchema }); export const connector = new SupabaseConnector(); -powerSync.connect(connector); const logger = createBaseLogger(); logger.useDefaults(); diff --git a/demos/yjs-react-supabase-text-collab/src/library/powersync/AppSchema.ts b/demos/yjs-react-supabase-text-collab/src/library/powersync/AppSchema.ts index 15cf6519b..e79076297 100644 --- a/demos/yjs-react-supabase-text-collab/src/library/powersync/AppSchema.ts +++ b/demos/yjs-react-supabase-text-collab/src/library/powersync/AppSchema.ts @@ -9,7 +9,10 @@ const document_updates = new Table( { document_id: column.text, created_at: column.text, - update_b64: column.text + update_b64: column.text, + // Store an id of whom the update was created by. + // This is only used to not reapply updates which were created by the local editor. + editor_id: column.text }, { indexes: { by_document: ['document_id'] } } ); diff --git a/demos/yjs-react-supabase-text-collab/src/library/powersync/PowerSyncYjsProvider.ts b/demos/yjs-react-supabase-text-collab/src/library/powersync/PowerSyncYjsProvider.ts index 1b0e28447..c1bcf051b 100644 --- a/demos/yjs-react-supabase-text-collab/src/library/powersync/PowerSyncYjsProvider.ts +++ b/demos/yjs-react-supabase-text-collab/src/library/powersync/PowerSyncYjsProvider.ts @@ -1,9 +1,10 @@ import * as Y from 'yjs'; import { b64ToUint8Array, Uint8ArrayTob64 } from '@/library/binary-utils'; -import { v4 as uuidv4 } from 'uuid'; -import { AbstractPowerSyncDatabase } from '@powersync/web'; +import { AbstractPowerSyncDatabase, GetAllQuery, IncrementalWatchMode } from '@powersync/web'; import { ObservableV2 } from 'lib0/observable'; +import { v4 as uuidv4 } from 'uuid'; +import { DocumentUpdates } from './AppSchema'; export interface PowerSyncYjsEvents { /** @@ -24,8 +25,9 @@ export interface PowerSyncYjsEvents { * @param documentId */ export class PowerSyncYjsProvider extends ObservableV2 { - private seenDocUpdates = new Set(); private abortController = new AbortController(); + // This ID is updated on every new instance of the provider. + private id = uuidv4(); constructor( public readonly doc: Y.Doc, @@ -34,57 +36,72 @@ export class PowerSyncYjsProvider extends ObservableV2 { ) { super(); - const updates = db.watch('SELECT * FROM document_updates WHERE document_id = ?', [documentId], { - signal: this.abortController.signal + /** + * Watch for changes to the `document_updates` table for this document. + * This will be used to apply updates from other editors. + * When we received an added item we apply the update to the Yjs document. + */ + const updateQuery = db.incrementalWatch({ mode: IncrementalWatchMode.DIFFERENTIAL }).build({ + watch: { + query: new GetAllQuery({ + sql: /* sql */ ` + SELECT + * + FROM + document_updates + WHERE + document_id = ? + AND editor_id != ? + `, + parameters: [documentId, this.id] + }) + } }); + this.abortController.signal.addEventListener( + 'abort', + () => { + // Stop the watch query when the abort signal is triggered + updateQuery.close(); + }, + { once: true } + ); + this._storeUpdate = this._storeUpdate.bind(this); this.destroy = this.destroy.bind(this); let synced = false; - const watchLoop = async () => { - for await (const results of updates) { - if (this.abortController.signal.aborted) { - break; + updateQuery.subscribe({ + onData: async (diff) => { + for (const added of diff.added) { + Y.applyUpdateV2(doc, b64ToUint8Array(added.update_b64)); } - - // New data detected in the database - for (const update of results.rows!._array) { - // Ignore any updates we've already seen - if (!this.seenDocUpdates.has(update.id)) { - this.seenDocUpdates.add(update.id); - // apply the update from the database to the doc - const origin = this; - Y.applyUpdateV2(doc, b64ToUint8Array(update.update_b64), origin); - } - } - if (!synced) { synced = true; this.emit('synced', []); } + }, + onError: (error) => { + console.error('Error in PowerSyncYjsProvider update query:', error); } - }; - watchLoop(); + }); doc.on('updateV2', this._storeUpdate); doc.on('destroy', this.destroy); } private async _storeUpdate(update: Uint8Array, origin: any) { - if (origin === this) { - // update originated from the database / PowerSync - ignore - return; - } // update originated from elsewhere - save to the database - const docUpdateId = uuidv4(); - this.seenDocUpdates.add(docUpdateId); - await this.db.execute('INSERT INTO document_updates(id, document_id, update_b64) VALUES(?, ?, ?)', [ - docUpdateId, - this.documentId, - Uint8ArrayTob64(update) - ]); + await this.db.execute( + /* sql */ ` + INSERT INTO + document_updates (id, document_id, update_b64, editor_id) + VALUES + (uuid (), ?, ?, ?) + `, + [this.documentId, Uint8ArrayTob64(update), this.id] + ); } /** @@ -102,6 +119,13 @@ export class PowerSyncYjsProvider extends ObservableV2 { * Also call `destroy()` to remove any event listeners and prevent future updates to the database. */ async deleteData() { - await this.db.execute('DELETE FROM document_updates WHERE document_id = ?', [this.documentId]); + await this.db.execute( + /* sql */ ` + DELETE FROM document_updates + WHERE + document_id = ? + `, + [this.documentId] + ); } } diff --git a/demos/yjs-react-supabase-text-collab/supabase/functions/merge-document-updates/index.ts b/demos/yjs-react-supabase-text-collab/supabase/functions/merge-document-updates/index.ts index 8e23df2fe..5142e2853 100644 --- a/demos/yjs-react-supabase-text-collab/supabase/functions/merge-document-updates/index.ts +++ b/demos/yjs-react-supabase-text-collab/supabase/functions/merge-document-updates/index.ts @@ -55,7 +55,8 @@ Deno.serve(async (req) => { // insert the new merged update as new single update for the document const supabaseInsert = await supabase.from('document_updates').insert({ document_id: document_id, - update_data: Uint8ArrayToHex(docState) + update_data: Uint8ArrayToHex(docState), + editor_id: 'merged_update' }); if (supabaseInsert.error) { throw new Error(supabaseInsert.error); diff --git a/demos/yjs-react-supabase-text-collab/supabase/migrations/20250618064101_configure_powersync.sql b/demos/yjs-react-supabase-text-collab/supabase/migrations/20250618064101_configure_powersync.sql index ecc41e43a..61209693f 100644 --- a/demos/yjs-react-supabase-text-collab/supabase/migrations/20250618064101_configure_powersync.sql +++ b/demos/yjs-react-supabase-text-collab/supabase/migrations/20250618064101_configure_powersync.sql @@ -10,7 +10,8 @@ CREATE TABLE document_updates( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), created_at timestamptz DEFAULT now(), document_id UUID, - update_data BYTEA + update_data BYTEA, + editor_id UUID ); @@ -27,11 +28,12 @@ $$ LANGUAGE SQL; CREATE OR REPLACE FUNCTION insert_document_updates(batch TEXT) RETURNS VOID AS $$ BEGIN - INSERT INTO document_updates (id, document_id, update_data) + INSERT INTO document_updates (id, document_id, update_data, editor_id) SELECT (elem->>'id')::UUID, (elem->>'document_id')::UUID, - decode(elem->>'update_b64', 'base64') + decode(elem->>'update_b64', 'base64'), + (elem->>'editor_id')::UUID FROM json_array_elements(batch::json) AS elem ON CONFLICT (id) DO NOTHING; END; diff --git a/demos/yjs-react-supabase-text-collab/sync-rules.yaml b/demos/yjs-react-supabase-text-collab/sync-rules.yaml index bbf450355..0dfb4c242 100644 --- a/demos/yjs-react-supabase-text-collab/sync-rules.yaml +++ b/demos/yjs-react-supabase-text-collab/sync-rules.yaml @@ -3,10 +3,10 @@ # Sync-rule docs: https://docs.powersync.com/usage/sync-rules bucket_definitions: documents: - data: - - SELECT * FROM documents - updates: # Allow remote changes to be synchronized even while there are local changes priority: 0 + parameters: SELECT (request.parameters() ->> 'document_id') as document_id data: - - SELECT id, document_id, base64(update_data) as update_b64 FROM document_updates + - SELECT * FROM documents WHERE id = bucket.document_id + - SELECT * FROM documents WHERE id = bucket.document_id + - SELECT id, document_id, base64(update_data) as update_b64, editor_id FROM document_updates WHERE document_id = bucket.document_id From 306cf4399b4d9f9bd5eeb7ee5eb09fca9e09b45d Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Thu, 19 Jun 2025 14:21:58 +0200 Subject: [PATCH 54/75] unify watched query listeners and subscriptions to consistent API --- .../components/providers/SystemProvider.tsx | 2 +- .../powersync.yaml | 5 -- .../library/powersync/PowerSyncYjsProvider.ts | 2 +- .../src/client/AbstractPowerSyncDatabase.ts | 4 +- .../common/src/client/watched/GetAllQuery.ts | 14 ++-- .../common/src/client/watched/WatchedQuery.ts | 35 ++++---- .../processors/AbstractQueryProcessor.ts | 37 +-------- .../ComparisonWatchedQueryBuilder.ts | 21 ++++- .../processors/DifferentialQueryProcessor.ts | 32 +++++++- .../DifferentialWatchedQueryBuilder.ts | 40 ++++++--- .../client/watched/processors/comparators.ts | 2 +- packages/common/src/utils/BaseObserver.ts | 6 +- packages/common/src/utils/MetaBaseObserver.ts | 81 +++++++++++++++++++ .../kysely-driver/tests/sqlite/watch.test.ts | 2 +- packages/react/src/QueryStore.ts | 23 +++++- .../src/hooks/suspense/suspense-utils.ts | 4 +- .../useWatchedQuerySuspenseSubscription.ts | 2 +- .../src/hooks/watched/useWatchedQuery.ts | 2 +- .../watched/useWatchedQuerySubscription.ts | 2 +- .../react/tests/useSuspenseQuery.test.tsx | 13 ++- .../vue/src/composables/useWatchedQuery.ts | 2 +- packages/web/tests/watch.test.ts | 14 ++-- 22 files changed, 229 insertions(+), 116 deletions(-) create mode 100644 packages/common/src/utils/MetaBaseObserver.ts diff --git a/demos/react-supabase-todolist/src/components/providers/SystemProvider.tsx b/demos/react-supabase-todolist/src/components/providers/SystemProvider.tsx index 911a4f66c..a0b981f8c 100644 --- a/demos/react-supabase-todolist/src/components/providers/SystemProvider.tsx +++ b/demos/react-supabase-todolist/src/components/providers/SystemProvider.tsx @@ -72,7 +72,7 @@ export const SystemProvider = ({ children }: { children: React.ReactNode }) => { }); // This updates a cache in order to display results instantly on page load. - listsQuery.subscribe({ + listsQuery.registerListener({ onData: (data) => { // Store the data in localStorage for instant caching localStorage.setItem('listscache', JSON.stringify(data)); diff --git a/demos/yjs-react-supabase-text-collab/powersync.yaml b/demos/yjs-react-supabase-text-collab/powersync.yaml index 089596618..bedd899f4 100644 --- a/demos/yjs-react-supabase-text-collab/powersync.yaml +++ b/demos/yjs-react-supabase-text-collab/powersync.yaml @@ -23,11 +23,6 @@ replication: # Multiple connection support is on the roadmap connections: - type: postgresql - # The PowerSync server container can access the Postgres DB via the DB's service name. - # In this case the hostname is pg-db - - # The connection URI or individual parameters can be specified. - # Individual params take precedence over URI params uri: postgresql://postgres:postgres@supabase_db_yjs-react-supabase-text-collab:5432/postgres # SSL settings diff --git a/demos/yjs-react-supabase-text-collab/src/library/powersync/PowerSyncYjsProvider.ts b/demos/yjs-react-supabase-text-collab/src/library/powersync/PowerSyncYjsProvider.ts index c1bcf051b..524709f2f 100644 --- a/demos/yjs-react-supabase-text-collab/src/library/powersync/PowerSyncYjsProvider.ts +++ b/demos/yjs-react-supabase-text-collab/src/library/powersync/PowerSyncYjsProvider.ts @@ -72,7 +72,7 @@ export class PowerSyncYjsProvider extends ObservableV2 { let synced = false; - updateQuery.subscribe({ + updateQuery.registerListener({ onData: async (diff) => { for (const added of diff.added) { Y.applyUpdateV2(doc, b64ToUint8Array(added.update_b64)); diff --git a/packages/common/src/client/AbstractPowerSyncDatabase.ts b/packages/common/src/client/AbstractPowerSyncDatabase.ts index 02937e6f0..3f4de6bb7 100644 --- a/packages/common/src/client/AbstractPowerSyncDatabase.ts +++ b/packages/common/src/client/AbstractPowerSyncDatabase.ts @@ -921,7 +921,7 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver { if (!data) { // This should not happen. We only use null for the initial data. diff --git a/packages/common/src/client/watched/GetAllQuery.ts b/packages/common/src/client/watched/GetAllQuery.ts index b93fecda8..116fe1fd4 100644 --- a/packages/common/src/client/watched/GetAllQuery.ts +++ b/packages/common/src/client/watched/GetAllQuery.ts @@ -9,16 +9,16 @@ export type GetAllQueryOptions = { sql: string; parameters?: ReadonlyArray; /** - * Optional transformer function to convert raw rows into the desired RowType. + * Optional mapper function to convert raw rows into the desired RowType. * @example - * ```typescript - * (rawRow: Record) => ({ - * id: rawRow.id as string, + * ```javascript + * (rawRow) => ({ + * id: rawRow.id, * created_at: new Date(rawRow.created_at), * }) * ``` */ - transformer?: (rawRow: Record) => RowType; + mapper?: (rawRow: Record) => RowType; }; /** @@ -38,8 +38,8 @@ export class GetAllQuery implements WatchCompatibleQuery(sql, [...parameters]); - if (this.options.transformer) { - return rawResult.map(this.options.transformer); + if (this.options.mapper) { + return rawResult.map(this.options.mapper); } return rawResult as RowType[]; } diff --git a/packages/common/src/client/watched/WatchedQuery.ts b/packages/common/src/client/watched/WatchedQuery.ts index db361c0dd..e4e2ad10d 100644 --- a/packages/common/src/client/watched/WatchedQuery.ts +++ b/packages/common/src/client/watched/WatchedQuery.ts @@ -1,7 +1,11 @@ import { CompiledQuery } from '../../types/types.js'; -import { BaseListener, BaseObserverInterface } from '../../utils/BaseObserver.js'; +import { BaseListener } from '../../utils/BaseObserver.js'; +import { MetaBaseObserverInterface } from '../../utils/MetaBaseObserver.js'; import { AbstractPowerSyncDatabase } from '../AbstractPowerSyncDatabase.js'; +/** + * State for {@link WatchedQuery} instances. + */ export interface WatchedQueryState { /** * Indicates the initial loading state (hard loading). @@ -57,29 +61,22 @@ export interface WatchedQueryOptions { reportFetching?: boolean; } -export enum WatchedQuerySubscriptionEvent { +export enum WatchedQueryListenerEvent { ON_DATA = 'onData', ON_ERROR = 'onError', - ON_STATE_CHANGE = 'onStateChange' -} - -export interface WatchedQuerySubscription { - [WatchedQuerySubscriptionEvent.ON_DATA]?: (data: Data) => void | Promise; - [WatchedQuerySubscriptionEvent.ON_ERROR]?: (error: Error) => void | Promise; - [WatchedQuerySubscriptionEvent.ON_STATE_CHANGE]?: (state: WatchedQueryState) => void | Promise; + ON_STATE_CHANGE = 'onStateChange', + CLOSED = 'closed' } -export type SubscriptionCounts = Record & { - total: number; -}; - -export interface WatchedQueryListener extends BaseListener { - closed: () => void; - subscriptionsChanged: (counts: SubscriptionCounts) => void; +export interface WatchedQueryListener extends BaseListener { + [WatchedQueryListenerEvent.ON_DATA]?: (data: Data) => void | Promise; + [WatchedQueryListenerEvent.ON_ERROR]?: (error: Error) => void | Promise; + [WatchedQueryListenerEvent.ON_STATE_CHANGE]?: (state: WatchedQueryState) => void | Promise; + [WatchedQueryListenerEvent.CLOSED]?: () => void | Promise; } export interface WatchedQuery - extends BaseObserverInterface { + extends MetaBaseObserverInterface> { /** * Current state of the watched query. */ @@ -87,13 +84,11 @@ export interface WatchedQuery): () => void; + registerListener(listener: WatchedQueryListener): () => void; /** * Updates the underlying query options. diff --git a/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts b/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts index 8a165e35f..dd0f55ea2 100644 --- a/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts +++ b/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts @@ -1,14 +1,6 @@ import { AbstractPowerSyncDatabase } from '../../../client/AbstractPowerSyncDatabase.js'; -import { BaseObserver } from '../../../utils/BaseObserver.js'; -import { - SubscriptionCounts, - WatchedQuery, - WatchedQueryListener, - WatchedQueryOptions, - WatchedQueryState, - WatchedQuerySubscription, - WatchedQuerySubscriptionEvent -} from '../WatchedQuery.js'; +import { MetaBaseObserver } from '../../../utils/MetaBaseObserver.js'; +import { WatchedQuery, WatchedQueryListener, WatchedQueryOptions, WatchedQueryState } from '../WatchedQuery.js'; /** * @internal @@ -27,7 +19,7 @@ export interface LinkQueryOptions = WatchedQuerySubscription & WatchedQueryListener; +type WatchedQueryProcessorListener = WatchedQueryListener; /** * Performs underlying watching and yields a stream of results. @@ -37,7 +29,7 @@ export abstract class AbstractQueryProcessor< Data = unknown[], Settings extends WatchedQueryOptions = WatchedQueryOptions > - extends BaseObserver> + extends MetaBaseObserver> implements WatchedQuery { readonly state: WatchedQueryState; @@ -51,15 +43,6 @@ export abstract class AbstractQueryProcessor< return this._closed; } - get subscriptionCounts() { - const listenersArray = Array.from(this.listeners); - return Object.values(WatchedQuerySubscriptionEvent).reduce((totals: Partial, key) => { - totals[key] = listenersArray.filter((l) => !!l[key]).length; - totals.total = (totals.total ?? 0) + totals[key]; - return totals; - }, {}) as SubscriptionCounts; - } - constructor(protected options: AbstractQueryProcessorOptions) { super(); this.abortController = new AbortController(); @@ -156,18 +139,6 @@ export abstract class AbstractQueryProcessor< }); } - subscribe(subscription: WatchedQuerySubscription): () => void { - // hook in to subscription events in order to report changes - const baseDispose = this.registerListener({ ...subscription }); - - this.iterateListeners((l) => l.subscriptionsChanged?.(this.subscriptionCounts)); - - return () => { - baseDispose(); - this.iterateListeners((l) => l.subscriptionsChanged?.(this.subscriptionCounts)); - }; - } - async close() { await this.initialized; this.abortController.abort(); diff --git a/packages/common/src/client/watched/processors/ComparisonWatchedQueryBuilder.ts b/packages/common/src/client/watched/processors/ComparisonWatchedQueryBuilder.ts index 6b6b4466e..93f1d07c4 100644 --- a/packages/common/src/client/watched/processors/ComparisonWatchedQueryBuilder.ts +++ b/packages/common/src/client/watched/processors/ComparisonWatchedQueryBuilder.ts @@ -4,11 +4,28 @@ import { WatchedQueryBuilder } from '../WatchedQueryBuilder.js'; import { WatchedQueryComparator } from './comparators.js'; import { ComparisonWatchedQuerySettings, OnChangeQueryProcessor } from './OnChangeQueryProcessor.js'; +/** + * Options for building incrementally watched queries that compare the result set. + * It uses a comparator to determine if the result set has changed since the last update. + * If the result set has changed, it emits the new result set. + */ export interface ComparisonWatchProcessorOptions { comparator?: WatchedQueryComparator; watch: ComparisonWatchedQuerySettings; } +/** + * Default implementation of the {@link WatchedQueryComparator} for watched queries. + * It uses JSON stringification to compare the entire result set. + * Array based results should use {@link ArrayComparator} for more efficient item comparison. + */ +export const DEFAULT_WATCHED_QUERY_COMPARATOR: WatchedQueryComparator = { + checkEquality: (a, b) => JSON.stringify(a) === JSON.stringify(b) +}; + +/** + * Builds an incrementally watched query that emits results after comparing the result set for changes. + */ export class ComparisonWatchedQueryBuilder implements WatchedQueryBuilder { constructor(protected db: AbstractPowerSyncDatabase) {} @@ -41,9 +58,7 @@ export class ComparisonWatchedQueryBuilder implements WatchedQueryBuilder { ): WatchedQuery> { return new OnChangeQueryProcessor({ db: this.db, - comparator: options.comparator ?? { - checkEquality: (a, b) => JSON.stringify(a) == JSON.stringify(b) - }, + comparator: options.comparator ?? DEFAULT_WATCHED_QUERY_COMPARATOR, watchOptions: options.watch, placeholderData: options.watch.placeholderData }); diff --git a/packages/common/src/client/watched/processors/DifferentialQueryProcessor.ts b/packages/common/src/client/watched/processors/DifferentialQueryProcessor.ts index 6215425f8..910ac07f2 100644 --- a/packages/common/src/client/watched/processors/DifferentialQueryProcessor.ts +++ b/packages/common/src/client/watched/processors/DifferentialQueryProcessor.ts @@ -1,24 +1,44 @@ import { WatchCompatibleQuery, WatchedQuery, WatchedQueryOptions, WatchedQueryState } from '../WatchedQuery.js'; import { AbstractQueryProcessor, AbstractQueryProcessorOptions, LinkQueryOptions } from './AbstractQueryProcessor.js'; -export interface Differential { +/** + * Represents an updated row in a differential watched query. + * It contains both the current and previous state of the row. + */ +export interface WatchedQueryRowDifferential { current: RowType; previous: RowType; } +/** + * Represents the result of a watched query that has been differentiated. + * {@link WatchedQueryState.data} is of the {@link WatchedQueryDifferential} form when using the {@link IncrementalWatchMode.DIFFERENTIAL} mode. + */ export interface WatchedQueryDifferential { added: RowType[]; all: RowType[]; removed: RowType[]; - updated: Differential[]; + updated: WatchedQueryRowDifferential[]; unchanged: RowType[]; } -export interface Differentiator { +/** + * Differentiator for incremental watched queries which allows to identify and compare items in the result set. + */ +export interface WatchedQueryDifferentiator { + /** + * Unique identifier for the item. + */ identify: (item: RowType) => string; + /** + * Generates a key for comparing items with matching identifiers. + */ compareBy: (item: RowType) => string; } +/** + * Settings for incremental watched queries using the {@link IncrementalWatchMode.DIFFERENTIAL} mode. + */ export interface DifferentialWatchedQuerySettings extends WatchedQueryOptions { /** * The query here must return an array of items that can be differentiated. @@ -37,11 +57,15 @@ export interface DifferentialWatchedQuerySettings extends WatchedQueryO */ export interface DifferentialQueryProcessorOptions extends AbstractQueryProcessorOptions, DifferentialWatchedQuerySettings> { - differentiator: Differentiator; + differentiator: WatchedQueryDifferentiator; } type DataHashMap = Map; +/** + * An empty differential result set. + * This is used as the initial state for differential incrementally watched queries. + */ export const EMPTY_DIFFERENTIAL = { added: [], all: [], diff --git a/packages/common/src/client/watched/processors/DifferentialWatchedQueryBuilder.ts b/packages/common/src/client/watched/processors/DifferentialWatchedQueryBuilder.ts index 803449d20..34754de1b 100644 --- a/packages/common/src/client/watched/processors/DifferentialWatchedQueryBuilder.ts +++ b/packages/common/src/client/watched/processors/DifferentialWatchedQueryBuilder.ts @@ -4,16 +4,38 @@ import { WatchedQueryBuilder } from '../WatchedQueryBuilder.js'; import { DifferentialQueryProcessor, DifferentialWatchedQuerySettings, - Differentiator, EMPTY_DIFFERENTIAL, - WatchedQueryDifferential + WatchedQueryDifferential, + WatchedQueryDifferentiator } from './DifferentialQueryProcessor.js'; +/** + * Options for creating an incrementally watched query that emits differential results. + * + */ export type DifferentialWatchedQueryBuilderOptions = { - differentiator?: Differentiator; + differentiator?: WatchedQueryDifferentiator; watch: DifferentialWatchedQuerySettings; }; +/** + * Default implementation of the {@link Differentiator} for watched queries. + * It identifies items by their `id` property if available, otherwise it uses JSON stringification + * of the entire item for identification and comparison. + */ +export const DEFAULT_WATCHED_QUERY_DIFFERENTIATOR: WatchedQueryDifferentiator = { + identify: (item) => { + if (item && typeof item == 'object' && typeof item['id'] == 'string') { + return item['id']; + } + return JSON.stringify(item); + }, + compareBy: (item) => JSON.stringify(item) +}; + +/** + * Builds a watched query which emits differential results based on the provided differentiator. + */ export class DifferentialWatchedQueryBuilder implements WatchedQueryBuilder { constructor(protected db: AbstractPowerSyncDatabase) {} @@ -39,7 +61,7 @@ export class DifferentialWatchedQueryBuilder implements WatchedQueryBuilder { * FROM * assets * ', - * transformer: (raw) => { + * mapper: (raw) => { * return { * id: raw.id as string, * make: raw.make as string @@ -55,15 +77,7 @@ export class DifferentialWatchedQueryBuilder implements WatchedQueryBuilder { ): WatchedQuery, DifferentialWatchedQuerySettings> { return new DifferentialQueryProcessor({ db: this.db, - differentiator: options.differentiator ?? { - identify: (item: RowType) => { - if (item && typeof item == 'object' && typeof item['id'] == 'string') { - return item['id']; - } - return JSON.stringify(item); - }, - compareBy: (item: RowType) => JSON.stringify(item) - }, + differentiator: options.differentiator ?? DEFAULT_WATCHED_QUERY_DIFFERENTIATOR, watchOptions: options.watch, placeholderData: options.watch.placeholderData ?? EMPTY_DIFFERENTIAL }); diff --git a/packages/common/src/client/watched/processors/comparators.ts b/packages/common/src/client/watched/processors/comparators.ts index 9d8552f8a..827630e9c 100644 --- a/packages/common/src/client/watched/processors/comparators.ts +++ b/packages/common/src/client/watched/processors/comparators.ts @@ -13,7 +13,7 @@ export type ArrayComparatorOptions = { }; /** - * Compares array results of watched queries. + * Compares array results of watched queries for incrementally watched queries created in the {@link IncrementalWatchMode.COMPARISON} mode. */ export class ArrayComparator implements WatchedQueryComparator { constructor(protected options: ArrayComparatorOptions) {} diff --git a/packages/common/src/utils/BaseObserver.ts b/packages/common/src/utils/BaseObserver.ts index df56e9e61..5ad4bf05d 100644 --- a/packages/common/src/utils/BaseObserver.ts +++ b/packages/common/src/utils/BaseObserver.ts @@ -2,14 +2,12 @@ export interface Disposable { dispose: () => Promise; } +export type BaseListener = Record any) | undefined>; + export interface BaseObserverInterface { registerListener(listener: Partial): () => void; } -export type BaseListener = { - [key: string]: ((...event: any) => any) | undefined; -}; - export class BaseObserver implements BaseObserverInterface { protected listeners = new Set>(); diff --git a/packages/common/src/utils/MetaBaseObserver.ts b/packages/common/src/utils/MetaBaseObserver.ts new file mode 100644 index 000000000..73bb038f8 --- /dev/null +++ b/packages/common/src/utils/MetaBaseObserver.ts @@ -0,0 +1,81 @@ +import { BaseListener, BaseObserver, BaseObserverInterface } from './BaseObserver.js'; + +/** + * Represents the counts of listeners for each event type in a BaseListener. + */ +export type ListenerCounts = Partial> & { + total: number; +}; + +/** + * Meta listener which reports the counts of listeners for each event type. + */ +export interface MetaListener extends BaseListener { + listenersChanged?: (counts: ListenerCounts) => void; +} + +export interface ListenerMetaManager + extends BaseObserverInterface> { + counts: ListenerCounts; +} + +export interface MetaBaseObserverInterface extends BaseObserverInterface { + listenerMeta: ListenerMetaManager; +} + +/** + * A BaseObserver that tracks the counts of listeners for each event type. + */ +export class MetaBaseObserver + extends BaseObserver + implements MetaBaseObserverInterface +{ + protected get listenerCounts(): ListenerCounts { + const counts = {} as Partial>; + let total = 0; + for (const listener of this.listeners) { + for (const key in listener) { + if (listener[key]) { + counts[key] = (counts[key] ?? 0) + 1; + total++; + } + } + } + return { + ...counts, + total + }; + } + + get listenerMeta(): ListenerMetaManager { + return { + counts: this.listenerCounts, + // Allows registering a meta listener that will be notified of changes in listener counts + registerListener: (listener: Partial>) => { + return this.metaListener.registerListener(listener); + } + }; + } + + protected metaListener: BaseObserver>; + + constructor() { + super(); + this.metaListener = new BaseObserver>(); + } + + registerListener(listener: Partial): () => void { + const dispose = super.registerListener(listener); + const updatedCount = this.listenerCounts; + this.metaListener.iterateListeners((l) => { + l.listenersChanged?.(updatedCount); + }); + return () => { + dispose(); + const updatedCount = this.listenerCounts; + this.metaListener.iterateListeners((l) => { + l.listenersChanged?.(updatedCount); + }); + }; + } +} diff --git a/packages/kysely-driver/tests/sqlite/watch.test.ts b/packages/kysely-driver/tests/sqlite/watch.test.ts index abf4ef1bd..3b94b104f 100644 --- a/packages/kysely-driver/tests/sqlite/watch.test.ts +++ b/packages/kysely-driver/tests/sqlite/watch.test.ts @@ -273,7 +273,7 @@ describe('Watch Tests', () => { }); const latestDataPromise = new Promise>>((resolve) => { - const dispose = watch.subscribe({ + const dispose = watch.registerListener({ onData: (data) => { if (data.length > 0) { resolve(data); diff --git a/packages/react/src/QueryStore.ts b/packages/react/src/QueryStore.ts index 1ec2e741f..1a20d5d0a 100644 --- a/packages/react/src/QueryStore.ts +++ b/packages/react/src/QueryStore.ts @@ -1,4 +1,10 @@ -import { AbstractPowerSyncDatabase, IncrementalWatchMode, WatchCompatibleQuery, WatchedQuery } from '@powersync/common'; +import { + AbstractPowerSyncDatabase, + IncrementalWatchMode, + WatchCompatibleQuery, + WatchedQuery, + WatchedQueryListenerEvent +} from '@powersync/common'; import { AdditionalOptions } from './hooks/watched/watch-types'; export function generateQueryKey( @@ -39,10 +45,19 @@ export class QueryStore { } }); - watchedQuery.registerListener({ - subscriptionsChanged: (counts) => { + watchedQuery.listenerMeta.registerListener({ + listenersChanged: (counts) => { // Dispose this query if there are no subscribers present - if (counts.total == 0) { + // We don't use the total here since we don't want to consider `onclose` listeners + const relevantCounts = [ + WatchedQueryListenerEvent.ON_DATA, + WatchedQueryListenerEvent.ON_STATE_CHANGE, + WatchedQueryListenerEvent.ON_ERROR + ].reduce((sum, event) => { + return sum + (counts[event] || 0); + }, 0); + + if (relevantCounts == 0) { watchedQuery.close(); this.cache.delete(key); } diff --git a/packages/react/src/hooks/suspense/suspense-utils.ts b/packages/react/src/hooks/suspense/suspense-utils.ts index 3d1d71be0..f80621587 100644 --- a/packages/react/src/hooks/suspense/suspense-utils.ts +++ b/packages/react/src/hooks/suspense/suspense-utils.ts @@ -24,7 +24,7 @@ export const useTemporaryHold = (watchedQuery?: WatchedQuery) => { }; } - const disposeSubscription = watchedQuery.subscribe({ + const disposeSubscription = watchedQuery.registerListener({ onStateChange: (state) => {} }); @@ -77,7 +77,7 @@ export const createSuspendingPromise = (query: WatchedQuery) => { // The listener here will dispose itself once the loading is done // This decreases the number of listeners on the query // even if the component is unmounted - const dispose = query.subscribe({ + const dispose = query.registerListener({ onStateChange: (state) => { // Returns to the hook if loading is completed or if loading resulted in an error if (!state.isLoading || state.error) { diff --git a/packages/react/src/hooks/suspense/useWatchedQuerySuspenseSubscription.ts b/packages/react/src/hooks/suspense/useWatchedQuerySuspenseSubscription.ts index 21867a1c9..d243ea409 100644 --- a/packages/react/src/hooks/suspense/useWatchedQuerySuspenseSubscription.ts +++ b/packages/react/src/hooks/suspense/useWatchedQuerySuspenseSubscription.ts @@ -32,7 +32,7 @@ export const useWatchedQuerySuspenseSubscription = (query: WatchedQu React.useEffect(() => { // This runs when the component came out of suspense // This add a permanent hold since a listener has been added to the query - const dispose = query.subscribe({ + const dispose = query.registerListener({ onStateChange() { // Trigger rerender setUpdateCounter((prev) => prev + 1); diff --git a/packages/react/src/hooks/watched/useWatchedQuery.ts b/packages/react/src/hooks/watched/useWatchedQuery.ts index 56182cda3..9c24ff779 100644 --- a/packages/react/src/hooks/watched/useWatchedQuery.ts +++ b/packages/react/src/hooks/watched/useWatchedQuery.ts @@ -30,7 +30,7 @@ export const useWatchedQuery = ( }, [powerSync]); React.useEffect(() => { - const dispose = watchedQuery.subscribe({ + const dispose = watchedQuery.registerListener({ onStateChange: (state) => { setOutputState({ ...state }); } diff --git a/packages/react/src/hooks/watched/useWatchedQuerySubscription.ts b/packages/react/src/hooks/watched/useWatchedQuerySubscription.ts index 721fcc984..051c7cd30 100644 --- a/packages/react/src/hooks/watched/useWatchedQuerySubscription.ts +++ b/packages/react/src/hooks/watched/useWatchedQuerySubscription.ts @@ -21,7 +21,7 @@ export const useWatchedQuerySubscription = ( const [output, setOutputState] = React.useState(query.state); React.useEffect(() => { - const dispose = query.subscribe({ + const dispose = query.registerListener({ onStateChange: (state) => { setOutputState({ ...state }); } diff --git a/packages/react/tests/useSuspenseQuery.test.tsx b/packages/react/tests/useSuspenseQuery.test.tsx index 488ea1062..0f253fbbd 100644 --- a/packages/react/tests/useSuspenseQuery.test.tsx +++ b/packages/react/tests/useSuspenseQuery.test.tsx @@ -1,4 +1,9 @@ -import { AbstractPowerSyncDatabase, IncrementalWatchMode, WatchedQuery } from '@powersync/common'; +import { + AbstractPowerSyncDatabase, + IncrementalWatchMode, + WatchedQuery, + WatchedQueryListenerEvent +} from '@powersync/common'; import { cleanup, renderHook, screen, waitFor } from '@testing-library/react'; import React from 'react'; import { ErrorBoundary } from 'react-error-boundary'; @@ -109,12 +114,12 @@ describe('useSuspenseQuery', () => { expect(watch).toBeDefined(); expect(watch!.closed).false; expect(watch!.state.data.length).eq(1); - expect(watch!.subscriptionCounts.onStateChange).greaterThanOrEqual(2); // should have a temporary hold and state listener + expect(watch!.listenerMeta.counts[WatchedQueryListenerEvent.ON_STATE_CHANGE]).greaterThanOrEqual(2); // should have a temporary hold and state listener // wait for the temporary hold to elapse await waitFor( async () => { - expect(watch!.subscriptionCounts.onStateChange).eq(1); + expect(watch!.listenerMeta.counts[WatchedQueryListenerEvent.ON_STATE_CHANGE]).eq(1); }, { timeout: 10_000, interval: 500 } ); @@ -125,7 +130,7 @@ describe('useSuspenseQuery', () => { // wait for the temporary hold to elapse await waitFor( async () => { - expect(watch!.subscriptionCounts.onStateChange).eq(0); + expect(watch!.listenerMeta.counts[WatchedQueryListenerEvent.ON_STATE_CHANGE]).undefined; expect(watch?.closed).true; }, { timeout: 10_000, interval: 500 } diff --git a/packages/vue/src/composables/useWatchedQuery.ts b/packages/vue/src/composables/useWatchedQuery.ts index c70e9b7d0..05693aaa1 100644 --- a/packages/vue/src/composables/useWatchedQuery.ts +++ b/packages/vue/src/composables/useWatchedQuery.ts @@ -76,7 +76,7 @@ export const useWatchedQuery = ( comparator: options.comparator ?? FalsyComparator }); - const disposer = watchedQuery.subscribe({ + const disposer = watchedQuery.registerListener({ onStateChange: (state) => { isLoading.value = state.isLoading; isFetching.value = state.isFetching; diff --git a/packages/web/tests/watch.test.ts b/packages/web/tests/watch.test.ts index bd1075c11..97fcad3bb 100644 --- a/packages/web/tests/watch.test.ts +++ b/packages/web/tests/watch.test.ts @@ -348,7 +348,7 @@ describe('Watch Tests', { sequential: true }, () => { const getNextState = () => new Promise>((resolve) => { - const dispose = watch.subscribe({ + const dispose = watch.registerListener({ onStateChange: (state) => { dispose(); resolve(state); @@ -393,7 +393,7 @@ describe('Watch Tests', { sequential: true }, () => { }); let notificationCount = 0; - const dispose = watch.subscribe({ + const dispose = watch.registerListener({ onData: () => { notificationCount++; } @@ -437,7 +437,7 @@ describe('Watch Tests', { sequential: true }, () => { expect(watch.state.isFetching).false; let notificationCount = 0; - const dispose = watch.subscribe({ + const dispose = watch.registerListener({ onStateChange: () => { notificationCount++; } @@ -526,7 +526,7 @@ describe('Watch Tests', { sequential: true }, () => { FROM assets `, - transformer: (raw) => { + mapper: (raw) => { return { id: raw.id as string, make: raw.make as string @@ -617,7 +617,7 @@ describe('Watch Tests', { sequential: true }, () => { FROM assets `, - transformer: (raw) => { + mapper: (raw) => { return { id: raw.id as string, make: raw.make as string @@ -687,7 +687,7 @@ describe('Watch Tests', { sequential: true }, () => { FROM assets `, - transformer: (raw) => { + mapper: (raw) => { return { id: raw.id as string, make: raw.make as string @@ -795,7 +795,7 @@ describe('Watch Tests', { sequential: true }, () => { FROM assets `, - transformer: (raw) => { + mapper: (raw) => { return { id: raw.id as string, make: raw.make as string From 5bffa25af32432e3f6b03002468b4f3ce6187656 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Thu, 19 Jun 2025 14:32:02 +0200 Subject: [PATCH 55/75] update yjs readme --- demos/yjs-react-supabase-text-collab/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/demos/yjs-react-supabase-text-collab/README.md b/demos/yjs-react-supabase-text-collab/README.md index 48e04dda8..062e28451 100644 --- a/demos/yjs-react-supabase-text-collab/README.md +++ b/demos/yjs-react-supabase-text-collab/README.md @@ -17,7 +17,7 @@ pnpm install pnpm build:packages ``` -### Quick Start: Local Development +#### Quick Start: Local Development This demo can be started with local PowerSync and Supabase services. @@ -140,8 +140,8 @@ To-do - [ ] Add button to the UI allowing the user to merge the Yjs edits i.e. `document_update` rows. Invoke `merge-document-updates` edge function in Supabase. - [ ] Prepopulate sample text into newly created documents. - [ ] Improve performance / rework inefficient parts of implementation: - - [ ] Optimize the 'seen updates' approach to filter the `SELECT` query for updates that have not yet been seen — perhaps based on `created_at` timestamp generated on the Postgres side. For the watch query — watch for certain tables instead of watching a query. This will allow querying `document_updates` with a dynamic parameter. - - [ ] Flush 'seen updates' when `document_updates` are merged. + - [] Optimize the 'seen updates' approach to filter the `SELECT` query for updates that have not yet been seen — perhaps based on `created_at` timestamp generated on the Postgres side. For the watch query — watch for certain tables instead of watching a query. This will allow querying `document_updates` with a dynamic parameter. + - [x] Flush 'seen updates' when `document_updates` are merged. Done From 1f9ffad70ae30b26a9f6eff15e45f90ac741fdf7 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Thu, 19 Jun 2025 16:32:50 +0200 Subject: [PATCH 56/75] update changesets --- .changeset/little-bananas-fetch.md | 4 +++- .changeset/nine-pens-ring.md | 14 ++++++++++++++ .changeset/plenty-rice-protect.md | 6 ++++++ .changeset/swift-guests-explain.md | 14 ++++++++++++++ 4 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 .changeset/nine-pens-ring.md create mode 100644 .changeset/plenty-rice-protect.md create mode 100644 .changeset/swift-guests-explain.md diff --git a/.changeset/little-bananas-fetch.md b/.changeset/little-bananas-fetch.md index e6d33e211..3717d324d 100644 --- a/.changeset/little-bananas-fetch.md +++ b/.changeset/little-bananas-fetch.md @@ -2,4 +2,6 @@ '@powersync/common': minor --- -Added additional listeners for `closing` and `closed` events in `AbstractPowerSyncDatabase`. +- Added additional listeners for `closing` and `closed` events in `AbstractPowerSyncDatabase`. +- Added `incrementalWatch` API for enhanced watched queries. +- Added `triggerImmediate` option to the `onChange` API. This allows emitting an initial event which can be useful for downstream use cases. diff --git a/.changeset/nine-pens-ring.md b/.changeset/nine-pens-ring.md new file mode 100644 index 000000000..6decf275c --- /dev/null +++ b/.changeset/nine-pens-ring.md @@ -0,0 +1,14 @@ +--- +'@powersync/vue': minor +--- + +- Added the ability to limit re-renders by specifying a `comparator` for query results. The `useQuery` hook will only emit `data` changes when the data has changed. + +```javascript + useQuery('SELECT * FROM lists WHERE name = ?', ['todo'], { + // This will be used to compare result sets between internal queries + comparator: new ArrayComparator({ + compareBy: (item) => JSON.stringify(item) + }) +}), +``` diff --git a/.changeset/plenty-rice-protect.md b/.changeset/plenty-rice-protect.md new file mode 100644 index 000000000..17c805e90 --- /dev/null +++ b/.changeset/plenty-rice-protect.md @@ -0,0 +1,6 @@ +--- +'@powersync/react': minor +'@powersync/vue': minor +--- + +- [Internal] Updated implementation to use shared `WatchedQuery` implementation. diff --git a/.changeset/swift-guests-explain.md b/.changeset/swift-guests-explain.md new file mode 100644 index 000000000..0bf96c1c7 --- /dev/null +++ b/.changeset/swift-guests-explain.md @@ -0,0 +1,14 @@ +--- +'@powersync/react': minor +--- + +- Added the ability to limit re-renders by specifying a `comparator` for query results. The `useQuery` hook will only emit `data` changes when the data has changed. + +```javascript + useQuery('SELECT * FROM lists WHERE name = ?', ['todo'], { + // This will be used to compare result sets between internal queries + comparator: new ArrayComparator({ + compareBy: (item) => JSON.stringify(item) + }) +}), +``` From 17680a572d9ff73f6862888db865648c459a7ff5 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Mon, 23 Jun 2025 11:20:09 +0200 Subject: [PATCH 57/75] fix multiple instance unit tests --- .../sync/bucket/BucketStorageAdapter.ts | 4 +- .../AbstractStreamingSyncImplementation.ts | 32 ++++++++++--- packages/common/src/utils/BaseObserver.ts | 6 ++- .../SharedWebStreamingSyncImplementation.ts | 2 + .../worker/sync/SharedSyncImplementation.ts | 5 +- packages/web/tests/multiple_instances.test.ts | 47 +++++++++++-------- 6 files changed, 62 insertions(+), 34 deletions(-) diff --git a/packages/common/src/client/sync/bucket/BucketStorageAdapter.ts b/packages/common/src/client/sync/bucket/BucketStorageAdapter.ts index 3d6080769..6a57f189e 100644 --- a/packages/common/src/client/sync/bucket/BucketStorageAdapter.ts +++ b/packages/common/src/client/sync/bucket/BucketStorageAdapter.ts @@ -1,4 +1,4 @@ -import { BaseListener, BaseObserver, Disposable } from '../../../utils/BaseObserver.js'; +import { BaseListener, BaseObserverInterface, Disposable } from '../../../utils/BaseObserver.js'; import { CrudBatch } from './CrudBatch.js'; import { CrudEntry, OpId } from './CrudEntry.js'; import { SyncDataBatch } from './SyncDataBatch.js'; @@ -72,7 +72,7 @@ export interface BucketStorageListener extends BaseListener { crudUpdate: () => void; } -export interface BucketStorageAdapter extends BaseObserver, Disposable { +export interface BucketStorageAdapter extends BaseObserverInterface, Disposable { init(): Promise; saveSyncData(batch: SyncDataBatch, fixedKeyFormat?: boolean): Promise; removeBuckets(buckets: string[]): Promise; diff --git a/packages/common/src/client/sync/stream/AbstractStreamingSyncImplementation.ts b/packages/common/src/client/sync/stream/AbstractStreamingSyncImplementation.ts index f24e72e7a..2f0f6e6f0 100644 --- a/packages/common/src/client/sync/stream/AbstractStreamingSyncImplementation.ts +++ b/packages/common/src/client/sync/stream/AbstractStreamingSyncImplementation.ts @@ -1,11 +1,11 @@ import Logger, { ILogger } from 'js-logger'; -import { DataStream } from '../../../utils/DataStream.js'; -import { SyncStatus, SyncStatusOptions } from '../../../db/crud/SyncStatus.js'; import { FULL_SYNC_PRIORITY, InternalProgressInformation } from '../../../db/crud/SyncProgress.js'; import * as sync_status from '../../../db/crud/SyncStatus.js'; +import { SyncStatus, SyncStatusOptions } from '../../../db/crud/SyncStatus.js'; import { AbortOperation } from '../../../utils/AbortOperation.js'; -import { BaseListener, BaseObserver, Disposable } from '../../../utils/BaseObserver.js'; +import { BaseListener, BaseObserver, BaseObserverInterface, Disposable } from '../../../utils/BaseObserver.js'; +import { DataStream } from '../../../utils/DataStream.js'; import { onAbortPromise, throttleLeadingTrailing } from '../../../utils/async.js'; import { BucketChecksum, @@ -17,6 +17,7 @@ import { import { CrudEntry } from '../bucket/CrudEntry.js'; import { SyncDataBucket } from '../bucket/SyncDataBucket.js'; import { AbstractRemote, FetchStrategy, SyncStreamOptions } from './AbstractRemote.js'; +import { EstablishSyncStream, Instruction, SyncPriorityStatus } from './core-instruction.js'; import { BucketRequest, StreamingSyncLine, @@ -28,7 +29,6 @@ import { isStreamingSyncCheckpointPartiallyComplete, isStreamingSyncData } from './streaming-sync-types.js'; -import { EstablishSyncStream, Instruction, SyncPriorityStatus } from './core-instruction.js'; export enum LockType { CRUD = 'crud', @@ -170,7 +170,9 @@ export interface AdditionalConnectionOptions { /** @internal */ export type RequiredAdditionalConnectionOptions = Required; -export interface StreamingSyncImplementation extends BaseObserver, Disposable { +export interface StreamingSyncImplementation + extends BaseObserverInterface, + Disposable { /** * Connects to the sync service */ @@ -222,6 +224,9 @@ export abstract class AbstractStreamingSyncImplementation protected _lastSyncedAt: Date | null; protected options: AbstractStreamingSyncImplementationOptions; protected abortController: AbortController | null; + // In rare cases, mostly for tests, uploads can be triggered without being properly connected. + // This allows ensuring that all upload processes can be aborted. + protected uploadAbortController: AbortController | null; protected crudUpdateListener?: () => void; protected streamingSyncPromise?: Promise; @@ -315,8 +320,10 @@ export abstract class AbstractStreamingSyncImplementation } async dispose() { + super.dispose(); this.crudUpdateListener?.(); this.crudUpdateListener = undefined; + this.uploadAbortController?.abort(); } abstract obtainLock(lockOptions: LockOptions): Promise; @@ -341,7 +348,17 @@ export abstract class AbstractStreamingSyncImplementation */ let checkedCrudItem: CrudEntry | undefined; - while (true) { + const controller = new AbortController(); + this.uploadAbortController = controller; + this.abortController?.signal.addEventListener( + 'abort', + () => { + controller.abort(); + }, + { once: true } + ); + + while (!controller.signal.aborted) { this.updateSyncStatus({ dataFlow: { uploading: true @@ -381,7 +398,7 @@ The next upload iteration will be delayed.`); uploadError: ex } }); - await this.delayRetry(); + await this.delayRetry(controller.signal); if (!this.isConnected) { // Exit the upload loop if the sync stream is no longer connected break; @@ -397,6 +414,7 @@ The next upload iteration will be delayed.`); }); } } + this.uploadAbortController = null; } }); } diff --git a/packages/common/src/utils/BaseObserver.ts b/packages/common/src/utils/BaseObserver.ts index 5ad4bf05d..fa8067226 100644 --- a/packages/common/src/utils/BaseObserver.ts +++ b/packages/common/src/utils/BaseObserver.ts @@ -1,5 +1,5 @@ export interface Disposable { - dispose: () => Promise; + dispose: () => Promise | void; } export type BaseListener = Record any) | undefined>; @@ -13,6 +13,10 @@ export class BaseObserver implements Base constructor() {} + dispose(): void { + this.listeners.clear(); + } + /** * Register a listener for updates to the PowerSync client. */ diff --git a/packages/web/src/db/sync/SharedWebStreamingSyncImplementation.ts b/packages/web/src/db/sync/SharedWebStreamingSyncImplementation.ts index dd059139b..1fe0177a7 100644 --- a/packages/web/src/db/sync/SharedWebStreamingSyncImplementation.ts +++ b/packages/web/src/db/sync/SharedWebStreamingSyncImplementation.ts @@ -207,6 +207,8 @@ export class SharedWebStreamingSyncImplementation extends WebStreamingSyncImplem async dispose(): Promise { await this.waitForReady(); + await super.dispose(); + await new Promise((resolve) => { // Listen for the close acknowledgment from the worker this.messagePort.addEventListener('message', (event) => { diff --git a/packages/web/src/worker/sync/SharedSyncImplementation.ts b/packages/web/src/worker/sync/SharedSyncImplementation.ts index 0af122ead..31bde2184 100644 --- a/packages/web/src/worker/sync/SharedSyncImplementation.ts +++ b/packages/web/src/worker/sync/SharedSyncImplementation.ts @@ -297,7 +297,6 @@ export class SharedSyncImplementation }); const shouldReconnect = !!this.connectionManager.syncStreamImplementation && this.ports.length > 0; - return { shouldReconnect, trackedPort @@ -473,10 +472,8 @@ export class SharedSyncImplementation */ private async _testUpdateAllStatuses(status: SyncStatusOptions) { if (!this.connectionManager.syncStreamImplementation) { - // This is just for testing purposes - this.connectionManager.syncStreamImplementation = this.generateStreamingImplementation(); + throw new Error('Cannot update status without a sync stream implementation'); } - // Only assigning, don't call listeners for this test this.connectionManager.syncStreamImplementation!.syncStatus = new SyncStatus(status); this.updateAllStatuses(status); diff --git a/packages/web/tests/multiple_instances.test.ts b/packages/web/tests/multiple_instances.test.ts index 4e11704f8..a92755fa0 100644 --- a/packages/web/tests/multiple_instances.test.ts +++ b/packages/web/tests/multiple_instances.test.ts @@ -132,11 +132,12 @@ describe('Multiple Instances', { sequential: true }, () => { await connector1.uploadData(db); }, identifier, + retryDelayMs: 90_000, // Large delay to allow for testing db: db.database as WebDBAdapter }; const stream1 = new SharedWebStreamingSyncImplementation(syncOptions1); - + await stream1.connect(); // Generate the second streaming sync implementation const connector2 = new TestConnector(); const syncOptions2: SharedWebStreamingSyncImplementationOptions = { @@ -188,20 +189,25 @@ describe('Multiple Instances', { sequential: true }, () => { triggerUpload1 = resolve; }); - // Create the first streaming client - const stream1 = new SharedWebStreamingSyncImplementation({ + const sharedSyncOptions = { adapter: new SqliteBucketStorage(db.database, new Mutex()), remote: new WebRemote(connector1), - uploadCrud: async () => { - triggerUpload1(); - connector1.uploadData(db); - }, db: db.database as WebDBAdapter, identifier, - retryDelayMs: 100, + // The large delay here allows us to test between connection retries + retryDelayMs: 90_000, flags: { broadcastLogs: true } + }; + + // Create the first streaming client + const stream1 = new SharedWebStreamingSyncImplementation({ + ...sharedSyncOptions, + uploadCrud: async () => { + triggerUpload1(); + connector1.uploadData(db); + } }); // Generate the second streaming sync implementation @@ -216,18 +222,11 @@ describe('Multiple Instances', { sequential: true }, () => { }); const stream2 = new SharedWebStreamingSyncImplementation({ - adapter: new SqliteBucketStorage(db.database, new Mutex()), - remote: new WebRemote(connector1), + ...sharedSyncOptions, uploadCrud: async () => { triggerUpload2(); connector2.uploadData(db); - }, - identifier, - retryDelayMs: 100, - flags: { - broadcastLogs: true - }, - db: db.database as WebDBAdapter + } }); // Waits for the stream to be marked as connected @@ -243,7 +242,9 @@ describe('Multiple Instances', { sequential: true }, () => { }); // hack to set the status to connected for tests - (stream1 as any)['_testUpdateStatus'](new SyncStatus({ connected: true })); + await stream1.connect(); + // Hack, set the status to connected in order to trigger the upload + await (stream1 as any)['_testUpdateStatus'](new SyncStatus({ connected: true })); // The status in the second stream client should be updated await stream2UpdatedPromise; @@ -256,7 +257,6 @@ describe('Multiple Instances', { sequential: true }, () => { 'steven@journeyapps.com' ]); - // Manual trigger since tests don't entirely configure watches for ps_crud stream1.triggerCrudUpload(); // The second connector should be called to upload await upload2TriggeredPromise; @@ -265,14 +265,21 @@ describe('Multiple Instances', { sequential: true }, () => { expect(spy2).toHaveBeenCalledOnce(); // Close the second client, leaving only the first one + /** + * This test is a bit hacky. If we dispose the second client, the shared sync worker + * will try and reconnect, but we don't actually want it to do that since that connection attempt + * will fail and it will report as `connected:false`. + * We can hack as disconnected for now. + */ await stream2.dispose(); + // Hack, set the status to connected in order to trigger the upload + await (stream1 as any)['_testUpdateStatus'](new SyncStatus({ connected: true })); stream1.triggerCrudUpload(); // It should now upload from the first client await upload1TriggeredPromise; expect(spy1).toHaveBeenCalledOnce(); - await stream1.dispose(); }); }); From 0b1e62761ddb6ab4af2a1aaf2a01b41b90f9a509 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Mon, 23 Jun 2025 11:49:36 +0200 Subject: [PATCH 58/75] prevent concurrent web package tests --- package.json | 2 +- packages/web/tests/multiple_instances.test.ts | 6 ------ 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/package.json b/package.json index ced658407..7acce0df6 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "format": "prettier --write .", "lint": "eslint .", "release": "pnpm build:packages:prod && pnpm changeset publish", - "test": "pnpm run -r test" + "test": "pnpm run -r --workspace-concurrency=0 test" }, "keywords": [], "type": "module", diff --git a/packages/web/tests/multiple_instances.test.ts b/packages/web/tests/multiple_instances.test.ts index a92755fa0..b82ba3b71 100644 --- a/packages/web/tests/multiple_instances.test.ts +++ b/packages/web/tests/multiple_instances.test.ts @@ -265,12 +265,6 @@ describe('Multiple Instances', { sequential: true }, () => { expect(spy2).toHaveBeenCalledOnce(); // Close the second client, leaving only the first one - /** - * This test is a bit hacky. If we dispose the second client, the shared sync worker - * will try and reconnect, but we don't actually want it to do that since that connection attempt - * will fail and it will report as `connected:false`. - * We can hack as disconnected for now. - */ await stream2.dispose(); // Hack, set the status to connected in order to trigger the upload From f86bca6fe3d11f12e437bbfc5514aa72d80d9654 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Mon, 23 Jun 2025 11:56:27 +0200 Subject: [PATCH 59/75] update vitest for hopefully better stability --- package.json | 4 +- packages/node/package.json | 2 +- pnpm-lock.yaml | 247 +++++++++++++++++++++---------------- 3 files changed, 143 insertions(+), 110 deletions(-) diff --git a/package.json b/package.json index 7acce0df6..2e82c5855 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "@actions/core": "^1.10.1", "@changesets/cli": "2.27.2", "@pnpm/workspace.find-packages": "^4.0.2", - "@vitest/browser": "^3.0.8", + "@vitest/browser": "^3.2.4", "husky": "^9.0.11", "lint-staged": "^15.2.2", "playwright": "^1.51.0", @@ -41,6 +41,6 @@ "prettier-plugin-embed": "^0.4.15", "prettier-plugin-sql": "^0.18.1", "typescript": "^5.7.2", - "vitest": "^3.0.8" + "vitest": "^3.2.4" } } diff --git a/packages/node/package.json b/packages/node/package.json index b2c578c56..e8f17a422 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -61,7 +61,7 @@ "drizzle-orm": "^0.35.2", "rollup": "4.14.3", "typescript": "^5.5.3", - "vitest": "^3.0.5" + "vitest": "^3.2.4" }, "keywords": [ "data sync", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 372f97893..262a6dba8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ importers: specifier: ^4.0.2 version: 4.0.14(@pnpm/logger@5.2.0) '@vitest/browser': - specifier: ^3.0.8 - version: 3.2.0(playwright@1.52.0)(vite@6.3.5(@types/node@22.15.29)(jiti@2.4.2)(less@4.2.2)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0))(vitest@3.2.0) + specifier: ^3.2.4 + version: 3.2.4(playwright@1.52.0)(vite@6.3.5(@types/node@22.15.29)(jiti@2.4.2)(less@4.2.2)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0))(vitest@3.2.4) husky: specifier: ^9.0.11 version: 9.1.7 @@ -42,8 +42,8 @@ importers: specifier: ^5.7.2 version: 5.8.3 vitest: - specifier: ^3.0.8 - version: 3.2.0(@types/debug@4.1.12)(@types/node@22.15.29)(@vitest/browser@3.2.0)(jsdom@24.1.3)(less@4.2.2)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0) + specifier: ^3.2.4 + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.15.29)(@vitest/browser@3.2.4)(jiti@2.4.2)(jsdom@24.1.3)(less@4.2.2)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0) demos/angular-supabase-todolist: dependencies: @@ -1803,8 +1803,8 @@ importers: specifier: ^5.5.3 version: 5.8.3 vitest: - specifier: ^3.0.5 - version: 3.2.0(@types/debug@4.1.12)(@types/node@22.15.29)(@vitest/browser@3.2.0)(jsdom@24.1.3)(less@4.2.2)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0) + specifier: ^3.2.4 + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.15.29)(@vitest/browser@3.2.4)(jiti@2.4.2)(jsdom@24.1.3)(less@4.2.2)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0) packages/powersync-op-sqlite: dependencies: @@ -7158,6 +7158,15 @@ packages: rollup: optional: true + '@rollup/plugin-node-resolve@15.3.1': + resolution: {integrity: sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.78.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + '@rollup/plugin-replace@2.4.2': resolution: {integrity: sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg==} peerDependencies: @@ -7205,6 +7214,15 @@ packages: rollup: optional: true + '@rollup/pluginutils@5.2.0': + resolution: {integrity: sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + '@rollup/rollup-android-arm-eabi@4.14.3': resolution: {integrity: sha512-X9alQ3XM6I9IlSlmC8ddAvMSyG1WuHk5oUnXGw+yUBs3BFoTizmG1La/Gr8fVJvDWAq+zlYTZ9DBgrlKRVY06g==} cpu: [arm] @@ -8729,6 +8747,9 @@ packages: '@types/estree@1.0.7': resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==} + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/express-serve-static-core@4.19.6': resolution: {integrity: sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==} @@ -9292,12 +9313,12 @@ packages: vite: ^5.0.0 || ^6.0.0 vue: ^3.2.25 - '@vitest/browser@3.2.0': - resolution: {integrity: sha512-sVpX5m53lX9/0ehAqkcTSQeJK1SVlTlvBrwE8rPQ2KJQgb/Iiorx+3y+VQdzIJ+CDqfG89bQEA5l1Z02VogDsA==} + '@vitest/browser@3.2.4': + resolution: {integrity: sha512-tJxiPrWmzH8a+w9nLKlQMzAKX/7VjFs50MWgcAj7p9XQ7AQ9/35fByFYptgPELyLw+0aixTnC4pUWV+APcZ/kw==} peerDependencies: playwright: '*' safaridriver: '*' - vitest: 3.2.0 + vitest: 3.2.4 webdriverio: ^7.0.0 || ^8.0.0 || ^9.0.0 peerDependenciesMeta: playwright: @@ -9307,11 +9328,11 @@ packages: webdriverio: optional: true - '@vitest/expect@3.2.0': - resolution: {integrity: sha512-0v4YVbhDKX3SKoy0PHWXpKhj44w+3zZkIoVES9Ex2pq+u6+Bijijbi2ua5kE+h3qT6LBWFTNZSCOEU37H8Y5sA==} + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} - '@vitest/mocker@3.2.0': - resolution: {integrity: sha512-HFcW0lAMx3eN9vQqis63H0Pscv0QcVMo1Kv8BNysZbxcmHu3ZUYv59DS6BGYiGQ8F5lUkmsfMMlPm4DJFJdf/A==} + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} peerDependencies: msw: ^2.4.9 vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 @@ -9321,20 +9342,20 @@ packages: vite: optional: true - '@vitest/pretty-format@3.2.0': - resolution: {integrity: sha512-gUUhaUmPBHFkrqnOokmfMGRBMHhgpICud9nrz/xpNV3/4OXCn35oG+Pl8rYYsKaTNd/FAIrqRHnwpDpmYxCYZw==} + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} - '@vitest/runner@3.2.0': - resolution: {integrity: sha512-bXdmnHxuB7fXJdh+8vvnlwi/m1zvu+I06i1dICVcDQFhyV4iKw2RExC/acavtDn93m/dRuawUObKsrNE1gJacA==} + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} - '@vitest/snapshot@3.2.0': - resolution: {integrity: sha512-z7P/EneBRMe7hdvWhcHoXjhA6at0Q4ipcoZo6SqgxLyQQ8KSMMCmvw1cSt7FHib3ozt0wnRHc37ivuUMbxzG/A==} + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} - '@vitest/spy@3.2.0': - resolution: {integrity: sha512-s3+TkCNUIEOX99S0JwNDfsHRaZDDZZR/n8F0mop0PmsEbQGKZikCGpTGZ6JRiHuONKew3Fb5//EPwCP+pUX9cw==} + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} - '@vitest/utils@3.2.0': - resolution: {integrity: sha512-gXXOe7Fj6toCsZKVQouTRLJftJwmvbhH5lKOBR6rlP950zUq9AitTUjnFoXS/CqjBC2aoejAztLPzzuva++XBw==} + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} '@volar/language-core@2.1.6': resolution: {integrity: sha512-pAlMCGX/HatBSiDFMdMyqUshkbwWbLxpN/RL7HCQDOo2gYBE+uS+nanosLc1qR6pTQ/U8q00xt8bdrrAFPSC0A==} @@ -14213,6 +14234,9 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@3.14.1: resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} hasBin: true @@ -14456,6 +14480,11 @@ packages: engines: {node: '>=16'} hasBin: true + lib0@0.2.109: + resolution: {integrity: sha512-jP0gbnyW0kwlx1Atc4dcHkBbrVAkdHjuyHxtClUPYla7qCmwIif1qZ6vQeJdR5FrOVdn26HvQT0ko01rgW7/Xw==} + engines: {node: '>=16'} + hasBin: true + license-webpack-plugin@4.0.2: resolution: {integrity: sha512-771TFWFD70G1wLTC4oU2Cw4qvtmNrIw+wRvBtn+okgHl7slJVi7zfNcdmqDL72BojM30VNJ2UHylr1o77U37Jw==} peerDependencies: @@ -14771,6 +14800,9 @@ packages: loupe@3.1.3: resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==} + loupe@3.1.4: + resolution: {integrity: sha512-wJzkKwJrheKtknCOKNEtDK4iqg/MxmZheEMtSTYvnzRdEYaZzmgH976nenp8WdJRdx5Vc1X/9MO0Oszl6ezeXg==} + lower-case@2.0.2: resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} @@ -18816,6 +18848,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-literal@3.0.0: + resolution: {integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==} + strip-outer@1.0.1: resolution: {integrity: sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==} engines: {node: '>=0.10.0'} @@ -19118,6 +19153,10 @@ packages: resolution: {integrity: sha512-7CotroY9a8DKsKprEy/a14aCCm8jYVmR7aFy4fpkZM8sdpNJbKkixuNjgM50yCmip2ezc8z4N7k3oe2+rfRJCQ==} engines: {node: ^18.0.0 || >=20.0.0} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + tinyrainbow@2.0.0: resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} engines: {node: '>=14.0.0'} @@ -19824,8 +19863,8 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - vite-node@3.2.0: - resolution: {integrity: sha512-8Fc5Ko5Y4URIJkmMF/iFP1C0/OJyY+VGVe9Nw6WAdZyw4bTO+eVg9mwxWkQp/y8NnAoQY3o9KAvE1ZdA2v+Vmg==} + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true @@ -19976,16 +20015,16 @@ packages: yaml: optional: true - vitest@3.2.0: - resolution: {integrity: sha512-P7Nvwuli8WBNmeMHHek7PnGW4oAZl9za1fddfRVidZar8wDZRi7hpznLKQePQ8JPLwSBEYDK11g+++j7uFJV8Q==} + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@types/debug': ^4.1.12 '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 - '@vitest/browser': 3.2.0 - '@vitest/ui': 3.2.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 happy-dom: '*' jsdom: '*' peerDependenciesMeta: @@ -25954,7 +25993,7 @@ snapshots: '@manypkg/find-root@1.1.0': dependencies: - '@babel/runtime': 7.27.6 + '@babel/runtime': 7.27.4 '@types/node': 12.20.55 find-up: 4.1.0 fs-extra: 8.1.0 @@ -28708,27 +28747,26 @@ snapshots: optionalDependencies: rollup: 4.14.3 - '@rollup/plugin-node-resolve@15.2.3(rollup@2.79.2)': + '@rollup/plugin-node-resolve@15.2.3(rollup@4.14.3)': dependencies: - '@rollup/pluginutils': 5.1.4(rollup@2.79.2) + '@rollup/pluginutils': 5.1.4(rollup@4.14.3) '@types/resolve': 1.20.2 deepmerge: 4.3.1 is-builtin-module: 3.2.1 is-module: 1.0.0 resolve: 1.22.10 optionalDependencies: - rollup: 2.79.2 + rollup: 4.14.3 - '@rollup/plugin-node-resolve@15.2.3(rollup@4.14.3)': + '@rollup/plugin-node-resolve@15.3.1(rollup@2.79.2)': dependencies: - '@rollup/pluginutils': 5.1.4(rollup@4.14.3) + '@rollup/pluginutils': 5.2.0(rollup@2.79.2) '@types/resolve': 1.20.2 deepmerge: 4.3.1 - is-builtin-module: 3.2.1 is-module: 1.0.0 resolve: 1.22.10 optionalDependencies: - rollup: 4.14.3 + rollup: 2.79.2 '@rollup/plugin-replace@2.4.2(rollup@2.79.2)': dependencies: @@ -28774,29 +28812,29 @@ snapshots: picomatch: 2.3.1 rollup: 2.79.2 - '@rollup/pluginutils@5.1.4(rollup@2.79.2)': + '@rollup/pluginutils@5.1.4(rollup@4.14.3)': dependencies: '@types/estree': 1.0.7 estree-walker: 2.0.2 picomatch: 4.0.2 optionalDependencies: - rollup: 2.79.2 + rollup: 4.14.3 - '@rollup/pluginutils@5.1.4(rollup@4.14.3)': + '@rollup/pluginutils@5.1.4(rollup@4.41.1)': dependencies: '@types/estree': 1.0.7 estree-walker: 2.0.2 picomatch: 4.0.2 optionalDependencies: - rollup: 4.14.3 + rollup: 4.41.1 - '@rollup/pluginutils@5.1.4(rollup@4.41.1)': + '@rollup/pluginutils@5.2.0(rollup@2.79.2)': dependencies: - '@types/estree': 1.0.7 + '@types/estree': 1.0.8 estree-walker: 2.0.2 picomatch: 4.0.2 optionalDependencies: - rollup: 4.41.1 + rollup: 2.79.2 '@rollup/rollup-android-arm-eabi@4.14.3': optional: true @@ -30638,6 +30676,8 @@ snapshots: '@types/estree@1.0.7': {} + '@types/estree@1.0.8': {} + '@types/express-serve-static-core@4.19.6': dependencies: '@types/node': 20.17.57 @@ -31434,16 +31474,16 @@ snapshots: vite: 5.4.19(@types/node@22.15.29)(less@4.2.2)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0) vue: 3.4.21(typescript@5.8.3) - '@vitest/browser@3.2.0(playwright@1.52.0)(vite@5.4.19(@types/node@22.15.29)(less@4.2.2)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0))(vitest@3.2.0)': + '@vitest/browser@3.2.4(playwright@1.52.0)(vite@6.3.5(@types/node@22.15.29)(jiti@2.4.2)(less@4.2.2)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0))(vitest@3.2.4)': dependencies: '@testing-library/dom': 10.4.0 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.0) - '@vitest/mocker': 3.2.0(vite@5.4.19(@types/node@22.15.29)(less@4.2.2)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)) - '@vitest/utils': 3.2.0 + '@vitest/mocker': 3.2.4(vite@6.3.5(@types/node@22.15.29)(jiti@2.4.2)(less@4.2.2)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0)) + '@vitest/utils': 3.2.4 magic-string: 0.30.17 sirv: 3.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.0(@types/debug@4.1.12)(@types/node@22.15.29)(@vitest/browser@3.2.0)(jsdom@24.1.3)(less@4.2.2)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.15.29)(@vitest/browser@3.2.4)(jiti@2.4.2)(jsdom@24.1.3)(less@4.2.2)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0) ws: 8.18.2 optionalDependencies: playwright: 1.52.0 @@ -31452,74 +31492,47 @@ snapshots: - msw - utf-8-validate - vite - optional: true - '@vitest/browser@3.2.0(playwright@1.52.0)(vite@6.3.5(@types/node@22.15.29)(jiti@2.4.2)(less@4.2.2)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0))(vitest@3.2.0)': - dependencies: - '@testing-library/dom': 10.4.0 - '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.0) - '@vitest/mocker': 3.2.0(vite@6.3.5(@types/node@22.15.29)(jiti@2.4.2)(less@4.2.2)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0)) - '@vitest/utils': 3.2.0 - magic-string: 0.30.17 - sirv: 3.0.1 - tinyrainbow: 2.0.0 - vitest: 3.2.0(@types/debug@4.1.12)(@types/node@22.15.29)(@vitest/browser@3.2.0)(jsdom@24.1.3)(less@4.2.2)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0) - ws: 8.18.2 - optionalDependencies: - playwright: 1.52.0 - transitivePeerDependencies: - - bufferutil - - msw - - utf-8-validate - - vite - - '@vitest/expect@3.2.0': + '@vitest/expect@3.2.4': dependencies: '@types/chai': 5.2.2 - '@vitest/spy': 3.2.0 - '@vitest/utils': 3.2.0 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 chai: 5.2.0 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.0(vite@5.4.19(@types/node@22.15.29)(less@4.2.2)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0))': - dependencies: - '@vitest/spy': 3.2.0 - estree-walker: 3.0.3 - magic-string: 0.30.17 - optionalDependencies: - vite: 5.4.19(@types/node@22.15.29)(less@4.2.2)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0) - - '@vitest/mocker@3.2.0(vite@6.3.5(@types/node@22.15.29)(jiti@2.4.2)(less@4.2.2)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0))': + '@vitest/mocker@3.2.4(vite@6.3.5(@types/node@22.15.29)(jiti@2.4.2)(less@4.2.2)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0))': dependencies: - '@vitest/spy': 3.2.0 + '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: vite: 6.3.5(@types/node@22.15.29)(jiti@2.4.2)(less@4.2.2)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0) - '@vitest/pretty-format@3.2.0': + '@vitest/pretty-format@3.2.4': dependencies: tinyrainbow: 2.0.0 - '@vitest/runner@3.2.0': + '@vitest/runner@3.2.4': dependencies: - '@vitest/utils': 3.2.0 + '@vitest/utils': 3.2.4 pathe: 2.0.3 + strip-literal: 3.0.0 - '@vitest/snapshot@3.2.0': + '@vitest/snapshot@3.2.4': dependencies: - '@vitest/pretty-format': 3.2.0 + '@vitest/pretty-format': 3.2.4 magic-string: 0.30.17 pathe: 2.0.3 - '@vitest/spy@3.2.0': + '@vitest/spy@3.2.4': dependencies: tinyspy: 4.0.3 - '@vitest/utils@3.2.0': + '@vitest/utils@3.2.4': dependencies: - '@vitest/pretty-format': 3.2.0 - loupe: 3.1.3 + '@vitest/pretty-format': 3.2.4 + loupe: 3.1.4 tinyrainbow: 2.0.0 '@volar/language-core@2.1.6': @@ -38352,6 +38365,8 @@ snapshots: js-tokens@4.0.0: {} + js-tokens@9.0.1: {} + js-yaml@3.14.1: dependencies: argparse: 1.0.10 @@ -38669,6 +38684,10 @@ snapshots: dependencies: isomorphic.js: 0.2.5 + lib0@0.2.109: + dependencies: + isomorphic.js: 0.2.5 + license-webpack-plugin@4.0.2(webpack@5.98.0(@swc/core@1.11.29)): dependencies: webpack-sources: 3.3.0 @@ -38989,6 +39008,8 @@ snapshots: loupe@3.1.3: {} + loupe@3.1.4: {} + lower-case@2.0.2: dependencies: tslib: 2.8.1 @@ -44643,6 +44664,10 @@ snapshots: strip-json-comments@3.1.1: {} + strip-literal@3.0.0: + dependencies: + js-tokens: 9.0.1 + strip-outer@1.0.1: dependencies: escape-string-regexp: 1.0.5 @@ -45122,6 +45147,8 @@ snapshots: tinypool@1.1.0: {} + tinypool@1.1.1: {} + tinyrainbow@2.0.0: {} tinyspy@4.0.3: {} @@ -45919,15 +45946,16 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.2 - vite-node@3.2.0(@types/node@22.15.29)(less@4.2.2)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0): + vite-node@3.2.4(@types/node@22.15.29)(jiti@2.4.2)(less@4.2.2)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0): dependencies: cac: 6.7.14 debug: 4.4.1(supports-color@8.1.1) es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 5.4.19(@types/node@22.15.29)(less@4.2.2)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0) + vite: 6.3.5(@types/node@22.15.29)(jiti@2.4.2)(less@4.2.2)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0) transitivePeerDependencies: - '@types/node' + - jiti - less - lightningcss - sass @@ -45936,6 +45964,8 @@ snapshots: - sugarss - supports-color - terser + - tsx + - yaml vite-plugin-pwa@0.19.8(vite@5.4.19(@types/node@20.17.57)(less@4.2.2)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0))(workbox-build@7.3.0(@types/babel__core@7.20.5))(workbox-window@7.3.0): dependencies: @@ -46134,16 +46164,16 @@ snapshots: tsx: 4.19.4 yaml: 2.8.0 - vitest@3.2.0(@types/debug@4.1.12)(@types/node@22.15.29)(@vitest/browser@3.2.0)(jsdom@24.1.3)(less@4.2.2)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.15.29)(@vitest/browser@3.2.4)(jiti@2.4.2)(jsdom@24.1.3)(less@4.2.2)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0): dependencies: '@types/chai': 5.2.2 - '@vitest/expect': 3.2.0 - '@vitest/mocker': 3.2.0(vite@5.4.19(@types/node@22.15.29)(less@4.2.2)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)) - '@vitest/pretty-format': 3.2.0 - '@vitest/runner': 3.2.0 - '@vitest/snapshot': 3.2.0 - '@vitest/spy': 3.2.0 - '@vitest/utils': 3.2.0 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@6.3.5(@types/node@22.15.29)(jiti@2.4.2)(less@4.2.2)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 chai: 5.2.0 debug: 4.4.1(supports-color@8.1.1) expect-type: 1.2.1 @@ -46154,17 +46184,18 @@ snapshots: tinybench: 2.9.0 tinyexec: 0.3.2 tinyglobby: 0.2.14 - tinypool: 1.1.0 + tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 5.4.19(@types/node@22.15.29)(less@4.2.2)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0) - vite-node: 3.2.0(@types/node@22.15.29)(less@4.2.2)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0) + vite: 6.3.5(@types/node@22.15.29)(jiti@2.4.2)(less@4.2.2)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0) + vite-node: 3.2.4(@types/node@22.15.29)(jiti@2.4.2)(less@4.2.2)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 '@types/node': 22.15.29 - '@vitest/browser': 3.2.0(playwright@1.52.0)(vite@5.4.19(@types/node@22.15.29)(less@4.2.2)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0))(vitest@3.2.0) + '@vitest/browser': 3.2.4(playwright@1.52.0)(vite@6.3.5(@types/node@22.15.29)(jiti@2.4.2)(less@4.2.2)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0))(vitest@3.2.4) jsdom: 24.1.3 transitivePeerDependencies: + - jiti - less - lightningcss - msw @@ -46174,6 +46205,8 @@ snapshots: - sugarss - supports-color - terser + - tsx + - yaml vlq@1.0.1: {} @@ -46839,7 +46872,7 @@ snapshots: '@babel/preset-env': 7.27.2(@babel/core@7.26.10) '@babel/runtime': 7.27.6 '@rollup/plugin-babel': 5.3.1(@babel/core@7.26.10)(@types/babel__core@7.20.5)(rollup@2.79.2) - '@rollup/plugin-node-resolve': 15.2.3(rollup@2.79.2) + '@rollup/plugin-node-resolve': 15.3.1(rollup@2.79.2) '@rollup/plugin-replace': 2.4.2(rollup@2.79.2) '@rollup/plugin-terser': 0.4.4(rollup@2.79.2) '@surma/rollup-plugin-off-main-thread': 2.2.3 @@ -47054,7 +47087,7 @@ snapshots: y-prosemirror@1.3.5(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27): dependencies: - lib0: 0.2.108 + lib0: 0.2.109 prosemirror-model: 1.25.1 prosemirror-state: 1.4.3 prosemirror-view: 1.40.0 @@ -47063,7 +47096,7 @@ snapshots: y-protocols@1.0.6(yjs@13.6.27): dependencies: - lib0: 0.2.108 + lib0: 0.2.109 yjs: 13.6.27 y18n@4.0.3: {} From 1c5f1ad1c0a529b134d9e1d6b4f42ffcb6a90b13 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Mon, 23 Jun 2025 13:17:59 +0200 Subject: [PATCH 60/75] Add powerSync to dependency array --- packages/react/README.md | 4 ++-- packages/react/src/hooks/watched/watch-utils.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react/README.md b/packages/react/README.md index 13fb4691a..700a51ebc 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -69,7 +69,7 @@ const Component = () => { ## Reactive Queries -The `useQuery` hook allows you to access the results of a watched query. Queries will automatically update when a dependant table is updated unless you set the `runQueryOnce` flag. You are also able to use a compilable query (e.g. [Kysely queries](https://github.com/powersync-ja/powersync-js/tree/main/packages/kysely-driver)) as a query argument in place of a SQL statement string. +The `useQuery` hook allows you to access the results of a watched query. Queries will automatically update when a dependent table is updated unless you set the `runQueryOnce` flag. You are also able to use a compilable query (e.g. [Kysely queries](https://github.com/powersync-ja/powersync-js/tree/main/packages/kysely-driver)) as a query argument in place of a SQL statement string. ```JSX // TodoListDisplay.jsx @@ -305,7 +305,7 @@ function MyWidget() { // - It will rerender on any state change of the watched query. E.g. if isFetching alternates // If MyWatchedWidget is memoized // - It will re-render if the data reference changes. By default the data reference changes after any - // change to the query's dependant tables. This can be optimized by using Incremental queries. + // change to the query's dependent tables. This can be optimized by using Incremental queries. ) } diff --git a/packages/react/src/hooks/watched/watch-utils.ts b/packages/react/src/hooks/watched/watch-utils.ts index b7272fa6f..fd72b2394 100644 --- a/packages/react/src/hooks/watched/watch-utils.ts +++ b/packages/react/src/hooks/watched/watch-utils.ts @@ -67,7 +67,7 @@ export const constructCompatibleQuery = ( execute: () => query.execute() }; } - }, [query]); + }, [query, powerSync]); const queryChanged = checkQueryChanged(parsedQuery, options); From a913020d5060421bf945c4e0f40e43408a9fb01b Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 24 Jun 2025 08:40:41 +0200 Subject: [PATCH 61/75] remove localStorage caching example --- .../src/components/providers/SystemProvider.tsx | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/demos/react-supabase-todolist/src/components/providers/SystemProvider.tsx b/demos/react-supabase-todolist/src/components/providers/SystemProvider.tsx index a0b981f8c..8ea25dda4 100644 --- a/demos/react-supabase-todolist/src/components/providers/SystemProvider.tsx +++ b/demos/react-supabase-todolist/src/components/providers/SystemProvider.tsx @@ -44,12 +44,7 @@ export const SystemProvider = ({ children }: { children: React.ReactNode }) => { compareBy: (item) => JSON.stringify(item) }), watch: { - // This provides instant caching of the query results. - // SQLite calls are asynchronous - therefore on page refresh the placeholder data will be used until the query is resolved. - // This uses localStorage to synchronously display a cached version while loading. - // Note that the TodoListsWidget is wraped by a GuardBySync component, which will prevent rendering until the query is resolved. - // Disable GuardBySync to see the placeholder data in action. - placeholderData: JSON.parse(localStorage.getItem('listscache') ?? '[]') as EnhancedListRecord[], + placeholderData: [], query: new GetAllQuery({ sql: /* sql */ ` SELECT @@ -71,14 +66,6 @@ export const SystemProvider = ({ children }: { children: React.ReactNode }) => { } }); - // This updates a cache in order to display results instantly on page load. - listsQuery.registerListener({ - onData: (data) => { - // Store the data in localStorage for instant caching - localStorage.setItem('listscache', JSON.stringify(data)); - } - }); - return { lists: listsQuery }; From f8f7b6d3d50c909215b564fef0e3e03b73fc499b Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Wed, 25 Jun 2025 12:57:31 +0200 Subject: [PATCH 62/75] improve readonly mutability in types. Persist previous object references better in differential queries. --- .../common/src/client/watched/WatchedQuery.ts | 2 +- .../processors/DifferentialQueryProcessor.ts | 68 ++++++++++------ .../processors/OnChangeQueryProcessor.ts | 2 +- packages/web/tests/watch.test.ts | 77 +++++++++++++++++++ 4 files changed, 125 insertions(+), 24 deletions(-) diff --git a/packages/common/src/client/watched/WatchedQuery.ts b/packages/common/src/client/watched/WatchedQuery.ts index e4e2ad10d..8582e526f 100644 --- a/packages/common/src/client/watched/WatchedQuery.ts +++ b/packages/common/src/client/watched/WatchedQuery.ts @@ -28,7 +28,7 @@ export interface WatchedQueryState { /** * The last data returned by the query. */ - data: Data; + readonly data: Data; } /** diff --git a/packages/common/src/client/watched/processors/DifferentialQueryProcessor.ts b/packages/common/src/client/watched/processors/DifferentialQueryProcessor.ts index 910ac07f2..53d631570 100644 --- a/packages/common/src/client/watched/processors/DifferentialQueryProcessor.ts +++ b/packages/common/src/client/watched/processors/DifferentialQueryProcessor.ts @@ -6,8 +6,8 @@ import { AbstractQueryProcessor, AbstractQueryProcessorOptions, LinkQueryOptions * It contains both the current and previous state of the row. */ export interface WatchedQueryRowDifferential { - current: RowType; - previous: RowType; + readonly current: RowType; + readonly previous: RowType; } /** @@ -15,11 +15,25 @@ export interface WatchedQueryRowDifferential { * {@link WatchedQueryState.data} is of the {@link WatchedQueryDifferential} form when using the {@link IncrementalWatchMode.DIFFERENTIAL} mode. */ export interface WatchedQueryDifferential { - added: RowType[]; - all: RowType[]; - removed: RowType[]; - updated: WatchedQueryRowDifferential[]; - unchanged: RowType[]; + added: ReadonlyArray; + /** + * The entire current result set. + * Array item object references are preserved between updates if the item is unchanged. + * + * e.g. In the query + * ```sql + * SELECT name, make FROM assets ORDER BY make ASC; + * ``` + * + * If a previous result set contains an item (A) `{name: 'pc', make: 'Cool PC'}` and + * an update has been made which adds another item (B) to the result set (the item A is unchanged) - then + * the updated result set will be contain the same object reference,to item A, as the previous resultset. + * This is regardless of the item A's position in the updated result set. + */ + all: ReadonlyArray; + removed: ReadonlyArray; + updated: ReadonlyArray>; + unchanged: ReadonlyArray; } /** @@ -98,36 +112,44 @@ export class DifferentialQueryProcessor let hasChanged = false; const currentMap = new Map(); - current.forEach((item) => { - currentMap.set(identify(item), { - hash: compareBy(item), - item - }); - }); - const removedTracker = new Set(previousMap.keys()); - const diff: WatchedQueryDifferential = { - all: current, - added: [], - removed: [], - updated: [], - unchanged: [] + // Allow mutating to populate the data temporarily. + const diff = { + all: [] as RowType[], + added: [] as RowType[], + removed: [] as RowType[], + updated: [] as WatchedQueryRowDifferential[], + unchanged: [] as RowType[] }; - for (const [key, { hash, item }] of currentMap) { + /** + * Looping over the current result set array is important to preserve + * the ordering of the result set. + * We can replace items in the current array with previous object references if they are equal. + */ + for (const item of current) { + const key = identify(item); + const hash = compareBy(item); + currentMap.set(key, { hash, item }); + const previousItem = previousMap.get(key); if (!previousItem) { // New item hasChanged = true; diff.added.push(item); + diff.all.push(item); } else { // Existing item if (hash == previousItem.hash) { diff.unchanged.push(item); + // Use the previous object reference + diff.all.push(previousItem.item); } else { hasChanged = true; diff.updated.push({ current: item, previous: previousItem.item }); + // Use the new reference + diff.all.push(item); } } // The item is present, we don't consider it removed @@ -175,7 +197,9 @@ export class DifferentialQueryProcessor await this.updateState({ isFetching: true }); } - const partialStateUpdate: Partial>> = {}; + const partialStateUpdate: Partial>> & { + data?: WatchedQueryDifferential; + } = {}; // Always run the query if an underlying table has changed const result = await watchOptions.query.execute({ diff --git a/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts b/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts index 7130c594a..4b47e49b8 100644 --- a/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts +++ b/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts @@ -56,7 +56,7 @@ export class OnChangeQueryProcessor extends AbstractQueryProcessor> = {}; + const partialStateUpdate: Partial> & { data?: Data } = {}; // Always run the query if an underlying table has changed const result = await watchOptions.query.execute({ diff --git a/packages/web/tests/watch.test.ts b/packages/web/tests/watch.test.ts index 97fcad3bb..f94f187df 100644 --- a/packages/web/tests/watch.test.ts +++ b/packages/web/tests/watch.test.ts @@ -668,6 +668,83 @@ describe('Watch Tests', { sequential: true }, () => { ); }); + it('should preserve object references in result set', async () => { + // Sort the results by the `make` column in ascending order + const watch = powersync + .incrementalWatch({ + mode: IncrementalWatchMode.DIFFERENTIAL + }) + .build({ + differentiator: { + identify: (item) => item.id, + compareBy: (item) => JSON.stringify(item) + }, + watch: { + query: new GetAllQuery({ + sql: /* sql */ ` + SELECT + * + FROM + assets + ORDER BY + make ASC; + `, + mapper: (raw) => { + return { + id: raw.id as string, + make: raw.make as string + }; + } + }) + } + }); + + // Create sample data + await powersync.execute( + /* sql */ ` + INSERT INTO + assets (id, make, customer_id) + VALUES + (uuid (), ?, uuid ()), + (uuid (), ?, uuid ()), + (uuid (), ?, uuid ()) + `, + ['a', 'b', 'd'] + ); + + await vi.waitFor( + () => { + expect(watch.state.data.all.map((i) => i.make)).deep.equals(['a', 'b', 'd']); + }, + { timeout: 1000 } + ); + + const initialData = watch.state.data.all; + + await powersync.execute( + /* sql */ ` + INSERT INTO + assets (id, make, customer_id) + VALUES + (uuid (), ?, uuid ()) + `, + ['c'] + ); + + await vi.waitFor( + () => { + expect(watch.state.data.all).toHaveLength(4); + }, + { timeout: 1000 } + ); + + console.log(JSON.stringify(watch.state.data.all)); + expect(initialData[0] == watch.state.data.all[0]).true; + expect(initialData[1] == watch.state.data.all[1]).true; + // The index after the insert should also still be the same ref as the previous item + expect(initialData[2] == watch.state.data.all[3]).true; + }); + it('should report differential query results from initial state', async () => { /** * Differential queries start with a placeholder data. We run a watched query under the hood From 3a6bef6ff4f94fefb1b1eab82b3bc74a6fa3efe5 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Thu, 26 Jun 2025 15:12:47 +0200 Subject: [PATCH 63/75] Add automated profiling benchmarks --- .../processors/DifferentialQueryProcessor.ts | 4 +- packages/react/package.json | 1 + packages/react/src/hooks/watched/useQuery.ts | 1 + packages/react/tests/profile.test.tsx | 431 ++++++++++++++++++ pnpm-lock.yaml | 193 ++++---- 5 files changed, 523 insertions(+), 107 deletions(-) create mode 100644 packages/react/tests/profile.test.tsx diff --git a/packages/common/src/client/watched/processors/DifferentialQueryProcessor.ts b/packages/common/src/client/watched/processors/DifferentialQueryProcessor.ts index 53d631570..3373c4bb4 100644 --- a/packages/common/src/client/watched/processors/DifferentialQueryProcessor.ts +++ b/packages/common/src/client/watched/processors/DifferentialQueryProcessor.ts @@ -27,7 +27,7 @@ export interface WatchedQueryDifferential { * * If a previous result set contains an item (A) `{name: 'pc', make: 'Cool PC'}` and * an update has been made which adds another item (B) to the result set (the item A is unchanged) - then - * the updated result set will be contain the same object reference,to item A, as the previous resultset. + * the updated result set will be contain the same object reference, to item A, as the previous result set. * This is regardless of the item A's position in the updated result set. */ all: ReadonlyArray; @@ -145,6 +145,8 @@ export class DifferentialQueryProcessor diff.unchanged.push(item); // Use the previous object reference diff.all.push(previousItem.item); + // update the map to preserve the reference + currentMap.set(key, previousItem); } else { hasChanged = true; diff.updated.push({ current: item, previous: previousItem.item }); diff --git a/packages/react/package.json b/packages/react/package.json index 47248969a..4d0b84636 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -39,6 +39,7 @@ "@types/react": "^18.3.1", "jsdom": "^24.0.0", "react": "18.3.1", + "react-dom": "18.3.1", "react-error-boundary": "^4.1.0" } } diff --git a/packages/react/src/hooks/watched/useQuery.ts b/packages/react/src/hooks/watched/useQuery.ts index e50f030cd..8a00c1d22 100644 --- a/packages/react/src/hooks/watched/useQuery.ts +++ b/packages/react/src/hooks/watched/useQuery.ts @@ -43,6 +43,7 @@ export const useQuery = ( powerSync, queryChanged, options: { + reportFetching: options.reportFetching, // Maintains backwards compatibility with previous versions // Comparisons are opt-in by default // We emit new data for each table change by default. diff --git a/packages/react/tests/profile.test.tsx b/packages/react/tests/profile.test.tsx new file mode 100644 index 000000000..f10b6b694 --- /dev/null +++ b/packages/react/tests/profile.test.tsx @@ -0,0 +1,431 @@ +import * as commonSdk from '@powersync/common'; +import { PowerSyncDatabase } from '@powersync/web'; +import React, { Profiler } from 'react'; +import ReactDOM from 'react-dom/client'; +import { beforeEach, describe, it, Mock, onTestFinished, vi } from 'vitest'; +import { PowerSyncContext } from '../src/hooks/PowerSyncContext'; +import { useQuery } from '../src/hooks/watched/useQuery'; +import { useWatchedQuerySubscription } from '../src/hooks/watched/useWatchedQuerySubscription'; + +let skipTests = true; +/** + * This does not run as part of all tests. Enable this suite manually to run performance tests. + * + * The tests here compare the time taken to render a list of items under different watched query modes. + * The Tests render a list of Items supplied from a Watched Query. + * + * Methodology: + * In all tests we start with an initial set of items then add a new item. + * The render time for the list widget is measured for each insert. + * + * Each watched query mode is tested with and without memoization of the components. + * + * The standard watch query mode returns new object references for each change. This means that the + * entire widget will render each time a new item is added - even if memoization is used. + * + * The differential watch mode will return previous object references for unchanged items. This can reduce the render time, + * but only if memoization is used. The time taken to process the differential changes is also measured, to make a fair comparison, + * the differential processing time is added to the render time for each insert. + * + * Initial data set volume is sweeped over a range of values. A memoized differential watch query should only render new items on insert. + * It is expected that render times will increase for regular watch queries as the initial data set volume increases. + */ +const AppSchema = new commonSdk.Schema({ + lists: new commonSdk.Table({ + name: commonSdk.column.text, + description: commonSdk.column.text, + items: commonSdk.column.integer + }) +}); + +type List = (typeof AppSchema)['types']['lists']; + +export const openPowerSync = () => { + const db = new PowerSyncDatabase({ + database: { dbFilename: 'test.db' }, + schema: AppSchema + }); + + onTestFinished(async () => { + await db.disconnectAndClear(); + await db.close(); + }); + + return db; +}; + +const TestWidget: React.FC<{ + getData: () => ReadonlyArray; + memoize: boolean; +}> = (props) => { + const data = props.getData(); + return ( +
+ {props.memoize + ? data.map((item) => ) + : data.map((item) => )} +
+ ); +}; + +const TestItemWidget: React.FC<{ item: List }> = (props) => { + const { item } = props; + return ( +
+
{item.id}
+
{item.name}
+
{item.description}
+
{item.items}
+
+ ); +}; + +const TestItemMemoized = React.memo(TestItemWidget); + +type InsertTestResult = { + initialRenderDuration: number; + renderDurations: number[]; + averageAdditionalRenderDuration: number; +}; + +/** + * Runs a single insert test for an amount of initial data and then inserts a number of items + * and measures the render time for each insert. + * Uses the data hook provided for rendering. + */ +const testInserts = async (options: { + db: commonSdk.AbstractPowerSyncDatabase; + getQueryData: () => ReadonlyArray; + useMemoize: boolean; + initialDataCount: number; + incrementalInsertsCount: number; +}): Promise => { + const { db, getQueryData, useMemoize, initialDataCount, incrementalInsertsCount } = options; + + const result: InsertTestResult = { + initialRenderDuration: 0, + renderDurations: [], + averageAdditionalRenderDuration: 0 + }; + + const container = document.createElement('div'); + document.body.appendChild(container); + const root = ReactDOM.createRoot(container); + let cleanupCompleted = false; + const cleanup = () => { + if (cleanupCompleted) return; + root.unmount(); + document.body.removeChild(container); + cleanupCompleted = true; + }; + onTestFinished(() => { + cleanup(); + }); + + /** + * The ordering of items by their numerically increasing name can cause items to be rendered in a different order + * React does not seem to efficiently handle rows being added in the middle of the list. + * This test tests if new items are sorted. + * We pad the name for correct sorting. + */ + const padWidth = Math.ceil((initialDataCount + incrementalInsertsCount) / 10); + const padName = (number: number) => number.toString().padStart(padWidth, '0'); + + const onRender: Mock = vi.fn(() => {}); + const getDataSpy = vi.fn(getQueryData); + const { benchmarkId } = await db.get<{ benchmarkId: string }>('select uuid() as benchmarkId'); + + root.render( + + + + + + ); + + // Create initial data + await db.writeTransaction(async (tx) => { + for (let i = 0; i < initialDataCount; i++) { + await tx.execute(/* sql */ ` + INSERT INTO + lists (id, name, description) + VALUES + ( + uuid (), + '${padName(i)}', + hex (randomblob (30)) + ) + `); + } + }); + + // The initial data should have been rendered after this returns correctly + await vi.waitFor( + () => { + expect(getDataSpy.mock.results.find((r) => r.value.length === initialDataCount)).toBeDefined(); + }, + { timeout: 100, interval: 10 } + ); + + // Get the last render time for update + const getLastUpdateProfile = () => [...onRender.mock.calls].reverse().find((call) => call[1] == 'update'); + const initialRenderProfile = getLastUpdateProfile(); + const initialRenderDuration = initialRenderProfile?.[2]; + + result.initialRenderDuration = initialRenderDuration ?? 0; + + const count = onRender.mock.calls.length; + for (let renderTestCount = 0; renderTestCount < incrementalInsertsCount; renderTestCount++) { + // Create a single item + await db.execute(/* sql */ ` + INSERT INTO + lists (id, name, description) + VALUES + ( + uuid (), + '${padName(initialDataCount + renderTestCount)}', + hex (randomblob (30)) + ) + `); + + // Wait for this change to be reflected in the UI + await vi.waitFor( + () => { + expect(getDataSpy.mock.results.find((r) => r.value.length == initialDataCount + renderTestCount)).toBeDefined(); + expect(onRender.mock.calls.length).toBe(count + 1 + renderTestCount); + }, + { + timeout: 1000, + interval: 10 + } + ); + const profile = getLastUpdateProfile(); + const duration = profile?.[2]; + if (duration != null) { + result.renderDurations.push(duration); + } else { + throw `No duration found for render ${renderTestCount + 1}`; + } + } + + cleanup(); + + result.averageAdditionalRenderDuration = + result.renderDurations.reduce((sum, duration) => sum + duration, 0) / result.renderDurations.length; + return result; +}; + +type DifferentialInsertTestResult = InsertTestResult & { + /** + * Represents the duration of the render, not including the differential processing. + * We add the differential processing time to the render time for comparison.s + */ + pureRenderDurations: number[]; +}; + +type TestsInsertsCompareResult = { + regular: InsertTestResult; + regularMemoized: InsertTestResult; + differential: DifferentialInsertTestResult; + differentialMemoized: DifferentialInsertTestResult; +}; + +const testsInsertsCompare = async (options: { + db: commonSdk.AbstractPowerSyncDatabase; + initialDataCount: number; + incrementalInsertsCount: number; +}) => { + const { db, incrementalInsertsCount, initialDataCount } = options; + const result: Partial = {}; + // Testing Regular Queries Without Memoization + result.regular = await testInserts({ + db, + incrementalInsertsCount, + initialDataCount, + useMemoize: false, + getQueryData: () => { + const { data } = useQuery('SELECT * FROM lists ORDER BY name ASC;', [], { + reportFetching: false + }); + return data; + } + }); + + // Testing Regular Queries Without Memoization + await db.execute('DELETE FROM lists;'); + result.regularMemoized = await testInserts({ + db, + incrementalInsertsCount, + initialDataCount, + useMemoize: true, + getQueryData: () => { + const { data } = useQuery('SELECT * FROM lists ORDER BY name ASC;', [], { + reportFetching: false + }); + return data; + } + }); + + // Testing Differential Updates + + const diffSpy = (query: commonSdk.WatchedQuery, outputTimes: number[]) => { + const base = (query as any).differentiate; + vi.spyOn(query as any, 'differentiate').mockImplementation((...params: any[]) => { + const start = performance.now(); + const result = base.apply(query, params); + const time = performance.now() - start; + outputTimes.push(time); + return result; + }); + }; + + const notMemoizedDifferentialTest = async () => { + await db.execute('DELETE FROM lists;'); + + const query = db.incrementalWatch({ mode: commonSdk.IncrementalWatchMode.DIFFERENTIAL }).build({ + watch: { + query: new commonSdk.GetAllQuery({ + sql: 'SELECT * FROM lists ORDER BY name ASC;' + }), + reportFetching: false + } + }); + + const times: number[] = []; + diffSpy(query, times); + + const baseResult = await testInserts({ + db, + incrementalInsertsCount, + initialDataCount, + useMemoize: false, + getQueryData: () => { + const result = useWatchedQuerySubscription(query).data.all; + return result; + } + }); + + const renderDurations = baseResult.renderDurations.map((d, i) => d + (times[i] ?? 0)); + const averageAdditionalRenderDuration = + renderDurations.reduce((sum, duration) => sum + duration, 0) / renderDurations.length; + result.differential = { + ...baseResult, + pureRenderDurations: baseResult.renderDurations, + renderDurations, + averageAdditionalRenderDuration + }; + + await query.close(); + }; + await notMemoizedDifferentialTest(); + + // Testing Differential With Memoization + await db.execute('DELETE FROM lists;'); + + // guardLog = true; // Prevents logging from TestItemWidget + const query = db.incrementalWatch({ mode: commonSdk.IncrementalWatchMode.DIFFERENTIAL }).build({ + watch: { + query: new commonSdk.GetAllQuery({ + sql: 'SELECT * FROM lists ORDER BY name ASC;' + }), + reportFetching: false + } + }); + + const times: number[] = []; + diffSpy(query, times); + + const baseResult = await testInserts({ + db, + incrementalInsertsCount, + initialDataCount, + useMemoize: true, + getQueryData: () => { + const result = useWatchedQuerySubscription(query).data.all; + return result; + } + }); + + const renderDurations = baseResult.renderDurations.map((d, i) => d + (times[i] ?? 0)); + const averageAdditionalRenderDuration = + renderDurations.reduce((sum, duration) => sum + duration, 0) / renderDurations.length; + result.differentialMemoized = { + ...baseResult, + pureRenderDurations: baseResult.renderDurations, + renderDurations, + averageAdditionalRenderDuration + }; + + await query.close(); + + await db.execute('DELETE FROM lists;'); + + return result as TestsInsertsCompareResult; +}; + +describe.skipIf(skipTests)('Performance', { timeout: 90_000 }, () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('Benchmark', async () => { + const db = openPowerSync(); + // const initialDataCount = 10; + const initialDataVolumeSteps = new Array(10).fill(0).map((_, i) => (i + 1) * 10); + const incrementalInsertsCount = 10; + const redoTestCount = 5; + + const totalResults: any[] = []; + + for (const initialDataCount of initialDataVolumeSteps) { + const results: TestsInsertsCompareResult[] = []; + for (let i = 0; i < redoTestCount; i++) { + console.log(`Running test for initial data count: ${initialDataCount}, iteration: ${i + 1} / ${redoTestCount}`); + // Run the test for the current initial data count + const result = await testsInsertsCompare({ + db, + initialDataCount, + incrementalInsertsCount + }); + results.push(result); + } + + // Average the individual averages over each iteration + const averageResult = { + initialDataCount, + regular: + results.reduce((acc, r) => { + return acc + r.regular.averageAdditionalRenderDuration; + }, 0) / redoTestCount, + regularMemoized: + results.reduce((acc, r) => { + return acc + r.regularMemoized.averageAdditionalRenderDuration; + }, 0) / redoTestCount, + differential: + results.reduce((acc, r) => { + return acc + r.differential.averageAdditionalRenderDuration; + }, 0) / redoTestCount, + differentialMemoized: + results.reduce((acc, r) => { + return acc + r.differentialMemoized.averageAdditionalRenderDuration; + }, 0) / redoTestCount, + differentialMemoImprovementPercentage: 0 + }; + + averageResult.differentialMemoImprovementPercentage = + ((averageResult.regular - averageResult.differentialMemoized) / averageResult.regular) * 100; + + totalResults.push(averageResult); + } + + // Unfortunately vitest browser mode does not support console.table + // This can be viewed if in the browser console. + console.table(totalResults); + + // CSV log + console.log(Object.keys(totalResults[0]).join(',')); + totalResults.forEach((r) => { + console.log(Object.values(r).join(',')); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 262a6dba8..343196793 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -125,7 +125,7 @@ importers: version: 4.0.1(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1)) '@expo/vector-icons': specifier: ^14.0.0 - version: 14.1.0(kpdfmw6ivudhnfw6o4uluiluqi) + version: 14.1.0(fdc5ce3de8aabd6226c79743411018d7) '@journeyapps/react-native-quick-sqlite': specifier: ^2.4.5 version: 2.4.5(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) @@ -146,7 +146,7 @@ importers: version: 0.1.11(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) '@react-navigation/drawer': specifier: ^7.1.1 - version: 7.4.1(xwon3p5r2ryxkrzljplculi3hm) + version: 7.4.1(62bded36bd875de6ca3ee26c42c5ea03) '@react-navigation/native': specifier: ^7.0.14 version: 7.1.10(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) @@ -170,7 +170,7 @@ importers: version: 2.1.10 expo-router: specifier: 4.0.21 - version: 4.0.21(7pkmiwofdx5cbd6rrboya7mm6y) + version: 4.0.21(e374635bcb9f01385d354b70deac59d5) expo-splash-screen: specifier: ~0.29.22 version: 0.29.24(expo@52.0.46(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@expo/metro-runtime@4.0.1(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1)))(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.12.5(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)) @@ -218,7 +218,7 @@ importers: version: 10.2.0 react-navigation-stack: specifier: ^2.10.4 - version: 2.10.4(n5q7nzlozgkktehjjhku7iswqa) + version: 2.10.4(cc782526f6f527a9fd49628df4caf975) typed-async-storage: specifier: ^3.1.2 version: 3.1.2 @@ -871,7 +871,7 @@ importers: version: 7.0.5(expo@52.0.46(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@expo/metro-runtime@4.0.1(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1)))(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.12.5(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) expo-router: specifier: 4.0.21 - version: 4.0.21(cpo3xaw6yrjernjvkkkt7bisia) + version: 4.0.21(b0bddf53ba1689b30337428eee4dc275) expo-splash-screen: specifier: ~0.29.22 version: 0.29.24(expo@52.0.46(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@expo/metro-runtime@4.0.1(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1)))(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.12.5(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)) @@ -944,7 +944,7 @@ importers: version: 1.0.2 '@expo/vector-icons': specifier: ^14.0.3 - version: 14.1.0(kpdfmw6ivudhnfw6o4uluiluqi) + version: 14.1.0(fdc5ce3de8aabd6226c79743411018d7) '@journeyapps/react-native-quick-sqlite': specifier: ^2.4.5 version: 2.4.5(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) @@ -965,7 +965,7 @@ importers: version: 0.1.11(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) '@react-navigation/drawer': specifier: ^7.1.1 - version: 7.4.1(xwon3p5r2ryxkrzljplculi3hm) + version: 7.4.1(62bded36bd875de6ca3ee26c42c5ea03) '@react-navigation/native': specifier: ^7.0.14 version: 7.1.10(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) @@ -989,7 +989,7 @@ importers: version: 0.13.3(expo@52.0.46(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@expo/metro-runtime@4.0.1(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1)))(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.12.5(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)) expo-camera: specifier: ~16.0.18 - version: 16.0.18(iufejmpajqz4jjoldpycss6ycq) + version: 16.0.18(3cdcf7b8e47f65c9a4496cca30210857) expo-constants: specifier: ~17.0.8 version: 17.0.8(expo@52.0.46(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@expo/metro-runtime@4.0.1(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1)))(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.12.5(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1)) @@ -1004,7 +1004,7 @@ importers: version: 7.0.5(expo@52.0.46(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@expo/metro-runtime@4.0.1(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1)))(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.12.5(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) expo-router: specifier: 4.0.21 - version: 4.0.21(7pkmiwofdx5cbd6rrboya7mm6y) + version: 4.0.21(e374635bcb9f01385d354b70deac59d5) expo-secure-store: specifier: ~14.0.1 version: 14.0.1(expo@52.0.46(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@expo/metro-runtime@4.0.1(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1)))(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.12.5(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)) @@ -1046,7 +1046,7 @@ importers: version: 4.4.0(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) react-navigation-stack: specifier: ^2.10.4 - version: 2.10.4(n5q7nzlozgkktehjjhku7iswqa) + version: 2.10.4(cc782526f6f527a9fd49628df4caf975) devDependencies: '@babel/core': specifier: ^7.26.10 @@ -1083,7 +1083,7 @@ importers: version: 4.0.1(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1)) '@expo/vector-icons': specifier: ^14.0.2 - version: 14.1.0(kpdfmw6ivudhnfw6o4uluiluqi) + version: 14.1.0(fdc5ce3de8aabd6226c79743411018d7) '@journeyapps/react-native-quick-sqlite': specifier: ^2.4.5 version: 2.4.5(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) @@ -1110,7 +1110,7 @@ importers: version: 7.3.14(@react-navigation/native@7.1.10(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native-safe-area-context@4.12.0(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native-screens@4.4.0(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) '@react-navigation/drawer': specifier: ^7.1.1 - version: 7.4.1(xwon3p5r2ryxkrzljplculi3hm) + version: 7.4.1(62bded36bd875de6ca3ee26c42c5ea03) '@react-navigation/native': specifier: ^7.0.14 version: 7.1.10(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) @@ -1131,7 +1131,7 @@ importers: version: 14.0.3(expo@52.0.46(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@expo/metro-runtime@4.0.1(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1)))(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.12.5(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) expo-camera: specifier: ~16.0.18 - version: 16.0.18(iufejmpajqz4jjoldpycss6ycq) + version: 16.0.18(3cdcf7b8e47f65c9a4496cca30210857) expo-constants: specifier: ~17.0.5 version: 17.0.8(expo@52.0.46(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@expo/metro-runtime@4.0.1(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1)))(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.12.5(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1)) @@ -1149,7 +1149,7 @@ importers: version: 7.0.5(expo@52.0.46(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@expo/metro-runtime@4.0.1(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1)))(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.12.5(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) expo-router: specifier: 4.0.21 - version: 4.0.21(7pkmiwofdx5cbd6rrboya7mm6y) + version: 4.0.21(e374635bcb9f01385d354b70deac59d5) expo-secure-store: specifier: ^14.0.1 version: 14.0.1(expo@52.0.46(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@expo/metro-runtime@4.0.1(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1)))(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.12.5(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)) @@ -1164,7 +1164,7 @@ importers: version: 0.2.2(expo@52.0.46(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@expo/metro-runtime@4.0.1(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1)))(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.12.5(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)) expo-system-ui: specifier: ~4.0.8 - version: 4.0.9(gkhgpojom75kfqjgntjbsh35pm) + version: 4.0.9(c0a3f55e662f74e948e2bd58fcbec8f1) expo-web-browser: specifier: ~14.0.2 version: 14.0.2(expo@52.0.46(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@expo/metro-runtime@4.0.1(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1)))(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.12.5(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1)) @@ -1225,7 +1225,7 @@ importers: version: 29.7.0(@types/node@20.17.57)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.57)(typescript@5.8.3)) jest-expo: specifier: ~52.0.3 - version: 52.0.6(k3ezlcldv4g4k2inpgpppmt2uy) + version: 52.0.6(5ecd6a454ab2aef14ba34b6a53fe2748) react-test-renderer: specifier: 18.3.1 version: 18.3.1(react@18.3.1) @@ -1875,6 +1875,9 @@ importers: react: specifier: 18.3.1 version: 18.3.1 + react-dom: + specifier: 18.3.1 + version: 18.3.1(react@18.3.1) react-error-boundary: specifier: ^4.1.0 version: 4.1.2(react@18.3.1) @@ -7158,15 +7161,6 @@ packages: rollup: optional: true - '@rollup/plugin-node-resolve@15.3.1': - resolution: {integrity: sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==} - engines: {node: '>=14.0.0'} - peerDependencies: - rollup: ^2.78.0||^3.0.0||^4.0.0 - peerDependenciesMeta: - rollup: - optional: true - '@rollup/plugin-replace@2.4.2': resolution: {integrity: sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg==} peerDependencies: @@ -7214,15 +7208,6 @@ packages: rollup: optional: true - '@rollup/pluginutils@5.2.0': - resolution: {integrity: sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw==} - engines: {node: '>=14.0.0'} - peerDependencies: - rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 - peerDependenciesMeta: - rollup: - optional: true - '@rollup/rollup-android-arm-eabi@4.14.3': resolution: {integrity: sha512-X9alQ3XM6I9IlSlmC8ddAvMSyG1WuHk5oUnXGw+yUBs3BFoTizmG1La/Gr8fVJvDWAq+zlYTZ9DBgrlKRVY06g==} cpu: [arm] @@ -8747,9 +8732,6 @@ packages: '@types/estree@1.0.7': resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==} - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - '@types/express-serve-static-core@4.19.6': resolution: {integrity: sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==} @@ -25107,13 +25089,13 @@ snapshots: '@expo/timeago.js@1.0.0': {} - '@expo/vector-icons@14.1.0(ka6rgkktlsuut5gotrymd2sdni)': + '@expo/vector-icons@14.1.0(99f35dc9d27b76831378288730881035)': dependencies: expo-font: 13.0.4(expo@52.0.46(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@expo/metro-runtime@4.0.1(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1)))(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.12.5(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react@18.3.1) react: 18.3.1 react-native: 0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1) - '@expo/vector-icons@14.1.0(kpdfmw6ivudhnfw6o4uluiluqi)': + '@expo/vector-icons@14.1.0(fdc5ce3de8aabd6226c79743411018d7)': dependencies: expo-font: 13.0.4(expo@52.0.46(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@expo/metro-runtime@4.0.1(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1)))(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.12.5(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react@18.3.1) react: 18.3.1 @@ -25993,7 +25975,7 @@ snapshots: '@manypkg/find-root@1.1.0': dependencies: - '@babel/runtime': 7.27.4 + '@babel/runtime': 7.27.6 '@types/node': 12.20.55 find-up: 4.1.0 fs-extra: 8.1.0 @@ -28578,7 +28560,23 @@ snapshots: use-latest-callback: 0.2.3(react@18.3.1) use-sync-external-store: 1.5.0(react@18.3.1) - '@react-navigation/drawer@7.4.1(nyxmcqdttlojx3ihgax6eihdpu)': + '@react-navigation/drawer@7.4.1(62bded36bd875de6ca3ee26c42c5ea03)': + dependencies: + '@react-navigation/elements': 2.4.3(@react-navigation/native@7.1.10(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native-safe-area-context@4.12.0(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) + '@react-navigation/native': 7.1.10(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) + color: 4.2.3 + react: 18.3.1 + react-native: 0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1) + react-native-drawer-layout: 4.1.10(react-native-gesture-handler@2.20.2(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native-reanimated@3.16.7(@babel/core@7.26.10)(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) + react-native-gesture-handler: 2.20.2(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) + react-native-reanimated: 3.16.7(@babel/core@7.26.10)(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) + react-native-safe-area-context: 4.12.0(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) + react-native-screens: 4.4.0(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) + use-latest-callback: 0.2.3(react@18.3.1) + transitivePeerDependencies: + - '@react-native-masked-view/masked-view' + + '@react-navigation/drawer@7.4.1(f2502081aada8c22c3fd2dbf46b9d114)': dependencies: '@react-navigation/elements': 2.4.3(@react-navigation/native@7.1.10(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native-safe-area-context@4.12.0(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) '@react-navigation/native': 7.1.10(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) @@ -28595,22 +28593,6 @@ snapshots: - '@react-native-masked-view/masked-view' optional: true - '@react-navigation/drawer@7.4.1(xwon3p5r2ryxkrzljplculi3hm)': - dependencies: - '@react-navigation/elements': 2.4.3(@react-navigation/native@7.1.10(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native-safe-area-context@4.12.0(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) - '@react-navigation/native': 7.1.10(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) - color: 4.2.3 - react: 18.3.1 - react-native: 0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1) - react-native-drawer-layout: 4.1.10(react-native-gesture-handler@2.20.2(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native-reanimated@3.16.7(@babel/core@7.26.10)(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) - react-native-gesture-handler: 2.20.2(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) - react-native-reanimated: 3.16.7(@babel/core@7.26.10)(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) - react-native-safe-area-context: 4.12.0(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) - react-native-screens: 4.4.0(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) - use-latest-callback: 0.2.3(react@18.3.1) - transitivePeerDependencies: - - '@react-native-masked-view/masked-view' - '@react-navigation/elements@2.4.3(@react-navigation/native@7.1.10(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native-safe-area-context@4.12.0(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)': dependencies: '@react-navigation/native': 7.1.10(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) @@ -28747,26 +28729,27 @@ snapshots: optionalDependencies: rollup: 4.14.3 - '@rollup/plugin-node-resolve@15.2.3(rollup@4.14.3)': + '@rollup/plugin-node-resolve@15.2.3(rollup@2.79.2)': dependencies: - '@rollup/pluginutils': 5.1.4(rollup@4.14.3) + '@rollup/pluginutils': 5.1.4(rollup@2.79.2) '@types/resolve': 1.20.2 deepmerge: 4.3.1 is-builtin-module: 3.2.1 is-module: 1.0.0 resolve: 1.22.10 optionalDependencies: - rollup: 4.14.3 + rollup: 2.79.2 - '@rollup/plugin-node-resolve@15.3.1(rollup@2.79.2)': + '@rollup/plugin-node-resolve@15.2.3(rollup@4.14.3)': dependencies: - '@rollup/pluginutils': 5.2.0(rollup@2.79.2) + '@rollup/pluginutils': 5.1.4(rollup@4.14.3) '@types/resolve': 1.20.2 deepmerge: 4.3.1 + is-builtin-module: 3.2.1 is-module: 1.0.0 resolve: 1.22.10 optionalDependencies: - rollup: 2.79.2 + rollup: 4.14.3 '@rollup/plugin-replace@2.4.2(rollup@2.79.2)': dependencies: @@ -28812,29 +28795,29 @@ snapshots: picomatch: 2.3.1 rollup: 2.79.2 - '@rollup/pluginutils@5.1.4(rollup@4.14.3)': + '@rollup/pluginutils@5.1.4(rollup@2.79.2)': dependencies: '@types/estree': 1.0.7 estree-walker: 2.0.2 picomatch: 4.0.2 optionalDependencies: - rollup: 4.14.3 + rollup: 2.79.2 - '@rollup/pluginutils@5.1.4(rollup@4.41.1)': + '@rollup/pluginutils@5.1.4(rollup@4.14.3)': dependencies: '@types/estree': 1.0.7 estree-walker: 2.0.2 picomatch: 4.0.2 optionalDependencies: - rollup: 4.41.1 + rollup: 4.14.3 - '@rollup/pluginutils@5.2.0(rollup@2.79.2)': + '@rollup/pluginutils@5.1.4(rollup@4.41.1)': dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.7 estree-walker: 2.0.2 picomatch: 4.0.2 optionalDependencies: - rollup: 2.79.2 + rollup: 4.41.1 '@rollup/rollup-android-arm-eabi@4.14.3': optional: true @@ -30676,8 +30659,6 @@ snapshots: '@types/estree@1.0.7': {} - '@types/estree@1.0.8': {} - '@types/express-serve-static-core@4.19.6': dependencies: '@types/node': 20.17.57 @@ -35513,7 +35494,7 @@ snapshots: expo: 52.0.46(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@expo/metro-runtime@4.0.1(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1)))(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.12.5(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) semver: 7.7.2 - expo-camera@16.0.18(iufejmpajqz4jjoldpycss6ycq): + expo-camera@16.0.18(3cdcf7b8e47f65c9a4496cca30210857): dependencies: expo: 52.0.46(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@expo/metro-runtime@4.0.1(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1)))(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.12.5(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) invariant: 2.2.4 @@ -35673,29 +35654,29 @@ snapshots: dependencies: invariant: 2.2.4 - expo-router@4.0.21(7pkmiwofdx5cbd6rrboya7mm6y): + expo-router@4.0.21(b0bddf53ba1689b30337428eee4dc275): dependencies: - '@expo/metro-runtime': 4.0.1(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1)) + '@expo/metro-runtime': 4.0.1(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1)) '@expo/server': 0.5.3 '@radix-ui/react-slot': 1.0.1(react@18.3.1) - '@react-navigation/bottom-tabs': 7.3.14(@react-navigation/native@7.1.10(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native-safe-area-context@4.12.0(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native-screens@4.4.0(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) - '@react-navigation/native': 7.1.10(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) - '@react-navigation/native-stack': 7.3.14(@react-navigation/native@7.1.10(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native-safe-area-context@4.12.0(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native-screens@4.4.0(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) + '@react-navigation/bottom-tabs': 7.3.14(@react-navigation/native@7.1.10(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native-safe-area-context@4.12.0(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native-screens@4.4.0(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) + '@react-navigation/native': 7.1.10(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) + '@react-navigation/native-stack': 7.3.14(@react-navigation/native@7.1.10(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native-safe-area-context@4.12.0(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native-screens@4.4.0(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) client-only: 0.0.1 - expo: 52.0.46(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@expo/metro-runtime@4.0.1(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1)))(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.12.5(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) - expo-constants: 17.0.8(expo@52.0.46(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@expo/metro-runtime@4.0.1(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1)))(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.12.5(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1)) - expo-linking: 7.0.5(expo@52.0.46(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@expo/metro-runtime@4.0.1(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1)))(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.12.5(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) + expo: 52.0.46(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@expo/metro-runtime@4.0.1(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1)))(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.12.5(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) + expo-constants: 17.0.8(expo@52.0.46(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@expo/metro-runtime@4.0.1(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1)))(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.12.5(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1)) + expo-linking: 7.0.5(expo@52.0.46(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@expo/metro-runtime@4.0.1(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1)))(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.12.5(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) react-helmet-async: 1.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-native-helmet-async: 2.0.4(react@18.3.1) - react-native-is-edge-to-edge: 1.1.7(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) - react-native-safe-area-context: 4.12.0(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) - react-native-screens: 4.4.0(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) + react-native-is-edge-to-edge: 1.1.7(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) + react-native-safe-area-context: 4.12.0(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) + react-native-screens: 4.4.0(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) schema-utils: 4.3.2 semver: 7.6.3 server-only: 0.0.1 optionalDependencies: - '@react-navigation/drawer': 7.4.1(xwon3p5r2ryxkrzljplculi3hm) - react-native-reanimated: 3.16.7(@babel/core@7.26.10)(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) + '@react-navigation/drawer': 7.4.1(f2502081aada8c22c3fd2dbf46b9d114) + react-native-reanimated: 3.16.7(@babel/core@7.26.10)(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) transitivePeerDependencies: - '@react-native-masked-view/masked-view' - react @@ -35703,29 +35684,29 @@ snapshots: - react-native - supports-color - expo-router@4.0.21(cpo3xaw6yrjernjvkkkt7bisia): + expo-router@4.0.21(e374635bcb9f01385d354b70deac59d5): dependencies: - '@expo/metro-runtime': 4.0.1(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1)) + '@expo/metro-runtime': 4.0.1(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1)) '@expo/server': 0.5.3 '@radix-ui/react-slot': 1.0.1(react@18.3.1) - '@react-navigation/bottom-tabs': 7.3.14(@react-navigation/native@7.1.10(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native-safe-area-context@4.12.0(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native-screens@4.4.0(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) - '@react-navigation/native': 7.1.10(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) - '@react-navigation/native-stack': 7.3.14(@react-navigation/native@7.1.10(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native-safe-area-context@4.12.0(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native-screens@4.4.0(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) + '@react-navigation/bottom-tabs': 7.3.14(@react-navigation/native@7.1.10(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native-safe-area-context@4.12.0(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native-screens@4.4.0(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) + '@react-navigation/native': 7.1.10(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) + '@react-navigation/native-stack': 7.3.14(@react-navigation/native@7.1.10(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native-safe-area-context@4.12.0(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native-screens@4.4.0(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) client-only: 0.0.1 - expo: 52.0.46(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@expo/metro-runtime@4.0.1(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1)))(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.12.5(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) - expo-constants: 17.0.8(expo@52.0.46(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@expo/metro-runtime@4.0.1(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1)))(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.12.5(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1)) - expo-linking: 7.0.5(expo@52.0.46(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@expo/metro-runtime@4.0.1(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1)))(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.12.5(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) + expo: 52.0.46(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@expo/metro-runtime@4.0.1(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1)))(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.12.5(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) + expo-constants: 17.0.8(expo@52.0.46(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@expo/metro-runtime@4.0.1(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1)))(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.12.5(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1)) + expo-linking: 7.0.5(expo@52.0.46(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@expo/metro-runtime@4.0.1(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1)))(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.12.5(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) react-helmet-async: 1.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-native-helmet-async: 2.0.4(react@18.3.1) - react-native-is-edge-to-edge: 1.1.7(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) - react-native-safe-area-context: 4.12.0(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) - react-native-screens: 4.4.0(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) + react-native-is-edge-to-edge: 1.1.7(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) + react-native-safe-area-context: 4.12.0(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) + react-native-screens: 4.4.0(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) schema-utils: 4.3.2 semver: 7.6.3 server-only: 0.0.1 optionalDependencies: - '@react-navigation/drawer': 7.4.1(nyxmcqdttlojx3ihgax6eihdpu) - react-native-reanimated: 3.16.7(@babel/core@7.26.10)(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) + '@react-navigation/drawer': 7.4.1(62bded36bd875de6ca3ee26c42c5ea03) + react-native-reanimated: 3.16.7(@babel/core@7.26.10)(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) transitivePeerDependencies: - '@react-native-masked-view/masked-view' - react @@ -35766,7 +35747,7 @@ snapshots: expo: 52.0.46(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@expo/metro-runtime@4.0.1(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1)))(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.12.5(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) sf-symbols-typescript: 2.1.0 - expo-system-ui@4.0.9(gkhgpojom75kfqjgntjbsh35pm): + expo-system-ui@4.0.9(c0a3f55e662f74e948e2bd58fcbec8f1): dependencies: '@react-native/normalize-colors': 0.76.8 debug: 4.4.1(supports-color@8.1.1) @@ -35794,7 +35775,7 @@ snapshots: '@expo/config-plugins': 9.0.17 '@expo/fingerprint': 0.11.11 '@expo/metro-config': 0.19.12 - '@expo/vector-icons': 14.1.0(ka6rgkktlsuut5gotrymd2sdni) + '@expo/vector-icons': 14.1.0(99f35dc9d27b76831378288730881035) babel-preset-expo: 12.0.11(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10)) expo-asset: 11.0.5(expo@52.0.46(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@expo/metro-runtime@4.0.1(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1)))(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.12.5(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) expo-constants: 17.0.8(expo@52.0.46(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@expo/metro-runtime@4.0.1(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1)))(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.12.5(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1)) @@ -35830,7 +35811,7 @@ snapshots: '@expo/config-plugins': 9.0.17 '@expo/fingerprint': 0.11.11 '@expo/metro-config': 0.19.12 - '@expo/vector-icons': 14.1.0(kpdfmw6ivudhnfw6o4uluiluqi) + '@expo/vector-icons': 14.1.0(fdc5ce3de8aabd6226c79743411018d7) babel-preset-expo: 12.0.11(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10)) expo-asset: 11.0.5(expo@52.0.46(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@expo/metro-runtime@4.0.1(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1)))(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.12.5(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) expo-constants: 17.0.8(expo@52.0.46(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@expo/metro-runtime@4.0.1(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1)))(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.12.5(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1)) @@ -36825,7 +36806,7 @@ snapshots: history@4.10.1: dependencies: - '@babel/runtime': 7.27.4 + '@babel/runtime': 7.27.6 loose-envify: 1.4.0 resolve-pathname: 3.0.0 tiny-invariant: 1.3.3 @@ -38017,7 +37998,7 @@ snapshots: jest-mock: 29.7.0 jest-util: 29.7.0 - jest-expo@52.0.6(k3ezlcldv4g4k2inpgpppmt2uy): + jest-expo@52.0.6(5ecd6a454ab2aef14ba34b6a53fe2748): dependencies: '@expo/config': 10.0.11 '@expo/json-file': 9.1.4 @@ -43100,7 +43081,7 @@ snapshots: - supports-color - utf-8-validate - react-navigation-stack@2.10.4(n5q7nzlozgkktehjjhku7iswqa): + react-navigation-stack@2.10.4(cc782526f6f527a9fd49628df4caf975): dependencies: '@react-native-community/masked-view': 0.1.11(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.3))(@types/react@18.3.23)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) color: 3.2.1 @@ -46872,7 +46853,7 @@ snapshots: '@babel/preset-env': 7.27.2(@babel/core@7.26.10) '@babel/runtime': 7.27.6 '@rollup/plugin-babel': 5.3.1(@babel/core@7.26.10)(@types/babel__core@7.20.5)(rollup@2.79.2) - '@rollup/plugin-node-resolve': 15.3.1(rollup@2.79.2) + '@rollup/plugin-node-resolve': 15.2.3(rollup@2.79.2) '@rollup/plugin-replace': 2.4.2(rollup@2.79.2) '@rollup/plugin-terser': 0.4.4(rollup@2.79.2) '@surma/rollup-plugin-off-main-thread': 2.2.3 From 5c7c76e0ea39ee31f18ce600edeaf9964b2715ee Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 1 Jul 2025 16:05:13 +0200 Subject: [PATCH 64/75] Invert Watched Query creation API. Update hook packages to provide differentiator implementation. --- .changeset/little-bananas-fetch.md | 2 +- .../yjs-react-supabase-text-collab/README.md | 2 +- .../library/powersync/PowerSyncYjsProvider.ts | 36 +- .../src/client/AbstractPowerSyncDatabase.ts | 93 +++-- packages/common/src/client/CustomQuery.ts | 55 +++ packages/common/src/client/Query.ts | 88 +++++ .../common/src/client/watched/WatchedQuery.ts | 15 +- .../src/client/watched/WatchedQueryBuilder.ts | 13 - .../client/watched/WatchedQueryBuilderMap.ts | 19 - .../processors/AbstractQueryProcessor.ts | 32 +- .../ComparisonWatchedQueryBuilder.ts | 66 ---- .../processors/DifferentialQueryProcessor.ts | 116 ++++-- .../DifferentialWatchedQueryBuilder.ts | 85 ----- .../processors/OnChangeQueryProcessor.ts | 24 +- packages/common/src/index.ts | 4 +- packages/react/README.md | 15 +- packages/react/src/QueryStore.ts | 41 +- .../src/hooks/suspense/useSuspenseQuery.ts | 8 +- .../hooks/suspense/useWatchedSuspenseQuery.ts | 4 +- packages/react/src/hooks/watched/useQuery.ts | 6 +- .../src/hooks/watched/useWatchedQuery.ts | 40 +- .../watched/useWatchedQuerySubscription.ts | 2 +- .../react/src/hooks/watched/watch-types.ts | 6 +- packages/react/tests/useQuery.test.tsx | 18 +- .../react/tests/useSuspenseQuery.test.tsx | 38 +- .../vue/src/composables/useSingleQuery.ts | 8 +- .../vue/src/composables/useWatchedQuery.ts | 50 ++- packages/web/tests/watch.test.ts | 350 +++++++----------- 28 files changed, 597 insertions(+), 639 deletions(-) create mode 100644 packages/common/src/client/CustomQuery.ts create mode 100644 packages/common/src/client/Query.ts delete mode 100644 packages/common/src/client/watched/WatchedQueryBuilder.ts delete mode 100644 packages/common/src/client/watched/WatchedQueryBuilderMap.ts delete mode 100644 packages/common/src/client/watched/processors/ComparisonWatchedQueryBuilder.ts delete mode 100644 packages/common/src/client/watched/processors/DifferentialWatchedQueryBuilder.ts diff --git a/.changeset/little-bananas-fetch.md b/.changeset/little-bananas-fetch.md index 3717d324d..5cfc87b22 100644 --- a/.changeset/little-bananas-fetch.md +++ b/.changeset/little-bananas-fetch.md @@ -3,5 +3,5 @@ --- - Added additional listeners for `closing` and `closed` events in `AbstractPowerSyncDatabase`. -- Added `incrementalWatch` API for enhanced watched queries. +- Added `query` and `customQuery` APIs for enhanced watched queries. - Added `triggerImmediate` option to the `onChange` API. This allows emitting an initial event which can be useful for downstream use cases. diff --git a/demos/yjs-react-supabase-text-collab/README.md b/demos/yjs-react-supabase-text-collab/README.md index 062e28451..34810d4ae 100644 --- a/demos/yjs-react-supabase-text-collab/README.md +++ b/demos/yjs-react-supabase-text-collab/README.md @@ -44,7 +44,7 @@ docker run \ -p 8080:8080 \ -e POWERSYNC_CONFIG_B64=$(base64 -i ./powersync.yaml) \ -e POWERSYNC_SYNC_RULES_B64=$(base64 -i ./sync-rules.yaml) \ ---env-file ./.env \ +--env-file ./.env.local \ --network supabase_network_yjs-react-supabase-text-collab \ --name my-powersync journeyapps/powersync-service:latest ``` diff --git a/demos/yjs-react-supabase-text-collab/src/library/powersync/PowerSyncYjsProvider.ts b/demos/yjs-react-supabase-text-collab/src/library/powersync/PowerSyncYjsProvider.ts index 524709f2f..cdefd86b6 100644 --- a/demos/yjs-react-supabase-text-collab/src/library/powersync/PowerSyncYjsProvider.ts +++ b/demos/yjs-react-supabase-text-collab/src/library/powersync/PowerSyncYjsProvider.ts @@ -1,7 +1,7 @@ import * as Y from 'yjs'; import { b64ToUint8Array, Uint8ArrayTob64 } from '@/library/binary-utils'; -import { AbstractPowerSyncDatabase, GetAllQuery, IncrementalWatchMode } from '@powersync/web'; +import { AbstractPowerSyncDatabase } from '@powersync/web'; import { ObservableV2 } from 'lib0/observable'; import { v4 as uuidv4 } from 'uuid'; import { DocumentUpdates } from './AppSchema'; @@ -41,22 +41,20 @@ export class PowerSyncYjsProvider extends ObservableV2 { * This will be used to apply updates from other editors. * When we received an added item we apply the update to the Yjs document. */ - const updateQuery = db.incrementalWatch({ mode: IncrementalWatchMode.DIFFERENTIAL }).build({ - watch: { - query: new GetAllQuery({ - sql: /* sql */ ` - SELECT - * - FROM - document_updates - WHERE - document_id = ? - AND editor_id != ? - `, - parameters: [documentId, this.id] - }) - } - }); + const updateQuery = db + .query({ + sql: /* sql */ ` + SELECT + * + FROM + document_updates + WHERE + document_id = ? + AND editor_id != ? + `, + parameters: [documentId, this.id] + }) + .differentialWatch(); this.abortController.signal.addEventListener( 'abort', @@ -73,8 +71,8 @@ export class PowerSyncYjsProvider extends ObservableV2 { let synced = false; updateQuery.registerListener({ - onData: async (diff) => { - for (const added of diff.added) { + onStateChange: async () => { + for (const added of updateQuery.state.diff.added) { Y.applyUpdateV2(doc, b64ToUint8Array(added.update_b64)); } if (!synced) { diff --git a/packages/common/src/client/AbstractPowerSyncDatabase.ts b/packages/common/src/client/AbstractPowerSyncDatabase.ts index 3f4de6bb7..d4eee626f 100644 --- a/packages/common/src/client/AbstractPowerSyncDatabase.ts +++ b/packages/common/src/client/AbstractPowerSyncDatabase.ts @@ -18,6 +18,8 @@ import { ControlledExecutor } from '../utils/ControlledExecutor.js'; import { throttleTrailing } from '../utils/async.js'; import { mutexRunExclusive } from '../utils/mutex.js'; import { ConnectionManager } from './ConnectionManager.js'; +import { CustomQuery } from './CustomQuery.js'; +import { ArrayQueryDefinition, Query } from './Query.js'; import { SQLOpenFactory, SQLOpenOptions, isDBAdapter, isSQLOpenFactory, isSQLOpenOptions } from './SQLOpenFactory.js'; import { PowerSyncBackendConnector } from './connection/PowerSyncBackendConnector.js'; import { BucketStorageAdapter, PSInternalTable } from './sync/bucket/BucketStorageAdapter.js'; @@ -33,9 +35,9 @@ import { type PowerSyncConnectionOptions, type RequiredAdditionalConnectionOptions } from './sync/stream/AbstractStreamingSyncImplementation.js'; -import { IncrementalWatchMode } from './watched/WatchedQueryBuilder.js'; -import { WatchedQueryBuilderMap } from './watched/WatchedQueryBuilderMap.js'; -import { FalsyComparator, WatchedQueryComparator } from './watched/processors/comparators.js'; +import { DEFAULT_WATCH_THROTTLE_MS, WatchCompatibleQuery } from './watched/WatchedQuery.js'; +import { OnChangeQueryProcessor } from './watched/processors/OnChangeQueryProcessor.js'; +import { WatchedQueryComparator } from './watched/processors/comparators.js'; export interface DisconnectAndClearOptions { /** When set to false, data in local-only tables is preserved. */ @@ -139,8 +141,6 @@ export const DEFAULT_POWERSYNC_CLOSE_OPTIONS: PowerSyncCloseOptions = { disconnect: true }; -export const DEFAULT_WATCH_THROTTLE_MS = 30; - export const DEFAULT_POWERSYNC_DB_OPTIONS = { retryDelayMs: 5000, logger: Logger.get('PowerSyncDatabase'), @@ -890,41 +890,59 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver ({ + * ...row, + * created_at: new Date(row.created_at as string) + * }) + * }) + * .watch() + * // OR use .differentialWatch() for fine-grained watches. * ``` + */ + query(query: ArrayQueryDefinition): Query { + const { sql, parameters = [], mapper } = query; + const compatibleQuery: WatchCompatibleQuery = { + compile: () => ({ + sql, + parameters + }), + execute: async ({ sql, parameters }) => { + const result = await this.getAll(sql, parameters); + return mapper ? result.map(mapper) : (result as RowType[]); + } + }; + return this.customQuery(compatibleQuery); + } + + /** + * Allows building a {@link WatchedQuery} using an existing {@link WatchCompatibleQuery}. + * The watched query will use the provided {@link WatchCompatibleQuery.execute} method to query results. * - * For a differential based watch , use {@link IncrementalWatchMode.DIFFERENTIAL}. - * See {@link DifferentialWatchedQueryBuilder} for more details. * @example * ```javascript - * const watchedQuery = powerSync - * .incrementalWatch({ mode: IncrementalWatchMode.DIFFERENTIAL }) - * .build({ - * // ... Options - * }) + * + * // Potentially a query from an ORM like Drizzle + * const query = db.select().from(lists); + * + * const watchedTodos = powersync.customQuery(query) + * .watch() + * // OR use .differentialWatch() for fine-grained watches. * ``` */ - incrementalWatch(options: { mode: Mode }): WatchedQueryBuilderMap[Mode] { - const { mode } = options; - const builderFactory = WatchedQueryBuilderMap[mode]; - if (!builderFactory) { - throw new Error( - `Unsupported watch mode: ${mode}. Please specify one of [${Object.values(IncrementalWatchMode).join(', ')}]` - ); - } - return builderFactory(this) as WatchedQueryBuilderMap[Mode]; + customQuery(query: WatchCompatibleQuery): Query { + return new CustomQuery({ + db: this, + query + }); } /** @@ -944,13 +962,15 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver({ + // This API yields a QueryResult type. + // This is not a standard Array result, which makes it incompatible with the .query API. + const watchedQuery = new OnChangeQueryProcessor({ + db: this, comparator, - watch: { + placeholderData: null, + watchOptions: { query: { compile: () => ({ sql: sql, @@ -958,7 +978,6 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver this.executeReadOnly(sql, parameters) }, - placeholderData: null, reportFetching: false, throttleMs: options?.throttleMs ?? DEFAULT_WATCH_THROTTLE_MS } diff --git a/packages/common/src/client/CustomQuery.ts b/packages/common/src/client/CustomQuery.ts new file mode 100644 index 000000000..ea29e53ce --- /dev/null +++ b/packages/common/src/client/CustomQuery.ts @@ -0,0 +1,55 @@ +import { AbstractPowerSyncDatabase } from './AbstractPowerSyncDatabase.js'; +import { Query, StandardWatchedQueryOptions } from './Query.js'; +import { FalsyComparator } from './watched/processors/comparators.js'; +import { + DifferentialQueryProcessor, + DifferentialWatchedQueryOptions +} from './watched/processors/DifferentialQueryProcessor.js'; +import { OnChangeQueryProcessor } from './watched/processors/OnChangeQueryProcessor.js'; +import { DEFAULT_WATCH_QUERY_OPTIONS, WatchCompatibleQuery, WatchedQueryOptions } from './watched/WatchedQuery.js'; + +/** + * @internal + */ +export interface CustomQueryOptions { + db: AbstractPowerSyncDatabase; + query: WatchCompatibleQuery; +} + +/** + * @internal + */ +export class CustomQuery implements Query { + constructor(protected options: CustomQueryOptions) {} + + protected resolveOptions(options: WatchedQueryOptions) { + return { + reportFetching: options?.reportFetching ?? DEFAULT_WATCH_QUERY_OPTIONS.reportFetching, + throttleMs: options?.throttleMs ?? DEFAULT_WATCH_QUERY_OPTIONS.throttleMs + }; + } + + watch(watchOptions: StandardWatchedQueryOptions) { + return new OnChangeQueryProcessor({ + db: this.options.db, + comparator: watchOptions?.comparator ?? FalsyComparator, + placeholderData: watchOptions?.placeholderData ?? [], + watchOptions: { + ...this.resolveOptions(watchOptions), + query: this.options.query + } + }); + } + + differentialWatch(differentialWatchOptions: DifferentialWatchedQueryOptions) { + return new DifferentialQueryProcessor({ + db: this.options.db, + differentiator: differentialWatchOptions?.differentiator, + placeholderData: differentialWatchOptions?.placeholderData ?? [], + watchOptions: { + ...this.resolveOptions(differentialWatchOptions), + query: this.options.query + } + }); + } +} diff --git a/packages/common/src/client/Query.ts b/packages/common/src/client/Query.ts new file mode 100644 index 000000000..2190b5ca7 --- /dev/null +++ b/packages/common/src/client/Query.ts @@ -0,0 +1,88 @@ +import { ArrayComparator } from './watched/processors/comparators.js'; +import { + DifferentialWatchedQuery, + DifferentialWatchedQueryOptions +} from './watched/processors/DifferentialQueryProcessor.js'; +import { ComparisonWatchedQuery } from './watched/processors/OnChangeQueryProcessor.js'; +import { WatchedQueryOptions } from './watched/WatchedQuery.js'; + +/** + * Query parameters for {@link ArrayQueryDefinition.parameters} + */ +export type QueryParam = string | number | boolean | null | undefined | bigint | Uint8Array; + +/** + * Options for building a query with {@link AbstractPowerSyncDatabase.query}. + * This query will be executed with {@link AbstractPowerSyncDatabase.getAll}. + */ +export interface ArrayQueryDefinition { + sql: string; + parameters?: ReadonlyArray; + /** + * Maps the raw SQLite row to a custom typed object. + * @example + * ```javascript + * mapper: (row) => ({ + * ...row, + * created_at: new Date(row.created_at as string), + * }) + * ``` + */ + mapper?: (row: Record) => RowType; +} + +/** + * Options for {@link Query.watch}. + */ +export interface StandardWatchedQueryOptions extends WatchedQueryOptions { + /** + * Optional comparator which processes the items of an array of rows. + * The comparator compares the result set rows by index using the {@link ArrayComparatorOptions.compareBy} function. + * The comparator reports a changed result set as soon as a row does not match the previous result set. + * + * @example + * ```javascript + * comparator: new ArrayComparator({ + * compareBy: (item) => JSON.stringify(item) + * }) + * ``` + */ + comparator?: ArrayComparator; + + /** + * The initial data state reported while the query is loading for the first time. + * @default [] + */ + placeholderData?: RowType[]; +} + +export interface Query { + /** + * Creates a {@link WatchedQuery} which watches and emits results of the linked query. + * + * By default the returned watched query will emit changes whenever a change to the underlying SQLite tables is made. + * These changes might not be relevant to the query, but the query will emit a new result set. + * + * A {@link StandardWatchedQueryOptions.comparator} can be provided to limit the data emissions. The watched query will still + * query the underlying DB on a underlying table changes, but the result will only be emitted if the comparator detects a change in the results. + * + * The comparator in this method is optimized and returns early as soon as it detects a change. Each data emission will correlate to a change in the result set, + * but note that the result set will not maintain internal object references to the previous result set. If internal object references are needed, + * consider using {@link Query.differentialWatch} instead. + */ + watch(options?: StandardWatchedQueryOptions): ComparisonWatchedQuery; + + /** + * Creates a {@link WatchedQuery} which watches and emits results of the linked query. + * + * This query method watches for changes in the underlying SQLite tables and runs the query on each table change. + * The difference between the current and previous result set is computed. + * The watched query will not emit changes if the result set is identical to the previous result set. + * If the result set is different, the watched query will emit the new result set and provide a detailed diff of the changes. + * + * The deep differentiation allows maintaining result set object references between result emissions. + * The {@link DifferentialWatchedQuery.state.data} array will contain the previous row references for unchanged rows. + * A detailed diff of the changes can be accessed via {@link DifferentialWatchedQuery.state.diff}. + */ + differentialWatch(options?: DifferentialWatchedQueryOptions): DifferentialWatchedQuery; +} diff --git a/packages/common/src/client/watched/WatchedQuery.ts b/packages/common/src/client/watched/WatchedQuery.ts index 8582e526f..7c8593148 100644 --- a/packages/common/src/client/watched/WatchedQuery.ts +++ b/packages/common/src/client/watched/WatchedQuery.ts @@ -11,20 +11,20 @@ export interface WatchedQueryState { * Indicates the initial loading state (hard loading). * Loading becomes false once the first set of results from the watched query is available or an error occurs. */ - isLoading: boolean; + readonly isLoading: boolean; /** * Indicates whether the query is currently fetching data, is true during the initial load * and any time when the query is re-evaluating (useful for large queries). */ - isFetching: boolean; + readonly isFetching: boolean; /** * The last error that occurred while executing the query. */ - error: Error | null; + readonly error: Error | null; /** * The last time the query was updated. */ - lastUpdated: Date | null; + readonly lastUpdated: Date | null; /** * The last data returned by the query. */ @@ -75,6 +75,13 @@ export interface WatchedQueryListener extends BaseListener { [WatchedQueryListenerEvent.CLOSED]?: () => void | Promise; } +export const DEFAULT_WATCH_THROTTLE_MS = 30; + +export const DEFAULT_WATCH_QUERY_OPTIONS: WatchedQueryOptions = { + throttleMs: DEFAULT_WATCH_THROTTLE_MS, + reportFetching: true +}; + export interface WatchedQuery extends MetaBaseObserverInterface> { /** diff --git a/packages/common/src/client/watched/WatchedQueryBuilder.ts b/packages/common/src/client/watched/WatchedQueryBuilder.ts deleted file mode 100644 index a8daa7f5c..000000000 --- a/packages/common/src/client/watched/WatchedQueryBuilder.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { WatchedQuery } from './WatchedQuery.js'; - -export enum IncrementalWatchMode { - COMPARISON = 'comparison', - DIFFERENTIAL = 'differential' -} - -/** - * Builds a {@link WatchedQuery} instance given a set of options. - */ -export interface WatchedQueryBuilder { - build(options: {}): WatchedQuery; -} diff --git a/packages/common/src/client/watched/WatchedQueryBuilderMap.ts b/packages/common/src/client/watched/WatchedQueryBuilderMap.ts deleted file mode 100644 index db8ae9f68..000000000 --- a/packages/common/src/client/watched/WatchedQueryBuilderMap.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { AbstractPowerSyncDatabase } from '../AbstractPowerSyncDatabase.js'; -import { ComparisonWatchedQueryBuilder } from './processors/ComparisonWatchedQueryBuilder.js'; -import { DifferentialWatchedQueryBuilder } from './processors/DifferentialWatchedQueryBuilder.js'; -import { IncrementalWatchMode } from './WatchedQueryBuilder.js'; - -/** - * @internal - */ -export const WatchedQueryBuilderMap = { - [IncrementalWatchMode.COMPARISON]: (db: AbstractPowerSyncDatabase) => new ComparisonWatchedQueryBuilder(db), - [IncrementalWatchMode.DIFFERENTIAL]: (db: AbstractPowerSyncDatabase) => new DifferentialWatchedQueryBuilder(db) -}; - -/** - * @internal - */ -export type WatchedQueryBuilderMap = { - [key in IncrementalWatchMode]: ReturnType<(typeof WatchedQueryBuilderMap)[key]>; -}; diff --git a/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts b/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts index dd0f55ea2..ceed509b2 100644 --- a/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts +++ b/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts @@ -19,6 +19,19 @@ export interface LinkQueryOptions = + T extends ReadonlyArray + ? U[] // convert readonly arrays to mutable arrays + : T; + +/** + * @internal Mutable version of {@link WatchedQueryState}. + * This is used internally to allow updates to the state. + */ +export type MutableWatchedQueryState = { + -readonly [P in keyof WatchedQueryState]: MutableDeep[P]>; +}; + type WatchedQueryProcessorListener = WatchedQueryListener; /** @@ -47,15 +60,19 @@ export abstract class AbstractQueryProcessor< super(); this.abortController = new AbortController(); this._closed = false; - this.state = { + this.state = this.constructInitialState(); + this.disposeListeners = null; + this.initialized = this.init(); + } + + protected constructInitialState(): WatchedQueryState { + return { isLoading: true, isFetching: this.reportFetching, // Only set to true if we will report updates in future error: null, lastUpdated: null, - data: options.placeholderData + data: this.options.placeholderData }; - this.disposeListeners = null; - this.initialized = this.init(); } protected get reportFetching() { @@ -90,7 +107,7 @@ export abstract class AbstractQueryProcessor< */ protected abstract linkQuery(options: LinkQueryOptions): Promise; - protected async updateState(update: Partial>) { + protected async updateState(update: Partial>) { if (typeof update.error !== 'undefined') { await this.iterateAsyncListenersWithError(async (l) => l.onError?.(update.error!)); // An error always stops for the current fetching state @@ -98,11 +115,12 @@ export abstract class AbstractQueryProcessor< update.isLoading = false; } + Object.assign(this.state, { lastUpdated: new Date() } satisfies Partial>, update); + if (typeof update.data !== 'undefined') { - await this.iterateAsyncListenersWithError(async (l) => l.onData?.(update!.data!)); + await this.iterateAsyncListenersWithError(async (l) => l.onData?.(this.state.data)); } - Object.assign(this.state, { lastUpdated: new Date() } satisfies Partial>, update); await this.iterateAsyncListenersWithError(async (l) => l.onStateChange?.(this.state)); } diff --git a/packages/common/src/client/watched/processors/ComparisonWatchedQueryBuilder.ts b/packages/common/src/client/watched/processors/ComparisonWatchedQueryBuilder.ts deleted file mode 100644 index 93f1d07c4..000000000 --- a/packages/common/src/client/watched/processors/ComparisonWatchedQueryBuilder.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { AbstractPowerSyncDatabase } from '../../../client/AbstractPowerSyncDatabase.js'; -import { WatchedQuery } from '../WatchedQuery.js'; -import { WatchedQueryBuilder } from '../WatchedQueryBuilder.js'; -import { WatchedQueryComparator } from './comparators.js'; -import { ComparisonWatchedQuerySettings, OnChangeQueryProcessor } from './OnChangeQueryProcessor.js'; - -/** - * Options for building incrementally watched queries that compare the result set. - * It uses a comparator to determine if the result set has changed since the last update. - * If the result set has changed, it emits the new result set. - */ -export interface ComparisonWatchProcessorOptions { - comparator?: WatchedQueryComparator; - watch: ComparisonWatchedQuerySettings; -} - -/** - * Default implementation of the {@link WatchedQueryComparator} for watched queries. - * It uses JSON stringification to compare the entire result set. - * Array based results should use {@link ArrayComparator} for more efficient item comparison. - */ -export const DEFAULT_WATCHED_QUERY_COMPARATOR: WatchedQueryComparator = { - checkEquality: (a, b) => JSON.stringify(a) === JSON.stringify(b) -}; - -/** - * Builds an incrementally watched query that emits results after comparing the result set for changes. - */ -export class ComparisonWatchedQueryBuilder implements WatchedQueryBuilder { - constructor(protected db: AbstractPowerSyncDatabase) {} - - /** - * Builds a watched query which emits results after comparing the result set. Results are only emitted if the result set changed. - * @example - * ``` javascript - * .build({ - * watch: { - * placeholderData: [], - * query: new GetAllQuery({ - * sql: `SELECT photo_id as id FROM todos WHERE photo_id IS NOT NULL`, - * parameters: [] - * }), - * throttleMs: 1000 - * }, - * // Optional comparator, defaults to JSON stringification of the entire result set. - * comparator: new ArrayComparator({ - * // By default the entire result set is stringified and compared. - * // Comparing the array items individual can be more efficient. - * // Alternatively a unique field can be used to compare items. - * // For example, if the items are objects with an `updated_at` field: - * compareBy: (item) => JSON.stringify(item) - * }) - * }) - * ``` - */ - build( - options: ComparisonWatchProcessorOptions - ): WatchedQuery> { - return new OnChangeQueryProcessor({ - db: this.db, - comparator: options.comparator ?? DEFAULT_WATCHED_QUERY_COMPARATOR, - watchOptions: options.watch, - placeholderData: options.watch.placeholderData - }); - } -} diff --git a/packages/common/src/client/watched/processors/DifferentialQueryProcessor.ts b/packages/common/src/client/watched/processors/DifferentialQueryProcessor.ts index 3373c4bb4..6647fb6c4 100644 --- a/packages/common/src/client/watched/processors/DifferentialQueryProcessor.ts +++ b/packages/common/src/client/watched/processors/DifferentialQueryProcessor.ts @@ -1,5 +1,10 @@ import { WatchCompatibleQuery, WatchedQuery, WatchedQueryOptions, WatchedQueryState } from '../WatchedQuery.js'; -import { AbstractQueryProcessor, AbstractQueryProcessorOptions, LinkQueryOptions } from './AbstractQueryProcessor.js'; +import { + AbstractQueryProcessor, + AbstractQueryProcessorOptions, + LinkQueryOptions, + MutableWatchedQueryState +} from './AbstractQueryProcessor.js'; /** * Represents an updated row in a differential watched query. @@ -15,7 +20,7 @@ export interface WatchedQueryRowDifferential { * {@link WatchedQueryState.data} is of the {@link WatchedQueryDifferential} form when using the {@link IncrementalWatchMode.DIFFERENTIAL} mode. */ export interface WatchedQueryDifferential { - added: ReadonlyArray; + readonly added: ReadonlyArray; /** * The entire current result set. * Array item object references are preserved between updates if the item is unchanged. @@ -30,10 +35,10 @@ export interface WatchedQueryDifferential { * the updated result set will be contain the same object reference, to item A, as the previous result set. * This is regardless of the item A's position in the updated result set. */ - all: ReadonlyArray; - removed: ReadonlyArray; - updated: ReadonlyArray>; - unchanged: ReadonlyArray; + readonly all: ReadonlyArray; + readonly removed: ReadonlyArray; + readonly updated: ReadonlyArray>; + readonly unchanged: ReadonlyArray; } /** @@ -50,28 +55,57 @@ export interface WatchedQueryDifferentiator { compareBy: (item: RowType) => string; } +/** + * Options for building a differential watched query with the {@link Query} builder. + */ +export interface DifferentialWatchedQueryOptions extends WatchedQueryOptions { + /** + * Initial result data which is presented while the initial loading is executing. + */ + placeholderData?: RowType[]; + + /** + * Differentiator used to identify and compare items in the result set. + * If not provided, the default differentiator will be used which identifies items by their `id` property if available, + * otherwise it uses JSON stringification of the entire item for identification and comparison. + * @defaultValue {@link DEFAULT_WATCHED_QUERY_DIFFERENTIATOR} + */ + differentiator?: WatchedQueryDifferentiator; +} + /** * Settings for incremental watched queries using the {@link IncrementalWatchMode.DIFFERENTIAL} mode. */ -export interface DifferentialWatchedQuerySettings extends WatchedQueryOptions { +export interface DifferentialWatchedQuerySettings extends DifferentialWatchedQueryOptions { /** * The query here must return an array of items that can be differentiated. */ query: WatchCompatibleQuery; +} +export interface DifferentialWatchedQueryState extends WatchedQueryState { /** - * Initial result data which is presented while the initial loading is executing. - * Defaults to an empty differential. + * The difference between the current and previous result set. */ - placeholderData?: WatchedQueryDifferential; + readonly diff: WatchedQueryDifferential; +} + +type MutableDifferentialWatchedQueryState = MutableWatchedQueryState & { + data: RowType[]; + diff: WatchedQueryDifferential; +}; + +export interface DifferentialWatchedQuery + extends WatchedQuery> { + readonly state: DifferentialWatchedQueryState; } /** * @internal */ export interface DifferentialQueryProcessorOptions - extends AbstractQueryProcessorOptions, DifferentialWatchedQuerySettings> { - differentiator: WatchedQueryDifferentiator; + extends AbstractQueryProcessorOptions> { + differentiator?: WatchedQueryDifferentiator; } type DataHashMap = Map; @@ -88,17 +122,48 @@ export const EMPTY_DIFFERENTIAL = { unchanged: [] }; +/** + * Default implementation of the {@link Differentiator} for watched queries. + * It identifies items by their `id` property if available, otherwise it uses JSON stringification + * of the entire item for identification and comparison. + */ +export const DEFAULT_WATCHED_QUERY_DIFFERENTIATOR: WatchedQueryDifferentiator = { + identify: (item) => { + if (item && typeof item == 'object' && typeof item['id'] == 'string') { + return item['id']; + } + return JSON.stringify(item); + }, + compareBy: (item) => JSON.stringify(item) +}; + /** * Uses the PowerSync onChange event to trigger watched queries. * Results are emitted on every change of the relevant tables. * @internal */ export class DifferentialQueryProcessor - extends AbstractQueryProcessor, DifferentialWatchedQuerySettings> - implements WatchedQuery, DifferentialWatchedQuerySettings> + extends AbstractQueryProcessor> + implements DifferentialWatchedQuery { + readonly state: DifferentialWatchedQueryState; + + protected differentiator: WatchedQueryDifferentiator; + constructor(protected options: DifferentialQueryProcessorOptions) { super(options); + this.state = this.constructInitialState(); + this.differentiator = options.differentiator ?? DEFAULT_WATCHED_QUERY_DIFFERENTIATOR; + } + + protected constructInitialState(): DifferentialWatchedQueryState { + return { + ...super.constructInitialState(), + diff: { + ...EMPTY_DIFFERENTIAL, + all: this.options.placeholderData + } + }; } /* @@ -108,7 +173,7 @@ export class DifferentialQueryProcessor current: RowType[], previousMap: DataHashMap ): { diff: WatchedQueryDifferential; map: DataHashMap; hasChanged: boolean } { - const { identify, compareBy } = this.options.differentiator; + const { identify, compareBy } = this.differentiator; let hasChanged = false; const currentMap = new Map(); @@ -178,14 +243,12 @@ export class DifferentialQueryProcessor let currentMap: DataHashMap = new Map(); // populate the currentMap from the placeholder data - if (this.state.data) { - this.state.data.all.forEach((item) => { - currentMap.set(this.options.differentiator.identify(item), { - hash: this.options.differentiator.compareBy(item), - item - }); + this.state.data.forEach((item) => { + currentMap.set(this.differentiator.identify(item), { + hash: this.differentiator.compareBy(item), + item }); - } + }); db.onChangeWithCallback( { @@ -199,9 +262,7 @@ export class DifferentialQueryProcessor await this.updateState({ isFetching: true }); } - const partialStateUpdate: Partial>> & { - data?: WatchedQueryDifferential; - } = {}; + const partialStateUpdate: Partial> = {}; // Always run the query if an underlying table has changed const result = await watchOptions.query.execute({ @@ -225,7 +286,10 @@ export class DifferentialQueryProcessor currentMap = map; if (hasChanged) { - partialStateUpdate.data = diff; + Object.assign(partialStateUpdate, { + data: diff.all, + diff + }); } if (Object.keys(partialStateUpdate).length > 0) { diff --git a/packages/common/src/client/watched/processors/DifferentialWatchedQueryBuilder.ts b/packages/common/src/client/watched/processors/DifferentialWatchedQueryBuilder.ts deleted file mode 100644 index 34754de1b..000000000 --- a/packages/common/src/client/watched/processors/DifferentialWatchedQueryBuilder.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { AbstractPowerSyncDatabase } from '../../../client/AbstractPowerSyncDatabase.js'; -import { WatchedQuery } from '../WatchedQuery.js'; -import { WatchedQueryBuilder } from '../WatchedQueryBuilder.js'; -import { - DifferentialQueryProcessor, - DifferentialWatchedQuerySettings, - EMPTY_DIFFERENTIAL, - WatchedQueryDifferential, - WatchedQueryDifferentiator -} from './DifferentialQueryProcessor.js'; - -/** - * Options for creating an incrementally watched query that emits differential results. - * - */ -export type DifferentialWatchedQueryBuilderOptions = { - differentiator?: WatchedQueryDifferentiator; - watch: DifferentialWatchedQuerySettings; -}; - -/** - * Default implementation of the {@link Differentiator} for watched queries. - * It identifies items by their `id` property if available, otherwise it uses JSON stringification - * of the entire item for identification and comparison. - */ -export const DEFAULT_WATCHED_QUERY_DIFFERENTIATOR: WatchedQueryDifferentiator = { - identify: (item) => { - if (item && typeof item == 'object' && typeof item['id'] == 'string') { - return item['id']; - } - return JSON.stringify(item); - }, - compareBy: (item) => JSON.stringify(item) -}; - -/** - * Builds a watched query which emits differential results based on the provided differentiator. - */ -export class DifferentialWatchedQueryBuilder implements WatchedQueryBuilder { - constructor(protected db: AbstractPowerSyncDatabase) {} - - /** - * Builds a watched query which emits differential results based on the provided differentiator. - * The {@link WatchedQueryState.data} is of the {@link WatchedQueryDifferential} form. - * The data delta relates to the difference between the query result set since the last change to the dataset. - * - * @example - * ```javascript - * .build({ - * // Optional differentiator, defaults to using the `id` field of the items if available, - * // otherwise it uses JSON stringification of the entire item. - * differentiator: { - * identify: (item) => item.id, - * compareBy: (item) => JSON.stringify(item) - * }, - * watch: { - * query: new GetAllQuery({ - * sql: ' - * SELECT - * * - * FROM - * assets - * ', - * mapper: (raw) => { - * return { - * id: raw.id as string, - * make: raw.make as string - * }; - * } - * }) - * }, - * }); - * ``` - */ - build( - options: DifferentialWatchedQueryBuilderOptions - ): WatchedQuery, DifferentialWatchedQuerySettings> { - return new DifferentialQueryProcessor({ - db: this.db, - differentiator: options.differentiator ?? DEFAULT_WATCHED_QUERY_DIFFERENTIATOR, - watchOptions: options.watch, - placeholderData: options.watch.placeholderData ?? EMPTY_DIFFERENTIAL - }); - } -} diff --git a/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts b/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts index 4b47e49b8..40d17e180 100644 --- a/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts +++ b/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts @@ -1,16 +1,18 @@ -import { WatchCompatibleQuery, WatchedQueryOptions, WatchedQueryState } from '../WatchedQuery.js'; -import { AbstractQueryProcessor, AbstractQueryProcessorOptions, LinkQueryOptions } from './AbstractQueryProcessor.js'; +import { WatchCompatibleQuery, WatchedQuery, WatchedQueryOptions } from '../WatchedQuery.js'; +import { + AbstractQueryProcessor, + AbstractQueryProcessorOptions, + LinkQueryOptions, + MutableWatchedQueryState +} from './AbstractQueryProcessor.js'; import { WatchedQueryComparator } from './comparators.js'; export interface ComparisonWatchedQuerySettings extends WatchedQueryOptions { query: WatchCompatibleQuery; - /** - * Initial result data which is presented while the initial loading is executing. - * Defaults to an empty differential. - */ - placeholderData: DataType; } +export type ComparisonWatchedQuery = WatchedQuery>; + /** * @internal */ @@ -29,7 +31,7 @@ export class OnChangeQueryProcessor extends AbstractQueryProcessor extends AbstractQueryProcessor> & { data?: Data } = {}; + const partialStateUpdate: Partial> & { data?: Data } = {}; // Always run the query if an underlying table has changed const result = await watchOptions.query.execute({ @@ -77,7 +79,9 @@ export class OnChangeQueryProcessor extends AbstractQueryProcessor 0) { diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 04179a8d5..40d4f7330 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -31,15 +31,13 @@ export * from './db/schema/Schema.js'; export * from './db/schema/Table.js'; export * from './db/schema/TableV2.js'; +export * from './client/Query.js'; export * from './client/watched/GetAllQuery.js'; export * from './client/watched/processors/AbstractQueryProcessor.js'; export * from './client/watched/processors/comparators.js'; -export * from './client/watched/processors/ComparisonWatchedQueryBuilder.js'; export * from './client/watched/processors/DifferentialQueryProcessor.js'; -export * from './client/watched/processors/DifferentialWatchedQueryBuilder.js'; export * from './client/watched/processors/OnChangeQueryProcessor.js'; export * from './client/watched/WatchedQuery.js'; -export * from './client/watched/WatchedQueryBuilder.js'; export * from './utils/AbortOperation.js'; export * from './utils/BaseObserver.js'; diff --git a/packages/react/README.md b/packages/react/README.md index 700a51ebc..e1eca8cc5 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -335,6 +335,7 @@ function MyWidget() { ``` Incremental watched queries ensure that the `data` member of the `useQuery` result maintains the same Array reference if the result set is unchanged. +Additionally, the internal array items maintain object references when unchanged. ```tsx function MyWidget() { @@ -345,9 +346,10 @@ function MyWidget() { // Note that isFetching is set (by default) whenever the query is being fetched/checked. // This will result in `MyWidget` re-rendering for any change to the `cats` table. const { data, isLoading, isFetching } = useQuery(`SELECT * FROM cats WHERE breed = 'tabby'`, [], { - comparator: new ArrayComparator({ - compareBy: (cat) => JSON.stringify(cat) - }) + differentiator: { + identify: (item) => item.id, + compareBy: (item) => JSON.stringify(item) + } }) // ... Widget code @@ -372,9 +374,10 @@ function MyWidget() { // When reportFetching == false the object returned from useQuery will only be changed when the data, isLoading or error state changes. // This method performs a comparison in memory in order to determine changes. const { data, isLoading } = useQuery(`SELECT * FROM cats WHERE breed = 'tabby'`, [], { - comparator: new ArrayComparator({ - compareBy: (cat) => JSON.stringify(cat) - }, + differentiator: { + identify: (item) => item.id, + compareBy: (item) => JSON.stringify(item) + } reportFetching: false }) diff --git a/packages/react/src/QueryStore.ts b/packages/react/src/QueryStore.ts index 1a20d5d0a..a0f817815 100644 --- a/packages/react/src/QueryStore.ts +++ b/packages/react/src/QueryStore.ts @@ -1,6 +1,5 @@ import { AbstractPowerSyncDatabase, - IncrementalWatchMode, WatchCompatibleQuery, WatchedQuery, WatchedQueryListenerEvent @@ -20,32 +19,36 @@ export class QueryStore { constructor(private db: AbstractPowerSyncDatabase) {} - getQuery(key: string, query: WatchCompatibleQuery, options: AdditionalOptions) { + getQuery( + key: string, + query: WatchCompatibleQuery, + options: AdditionalOptions + ): WatchedQuery { if (this.cache.has(key)) { - return this.cache.get(key); + return this.cache.get(key) as WatchedQuery; } - const watchedQuery = this.db - .incrementalWatch({ - mode: IncrementalWatchMode.COMPARISON - }) - .build({ - watch: { - query, - placeholderData: [], + const watch = options.differentiator + ? this.db.customQuery(query).differentialWatch({ + differentiator: options.differentiator, + reportFetching: options.reportFetching, throttleMs: options.throttleMs - }, - comparator: options.comparator - }); + }) + : this.db.customQuery(query).watch({ + reportFetching: options.reportFetching, + throttleMs: options.throttleMs + }); + + this.cache.set(key, watch); - const disposer = watchedQuery.registerListener({ + const disposer = watch.registerListener({ closed: () => { this.cache.delete(key); disposer?.(); } }); - watchedQuery.listenerMeta.registerListener({ + watch.listenerMeta.registerListener({ listenersChanged: (counts) => { // Dispose this query if there are no subscribers present // We don't use the total here since we don't want to consider `onclose` listeners @@ -58,15 +61,13 @@ export class QueryStore { }, 0); if (relevantCounts == 0) { - watchedQuery.close(); + watch.close(); this.cache.delete(key); } } }); - this.cache.set(key, watchedQuery); - - return watchedQuery; + return watch; } } diff --git a/packages/react/src/hooks/suspense/useSuspenseQuery.ts b/packages/react/src/hooks/suspense/useSuspenseQuery.ts index c8c6a1671..f4ff95c5c 100644 --- a/packages/react/src/hooks/suspense/useSuspenseQuery.ts +++ b/packages/react/src/hooks/suspense/useSuspenseQuery.ts @@ -1,4 +1,4 @@ -import { CompilableQuery, FalsyComparator } from '@powersync/common'; +import { CompilableQuery } from '@powersync/common'; import { AdditionalOptions } from '../watched/watch-types'; import { SuspenseQueryResult } from './SuspenseQueryResult'; import { useSingleSuspenseQuery } from './useSingleSuspenseQuery'; @@ -34,10 +34,6 @@ export const useSuspenseQuery = ( case true: return useSingleSuspenseQuery(query, parameters, options); default: - return useWatchedSuspenseQuery(query, parameters, { - ...options, - // Default comparator that always reports changed result sets - comparator: options.comparator ?? FalsyComparator - }); + return useWatchedSuspenseQuery(query, parameters, options); } }; diff --git a/packages/react/src/hooks/suspense/useWatchedSuspenseQuery.ts b/packages/react/src/hooks/suspense/useWatchedSuspenseQuery.ts index ec8aec57d..ab9179fc1 100644 --- a/packages/react/src/hooks/suspense/useWatchedSuspenseQuery.ts +++ b/packages/react/src/hooks/suspense/useWatchedSuspenseQuery.ts @@ -1,4 +1,4 @@ -import { CompilableQuery, WatchedQuery } from '@powersync/common'; +import { CompilableQuery } from '@powersync/common'; import { generateQueryKey, getQueryStore } from '../../QueryStore'; import { usePowerSync } from '../PowerSyncContext'; import { AdditionalOptions } from '../watched/watch-types'; @@ -28,7 +28,7 @@ export const useWatchedSuspenseQuery = ( // on the query. // Once the component "commits", we exchange that for a permanent hold. const store = getQueryStore(powerSync); - const watchedQuery = store.getQuery(key, parsedQuery, options) as WatchedQuery; + const watchedQuery = store.getQuery(key, parsedQuery, options); return useWatchedQuerySuspenseSubscription(watchedQuery); }; diff --git a/packages/react/src/hooks/watched/useQuery.ts b/packages/react/src/hooks/watched/useQuery.ts index 8a00c1d22..e18c692f7 100644 --- a/packages/react/src/hooks/watched/useQuery.ts +++ b/packages/react/src/hooks/watched/useQuery.ts @@ -1,4 +1,4 @@ -import { FalsyComparator, type CompilableQuery } from '@powersync/common'; +import { type CompilableQuery } from '@powersync/common'; import { usePowerSync } from '../PowerSyncContext'; import { useSingleQuery } from './useSingleQuery'; import { useWatchedQuery } from './useWatchedQuery'; @@ -45,9 +45,9 @@ export const useQuery = ( options: { reportFetching: options.reportFetching, // Maintains backwards compatibility with previous versions - // Comparisons are opt-in by default + // Differentiation is opt-in by default // We emit new data for each table change by default. - comparator: options.comparator ?? FalsyComparator + differentiator: options.differentiator } }); } diff --git a/packages/react/src/hooks/watched/useWatchedQuery.ts b/packages/react/src/hooks/watched/useWatchedQuery.ts index 9c24ff779..d731b62e1 100644 --- a/packages/react/src/hooks/watched/useWatchedQuery.ts +++ b/packages/react/src/hooks/watched/useWatchedQuery.ts @@ -1,5 +1,5 @@ -import { FalsyComparator, IncrementalWatchMode } from '@powersync/common'; import React from 'react'; +import { useWatchedQuerySubscription } from './useWatchedQuerySubscription'; import { HookWatchOptions, QueryResult } from './watch-types'; import { InternalHookOptions } from './watch-utils'; @@ -9,45 +9,31 @@ export const useWatchedQuery = ( const { query, powerSync, queryChanged, options: hookOptions } = options; const createWatchedQuery = React.useCallback(() => { - return powerSync.incrementalWatch({ mode: IncrementalWatchMode.COMPARISON }).build({ - watch: { - placeholderData: [], - query, - throttleMs: hookOptions.throttleMs, - reportFetching: hookOptions.reportFetching - }, - comparator: hookOptions.comparator ?? FalsyComparator - }); + const watch = hookOptions.differentiator + ? powerSync.customQuery(query).differentialWatch({ + differentiator: hookOptions.differentiator, + reportFetching: hookOptions.reportFetching, + throttleMs: hookOptions.throttleMs + }) + : powerSync.customQuery(query).watch({ + reportFetching: hookOptions.reportFetching, + throttleMs: hookOptions.throttleMs + }); + return watch; }, []); const [watchedQuery, setWatchedQuery] = React.useState(createWatchedQuery); - const [output, setOutputState] = React.useState(watchedQuery.state); - React.useEffect(() => { watchedQuery.close(); setWatchedQuery(createWatchedQuery); }, [powerSync]); - React.useEffect(() => { - const dispose = watchedQuery.registerListener({ - onStateChange: (state) => { - setOutputState({ ...state }); - } - }); - - return () => { - dispose(); - watchedQuery.close(); - }; - }, [watchedQuery]); - // Indicates that the query will be re-fetched due to a change in the query. // Used when `isFetching` hasn't been set to true yet due to React execution. React.useEffect(() => { if (queryChanged) { watchedQuery.updateSettings({ - placeholderData: [], query, throttleMs: hookOptions.throttleMs, reportFetching: hookOptions.reportFetching @@ -55,5 +41,5 @@ export const useWatchedQuery = ( } }, [queryChanged]); - return output; + return useWatchedQuerySubscription(watchedQuery); }; diff --git a/packages/react/src/hooks/watched/useWatchedQuerySubscription.ts b/packages/react/src/hooks/watched/useWatchedQuerySubscription.ts index 051c7cd30..ecfd2e75b 100644 --- a/packages/react/src/hooks/watched/useWatchedQuerySubscription.ts +++ b/packages/react/src/hooks/watched/useWatchedQuerySubscription.ts @@ -5,7 +5,7 @@ import React from 'react'; * A hook to access and subscribe to the results of an existing {@link WatchedQuery} instance. * @example * export const ContentComponent = () => { - * const { data: lists } = useWatchedQuerySuspenseSubscription(listsQuery); + * const { data: lists } = useWatchedQuerySubscription(listsQuery); * * return * {lists.map((l) => ( diff --git a/packages/react/src/hooks/watched/watch-types.ts b/packages/react/src/hooks/watched/watch-types.ts index 0365025a8..b649bbd72 100644 --- a/packages/react/src/hooks/watched/watch-types.ts +++ b/packages/react/src/hooks/watched/watch-types.ts @@ -1,8 +1,8 @@ -import { WatchedQueryComparator, type SQLWatchOptions } from '@powersync/common'; +import { SQLOnChangeOptions, WatchedQueryDifferentiator } from '@powersync/common'; -export interface HookWatchOptions extends Omit { +export interface HookWatchOptions extends Omit { reportFetching?: boolean; - comparator?: WatchedQueryComparator; + differentiator?: WatchedQueryDifferentiator; } export interface AdditionalOptions extends HookWatchOptions { diff --git a/packages/react/tests/useQuery.test.tsx b/packages/react/tests/useQuery.test.tsx index a24cf37e6..26897a3ab 100644 --- a/packages/react/tests/useQuery.test.tsx +++ b/packages/react/tests/useQuery.test.tsx @@ -202,9 +202,10 @@ describe('useQuery', () => { const { result } = renderHook( () => useQuery('SELECT * FROM lists WHERE name = ?', ['aname'], { - comparator: new commonSdk.ArrayComparator({ + differentiator: { + identify: (item) => item.id, compareBy: (item) => JSON.stringify(item) - }) + } }), { wrapper } ); @@ -314,18 +315,7 @@ describe('useQuery', () => { // This query can be instantiated once and reused. // The query retains it's state and will not re-fetch the data unless the result changes. // This is useful for queries that are used in multiple components. - const listsQuery = db.incrementalWatch({ mode: commonSdk.IncrementalWatchMode.COMPARISON }).build({ - watch: { - placeholderData: [], - query: { - compile: () => ({ - sql: `SELECT * FROM lists`, - parameters: [] - }), - execute: ({ sql, parameters }) => db.getAll(sql, parameters) - } - } - }); + const listsQuery = db.query({ sql: `SELECT * FROM lists`, parameters: [] }).differentialWatch(); const wrapper = ({ children }) => {children}; const { result } = renderHook(() => useWatchedQuerySubscription(listsQuery), { diff --git a/packages/react/tests/useSuspenseQuery.test.tsx b/packages/react/tests/useSuspenseQuery.test.tsx index 0f253fbbd..d24d9a7bc 100644 --- a/packages/react/tests/useSuspenseQuery.test.tsx +++ b/packages/react/tests/useSuspenseQuery.test.tsx @@ -1,9 +1,4 @@ -import { - AbstractPowerSyncDatabase, - IncrementalWatchMode, - WatchedQuery, - WatchedQueryListenerEvent -} from '@powersync/common'; +import { AbstractPowerSyncDatabase, WatchedQuery, WatchedQueryListenerEvent } from '@powersync/common'; import { cleanup, renderHook, screen, waitFor } from '@testing-library/react'; import React from 'react'; import { ErrorBoundary } from 'react-error-boundary'; @@ -68,18 +63,15 @@ describe('useSuspenseQuery', () => { it('should suspend on initial load', async () => { // spy on watched query generation - const baseImplementation = powersync.incrementalWatch; + const baseImplementation = powersync.customQuery; let watch: WatchedQuery | null = null; - const spy = vi.spyOn(powersync, 'incrementalWatch').mockImplementation((options) => { - if (options.mode !== IncrementalWatchMode.COMPARISON) { - return baseImplementation.call(powersync, options); - } - + const spy = vi.spyOn(powersync, 'customQuery').mockImplementation((options) => { const builder = baseImplementation.call(powersync, options); - const baseBuild = builder.build; + const baseBuild = builder.differentialWatch; - vi.spyOn(builder, 'build').mockImplementation((buildOptions) => { + // The hooks use the `watch` method if no differentiator is set + vi.spyOn(builder, 'watch').mockImplementation((buildOptions) => { watch = baseBuild.call(builder, buildOptions); return watch!; }); @@ -267,18 +259,12 @@ describe('useSuspenseQuery', () => { // This query can be instantiated once and reused. // The query retains it's state and will not re-fetch the data unless the result changes. // This is useful for queries that are used in multiple components. - const listsQuery = db.incrementalWatch({ mode: IncrementalWatchMode.COMPARISON }).build({ - watch: { - placeholderData: [], - query: { - compile: () => ({ - sql: `SELECT * FROM lists`, - parameters: [] - }), - execute: ({ sql, parameters }) => db.getAll(sql, parameters) - } - } - }); + const listsQuery = db + .query({ + sql: `SELECT * FROM lists`, + parameters: [] + }) + .watch(); const wrapper = ({ children }) => {children}; const { result } = renderHook(() => useWatchedQuerySuspenseSubscription(listsQuery), { diff --git a/packages/vue/src/composables/useSingleQuery.ts b/packages/vue/src/composables/useSingleQuery.ts index f1ec69992..e03c6a2f4 100644 --- a/packages/vue/src/composables/useSingleQuery.ts +++ b/packages/vue/src/composables/useSingleQuery.ts @@ -1,16 +1,16 @@ import { type CompilableQuery, ParsedQuery, - type SQLWatchOptions, - WatchedQueryComparator, + SQLOnChangeOptions, + WatchedQueryDifferentiator, parseQuery } from '@powersync/common'; import { type MaybeRef, type Ref, ref, toValue, watchEffect } from 'vue'; import { usePowerSync } from './powerSync'; -export interface AdditionalOptions extends Omit { +export interface AdditionalOptions extends Omit { runQueryOnce?: boolean; - comparator?: WatchedQueryComparator; + differentiator?: WatchedQueryDifferentiator; } export type WatchedQueryResult = { diff --git a/packages/vue/src/composables/useWatchedQuery.ts b/packages/vue/src/composables/useWatchedQuery.ts index 05693aaa1..a2a377e2e 100644 --- a/packages/vue/src/composables/useWatchedQuery.ts +++ b/packages/vue/src/composables/useWatchedQuery.ts @@ -1,10 +1,4 @@ -import { - type CompilableQuery, - FalsyComparator, - IncrementalWatchMode, - ParsedQuery, - parseQuery -} from '@powersync/common'; +import { type CompilableQuery, ParsedQuery, parseQuery, WatchCompatibleQuery } from '@powersync/common'; import { type MaybeRef, type Ref, ref, toValue, watchEffect } from 'vue'; import { usePowerSync } from './powerSync'; import { AdditionalOptions, WatchedQueryResult } from './useSingleQuery'; @@ -55,28 +49,26 @@ export const useWatchedQuery = ( const { sqlStatement: sql, parameters } = parsedQuery; - const watchedQuery = powerSync.value - .incrementalWatch({ - mode: IncrementalWatchMode.COMPARISON - }) - .build({ - watch: { - placeholderData: [], - query: { - compile: () => ({ sql, parameters }), - execute: async ({ db, sql, parameters }) => { - if (typeof queryValue === 'string') { - return db.getAll(sql, parameters); - } - return queryValue.execute(); - } - } - }, - // Maintains backwards compatibility with previous versions - comparator: options.comparator ?? FalsyComparator - }); + const compatibleQuery: WatchCompatibleQuery = { + compile: () => ({ sql, parameters }), + execute: async ({ db, sql, parameters }) => { + if (typeof queryValue === 'string') { + return db.getAll(sql, parameters); + } + return queryValue.execute(); + } + }; + + const watch = options.differentiator + ? powerSync.value.customQuery(compatibleQuery).differentialWatch({ + differentiator: options.differentiator, + throttleMs: options.throttleMs + }) + : powerSync.value.customQuery(compatibleQuery).watch({ + throttleMs: options.throttleMs + }); - const disposer = watchedQuery.registerListener({ + const disposer = watch.registerListener({ onStateChange: (state) => { isLoading.value = state.isLoading; isFetching.value = state.isFetching; @@ -93,7 +85,7 @@ export const useWatchedQuery = ( onCleanup(() => { disposer(); - watchedQuery.close(); + watch.close(); }); }); diff --git a/packages/web/tests/watch.test.ts b/packages/web/tests/watch.test.ts index f94f187df..945c1325f 100644 --- a/packages/web/tests/watch.test.ts +++ b/packages/web/tests/watch.test.ts @@ -1,10 +1,4 @@ -import { - AbstractPowerSyncDatabase, - EMPTY_DIFFERENTIAL, - GetAllQuery, - IncrementalWatchMode, - WatchedQueryState -} from '@powersync/common'; +import { AbstractPowerSyncDatabase, ArrayComparator, GetAllQuery, WatchedQueryState } from '@powersync/common'; import { PowerSyncDatabase } from '@powersync/web'; import { v4 as uuid } from 'uuid'; import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'; @@ -333,18 +327,11 @@ describe('Watch Tests', { sequential: true }, () => { it('should stream watch results', async () => { const watch = powersync - .incrementalWatch({ - mode: IncrementalWatchMode.COMPARISON + .query({ + sql: 'SELECT * FROM assets', + parameters: [] }) - .build({ - watch: { - query: new GetAllQuery({ - sql: 'SELECT * FROM assets', - parameters: [] - }), - placeholderData: [] - } - }); + .watch(); const getNextState = () => new Promise>((resolve) => { @@ -376,20 +363,14 @@ describe('Watch Tests', { sequential: true }, () => { it('should only report updates for relevant changes', async () => { const watch = powersync - .incrementalWatch({ - mode: IncrementalWatchMode.COMPARISON + .query<{ make: string }>({ + sql: 'SELECT * FROM assets where make = ?', + parameters: ['test'] }) - .build({ - watch: { - query: { - compile: () => ({ - sql: 'SELECT * FROM assets where make = ?', - parameters: ['test'] - }), - execute: ({ sql, parameters }) => powersync.getAll(sql, parameters) - }, - placeholderData: [] - } + .watch({ + comparator: new ArrayComparator({ + compareBy: (item) => JSON.stringify(item) + }) }); let notificationCount = 0; @@ -417,21 +398,12 @@ describe('Watch Tests', { sequential: true }, () => { it('should not report fetching status', async () => { const watch = powersync - .incrementalWatch({ - mode: IncrementalWatchMode.COMPARISON + .query({ + sql: 'SELECT * FROM assets where make = ?', + parameters: ['test'] }) - .build({ - watch: { - query: { - compile: () => ({ - sql: 'SELECT * FROM assets where make = ?', - parameters: ['test'] - }), - execute: ({ sql, parameters }) => powersync.getAll(sql, parameters) - }, - placeholderData: [], - reportFetching: false - } + .watch({ + reportFetching: false }); expect(watch.state.isFetching).false; @@ -465,18 +437,12 @@ describe('Watch Tests', { sequential: true }, () => { await powersync.execute('INSERT INTO assets(id, make, customer_id) VALUES (uuid(), ?, ?)', ['nottest', uuid()]); const watch = powersync - .incrementalWatch({ - mode: IncrementalWatchMode.COMPARISON + .query<{ make: string }>({ + sql: 'SELECT * FROM assets where make = ?', + parameters: ['test'] }) - .build({ - watch: { - query: new GetAllQuery<{ make: string }>({ - sql: 'SELECT * FROM assets where make = ?', - parameters: ['test'] - }), - placeholderData: [], - reportFetching: false - } + .watch({ + reportFetching: false }); expect(watch.state.isFetching).false; @@ -492,7 +458,6 @@ describe('Watch Tests', { sequential: true }, () => { expect(watch.state.data[0].make).equals('test'); await watch.updateSettings({ - placeholderData: [], query: new GetAllQuery<{ make: string }>({ sql: 'SELECT * FROM assets where make = ?', parameters: ['nottest'] @@ -514,27 +479,21 @@ describe('Watch Tests', { sequential: true }, () => { it('should report differential query results', async () => { const watch = powersync - .incrementalWatch({ - mode: IncrementalWatchMode.DIFFERENTIAL - }) - .build({ - watch: { - query: new GetAllQuery({ - sql: /* sql */ ` - SELECT - * - FROM - assets - `, - mapper: (raw) => { - return { - id: raw.id as string, - make: raw.make as string - }; - } - }) + .query({ + sql: /* sql */ ` + SELECT + * + FROM + assets + `, + mapper: (raw) => { + return { + id: raw.id as string, + make: raw.make as string + }; } - }); + }) + .differentialWatch(); // Create sample data await powersync.execute( @@ -549,7 +508,7 @@ describe('Watch Tests', { sequential: true }, () => { await vi.waitFor( () => { - expect(watch.state.data.added[0]?.make).equals('test1'); + expect(watch.state.diff.added[0]?.make).equals('test1'); }, { timeout: 1000 } ); @@ -567,11 +526,11 @@ describe('Watch Tests', { sequential: true }, () => { await vi.waitFor( () => { // This should now reflect that we had one change since the last event - expect(watch.state.data.added).toHaveLength(1); - expect(watch.state.data.added[0]?.make).equals('test2'); + expect(watch.state.diff.added).toHaveLength(1); + expect(watch.state.diff.added[0]?.make).equals('test2'); - expect(watch.state.data.removed).toHaveLength(0); - expect(watch.state.data.all).toHaveLength(2); + expect(watch.state.diff.removed).toHaveLength(0); + expect(watch.state.diff.all).toHaveLength(2); }, { timeout: 1000 } ); @@ -587,13 +546,13 @@ describe('Watch Tests', { sequential: true }, () => { await vi.waitFor( () => { - expect(watch.state.data.added).toHaveLength(0); - expect(watch.state.data.all).toHaveLength(1); - expect(watch.state.data.unchanged).toHaveLength(1); - expect(watch.state.data.unchanged[0]?.make).equals('test1'); + expect(watch.state.diff.added).toHaveLength(0); + expect(watch.state.diff.all).toHaveLength(1); + expect(watch.state.diff.unchanged).toHaveLength(1); + expect(watch.state.diff.unchanged[0]?.make).equals('test1'); - expect(watch.state.data.removed).toHaveLength(1); - expect(watch.state.data.removed[0]?.make).equals('test2'); + expect(watch.state.diff.removed).toHaveLength(1); + expect(watch.state.diff.removed[0]?.make).equals('test2'); }, { timeout: 1000 } ); @@ -601,29 +560,24 @@ describe('Watch Tests', { sequential: true }, () => { it('should report differential query results with a custom differentiator', async () => { const watch = powersync - .incrementalWatch({ - mode: IncrementalWatchMode.DIFFERENTIAL + .query({ + sql: /* sql */ ` + SELECT + * + FROM + assets + `, + mapper: (raw) => { + return { + id: raw.id as string, + make: raw.make as string + }; + } }) - .build({ + .differentialWatch({ differentiator: { identify: (item) => item.id, compareBy: (item) => JSON.stringify(item) - }, - watch: { - query: new GetAllQuery({ - sql: /* sql */ ` - SELECT - * - FROM - assets - `, - mapper: (raw) => { - return { - id: raw.id as string, - make: raw.make as string - }; - } - }) } }); @@ -640,7 +594,7 @@ describe('Watch Tests', { sequential: true }, () => { await vi.waitFor( () => { - expect(watch.state.data.added[0]?.make).equals('test1'); + expect(watch.state.diff.added[0]?.make).equals('test1'); }, { timeout: 1000 } ); @@ -658,11 +612,11 @@ describe('Watch Tests', { sequential: true }, () => { await vi.waitFor( () => { // This should now reflect that we had one change since the last event - expect(watch.state.data.added).toHaveLength(1); - expect(watch.state.data.added[0]?.make).equals('test2'); + expect(watch.state.diff.added).toHaveLength(1); + expect(watch.state.diff.added[0]?.make).equals('test2'); - expect(watch.state.data.removed).toHaveLength(0); - expect(watch.state.data.all).toHaveLength(2); + expect(watch.state.diff.removed).toHaveLength(0); + expect(watch.state.diff.all).toHaveLength(2); }, { timeout: 1000 } ); @@ -671,31 +625,26 @@ describe('Watch Tests', { sequential: true }, () => { it('should preserve object references in result set', async () => { // Sort the results by the `make` column in ascending order const watch = powersync - .incrementalWatch({ - mode: IncrementalWatchMode.DIFFERENTIAL + .query({ + sql: /* sql */ ` + SELECT + * + FROM + assets + ORDER BY + make ASC; + `, + mapper: (raw) => { + return { + id: raw.id as string, + make: raw.make as string + }; + } }) - .build({ + .differentialWatch({ differentiator: { identify: (item) => item.id, compareBy: (item) => JSON.stringify(item) - }, - watch: { - query: new GetAllQuery({ - sql: /* sql */ ` - SELECT - * - FROM - assets - ORDER BY - make ASC; - `, - mapper: (raw) => { - return { - id: raw.id as string, - make: raw.make as string - }; - } - }) } }); @@ -714,12 +663,12 @@ describe('Watch Tests', { sequential: true }, () => { await vi.waitFor( () => { - expect(watch.state.data.all.map((i) => i.make)).deep.equals(['a', 'b', 'd']); + expect(watch.state.data.map((i) => i.make)).deep.equals(['a', 'b', 'd']); }, { timeout: 1000 } ); - const initialData = watch.state.data.all; + const initialData = watch.state.data; await powersync.execute( /* sql */ ` @@ -733,16 +682,16 @@ describe('Watch Tests', { sequential: true }, () => { await vi.waitFor( () => { - expect(watch.state.data.all).toHaveLength(4); + expect(watch.state.data).toHaveLength(4); }, { timeout: 1000 } ); - console.log(JSON.stringify(watch.state.data.all)); - expect(initialData[0] == watch.state.data.all[0]).true; - expect(initialData[1] == watch.state.data.all[1]).true; + console.log(JSON.stringify(watch.state.data)); + expect(initialData[0] == watch.state.data[0]).true; + expect(initialData[1] == watch.state.data[1]).true; // The index after the insert should also still be the same ref as the previous item - expect(initialData[2] == watch.state.data.all[3]).true; + expect(initialData[2] == watch.state.data[3]).true; }); it('should report differential query results from initial state', async () => { @@ -756,22 +705,6 @@ describe('Watch Tests', { sequential: true }, () => { * which is the initial state of the query. */ - // Store the query for reuse - const query = new GetAllQuery({ - sql: /* sql */ ` - SELECT - * - FROM - assets - `, - mapper: (raw) => { - return { - id: raw.id as string, - make: raw.make as string - }; - } - }); - // Create sample data await powersync.execute( /* sql */ ` @@ -784,23 +717,29 @@ describe('Watch Tests', { sequential: true }, () => { ); const watch = powersync - .incrementalWatch({ - mode: IncrementalWatchMode.DIFFERENTIAL - }) - .build({ - watch: { - query, - placeholderData: { - ...EMPTY_DIFFERENTIAL, - // Fetch the initial state as a baseline before creating the watch. - // Any changes after this state will be reported as changes. - all: await query.execute({ db: powersync }) - } + .query({ + sql: /* sql */ ` + SELECT + * + FROM + assets + `, + mapper: (raw) => { + return { + id: raw.id as string, + make: raw.make as string + }; } + }) + .differentialWatch({ + placeholderData: + // Fetch the initial state as a baseline before creating the watch. + // Any changes after this state will be reported as changes. + await powersync.getAll(`SELECT * FROM assets`) }); // It should have the initial value - expect(watch.state.data.all).toHaveLength(1); + expect(watch.state.data).toHaveLength(1); await powersync.execute( /* sql */ ` @@ -815,11 +754,11 @@ describe('Watch Tests', { sequential: true }, () => { await vi.waitFor( () => { // This should now reflect that we had one change since the last event - expect(watch.state.data.added).toHaveLength(1); - expect(watch.state.data.added[0]?.make).equals('test2'); + expect(watch.state.diff.added).toHaveLength(1); + expect(watch.state.diff.added[0]?.make).equals('test2'); - expect(watch.state.data.removed).toHaveLength(0); - expect(watch.state.data.all).toHaveLength(2); + expect(watch.state.diff.removed).toHaveLength(0); + expect(watch.state.diff.all).toHaveLength(2); }, { timeout: 1000 } ); @@ -835,13 +774,13 @@ describe('Watch Tests', { sequential: true }, () => { await vi.waitFor( () => { - expect(watch.state.data.added).toHaveLength(0); - expect(watch.state.data.all).toHaveLength(1); - expect(watch.state.data.unchanged).toHaveLength(1); - expect(watch.state.data.unchanged[0]?.make).equals('test1'); + expect(watch.state.diff.added).toHaveLength(0); + expect(watch.state.diff.all).toHaveLength(1); + expect(watch.state.diff.unchanged).toHaveLength(1); + expect(watch.state.diff.unchanged[0]?.make).equals('test1'); - expect(watch.state.data.removed).toHaveLength(1); - expect(watch.state.data.removed[0]?.make).equals('test2'); + expect(watch.state.diff.removed).toHaveLength(1); + expect(watch.state.diff.removed[0]?.make).equals('test2'); }, { timeout: 1000 } ); @@ -860,32 +799,29 @@ describe('Watch Tests', { sequential: true }, () => { ); const watch = powersync - .incrementalWatch({ - mode: IncrementalWatchMode.DIFFERENTIAL - }) - .build({ - watch: { - query: new GetAllQuery({ - sql: /* sql */ ` - SELECT - * - FROM - assets - `, - mapper: (raw) => { - return { - id: raw.id as string, - make: raw.make as string - }; - } - }) + .query({ + sql: /* sql */ ` + SELECT + * + FROM + assets + `, + mapper: (raw) => { + return { + id: raw.id as string, + make: raw.make as string + }; } - }); + }) + .differentialWatch(); - await vi.waitFor(() => { - // Wait for the data to be loaded - expect(watch.state.data.all[0]?.make).equals('test1'); - }); + await vi.waitFor( + () => { + // Wait for the data to be loaded + expect(watch.state.data[0]?.make).equals('test1'); + }, + { timeout: 1000, interval: 100 } + ); await powersync.execute( /* sql */ ` @@ -900,16 +836,16 @@ describe('Watch Tests', { sequential: true }, () => { await vi.waitFor( () => { - expect(watch.state.data.added).toHaveLength(0); - const updated = watch.state.data.updated[0]; + expect(watch.state.diff.added).toHaveLength(0); + const updated = watch.state.diff.updated[0]; // The update should contain previous and current values of changed rows expect(updated).toBeDefined(); expect(updated.previous.make).equals('test1'); expect(updated.current.make).equals('test2'); - expect(watch.state.data.removed).toHaveLength(0); - expect(watch.state.data.all).toHaveLength(1); + expect(watch.state.diff.removed).toHaveLength(0); + expect(watch.state.diff.all).toHaveLength(1); }, { timeout: 1000 } ); From 3fbe20804c193009f20af7bb1bb9fdbb304045a9 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 1 Jul 2025 16:44:11 +0200 Subject: [PATCH 65/75] cleanup docs. More readonly work. --- docs/.gitignore | 1 + .../src/client/AbstractPowerSyncDatabase.ts | 14 +++++++------- packages/common/src/client/Query.ts | 14 +++++++------- .../processors/DifferentialQueryProcessor.ts | 16 ++++++++-------- packages/react/src/QueryStore.ts | 6 +----- .../hooks/suspense/useWatchedSuspenseQuery.ts | 7 ++++++- .../react/src/hooks/watched/useWatchedQuery.ts | 8 +++++++- packages/react/tests/useQuery.test.tsx | 4 +++- packages/vue/src/composables/useWatchedQuery.ts | 3 ++- 9 files changed, 42 insertions(+), 31 deletions(-) diff --git a/docs/.gitignore b/docs/.gitignore index 0814a0d06..c32a3c8c3 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -19,6 +19,7 @@ npm-debug.log* docs/attachments-sdk/ docs/common-sdk/ +docs/node-sdk/ docs/react-native-sdk/ docs/react-sdk/ docs/vue-sdk/ diff --git a/packages/common/src/client/AbstractPowerSyncDatabase.ts b/packages/common/src/client/AbstractPowerSyncDatabase.ts index d4eee626f..691bb2bbb 100644 --- a/packages/common/src/client/AbstractPowerSyncDatabase.ts +++ b/packages/common/src/client/AbstractPowerSyncDatabase.ts @@ -891,18 +891,18 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver ({ - * ...row, - * created_at: new Date(row.created_at as string) - * }) + * sql: `SELECT photo_id as id FROM todos WHERE photo_id IS NOT NULL`, + * parameters: [], + * mapper: (row) => ({ + * ...row, + * created_at: new Date(row.created_at as string) + * }) * }) * .watch() * // OR use .differentialWatch() for fine-grained watches. diff --git a/packages/common/src/client/Query.ts b/packages/common/src/client/Query.ts index 2190b5ca7..9d7a8650b 100644 --- a/packages/common/src/client/Query.ts +++ b/packages/common/src/client/Query.ts @@ -7,7 +7,7 @@ import { ComparisonWatchedQuery } from './watched/processors/OnChangeQueryProces import { WatchedQueryOptions } from './watched/WatchedQuery.js'; /** - * Query parameters for {@link ArrayQueryDefinition.parameters} + * Query parameters for {@link ArrayQueryDefinition#parameters} */ export type QueryParam = string | number | boolean | null | undefined | bigint | Uint8Array; @@ -17,7 +17,7 @@ export type QueryParam = string | number | boolean | null | undefined | bigint | */ export interface ArrayQueryDefinition { sql: string; - parameters?: ReadonlyArray; + parameters?: ReadonlyArray>; /** * Maps the raw SQLite row to a custom typed object. * @example @@ -63,14 +63,14 @@ export interface Query { * By default the returned watched query will emit changes whenever a change to the underlying SQLite tables is made. * These changes might not be relevant to the query, but the query will emit a new result set. * - * A {@link StandardWatchedQueryOptions.comparator} can be provided to limit the data emissions. The watched query will still + * A {@link StandardWatchedQueryOptions#comparator} can be provided to limit the data emissions. The watched query will still * query the underlying DB on a underlying table changes, but the result will only be emitted if the comparator detects a change in the results. * * The comparator in this method is optimized and returns early as soon as it detects a change. Each data emission will correlate to a change in the result set, * but note that the result set will not maintain internal object references to the previous result set. If internal object references are needed, - * consider using {@link Query.differentialWatch} instead. + * consider using {@link Query#differentialWatch} instead. */ - watch(options?: StandardWatchedQueryOptions): ComparisonWatchedQuery; + watch(options?: StandardWatchedQueryOptions): ComparisonWatchedQuery>>; /** * Creates a {@link WatchedQuery} which watches and emits results of the linked query. @@ -81,8 +81,8 @@ export interface Query { * If the result set is different, the watched query will emit the new result set and provide a detailed diff of the changes. * * The deep differentiation allows maintaining result set object references between result emissions. - * The {@link DifferentialWatchedQuery.state.data} array will contain the previous row references for unchanged rows. - * A detailed diff of the changes can be accessed via {@link DifferentialWatchedQuery.state.diff}. + * The {@link DifferentialWatchedQuery#state} `data` array will contain the previous row references for unchanged rows. + * A detailed diff of the changes can be accessed via {@link DifferentialWatchedQuery#state} `diff`. */ differentialWatch(options?: DifferentialWatchedQueryOptions): DifferentialWatchedQuery; } diff --git a/packages/common/src/client/watched/processors/DifferentialQueryProcessor.ts b/packages/common/src/client/watched/processors/DifferentialQueryProcessor.ts index 6647fb6c4..6cec93b6b 100644 --- a/packages/common/src/client/watched/processors/DifferentialQueryProcessor.ts +++ b/packages/common/src/client/watched/processors/DifferentialQueryProcessor.ts @@ -20,7 +20,7 @@ export interface WatchedQueryRowDifferential { * {@link WatchedQueryState.data} is of the {@link WatchedQueryDifferential} form when using the {@link IncrementalWatchMode.DIFFERENTIAL} mode. */ export interface WatchedQueryDifferential { - readonly added: ReadonlyArray; + readonly added: ReadonlyArray>; /** * The entire current result set. * Array item object references are preserved between updates if the item is unchanged. @@ -35,10 +35,10 @@ export interface WatchedQueryDifferential { * the updated result set will be contain the same object reference, to item A, as the previous result set. * This is regardless of the item A's position in the updated result set. */ - readonly all: ReadonlyArray; - readonly removed: ReadonlyArray; - readonly updated: ReadonlyArray>; - readonly unchanged: ReadonlyArray; + readonly all: ReadonlyArray>; + readonly removed: ReadonlyArray>; + readonly updated: ReadonlyArray>>; + readonly unchanged: ReadonlyArray>; } /** @@ -83,7 +83,7 @@ export interface DifferentialWatchedQuerySettings extends DifferentialW query: WatchCompatibleQuery; } -export interface DifferentialWatchedQueryState extends WatchedQueryState { +export interface DifferentialWatchedQueryState extends WatchedQueryState>> { /** * The difference between the current and previous result set. */ @@ -96,7 +96,7 @@ type MutableDifferentialWatchedQueryState = MutableWatchedQueryState - extends WatchedQuery> { + extends WatchedQuery>, DifferentialWatchedQuerySettings> { readonly state: DifferentialWatchedQueryState; } @@ -143,7 +143,7 @@ export const DEFAULT_WATCHED_QUERY_DIFFERENTIATOR: WatchedQueryDifferentiator - extends AbstractQueryProcessor> + extends AbstractQueryProcessor>, DifferentialWatchedQuerySettings> implements DifferentialWatchedQuery { readonly state: DifferentialWatchedQueryState; diff --git a/packages/react/src/QueryStore.ts b/packages/react/src/QueryStore.ts index a0f817815..bea79f596 100644 --- a/packages/react/src/QueryStore.ts +++ b/packages/react/src/QueryStore.ts @@ -19,11 +19,7 @@ export class QueryStore { constructor(private db: AbstractPowerSyncDatabase) {} - getQuery( - key: string, - query: WatchCompatibleQuery, - options: AdditionalOptions - ): WatchedQuery { + getQuery(key: string, query: WatchCompatibleQuery, options: AdditionalOptions) { if (this.cache.has(key)) { return this.cache.get(key) as WatchedQuery; } diff --git a/packages/react/src/hooks/suspense/useWatchedSuspenseQuery.ts b/packages/react/src/hooks/suspense/useWatchedSuspenseQuery.ts index ab9179fc1..49c095299 100644 --- a/packages/react/src/hooks/suspense/useWatchedSuspenseQuery.ts +++ b/packages/react/src/hooks/suspense/useWatchedSuspenseQuery.ts @@ -30,5 +30,10 @@ export const useWatchedSuspenseQuery = ( const store = getQueryStore(powerSync); const watchedQuery = store.getQuery(key, parsedQuery, options); - return useWatchedQuerySuspenseSubscription(watchedQuery); + const result = useWatchedQuerySuspenseSubscription(watchedQuery); + return { + ...result, + // The result above is readonly, but this API expects a mutable array + data: [...result.data] + }; }; diff --git a/packages/react/src/hooks/watched/useWatchedQuery.ts b/packages/react/src/hooks/watched/useWatchedQuery.ts index d731b62e1..487b67368 100644 --- a/packages/react/src/hooks/watched/useWatchedQuery.ts +++ b/packages/react/src/hooks/watched/useWatchedQuery.ts @@ -41,5 +41,11 @@ export const useWatchedQuery = ( } }, [queryChanged]); - return useWatchedQuerySubscription(watchedQuery); + const result = useWatchedQuerySubscription(watchedQuery); + return { + ...result, + // The Watched Query API returns readonly arrays, + // This allows compatibility with the hook API. + data: [...result.data] + }; }; diff --git a/packages/react/tests/useQuery.test.tsx b/packages/react/tests/useQuery.test.tsx index 26897a3ab..76c8107a8 100644 --- a/packages/react/tests/useQuery.test.tsx +++ b/packages/react/tests/useQuery.test.tsx @@ -315,7 +315,9 @@ describe('useQuery', () => { // This query can be instantiated once and reused. // The query retains it's state and will not re-fetch the data unless the result changes. // This is useful for queries that are used in multiple components. - const listsQuery = db.query({ sql: `SELECT * FROM lists`, parameters: [] }).differentialWatch(); + const listsQuery = db + .query<{ id: string; name: string }>({ sql: `SELECT * FROM lists`, parameters: [] }) + .differentialWatch(); const wrapper = ({ children }) => {children}; const { result } = renderHook(() => useWatchedQuerySubscription(listsQuery), { diff --git a/packages/vue/src/composables/useWatchedQuery.ts b/packages/vue/src/composables/useWatchedQuery.ts index a2a377e2e..9c5a05544 100644 --- a/packages/vue/src/composables/useWatchedQuery.ts +++ b/packages/vue/src/composables/useWatchedQuery.ts @@ -72,7 +72,8 @@ export const useWatchedQuery = ( onStateChange: (state) => { isLoading.value = state.isLoading; isFetching.value = state.isFetching; - data.value = state.data; + // The watched query state is readonly + data.value = [...state.data]; if (state.error) { const wrappedError = new Error('PowerSync failed to fetch data: ' + state.error.message); wrappedError.cause = state.error; From 3cbfc8fd380730b01e31c400f1027f451e5585f1 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 1 Jul 2025 16:57:38 +0200 Subject: [PATCH 66/75] update docs and demos. --- .../components/providers/SystemProvider.tsx | 58 +++++++------------ .../processors/DifferentialQueryProcessor.ts | 6 +- .../client/watched/processors/comparators.ts | 2 +- .../kysely-driver/tests/sqlite/watch.test.ts | 11 +--- packages/react/tests/profile.test.tsx | 31 ++++------ packages/sqljs/sql.js | 1 + 6 files changed, 42 insertions(+), 67 deletions(-) create mode 160000 packages/sqljs/sql.js diff --git a/demos/react-supabase-todolist/src/components/providers/SystemProvider.tsx b/demos/react-supabase-todolist/src/components/providers/SystemProvider.tsx index 8ea25dda4..8a3f3c209 100644 --- a/demos/react-supabase-todolist/src/components/providers/SystemProvider.tsx +++ b/demos/react-supabase-todolist/src/components/providers/SystemProvider.tsx @@ -3,15 +3,7 @@ import { AppSchema, ListRecord, LISTS_TABLE, TODOS_TABLE } from '@/library/power import { SupabaseConnector } from '@/library/powersync/SupabaseConnector'; import { CircularProgress } from '@mui/material'; import { PowerSyncContext } from '@powersync/react'; -import { - ArrayComparator, - createBaseLogger, - GetAllQuery, - IncrementalWatchMode, - LogLevel, - PowerSyncDatabase, - WatchedQuery -} from '@powersync/web'; +import { createBaseLogger, DifferentialWatchedQuery, LogLevel, PowerSyncDatabase } from '@powersync/web'; import React, { Suspense } from 'react'; import { NavigationPanelContextProvider } from '../navigation/NavigationPanelContext'; @@ -28,7 +20,7 @@ export const db = new PowerSyncDatabase({ export type EnhancedListRecord = ListRecord & { total_tasks: number; completed_tasks: number }; export type QueryStore = { - lists: WatchedQuery; + lists: DifferentialWatchedQuery; }; const QueryStore = React.createContext(null); @@ -39,32 +31,26 @@ export const SystemProvider = ({ children }: { children: React.ReactNode }) => { const [powerSync] = React.useState(db); const [queryStore] = React.useState(() => { - const listsQuery = db.incrementalWatch({ mode: IncrementalWatchMode.COMPARISON }).build({ - comparator: new ArrayComparator({ - compareBy: (item) => JSON.stringify(item) - }), - watch: { - placeholderData: [], - query: new GetAllQuery({ - sql: /* sql */ ` - SELECT - ${LISTS_TABLE}.*, - COUNT(${TODOS_TABLE}.id) AS total_tasks, - SUM( - CASE - WHEN ${TODOS_TABLE}.completed = true THEN 1 - ELSE 0 - END - ) as completed_tasks - FROM - ${LISTS_TABLE} - LEFT JOIN ${TODOS_TABLE} ON ${LISTS_TABLE}.id = ${TODOS_TABLE}.list_id - GROUP BY - ${LISTS_TABLE}.id; - ` - }) - } - }); + const listsQuery = db + .query({ + sql: /* sql */ ` + SELECT + ${LISTS_TABLE}.*, + COUNT(${TODOS_TABLE}.id) AS total_tasks, + SUM( + CASE + WHEN ${TODOS_TABLE}.completed = true THEN 1 + ELSE 0 + END + ) as completed_tasks + FROM + ${LISTS_TABLE} + LEFT JOIN ${TODOS_TABLE} ON ${LISTS_TABLE}.id = ${TODOS_TABLE}.list_id + GROUP BY + ${LISTS_TABLE}.id; + ` + }) + .differentialWatch(); return { lists: listsQuery diff --git a/packages/common/src/client/watched/processors/DifferentialQueryProcessor.ts b/packages/common/src/client/watched/processors/DifferentialQueryProcessor.ts index 6cec93b6b..4be17f99c 100644 --- a/packages/common/src/client/watched/processors/DifferentialQueryProcessor.ts +++ b/packages/common/src/client/watched/processors/DifferentialQueryProcessor.ts @@ -16,8 +16,8 @@ export interface WatchedQueryRowDifferential { } /** - * Represents the result of a watched query that has been differentiated. - * {@link WatchedQueryState.data} is of the {@link WatchedQueryDifferential} form when using the {@link IncrementalWatchMode.DIFFERENTIAL} mode. + * Represents the result of a watched query that has been diffed. + * {@link DifferentialWatchedQueryState#diff} is of the {@link WatchedQueryDifferential} form. */ export interface WatchedQueryDifferential { readonly added: ReadonlyArray>; @@ -74,7 +74,7 @@ export interface DifferentialWatchedQueryOptions extends WatchedQueryOp } /** - * Settings for incremental watched queries using the {@link IncrementalWatchMode.DIFFERENTIAL} mode. + * Settings for differential incremental watched queries using. */ export interface DifferentialWatchedQuerySettings extends DifferentialWatchedQueryOptions { /** diff --git a/packages/common/src/client/watched/processors/comparators.ts b/packages/common/src/client/watched/processors/comparators.ts index 827630e9c..6d2500d3c 100644 --- a/packages/common/src/client/watched/processors/comparators.ts +++ b/packages/common/src/client/watched/processors/comparators.ts @@ -13,7 +13,7 @@ export type ArrayComparatorOptions = { }; /** - * Compares array results of watched queries for incrementally watched queries created in the {@link IncrementalWatchMode.COMPARISON} mode. + * Compares array results of watched queries for incrementally watched queries created in the standard mode. */ export class ArrayComparator implements WatchedQueryComparator { constructor(protected options: ArrayComparatorOptions) {} diff --git a/packages/kysely-driver/tests/sqlite/watch.test.ts b/packages/kysely-driver/tests/sqlite/watch.test.ts index 3b94b104f..1a499a5fe 100644 --- a/packages/kysely-driver/tests/sqlite/watch.test.ts +++ b/packages/kysely-driver/tests/sqlite/watch.test.ts @@ -1,4 +1,4 @@ -import { AbstractPowerSyncDatabase, column, IncrementalWatchMode, Schema, Table } from '@powersync/common'; +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'; @@ -265,18 +265,13 @@ describe('Watch Tests', () => { it('incremental watch should accept queries', async () => { const query = db.selectFrom('assets').select(db.fn.count('assets.id').as('count')); - const watch = powerSyncDb.incrementalWatch({ mode: IncrementalWatchMode.COMPARISON }).build({ - watch: { - query, - placeholderData: [] - } - }); + const watch = powerSyncDb.customQuery(query).watch(); const latestDataPromise = new Promise>>((resolve) => { const dispose = watch.registerListener({ onData: (data) => { if (data.length > 0) { - resolve(data); + resolve([...data]); dispose(); } } diff --git a/packages/react/tests/profile.test.tsx b/packages/react/tests/profile.test.tsx index f10b6b694..149323160 100644 --- a/packages/react/tests/profile.test.tsx +++ b/packages/react/tests/profile.test.tsx @@ -282,14 +282,13 @@ const testsInsertsCompare = async (options: { const notMemoizedDifferentialTest = async () => { await db.execute('DELETE FROM lists;'); - const query = db.incrementalWatch({ mode: commonSdk.IncrementalWatchMode.DIFFERENTIAL }).build({ - watch: { - query: new commonSdk.GetAllQuery({ - sql: 'SELECT * FROM lists ORDER BY name ASC;' - }), + const query = db + .query({ + sql: 'SELECT * FROM lists ORDER BY name ASC;' + }) + .differentialWatch({ reportFetching: false - } - }); + }); const times: number[] = []; diffSpy(query, times); @@ -300,8 +299,8 @@ const testsInsertsCompare = async (options: { initialDataCount, useMemoize: false, getQueryData: () => { - const result = useWatchedQuerySubscription(query).data.all; - return result; + const { data } = useWatchedQuerySubscription(query); + return [...data]; } }); @@ -322,14 +321,8 @@ const testsInsertsCompare = async (options: { // Testing Differential With Memoization await db.execute('DELETE FROM lists;'); - // guardLog = true; // Prevents logging from TestItemWidget - const query = db.incrementalWatch({ mode: commonSdk.IncrementalWatchMode.DIFFERENTIAL }).build({ - watch: { - query: new commonSdk.GetAllQuery({ - sql: 'SELECT * FROM lists ORDER BY name ASC;' - }), - reportFetching: false - } + const query = db.query({ sql: 'SELECT * FROM lists ORDER BY name ASC;' }).differentialWatch({ + reportFetching: false }); const times: number[] = []; @@ -341,8 +334,8 @@ const testsInsertsCompare = async (options: { initialDataCount, useMemoize: true, getQueryData: () => { - const result = useWatchedQuerySubscription(query).data.all; - return result; + const { data } = useWatchedQuerySubscription(query); + return [...data]; } }); diff --git a/packages/sqljs/sql.js b/packages/sqljs/sql.js new file mode 160000 index 000000000..52e5649f3 --- /dev/null +++ b/packages/sqljs/sql.js @@ -0,0 +1 @@ +Subproject commit 52e5649f3a3a2a46aa4ad58a79d118c22f56cf30 From 271737cdaf0b835d1c6f0d7cc6a0e57e1afc772d Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 1 Jul 2025 17:05:02 +0200 Subject: [PATCH 67/75] update profile timeout --- packages/react/tests/profile.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react/tests/profile.test.tsx b/packages/react/tests/profile.test.tsx index 149323160..028cc4324 100644 --- a/packages/react/tests/profile.test.tsx +++ b/packages/react/tests/profile.test.tsx @@ -7,7 +7,7 @@ import { PowerSyncContext } from '../src/hooks/PowerSyncContext'; import { useQuery } from '../src/hooks/watched/useQuery'; import { useWatchedQuerySubscription } from '../src/hooks/watched/useWatchedQuerySubscription'; -let skipTests = true; +let skipTests = false; /** * This does not run as part of all tests. Enable this suite manually to run performance tests. * @@ -356,7 +356,7 @@ const testsInsertsCompare = async (options: { return result as TestsInsertsCompareResult; }; -describe.skipIf(skipTests)('Performance', { timeout: 90_000 }, () => { +describe.skipIf(skipTests)('Performance', { timeout: Infinity }, () => { beforeEach(() => { vi.clearAllMocks(); }); From 2d241cced67aadfca61a7dc031efe018e4041d23 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 1 Jul 2025 17:12:51 +0200 Subject: [PATCH 68/75] wierd submodule --- packages/sqljs/sql.js | 1 - 1 file changed, 1 deletion(-) delete mode 160000 packages/sqljs/sql.js diff --git a/packages/sqljs/sql.js b/packages/sqljs/sql.js deleted file mode 160000 index 52e5649f3..000000000 --- a/packages/sqljs/sql.js +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 52e5649f3a3a2a46aa4ad58a79d118c22f56cf30 From 0b8f9ae8dd14e457bb101bdc5d01a7d529c9e5ad Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 1 Jul 2025 17:22:01 +0200 Subject: [PATCH 69/75] skip profile tests by default --- packages/react/tests/profile.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/tests/profile.test.tsx b/packages/react/tests/profile.test.tsx index 028cc4324..0961150e1 100644 --- a/packages/react/tests/profile.test.tsx +++ b/packages/react/tests/profile.test.tsx @@ -7,7 +7,7 @@ import { PowerSyncContext } from '../src/hooks/PowerSyncContext'; import { useQuery } from '../src/hooks/watched/useQuery'; import { useWatchedQuerySubscription } from '../src/hooks/watched/useWatchedQuerySubscription'; -let skipTests = false; +let skipTests = true; /** * This does not run as part of all tests. Enable this suite manually to run performance tests. * From 937c1e29ac1faa3f1c1161d8c9588f7085965152 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 1 Jul 2025 17:34:09 +0200 Subject: [PATCH 70/75] fix tests --- packages/react/src/hooks/suspense/useWatchedSuspenseQuery.ts | 3 ++- packages/react/src/hooks/watched/useWatchedQuery.ts | 5 +++-- packages/vue/src/composables/useWatchedQuery.ts | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/react/src/hooks/suspense/useWatchedSuspenseQuery.ts b/packages/react/src/hooks/suspense/useWatchedSuspenseQuery.ts index 49c095299..fe5f84b17 100644 --- a/packages/react/src/hooks/suspense/useWatchedSuspenseQuery.ts +++ b/packages/react/src/hooks/suspense/useWatchedSuspenseQuery.ts @@ -34,6 +34,7 @@ export const useWatchedSuspenseQuery = ( return { ...result, // The result above is readonly, but this API expects a mutable array - data: [...result.data] + // We need to keep the same reference also. + data: result.data as T[] }; }; diff --git a/packages/react/src/hooks/watched/useWatchedQuery.ts b/packages/react/src/hooks/watched/useWatchedQuery.ts index 487b67368..a772a91a3 100644 --- a/packages/react/src/hooks/watched/useWatchedQuery.ts +++ b/packages/react/src/hooks/watched/useWatchedQuery.ts @@ -44,8 +44,9 @@ export const useWatchedQuery = ( const result = useWatchedQuerySubscription(watchedQuery); return { ...result, - // The Watched Query API returns readonly arrays, + // The Watched Query API returns readonly arrays. + // We need to keep the same reference also. // This allows compatibility with the hook API. - data: [...result.data] + data: result.data as RowType[] }; }; diff --git a/packages/vue/src/composables/useWatchedQuery.ts b/packages/vue/src/composables/useWatchedQuery.ts index 9c5a05544..f9df8416c 100644 --- a/packages/vue/src/composables/useWatchedQuery.ts +++ b/packages/vue/src/composables/useWatchedQuery.ts @@ -73,7 +73,7 @@ export const useWatchedQuery = ( isLoading.value = state.isLoading; isFetching.value = state.isFetching; // The watched query state is readonly - data.value = [...state.data]; + data.value = state.data as T[]; if (state.error) { const wrappedError = new Error('PowerSync failed to fetch data: ' + state.error.message); wrappedError.cause = state.error; From 860c36235d79c59eecd13ee5b85875e4bf5e90ac Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Wed, 2 Jul 2025 09:50:55 +0200 Subject: [PATCH 71/75] Update hook results to be readonly --- .changeset/tricky-bottles-greet.md | 6 ++++++ packages/react/src/hooks/watched/useWatchedQuery.ts | 9 +-------- packages/react/src/hooks/watched/watch-types.ts | 8 ++++---- packages/vue/src/composables/useQuery.ts | 8 ++++---- packages/vue/src/composables/useSingleQuery.ts | 10 +++++----- packages/vue/src/composables/useWatchedQuery.ts | 5 ++--- 6 files changed, 22 insertions(+), 24 deletions(-) create mode 100644 .changeset/tricky-bottles-greet.md diff --git a/.changeset/tricky-bottles-greet.md b/.changeset/tricky-bottles-greet.md new file mode 100644 index 000000000..334e2329a --- /dev/null +++ b/.changeset/tricky-bottles-greet.md @@ -0,0 +1,6 @@ +--- +'@powersync/react': minor +'@powersync/vue': minor +--- + +[Potentially breaking change] The `useQuery` hook results are now explicitly defined as readonly. These values should not be mutated. diff --git a/packages/react/src/hooks/watched/useWatchedQuery.ts b/packages/react/src/hooks/watched/useWatchedQuery.ts index a772a91a3..d731b62e1 100644 --- a/packages/react/src/hooks/watched/useWatchedQuery.ts +++ b/packages/react/src/hooks/watched/useWatchedQuery.ts @@ -41,12 +41,5 @@ export const useWatchedQuery = ( } }, [queryChanged]); - const result = useWatchedQuerySubscription(watchedQuery); - return { - ...result, - // The Watched Query API returns readonly arrays. - // We need to keep the same reference also. - // This allows compatibility with the hook API. - data: result.data as RowType[] - }; + return useWatchedQuerySubscription(watchedQuery); }; diff --git a/packages/react/src/hooks/watched/watch-types.ts b/packages/react/src/hooks/watched/watch-types.ts index b649bbd72..f0eb0561c 100644 --- a/packages/react/src/hooks/watched/watch-types.ts +++ b/packages/react/src/hooks/watched/watch-types.ts @@ -10,16 +10,16 @@ export interface AdditionalOptions extends HookWatchOptions = { - data: RowType[]; + readonly data: ReadonlyArray>; /** * Indicates the initial loading state (hard loading). Loading becomes false once the first set of results from the watched query is available or an error occurs. */ - isLoading: boolean; + readonly isLoading: boolean; /** * Indicates whether the query is currently fetching data, is true during the initial load and any time when the query is re-evaluating (useful for large queries). */ - isFetching: boolean; - error: Error | undefined; + readonly isFetching: boolean; + readonly error: Error | undefined; /** * Function used to run the query again. */ diff --git a/packages/vue/src/composables/useQuery.ts b/packages/vue/src/composables/useQuery.ts index cbf5353b6..9768d40b6 100644 --- a/packages/vue/src/composables/useQuery.ts +++ b/packages/vue/src/composables/useQuery.ts @@ -4,16 +4,16 @@ import { AdditionalOptions, useSingleQuery } from './useSingleQuery'; import { useWatchedQuery } from './useWatchedQuery'; export type WatchedQueryResult = { - data: Ref; + readonly data: Ref>>; /** * Indicates the initial loading state (hard loading). Loading becomes false once the first set of results from the watched query is available or an error occurs. */ - isLoading: Ref; + readonly isLoading: Ref; /** * Indicates whether the query is currently fetching data, is true during the initial load and any time when the query is re-evaluating (useful for large queries). */ - isFetching: Ref; - error: Ref; + readonly isFetching: Ref; + readonly error: Ref; /** * Function used to run the query again. */ diff --git a/packages/vue/src/composables/useSingleQuery.ts b/packages/vue/src/composables/useSingleQuery.ts index e03c6a2f4..5a5ab36f2 100644 --- a/packages/vue/src/composables/useSingleQuery.ts +++ b/packages/vue/src/composables/useSingleQuery.ts @@ -14,16 +14,16 @@ export interface AdditionalOptions extends Omit = { - data: Ref; + readonly data: Ref>>; /** * Indicates the initial loading state (hard loading). Loading becomes false once the first set of results from the watched query is available or an error occurs. */ - isLoading: Ref; + readonly isLoading: Ref; /** * Indicates whether the query is currently fetching data, is true during the initial load and any time when the query is re-evaluating (useful for large queries). */ - isFetching: Ref; - error: Ref; + readonly isFetching: Ref; + readonly error: Ref; /** * Function used to run the query again. */ @@ -35,7 +35,7 @@ export const useSingleQuery = ( sqlParameters: MaybeRef = [], options: AdditionalOptions = {} ): WatchedQueryResult => { - const data = ref([]) as Ref; + const data = ref>>([]) as Ref>>; const error = ref(undefined); const isLoading = ref(true); const isFetching = ref(true); diff --git a/packages/vue/src/composables/useWatchedQuery.ts b/packages/vue/src/composables/useWatchedQuery.ts index f9df8416c..e922a6ea6 100644 --- a/packages/vue/src/composables/useWatchedQuery.ts +++ b/packages/vue/src/composables/useWatchedQuery.ts @@ -8,7 +8,7 @@ export const useWatchedQuery = ( sqlParameters: MaybeRef = [], options: AdditionalOptions = {} ): WatchedQueryResult => { - const data = ref([]) as Ref; + const data = ref>>([]) as Ref>>; const error = ref(undefined); const isLoading = ref(true); const isFetching = ref(true); @@ -72,8 +72,7 @@ export const useWatchedQuery = ( onStateChange: (state) => { isLoading.value = state.isLoading; isFetching.value = state.isFetching; - // The watched query state is readonly - data.value = state.data as T[]; + data.value = state.data; if (state.error) { const wrappedError = new Error('PowerSync failed to fetch data: ' + state.error.message); wrappedError.cause = state.error; From c5912993a1f94467b563e87d47b625449355a5bd Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Wed, 2 Jul 2025 10:51:16 +0200 Subject: [PATCH 72/75] Add a nice chart to benchmarks --- packages/react/package.json | 1 + packages/react/tests/profile.test.tsx | 74 +++++++++++++++++++++++++++ pnpm-lock.yaml | 32 +++++++++--- 3 files changed, 101 insertions(+), 6 deletions(-) diff --git a/packages/react/package.json b/packages/react/package.json index 4d0b84636..e18ff924f 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -37,6 +37,7 @@ "@powersync/web": "workspace:*", "@testing-library/react": "^15.0.2", "@types/react": "^18.3.1", + "chart.js": "^4.5.0", "jsdom": "^24.0.0", "react": "18.3.1", "react-dom": "18.3.1", diff --git a/packages/react/tests/profile.test.tsx b/packages/react/tests/profile.test.tsx index 0961150e1..0c060751f 100644 --- a/packages/react/tests/profile.test.tsx +++ b/packages/react/tests/profile.test.tsx @@ -1,5 +1,6 @@ import * as commonSdk from '@powersync/common'; import { PowerSyncDatabase } from '@powersync/web'; +import { Chart } from 'chart.js/auto'; import React, { Profiler } from 'react'; import ReactDOM from 'react-dom/client'; import { beforeEach, describe, it, Mock, onTestFinished, vi } from 'vitest'; @@ -420,5 +421,78 @@ describe.skipIf(skipTests)('Performance', { timeout: Infinity }, () => { totalResults.forEach((r) => { console.log(Object.values(r).join(',')); }); + + // Make a nice chart, these are visible when running tests with a visible browser `headless: false` + const chartCanvas = document.createElement('canvas'); + document.body.appendChild(chartCanvas); + + // Chart the Average incremental render times + const testTypes = new Set(Object.keys(totalResults[0])); + // Don't show this on this chart + testTypes.delete('differentialMemoImprovementPercentage'); + testTypes.delete('initialDataCount'); + new Chart(chartCanvas, { + type: 'line', + data: { + labels: initialDataVolumeSteps, + datasets: Array.from(testTypes).map((resultType) => { + return { + label: resultType, + data: totalResults.map((r) => r[resultType]) + }; + }) + }, + options: { + scales: { + y: { + beginAtZero: true, + title: { + display: true, + text: 'Average incremental render time (ms)' + } + }, + x: { + title: { + display: true, + text: 'Initial count of items' + } + } + } + } + }); + + const percentCanvas = document.createElement('canvas'); + document.body.appendChild(percentCanvas); + + // Chart the Average incremental render times + new Chart(percentCanvas, { + type: 'line', + data: { + labels: initialDataVolumeSteps, + datasets: [ + { + label: 'Percentage decrease of render time for Differential Memoized', + data: totalResults.map((r) => r.differentialMemoImprovementPercentage) + } + ] + }, + options: { + scales: { + y: { + beginAtZero: true, + title: { + display: true, + text: 'Average incremental render time (ms)' + } + }, + x: { + title: { + display: true, + text: 'Initial count of items' + } + } + } + } + }); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 343196793..c21cf2427 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1869,6 +1869,9 @@ importers: '@types/react': specifier: ^18.3.1 version: 18.3.23 + chart.js: + specifier: ^4.5.0 + version: 4.5.0 jsdom: specifier: ^24.0.0 version: 24.1.3 @@ -5530,6 +5533,9 @@ packages: peerDependencies: tslib: '2' + '@kurkle/color@0.3.4': + resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} + '@leichtgewicht/ip-codec@2.0.5': resolution: {integrity: sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==} @@ -10489,6 +10495,10 @@ packages: charenc@0.0.2: resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==} + chart.js@4.5.0: + resolution: {integrity: sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==} + engines: {pnpm: '>=8'} + check-error@2.1.1: resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} engines: {node: '>= 16'} @@ -25785,6 +25795,8 @@ snapshots: dependencies: tslib: 2.8.1 + '@kurkle/color@0.3.4': {} + '@leichtgewicht/ip-codec@2.0.5': {} '@lexical/clipboard@0.15.0': @@ -26341,7 +26353,7 @@ snapshots: '@mui/private-theming@5.17.1(@types/react@18.3.23)(react@18.3.1)': dependencies: - '@babel/runtime': 7.27.4 + '@babel/runtime': 7.27.6 '@mui/utils': 5.17.1(@types/react@18.3.23)(react@18.3.1) prop-types: 15.8.1 react: 18.3.1 @@ -26350,7 +26362,7 @@ snapshots: '@mui/styled-engine@5.16.14(@emotion/react@11.11.4(@types/react@18.3.23)(react@18.3.1))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.3.23)(react@18.3.1))(@types/react@18.3.23)(react@18.3.1))(react@18.3.1)': dependencies: - '@babel/runtime': 7.27.4 + '@babel/runtime': 7.27.6 '@emotion/cache': 11.14.0 csstype: 3.1.3 prop-types: 15.8.1 @@ -26361,7 +26373,7 @@ snapshots: '@mui/styled-engine@5.16.14(@emotion/react@11.14.0(@types/react@18.3.23)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.23)(react@18.3.1))(@types/react@18.3.23)(react@18.3.1))(react@18.3.1)': dependencies: - '@babel/runtime': 7.27.4 + '@babel/runtime': 7.27.6 '@emotion/cache': 11.14.0 csstype: 3.1.3 prop-types: 15.8.1 @@ -27119,7 +27131,7 @@ snapshots: '@radix-ui/react-compose-refs@1.0.0(react@18.3.1)': dependencies: - '@babel/runtime': 7.27.4 + '@babel/runtime': 7.27.6 react: 18.3.1 '@radix-ui/react-slot@1.0.1(react@18.3.1)': @@ -28424,7 +28436,9 @@ snapshots: transitivePeerDependencies: - '@babel/core' - '@babel/preset-env' + - bufferutil - supports-color + - utf-8-validate '@react-native/metro-config@0.78.0(@babel/core@7.26.10)(@babel/preset-env@7.27.2(@babel/core@7.26.10))': dependencies: @@ -28435,7 +28449,9 @@ snapshots: transitivePeerDependencies: - '@babel/core' - '@babel/preset-env' + - bufferutil - supports-color + - utf-8-validate '@react-native/normalize-color@2.1.0': {} @@ -32345,7 +32361,7 @@ snapshots: babel-plugin-macros@3.1.0: dependencies: - '@babel/runtime': 7.27.4 + '@babel/runtime': 7.27.6 cosmiconfig: 7.1.0 resolve: 1.22.10 @@ -33027,6 +33043,10 @@ snapshots: charenc@0.0.2: {} + chart.js@4.5.0: + dependencies: + '@kurkle/color': 0.3.4 + check-error@2.1.1: {} cheerio-select@2.1.0: @@ -34208,7 +34228,7 @@ snapshots: dom-helpers@5.2.1: dependencies: - '@babel/runtime': 7.27.4 + '@babel/runtime': 7.27.6 csstype: 3.1.3 dom-serializer@1.4.1: From d4cf23868628aee8e4dd6951325bf15bdcb35048 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Wed, 2 Jul 2025 12:20:26 +0200 Subject: [PATCH 73/75] cleanup readme and docs --- packages/common/src/client/Query.ts | 8 ++++---- packages/react/README.md | 2 +- .../useWatchedQuerySuspenseSubscription.ts | 14 +++++++------- .../src/hooks/suspense/useWatchedSuspenseQuery.ts | 8 +------- .../hooks/watched/useWatchedQuerySubscription.ts | 11 +++++++---- 5 files changed, 20 insertions(+), 23 deletions(-) diff --git a/packages/common/src/client/Query.ts b/packages/common/src/client/Query.ts index 9d7a8650b..ddc3b057f 100644 --- a/packages/common/src/client/Query.ts +++ b/packages/common/src/client/Query.ts @@ -12,8 +12,8 @@ import { WatchedQueryOptions } from './watched/WatchedQuery.js'; export type QueryParam = string | number | boolean | null | undefined | bigint | Uint8Array; /** - * Options for building a query with {@link AbstractPowerSyncDatabase.query}. - * This query will be executed with {@link AbstractPowerSyncDatabase.getAll}. + * Options for building a query with {@link AbstractPowerSyncDatabase#query}. + * This query will be executed with {@link AbstractPowerSyncDatabase#getAll}. */ export interface ArrayQueryDefinition { sql: string; @@ -37,7 +37,7 @@ export interface ArrayQueryDefinition { export interface StandardWatchedQueryOptions extends WatchedQueryOptions { /** * Optional comparator which processes the items of an array of rows. - * The comparator compares the result set rows by index using the {@link ArrayComparatorOptions.compareBy} function. + * The comparator compares the result set rows by index using the {@link ArrayComparatorOptions#compareBy} function. * The comparator reports a changed result set as soon as a row does not match the previous result set. * * @example @@ -64,7 +64,7 @@ export interface Query { * These changes might not be relevant to the query, but the query will emit a new result set. * * A {@link StandardWatchedQueryOptions#comparator} can be provided to limit the data emissions. The watched query will still - * query the underlying DB on a underlying table changes, but the result will only be emitted if the comparator detects a change in the results. + * query the underlying DB on underlying table changes, but the result will only be emitted if the comparator detects a change in the results. * * The comparator in this method is optimized and returns early as soon as it detects a change. Each data emission will correlate to a change in the result set, * but note that the result set will not maintain internal object references to the previous result set. If internal object references are needed, diff --git a/packages/react/README.md b/packages/react/README.md index e1eca8cc5..748cf1259 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -357,7 +357,7 @@ function MyWidget() { return ( // Other components // The data array is the same reference if no changes have occurred between fetches - // Note: The array is a new reference is there are any changes in the result set (individual row object references are not preserved) + // Note: The array is a new reference is there are any changes in the result set (individual row object references are preserved for unchanged rows) // Note: CatCollection requires memoization in order to prevent re-rendering (due to the parent re-rendering on fetch) ) diff --git a/packages/react/src/hooks/suspense/useWatchedQuerySuspenseSubscription.ts b/packages/react/src/hooks/suspense/useWatchedQuerySuspenseSubscription.ts index d243ea409..c56e79fc5 100644 --- a/packages/react/src/hooks/suspense/useWatchedQuerySuspenseSubscription.ts +++ b/packages/react/src/hooks/suspense/useWatchedQuerySuspenseSubscription.ts @@ -23,7 +23,12 @@ import { createSuspendingPromise, useTemporaryHold } from './suspense-utils'; * ); * } */ -export const useWatchedQuerySuspenseSubscription = (query: WatchedQuery) => { +export const useWatchedQuerySuspenseSubscription = < + ResultType = unknown, + Query extends WatchedQuery = WatchedQuery +>( + query: Query +): Query['state'] => { const { releaseHold } = useTemporaryHold(query); // Force update state function @@ -53,12 +58,7 @@ export const useWatchedQuerySuspenseSubscription = (query: WatchedQu throw query.state.error; } else if (!query.state.isLoading) { // Happy path data return - return { - data: query.state.data, - refresh: async () => { - // no-op for watched queries - } - }; + return query.state; } else { // Notify suspense is required throw createSuspendingPromise(query); diff --git a/packages/react/src/hooks/suspense/useWatchedSuspenseQuery.ts b/packages/react/src/hooks/suspense/useWatchedSuspenseQuery.ts index fe5f84b17..ab9179fc1 100644 --- a/packages/react/src/hooks/suspense/useWatchedSuspenseQuery.ts +++ b/packages/react/src/hooks/suspense/useWatchedSuspenseQuery.ts @@ -30,11 +30,5 @@ export const useWatchedSuspenseQuery = ( const store = getQueryStore(powerSync); const watchedQuery = store.getQuery(key, parsedQuery, options); - const result = useWatchedQuerySuspenseSubscription(watchedQuery); - return { - ...result, - // The result above is readonly, but this API expects a mutable array - // We need to keep the same reference also. - data: result.data as T[] - }; + return useWatchedQuerySuspenseSubscription(watchedQuery); }; diff --git a/packages/react/src/hooks/watched/useWatchedQuerySubscription.ts b/packages/react/src/hooks/watched/useWatchedQuerySubscription.ts index ecfd2e75b..c8cfa9252 100644 --- a/packages/react/src/hooks/watched/useWatchedQuerySubscription.ts +++ b/packages/react/src/hooks/watched/useWatchedQuerySubscription.ts @@ -1,4 +1,4 @@ -import { WatchedQuery, WatchedQueryState } from '@powersync/common'; +import { WatchedQuery } from '@powersync/common'; import React from 'react'; /** @@ -15,9 +15,12 @@ import React from 'react'; * } * */ -export const useWatchedQuerySubscription = ( - query: WatchedQuery -): WatchedQueryState => { +export const useWatchedQuerySubscription = < + ResultType = unknown, + Query extends WatchedQuery = WatchedQuery +>( + query: Query +): Query['state'] => { const [output, setOutputState] = React.useState(query.state); React.useEffect(() => { From a98eb00558ff49fc6884accbcc3e80ae9a1e53ae Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Wed, 2 Jul 2025 17:02:28 +0200 Subject: [PATCH 74/75] Revert breaking changes for react package. --- .changeset/nine-pens-ring.md | 17 +++--- .changeset/swift-guests-explain.md | 15 ++--- .changeset/tricky-bottles-greet.md | 6 -- packages/react/src/QueryStore.ts | 6 +- .../src/hooks/suspense/SuspenseQueryResult.ts | 3 +- .../src/hooks/suspense/useSuspenseQuery.ts | 57 +++++++++++++++---- .../hooks/suspense/useWatchedSuspenseQuery.ts | 6 +- packages/react/src/hooks/watched/useQuery.ts | 45 ++++++++++++--- .../src/hooks/watched/useWatchedQuery.ts | 14 ++++- .../react/src/hooks/watched/watch-types.ts | 28 +++++++-- 10 files changed, 147 insertions(+), 50 deletions(-) delete mode 100644 .changeset/tricky-bottles-greet.md diff --git a/.changeset/nine-pens-ring.md b/.changeset/nine-pens-ring.md index 6decf275c..c77317319 100644 --- a/.changeset/nine-pens-ring.md +++ b/.changeset/nine-pens-ring.md @@ -2,13 +2,16 @@ '@powersync/vue': minor --- -- Added the ability to limit re-renders by specifying a `comparator` for query results. The `useQuery` hook will only emit `data` changes when the data has changed. +[Potentially breaking change] The `useQuery` hook results are now explicitly defined as readonly. These values should not be mutated. + +- Added the ability to limit re-renders by specifying a `differentiator` for query results. The `useQuery` hook will only emit `data` changes when the data has changed. ```javascript - useQuery('SELECT * FROM lists WHERE name = ?', ['todo'], { - // This will be used to compare result sets between internal queries - comparator: new ArrayComparator({ - compareBy: (item) => JSON.stringify(item) - }) -}), +// The data here will maintain previous object references for unchanged items. +const { data } = useQuery('SELECT * FROM lists WHERE name = ?', ['aname'], { + differentiator: { + identify: (item) => item.id, + compareBy: (item) => JSON.stringify(item) + } +}); ``` diff --git a/.changeset/swift-guests-explain.md b/.changeset/swift-guests-explain.md index 0bf96c1c7..90bb0f166 100644 --- a/.changeset/swift-guests-explain.md +++ b/.changeset/swift-guests-explain.md @@ -2,13 +2,14 @@ '@powersync/react': minor --- -- Added the ability to limit re-renders by specifying a `comparator` for query results. The `useQuery` hook will only emit `data` changes when the data has changed. +- Added the ability to limit re-renders by specifying a `differentiator` for query results. The `useQuery` hook will only emit `data` changes when the data has changed. ```javascript - useQuery('SELECT * FROM lists WHERE name = ?', ['todo'], { - // This will be used to compare result sets between internal queries - comparator: new ArrayComparator({ - compareBy: (item) => JSON.stringify(item) - }) -}), +// The data here will maintain previous object references for unchanged items. +const { data } = useQuery('SELECT * FROM lists WHERE name = ?', ['aname'], { + differentiator: { + identify: (item) => item.id, + compareBy: (item) => JSON.stringify(item) + } +}); ``` diff --git a/.changeset/tricky-bottles-greet.md b/.changeset/tricky-bottles-greet.md deleted file mode 100644 index 334e2329a..000000000 --- a/.changeset/tricky-bottles-greet.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@powersync/react': minor -'@powersync/vue': minor ---- - -[Potentially breaking change] The `useQuery` hook results are now explicitly defined as readonly. These values should not be mutated. diff --git a/packages/react/src/QueryStore.ts b/packages/react/src/QueryStore.ts index bea79f596..6ab44a955 100644 --- a/packages/react/src/QueryStore.ts +++ b/packages/react/src/QueryStore.ts @@ -4,12 +4,12 @@ import { WatchedQuery, WatchedQueryListenerEvent } from '@powersync/common'; -import { AdditionalOptions } from './hooks/watched/watch-types'; +import { DifferentialHookOptions } from './hooks/watched/watch-types'; export function generateQueryKey( sqlStatement: string, parameters: ReadonlyArray, - options: AdditionalOptions + options: DifferentialHookOptions ): string { return `${sqlStatement} -- ${JSON.stringify(parameters)} -- ${JSON.stringify(options)}`; } @@ -19,7 +19,7 @@ export class QueryStore { constructor(private db: AbstractPowerSyncDatabase) {} - getQuery(key: string, query: WatchCompatibleQuery, options: AdditionalOptions) { + getQuery(key: string, query: WatchCompatibleQuery, options: DifferentialHookOptions) { if (this.cache.has(key)) { return this.cache.get(key) as WatchedQuery; } diff --git a/packages/react/src/hooks/suspense/SuspenseQueryResult.ts b/packages/react/src/hooks/suspense/SuspenseQueryResult.ts index adc9dc2c8..d8b0f7b7e 100644 --- a/packages/react/src/hooks/suspense/SuspenseQueryResult.ts +++ b/packages/react/src/hooks/suspense/SuspenseQueryResult.ts @@ -1,3 +1,4 @@ -import { QueryResult } from '../watched/watch-types'; +import { QueryResult, ReadonlyQueryResult } from '../watched/watch-types'; export type SuspenseQueryResult = Pick, 'data' | 'refresh'>; +export type ReadonlySuspenseQueryResult = Pick, 'data' | 'refresh'>; diff --git a/packages/react/src/hooks/suspense/useSuspenseQuery.ts b/packages/react/src/hooks/suspense/useSuspenseQuery.ts index f4ff95c5c..d5bf0a59c 100644 --- a/packages/react/src/hooks/suspense/useSuspenseQuery.ts +++ b/packages/react/src/hooks/suspense/useSuspenseQuery.ts @@ -1,6 +1,6 @@ import { CompilableQuery } from '@powersync/common'; -import { AdditionalOptions } from '../watched/watch-types'; -import { SuspenseQueryResult } from './SuspenseQueryResult'; +import { AdditionalOptions, DifferentialHookOptions } from '../watched/watch-types'; +import { ReadonlySuspenseQueryResult, SuspenseQueryResult } from './SuspenseQueryResult'; import { useSingleSuspenseQuery } from './useSingleSuspenseQuery'; import { useWatchedSuspenseQuery } from './useWatchedSuspenseQuery'; @@ -8,6 +8,8 @@ import { useWatchedSuspenseQuery } from './useWatchedSuspenseQuery'; * A hook to access the results of a watched query that suspends until the initial result has loaded. * @example * export const ContentComponent = () => { + * // The lists array here will be a new Array reference whenever a change to the + * // lists table is made. * const { data: lists } = useSuspenseQuery('SELECT * from lists'); * * return @@ -24,16 +26,51 @@ import { useWatchedSuspenseQuery } from './useWatchedSuspenseQuery'; * * ); * } + * + * export const DiffContentComponent = () => { + * // A differential query will emit results when a change to the result set occurs. + * // The internal array object references are maintained for unchanged rows. + * // The returned lists array is read only when a `differentiator` is provided. + * const { data: lists } = useSuspenseQuery('SELECT * from lists', [], { + * differentiator: { + * identify: (item) => item.id, + * compareBy: (item) => JSON.stringify(item) + * } + * }); + * return + * {lists.map((l) => ( + * {JSON.stringify(l)} + * ))} + * ; + * } + * + * export const DisplayComponent = () => { + * return ( + * Loading content...}> + * + * + * ); + * } */ -export const useSuspenseQuery = ( - query: string | CompilableQuery, +export function useSuspenseQuery( + query: string | CompilableQuery, + parameters?: any[], + options?: AdditionalOptions +): SuspenseQueryResult; +export function useSuspenseQuery( + query: string | CompilableQuery, + paramerers?: any[], + options?: DifferentialHookOptions +): ReadonlySuspenseQueryResult; +export function useSuspenseQuery( + query: string | CompilableQuery, parameters: any[] = [], - options: AdditionalOptions = {} -): SuspenseQueryResult => { - switch (options.runQueryOnce) { + options: AdditionalOptions & DifferentialHookOptions = {} +) { + switch (options?.runQueryOnce) { case true: - return useSingleSuspenseQuery(query, parameters, options); + return useSingleSuspenseQuery(query, parameters, options); default: - return useWatchedSuspenseQuery(query, parameters, options); + return useWatchedSuspenseQuery(query, parameters, options); } -}; +} diff --git a/packages/react/src/hooks/suspense/useWatchedSuspenseQuery.ts b/packages/react/src/hooks/suspense/useWatchedSuspenseQuery.ts index ab9179fc1..c5171a01d 100644 --- a/packages/react/src/hooks/suspense/useWatchedSuspenseQuery.ts +++ b/packages/react/src/hooks/suspense/useWatchedSuspenseQuery.ts @@ -3,14 +3,16 @@ import { generateQueryKey, getQueryStore } from '../../QueryStore'; import { usePowerSync } from '../PowerSyncContext'; import { AdditionalOptions } from '../watched/watch-types'; import { constructCompatibleQuery } from '../watched/watch-utils'; -import { SuspenseQueryResult } from './SuspenseQueryResult'; import { useWatchedQuerySuspenseSubscription } from './useWatchedQuerySuspenseSubscription'; +/** + * @internal This is not exported in the index.ts + */ export const useWatchedSuspenseQuery = ( query: string | CompilableQuery, parameters: any[] = [], options: AdditionalOptions = {} -): SuspenseQueryResult => { +) => { const powerSync = usePowerSync(); if (!powerSync) { throw new Error('PowerSync not configured.'); diff --git a/packages/react/src/hooks/watched/useQuery.ts b/packages/react/src/hooks/watched/useQuery.ts index e18c692f7..9ee2b8bb4 100644 --- a/packages/react/src/hooks/watched/useQuery.ts +++ b/packages/react/src/hooks/watched/useQuery.ts @@ -2,13 +2,16 @@ import { type CompilableQuery } from '@powersync/common'; import { usePowerSync } from '../PowerSyncContext'; import { useSingleQuery } from './useSingleQuery'; import { useWatchedQuery } from './useWatchedQuery'; -import { AdditionalOptions, QueryResult } from './watch-types'; +import { AdditionalOptions, DifferentialHookOptions, QueryResult, ReadonlyQueryResult } from './watch-types'; import { constructCompatibleQuery } from './watch-utils'; /** * A hook to access the results of a watched query. * @example + * * export const Component = () => { + * // The lists array here will be a new Array reference whenever a change to the + * // lists table is made. * const { data: lists } = useQuery('SELECT * from lists'); * * return @@ -17,20 +20,48 @@ import { constructCompatibleQuery } from './watch-utils'; * ))} * * } + * + * export const DiffComponent = () => { + * // A differential query will emit results when a change to the result set occurs. + * // The internal array object references are maintained for unchanged rows. + * // The returned lists array is read only when a `differentiator` is provided. + * const { data: lists } = useQuery('SELECT * from lists', [], { + * differentiator: { + * identify: (item) => item.id, + * compareBy: (item) => JSON.stringify(item) + * } + * }); + * + * return + * {lists.map((l) => ( + * {JSON.stringify(l)} + * ))} + * + * } */ -export const useQuery = ( + +export function useQuery( + query: string | CompilableQuery, + parameters?: any[], + options?: AdditionalOptions +): QueryResult; +export function useQuery( + query: string | CompilableQuery, + paramerers?: any[], + options?: DifferentialHookOptions +): ReadonlyQueryResult; +export function useQuery( query: string | CompilableQuery, parameters: any[] = [], - options: AdditionalOptions = { runQueryOnce: false } -): QueryResult => { + options: AdditionalOptions & DifferentialHookOptions = {} +) { const powerSync = usePowerSync(); if (!powerSync) { return { isLoading: false, isFetching: false, data: [], error: new Error('PowerSync not configured.') }; } - const { parsedQuery, queryChanged } = constructCompatibleQuery(query, parameters, options); - switch (options.runQueryOnce) { + switch (options?.runQueryOnce) { case true: return useSingleQuery({ query: parsedQuery, @@ -51,4 +82,4 @@ export const useQuery = ( } }); } -}; +} diff --git a/packages/react/src/hooks/watched/useWatchedQuery.ts b/packages/react/src/hooks/watched/useWatchedQuery.ts index d731b62e1..dfe24a17e 100644 --- a/packages/react/src/hooks/watched/useWatchedQuery.ts +++ b/packages/react/src/hooks/watched/useWatchedQuery.ts @@ -1,11 +1,19 @@ import React from 'react'; import { useWatchedQuerySubscription } from './useWatchedQuerySubscription'; -import { HookWatchOptions, QueryResult } from './watch-types'; +import { DifferentialHookOptions, QueryResult, ReadonlyQueryResult } from './watch-types'; import { InternalHookOptions } from './watch-utils'; +/** + * @internal This is not exported from the index.ts + * + * When a differential query is used the return type is readonly. This is required + * since the implementation requires a stable ref. + * For legacy compatibility we allow mutating when a standard query is used. Mutations should + * not affect the internal implementation in this case. + */ export const useWatchedQuery = ( - options: InternalHookOptions & { options: HookWatchOptions } -): QueryResult => { + options: InternalHookOptions & { options: DifferentialHookOptions } +): QueryResult | ReadonlyQueryResult => { const { query, powerSync, queryChanged, options: hookOptions } = options; const createWatchedQuery = React.useCallback(() => { diff --git a/packages/react/src/hooks/watched/watch-types.ts b/packages/react/src/hooks/watched/watch-types.ts index f0eb0561c..f03555fbd 100644 --- a/packages/react/src/hooks/watched/watch-types.ts +++ b/packages/react/src/hooks/watched/watch-types.ts @@ -1,15 +1,18 @@ import { SQLOnChangeOptions, WatchedQueryDifferentiator } from '@powersync/common'; -export interface HookWatchOptions extends Omit { +export interface HookWatchOptions extends Omit { reportFetching?: boolean; - differentiator?: WatchedQueryDifferentiator; } -export interface AdditionalOptions extends HookWatchOptions { +export interface AdditionalOptions extends HookWatchOptions { runQueryOnce?: boolean; } -export type QueryResult = { +export interface DifferentialHookOptions extends HookWatchOptions { + differentiator?: WatchedQueryDifferentiator; +} + +export type ReadonlyQueryResult = { readonly data: ReadonlyArray>; /** * Indicates the initial loading state (hard loading). Loading becomes false once the first set of results from the watched query is available or an error occurs. @@ -25,3 +28,20 @@ export type QueryResult = { */ refresh?: (signal?: AbortSignal) => Promise; }; + +export type QueryResult = { + data: RowType[]; + /** + * Indicates the initial loading state (hard loading). Loading becomes false once the first set of results from the watched query is available or an error occurs. + */ + isLoading: boolean; + /** + * Indicates whether the query is currently fetching data, is true during the initial load and any time when the query is re-evaluating (useful for large queries). + */ + isFetching: boolean; + error: Error | undefined; + /** + * Function used to run the query again. + */ + refresh?: (signal?: AbortSignal) => Promise; +}; From be9cc12e914fc4a78a73bf72e4e301b2d7bf676c Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Wed, 2 Jul 2025 17:31:39 +0200 Subject: [PATCH 75/75] cleanup console logs --- packages/react/src/hooks/suspense/useSingleSuspenseQuery.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/react/src/hooks/suspense/useSingleSuspenseQuery.ts b/packages/react/src/hooks/suspense/useSingleSuspenseQuery.ts index 444f94fa7..10adf3e4e 100644 --- a/packages/react/src/hooks/suspense/useSingleSuspenseQuery.ts +++ b/packages/react/src/hooks/suspense/useSingleSuspenseQuery.ts @@ -62,7 +62,6 @@ export const useSingleSuspenseQuery = ( data: data ?? watchedQuery?.state.data ?? [], refresh: async (signal) => { try { - console.log('calling refresh for single query', key); const compiledQuery = parsedQuery.compile(); const result = await parsedQuery.execute({ sql: compiledQuery.sql, @@ -72,7 +71,6 @@ export const useSingleSuspenseQuery = ( if (signal.aborted) { return; // Abort if the signal is already aborted } - console.log('done with query refresh'); setData(result); setError(null); } catch (e) {