Skip to content

Commit 81d68e3

Browse files
maintain backwards compatibility
1 parent 40b849c commit 81d68e3

File tree

9 files changed

+152
-35
lines changed

9 files changed

+152
-35
lines changed

packages/common/src/client/AbstractPowerSyncDatabase.ts

Lines changed: 26 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
} from './sync/stream/AbstractStreamingSyncImplementation.js';
3535
import { WatchedQuery, WatchedQueryOptions } from './watched/WatchedQuery.js';
3636
import { OnChangeQueryProcessor, WatchedQueryComparator } from './watched/processors/OnChangeQueryProcessor.js';
37+
import { FalsyComparator } from './watched/processors/comparators.js';
3738

3839
export interface DisconnectAndClearOptions {
3940
/** When set to false, data in local-only tables is preserved. */
@@ -71,6 +72,18 @@ export interface PowerSyncDatabaseOptionsWithSettings extends BasePowerSyncDatab
7172
database: SQLOpenOptions;
7273
}
7374

75+
export interface WatchComparatorOptions<DataType> {
76+
mode: 'comparison';
77+
comparator?: WatchedQueryComparator<DataType>;
78+
}
79+
80+
export type WatchProcessorOptions<DataType> = WatchComparatorOptions<DataType>;
81+
82+
export interface IncrementalWatchOptions<DataType> {
83+
watch: WatchedQueryOptions<DataType>;
84+
processor?: WatchProcessorOptions<DataType>;
85+
}
86+
7487
export interface SQLWatchOptions {
7588
signal?: AbortSignal;
7689
tables?: string[];
@@ -92,7 +105,7 @@ export interface SQLWatchOptions {
92105
* Optional comparator which will be used to compare the results of the query.
93106
* The watched query will only yield results if the comparator returns false.
94107
*/
95-
comparator?: WatchedQueryComparator<QueryResult>;
108+
processor?: WatchProcessorOptions<QueryResult>;
96109
}
97110

98111
export interface WatchOnChangeEvent {
@@ -109,16 +122,6 @@ export interface WatchOnChangeHandler {
109122
onError?: (error: Error) => void;
110123
}
111124

112-
export interface ComparatorWatchOptions<DataType> {
113-
mode: 'comparison';
114-
comparator?: WatchedQueryComparator<DataType>;
115-
}
116-
117-
export interface IncrementalWatchOptions<DataType> {
118-
watch: WatchedQueryOptions<DataType>;
119-
processor?: ComparatorWatchOptions<DataType>;
120-
}
121-
122125
export interface PowerSyncDBListener extends StreamingSyncImplementationListener {
123126
initialized: () => void;
124127
schemaChanged: (schema: Schema) => void;
@@ -916,25 +919,27 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver<PowerSyncDB
916919
throw new Error('onResult is required');
917920
}
918921

919-
const watch = new OnChangeQueryProcessor({
920-
db: this,
921-
// Comparisons are disabled if no comparator is provided
922-
comparator: options?.comparator,
923-
watchOptions: {
924-
placeholderData: null,
922+
// Uses shared incremental watch logic under the hook, but maintains the same external API as the old watch method.
923+
const watchedQuery = this.incrementalWatch({
924+
watch: {
925925
query: {
926926
compile: () => ({
927927
sql: sql,
928928
parameters: parameters ?? []
929929
}),
930930
execute: () => this.executeReadOnly(sql, parameters)
931931
},
932-
throttleMs: options?.throttleMs ?? DEFAULT_WATCH_THROTTLE_MS,
933-
reportFetching: false
932+
placeholderData: null,
933+
reportFetching: false,
934+
throttleMs: options?.throttleMs ?? DEFAULT_WATCH_THROTTLE_MS
935+
},
936+
processor: options?.processor ?? {
937+
mode: 'comparison',
938+
comparator: FalsyComparator
934939
}
935940
});
936941

937-
const dispose = watch.subscribe({
942+
const dispose = watchedQuery.subscribe({
938943
onData: (data) => {
939944
if (!data) {
940945
// This should not happen. We only use null for the initial data.
@@ -949,7 +954,7 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver<PowerSyncDB
949954

950955
options?.signal?.addEventListener('abort', () => {
951956
dispose();
952-
watch.close();
957+
watchedQuery.close();
953958
});
954959
}
955960

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { WatchedQueryComparator } from './OnChangeQueryProcessor.js';
2+
3+
export type ArrayComparatorOptions<ItemType> = {
4+
compareBy: (item: ItemType) => string;
5+
};
6+
7+
/**
8+
* Compares array results of watched queries.
9+
*/
10+
export class ArrayComparator<ItemType> implements WatchedQueryComparator<ItemType[]> {
11+
constructor(protected options: ArrayComparatorOptions<ItemType>) {}
12+
13+
checkEquality(current: ItemType[], previous: ItemType[]) {
14+
if (current.length === 0 && previous.length === 0) {
15+
return true;
16+
}
17+
18+
if (current.length !== previous.length) {
19+
return false;
20+
}
21+
22+
const { compareBy } = this.options;
23+
24+
// At this point the lengths are equal
25+
for (let i = 0; i < current.length; i++) {
26+
const currentItem = compareBy(current[i]);
27+
const previousItem = compareBy(previous[i]);
28+
29+
if (currentItem !== previousItem) {
30+
return false;
31+
}
32+
}
33+
34+
return true;
35+
}
36+
}
37+
38+
/**
39+
* Watched query comparator that always reports changed result sets.
40+
*/
41+
export const FalsyComparator: WatchedQueryComparator<unknown> = {
42+
checkEquality: () => false // Default comparator that always returns false
43+
};

packages/common/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ export * from './db/schema/Schema.js';
3030
export * from './db/schema/Table.js';
3131
export * from './db/schema/TableV2.js';
3232

33-
// TODO other exports
33+
export * from './client/watched/processors/AbstractQueryProcessor.js';
34+
export * from './client/watched/processors/comparators.js';
3435
export * from './client/watched/WatchedQuery.js';
3536

3637
export * from './utils/AbortOperation.js';

packages/react/src/QueryStore.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ export class QueryStore {
2424
query,
2525
placeholderData: [],
2626
throttleMs: options.throttleMs
27-
}
27+
},
28+
processor: options.processor
2829
});
2930

3031
const disposer = watchedQuery.registerListener({

packages/react/src/hooks/suspense/useSuspenseQuery.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { CompilableQuery } from '@powersync/common';
1+
import { CompilableQuery, FalsyComparator } from '@powersync/common';
22
import { AdditionalOptions } from '../watched/watch-types';
33
import { SuspenseQueryResult } from './SuspenseQueryResult';
44
import { useSingleSuspenseQuery } from './useSingleSuspenseQuery';
@@ -28,12 +28,18 @@ import { useWatchedSuspenseQuery } from './useWatchedSuspenseQuery';
2828
export const useSuspenseQuery = <T = any>(
2929
query: string | CompilableQuery<T>,
3030
parameters: any[] = [],
31-
options: AdditionalOptions = {}
31+
options: AdditionalOptions<T> = {}
3232
): SuspenseQueryResult<T> => {
3333
switch (options.runQueryOnce) {
3434
case true:
3535
return useSingleSuspenseQuery<T>(query, parameters, options);
3636
default:
37-
return useWatchedSuspenseQuery<T>(query, parameters, options);
37+
return useWatchedSuspenseQuery<T>(query, parameters, {
38+
...options,
39+
processor: options.processor ?? {
40+
mode: 'comparison',
41+
comparator: FalsyComparator // Default comparator that always reports changed result sets
42+
}
43+
});
3844
}
3945
};

packages/react/src/hooks/watched/useQuery.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { type CompilableQuery } from '@powersync/common';
1+
import { FalsyComparator, type CompilableQuery } from '@powersync/common';
22
import { usePowerSync } from '../PowerSyncContext';
33
import { useSingleQuery } from './useSingleQuery';
44
import { useWatchedQuery } from './useWatchedQuery';
@@ -21,7 +21,7 @@ import { constructCompatibleQuery } from './watch-utils';
2121
export const useQuery = <RowType = any>(
2222
query: string | CompilableQuery<RowType>,
2323
parameters: any[] = [],
24-
options: AdditionalOptions = { runQueryOnce: false }
24+
options: AdditionalOptions<RowType> = { runQueryOnce: false }
2525
): QueryResult<RowType> => {
2626
const powerSync = usePowerSync();
2727
if (!powerSync) {
@@ -42,7 +42,16 @@ export const useQuery = <RowType = any>(
4242
query: parsedQuery,
4343
powerSync,
4444
queryChanged,
45-
options
45+
options: {
46+
...options,
47+
processor: options.processor ?? {
48+
// Maintains backwards compatibility with previous versions
49+
// Comparisons are opt-in by default
50+
// We emit new data for each table change by default.
51+
mode: 'comparison',
52+
comparator: FalsyComparator
53+
}
54+
}
4655
});
4756
}
4857
};

packages/react/src/hooks/watched/useWatchedQuery.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ export const useWatchedQuery = <RowType = unknown>(
1515
query,
1616
throttleMs: hookOptions.throttleMs,
1717
reportFetching: hookOptions.reportFetching
18-
}
18+
},
19+
processor: hookOptions.processor
1920
});
2021
}, []);
2122

packages/react/src/hooks/watched/watch-types.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import { type SQLWatchOptions } from '@powersync/common';
1+
import { WatchProcessorOptions, type SQLWatchOptions } from '@powersync/common';
22

3-
export interface HookWatchOptions extends Omit<SQLWatchOptions, 'signal'> {
3+
export interface HookWatchOptions<RowType = unknown> extends Omit<SQLWatchOptions, 'signal' | 'processor'> {
44
reportFetching?: boolean;
5+
processor?: WatchProcessorOptions<RowType[]>;
56
}
67

7-
export interface AdditionalOptions extends HookWatchOptions {
8+
export interface AdditionalOptions<RowType = unknown> extends HookWatchOptions<RowType> {
89
runQueryOnce?: boolean;
910
}
1011

packages/react/tests/useQuery.test.tsx

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import React from 'react';
21
import * as commonSdk from '@powersync/common';
32
import { PowerSyncDatabase } from '@powersync/web';
43
import { act, cleanup, renderHook, waitFor } from '@testing-library/react';
@@ -195,7 +194,18 @@ describe('useQuery', () => {
195194
it('should emit result data when query changes', async () => {
196195
const db = openPowerSync();
197196
const wrapper = ({ children }) => <PowerSyncContext.Provider value={db}>{children}</PowerSyncContext.Provider>;
198-
const { result } = renderHook(() => useQuery('SELECT * FROM lists WHERE name = ?', ['aname']), { wrapper });
197+
const { result } = renderHook(
198+
() =>
199+
useQuery('SELECT * FROM lists WHERE name = ?', ['aname'], {
200+
processor: {
201+
mode: 'comparison',
202+
comparator: new commonSdk.ArrayComparator({
203+
compareBy: (item) => JSON.stringify(item)
204+
})
205+
}
206+
}),
207+
{ wrapper }
208+
);
199209

200210
expect(result.current.isLoading).toEqual(true);
201211

@@ -256,6 +266,46 @@ describe('useQuery', () => {
256266
expect(data == result.current.data).toEqual(true);
257267
});
258268

269+
// Verifies backwards compatibility with the previous implementation (no comparison)
270+
it('should emit result data when data changes when not using comparator', async () => {
271+
const db = openPowerSync();
272+
const wrapper = ({ children }) => <PowerSyncContext.Provider value={db}>{children}</PowerSyncContext.Provider>;
273+
const { result } = renderHook(() => useQuery('SELECT * FROM lists WHERE name = ?', ['aname']), { wrapper });
274+
275+
expect(result.current.isLoading).toEqual(true);
276+
277+
await waitFor(
278+
async () => {
279+
const { current } = result;
280+
expect(current.isLoading).toEqual(false);
281+
},
282+
{ timeout: 500, interval: 100 }
283+
);
284+
285+
// This should trigger an update
286+
await db.execute('INSERT INTO lists(id, name) VALUES (uuid(), ?)', ['aname']);
287+
288+
// Keep track of the previous data reference
289+
let previousData = result.current.data;
290+
await waitFor(
291+
async () => {
292+
const { current } = result;
293+
expect(current.data.length).toEqual(1);
294+
previousData = current.data;
295+
},
296+
{ timeout: 500, interval: 100 }
297+
);
298+
299+
// This should still trigger an update since the underlaying tables changed.
300+
await db.execute('INSERT INTO lists(id, name) VALUES (uuid(), ?)', ['noname']);
301+
302+
// It's difficult to assert no update happened, but we can wait a bit
303+
await new Promise((resolve) => setTimeout(resolve, 1000));
304+
305+
// It should be the same data array reference, no update should have happened
306+
expect(result.current.data == previousData).false;
307+
});
308+
259309
it('should use an existing WatchedQuery instance', async () => {
260310
const db = openPowerSync();
261311

0 commit comments

Comments
 (0)