Skip to content

Commit c7d2b53

Browse files
Incremental Watched Queries (#614)
1 parent 6b38551 commit c7d2b53

File tree

90 files changed

+6295
-1799
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

90 files changed

+6295
-1799
lines changed

.changeset/little-bananas-fetch.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@powersync/common': minor
3+
---
4+
5+
- Added additional listeners for `closing` and `closed` events in `AbstractPowerSyncDatabase`.
6+
- Added `query` and `customQuery` APIs for enhanced watched queries.
7+
- Added `triggerImmediate` option to the `onChange` API. This allows emitting an initial event which can be useful for downstream use cases.

.changeset/nine-pens-ring.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
'@powersync/vue': minor
3+
---
4+
5+
[Potentially breaking change] The `useQuery` hook results are now explicitly defined as readonly. These values should not be mutated.
6+
7+
- Added the ability to limit re-renders by specifying a `rowComparator` for query results. The `useQuery` hook will only emit `data` changes when the data has changed.
8+
9+
```javascript
10+
// The data here will maintain previous object references for unchanged items.
11+
const { data } = useQuery('SELECT * FROM lists WHERE name = ?', ['aname'], {
12+
rowComparator: {
13+
keyBy: (item) => item.id,
14+
compareBy: (item) => JSON.stringify(item)
15+
}
16+
});
17+
```
18+
19+
- Added the ability to subscribe to an existing instance of a `WatchedQuery`
20+
21+
```vue
22+
<script setup>
23+
import { useWatchedQuerySubscription } from '@powersync/vue';
24+
25+
const listsQuery = powerSync
26+
.query({
27+
sql: `SELECT * FROM lists`
28+
})
29+
.differentialWatch();
30+
31+
const { data, isLoading, isFetching, error } = useWatchedQuerySubscription(listsQuery);
32+
</script>
33+
34+
<template>
35+
<div v-if="isLoading">Loading...</div>
36+
<div v-else-if="isFetching">Updating results...</div>
37+
38+
<div v-if="error">{{ error }}</div>
39+
<ul v-else>
40+
<li v-for="l in data" :key="l.id">{{ l.name }}</li>
41+
</ul>
42+
</template>
43+
```

.changeset/plenty-rice-protect.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@powersync/react': minor
3+
'@powersync/vue': minor
4+
---
5+
6+
- [Internal] Updated implementation to use shared `WatchedQuery` implementation.

.changeset/stale-dots-jog.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@powersync/web': minor
3+
---
4+
5+
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.

.changeset/swift-guests-explain.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
'@powersync/react': minor
3+
---
4+
5+
- Added the ability to limit re-renders by specifying a `rowComparator` for query results. The `useQuery` hook will only emit `data` changes when the data has changed.
6+
7+
```javascript
8+
// The data here will maintain previous object references for unchanged items.
9+
const { data } = useQuery('SELECT * FROM lists WHERE name = ?', ['aname'], {
10+
rowComparator: {
11+
keyBy: (item) => item.id,
12+
compareBy: (item) => JSON.stringify(item)
13+
}
14+
});
15+
```
16+
17+
- Added the ability to subscribe to an existing instance of a `WatchedQuery`
18+
19+
```jsx
20+
import { useWatchedQuerySubscription } from '@powersync/react';
21+
22+
const listsQuery = powerSync
23+
.query({
24+
sql: `SELECT * FROM lists`
25+
})
26+
.differentialWatch();
27+
28+
export const ListsWidget = (props) => {
29+
const { data: lists } = useWatchedQuerySubscription(listsQuery);
30+
31+
return (
32+
<div>
33+
{lists.map((list) => (
34+
<div key={list.id}>{list.name}</div>
35+
))}
36+
</div>
37+
);
38+
};
39+
```

.prettierrc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,6 @@
55
"jsxBracketSameLine": true,
66
"useTabs": false,
77
"printWidth": 120,
8-
"trailingComma": "none"
8+
"trailingComma": "none",
9+
"plugins": ["prettier-plugin-embed", "prettier-plugin-sql"]
910
}

demos/react-supabase-todolist/src/app/views/sql-console/page.tsx

Lines changed: 56 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,73 @@
1-
import React from 'react';
2-
import { useQuery } from '@powersync/react';
3-
import { Box, Button, Grid, TextField, styled } from '@mui/material';
4-
import { DataGrid } from '@mui/x-data-grid';
51
import { NavigationPage } from '@/components/navigation/NavigationPage';
2+
import { Alert, Box, Button, Grid, TextField, styled } from '@mui/material';
3+
import { DataGrid } from '@mui/x-data-grid';
4+
import { useQuery } from '@powersync/react';
5+
import React from 'react';
66

77
export type LoginFormParams = {
88
email: string;
99
password: string;
1010
};
1111

12-
const DEFAULT_QUERY = 'SELECT * FROM lists';
13-
14-
export default function SQLConsolePage() {
15-
const inputRef = React.useRef<HTMLInputElement>();
16-
const [query, setQuery] = React.useState(DEFAULT_QUERY);
17-
const { data: querySQLResult } = useQuery(query);
12+
const DEFAULT_QUERY = /* sql */ `
13+
SELECT
14+
*
15+
FROM
16+
lists
17+
`;
1818

19+
const TableDisplay = React.memo(({ data }: { data: ReadonlyArray<any> }) => {
1920
const queryDataGridResult = React.useMemo(() => {
20-
const firstItem = querySQLResult?.[0];
21-
21+
const firstItem = data?.[0];
2222
return {
2323
columns: firstItem
2424
? Object.keys(firstItem).map((field) => ({
2525
field,
2626
flex: 1
2727
}))
2828
: [],
29-
rows: querySQLResult
29+
rows: data
3030
};
31-
}, [querySQLResult]);
31+
}, [data]);
32+
33+
return (
34+
<S.QueryResultContainer>
35+
<DataGrid
36+
autoHeight={true}
37+
rows={queryDataGridResult.rows.map((r, index) => ({ ...r, id: r.id ?? index })) ?? []}
38+
columns={queryDataGridResult.columns}
39+
initialState={{
40+
pagination: {
41+
paginationModel: {
42+
pageSize: 20
43+
}
44+
}
45+
}}
46+
pageSizeOptions={[20]}
47+
disableRowSelectionOnClick
48+
/>
49+
</S.QueryResultContainer>
50+
);
51+
});
52+
53+
export default function SQLConsolePage() {
54+
const inputRef = React.useRef<HTMLInputElement>();
55+
const [query, setQuery] = React.useState(DEFAULT_QUERY);
56+
57+
const { data, error } = useQuery(query, [], {
58+
/**
59+
* We don't use the isFetching status here, we can avoid re-renders if we don't report on it.
60+
*/
61+
reportFetching: false,
62+
/**
63+
* The query here will only emit results when the query data set changes.
64+
* Result sets are compared by serializing each item to JSON and comparing the strings.
65+
*/
66+
rowComparator: {
67+
keyBy: (item: any) => JSON.stringify(item),
68+
compareBy: (item: any) => JSON.stringify(item)
69+
}
70+
});
3271

3372
return (
3473
<NavigationPage title="SQL Console">
@@ -57,33 +96,13 @@ export default function SQLConsolePage() {
5796
if (queryInput) {
5897
setQuery(queryInput);
5998
}
60-
}}
61-
>
99+
}}>
62100
Execute Query
63101
</Button>
64102
</S.CenteredGrid>
65103
</S.CenteredGrid>
66-
67-
{queryDataGridResult ? (
68-
<S.QueryResultContainer>
69-
{queryDataGridResult.columns ? (
70-
<DataGrid
71-
autoHeight={true}
72-
rows={queryDataGridResult.rows?.map((r, index) => ({ ...r, id: r.id ?? index })) ?? []}
73-
columns={queryDataGridResult.columns}
74-
initialState={{
75-
pagination: {
76-
paginationModel: {
77-
pageSize: 20
78-
}
79-
}
80-
}}
81-
pageSizeOptions={[20]}
82-
disableRowSelectionOnClick
83-
/>
84-
) : null}
85-
</S.QueryResultContainer>
86-
) : null}
104+
{error ? <Alert severity="error">{error.message}</Alert> : null}
105+
<TableDisplay data={data} />
87106
</S.MainContainer>
88107
</NavigationPage>
89108
);

demos/react-supabase-todolist/src/app/views/todo-lists/edit/page.tsx

Lines changed: 35 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import { usePowerSync, useQuery } from '@powersync/react';
1+
import { NavigationPage } from '@/components/navigation/NavigationPage';
2+
import { useSupabase } from '@/components/providers/SystemProvider';
3+
import { TodoItemWidget } from '@/components/widgets/TodoItemWidget';
4+
import { LISTS_TABLE, TODOS_TABLE, TodoRecord } from '@/library/powersync/AppSchema';
25
import AddIcon from '@mui/icons-material/Add';
36
import {
47
Box,
@@ -15,12 +18,9 @@ import {
1518
styled
1619
} from '@mui/material';
1720
import Fab from '@mui/material/Fab';
21+
import { usePowerSync, useQuery } from '@powersync/react';
1822
import React, { Suspense } from 'react';
1923
import { useParams } from 'react-router-dom';
20-
import { useSupabase } from '@/components/providers/SystemProvider';
21-
import { LISTS_TABLE, TODOS_TABLE, TodoRecord } from '@/library/powersync/AppSchema';
22-
import { NavigationPage } from '@/components/navigation/NavigationPage';
23-
import { TodoItemWidget } from '@/components/widgets/TodoItemWidget';
2424

2525
/**
2626
* useSearchParams causes the entire element to fall back to client side rendering
@@ -34,61 +34,53 @@ const TodoEditSection = () => {
3434

3535
const {
3636
data: [listRecord]
37-
} = useQuery<{ name: string }>(`SELECT name FROM ${LISTS_TABLE} WHERE id = ?`, [listID]);
37+
} = useQuery<{ name: string }>(
38+
/* sql */ `
39+
SELECT
40+
name
41+
FROM
42+
${LISTS_TABLE}
43+
WHERE
44+
id = ?
45+
`,
46+
[listID]
47+
);
3848

3949
const { data: todos } = useQuery<TodoRecord>(
40-
`SELECT * FROM ${TODOS_TABLE} WHERE list_id=? ORDER BY created_at DESC, id`,
50+
/* sql */ `
51+
SELECT
52+
*
53+
FROM
54+
${TODOS_TABLE}
55+
WHERE
56+
list_id = ?
57+
ORDER BY
58+
created_at DESC,
59+
id
60+
`,
4161
[listID]
4262
);
4363

4464
const [showPrompt, setShowPrompt] = React.useState(false);
4565
const nameInputRef = React.createRef<HTMLInputElement>();
4666

47-
const toggleCompletion = async (record: TodoRecord, completed: boolean) => {
48-
const updatedRecord = { ...record, completed: completed };
49-
if (completed) {
50-
const userID = supabase?.currentSession?.user.id;
51-
if (!userID) {
52-
throw new Error(`Could not get user ID.`);
53-
}
54-
updatedRecord.completed_at = new Date().toISOString();
55-
updatedRecord.completed_by = userID;
56-
} else {
57-
updatedRecord.completed_at = null;
58-
updatedRecord.completed_by = null;
59-
}
60-
await powerSync.execute(
61-
`UPDATE ${TODOS_TABLE}
62-
SET completed = ?,
63-
completed_at = ?,
64-
completed_by = ?
65-
WHERE id = ?`,
66-
[completed, updatedRecord.completed_at, updatedRecord.completed_by, record.id]
67-
);
68-
};
69-
7067
const createNewTodo = async (description: string) => {
7168
const userID = supabase?.currentSession?.user.id;
7269
if (!userID) {
7370
throw new Error(`Could not get user ID.`);
7471
}
7572

7673
await powerSync.execute(
77-
`INSERT INTO
78-
${TODOS_TABLE}
79-
(id, created_at, created_by, description, list_id)
80-
VALUES
81-
(uuid(), datetime(), ?, ?, ?)`,
74+
/* sql */ `
75+
INSERT INTO
76+
${TODOS_TABLE} (id, created_at, created_by, description, list_id)
77+
VALUES
78+
(uuid (), datetime (), ?, ?, ?)
79+
`,
8280
[userID, description, listID!]
8381
);
8482
};
8583

86-
const deleteTodo = async (id: string) => {
87-
await powerSync.writeTransaction(async (tx) => {
88-
await tx.execute(`DELETE FROM ${TODOS_TABLE} WHERE id = ?`, [id]);
89-
});
90-
};
91-
9284
if (!listRecord) {
9385
return (
9486
<Box>
@@ -106,13 +98,7 @@ const TodoEditSection = () => {
10698
<Box>
10799
<List dense={false}>
108100
{todos.map((r) => (
109-
<TodoItemWidget
110-
key={r.id}
111-
description={r.description}
112-
onDelete={() => deleteTodo(r.id)}
113-
isComplete={r.completed == 1}
114-
toggleCompletion={() => toggleCompletion(r, !r.completed)}
115-
/>
101+
<TodoItemWidget key={r.id} id={r.id} description={r.description} isComplete={r.completed == 1} />
116102
))}
117103
</List>
118104
</Box>
@@ -129,8 +115,7 @@ const TodoEditSection = () => {
129115
await createNewTodo(nameInputRef.current!.value);
130116
setShowPrompt(false);
131117
}
132-
}}
133-
>
118+
}}>
134119
<DialogTitle id="alert-dialog-title">{'Create Todo Item'}</DialogTitle>
135120
<DialogContent>
136121
<DialogContentText id="alert-dialog-description">Enter a description for a new todo item</DialogContentText>

0 commit comments

Comments
 (0)