diff --git a/.changeset/little-bananas-fetch.md b/.changeset/little-bananas-fetch.md new file mode 100644 index 000000000..5cfc87b22 --- /dev/null +++ b/.changeset/little-bananas-fetch.md @@ -0,0 +1,7 @@ +--- +'@powersync/common': minor +--- + +- Added additional listeners for `closing` and `closed` events in `AbstractPowerSyncDatabase`. +- 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/.changeset/nine-pens-ring.md b/.changeset/nine-pens-ring.md new file mode 100644 index 000000000..c77317319 --- /dev/null +++ b/.changeset/nine-pens-ring.md @@ -0,0 +1,17 @@ +--- +'@powersync/vue': minor +--- + +[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 +// 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/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/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/.changeset/swift-guests-explain.md b/.changeset/swift-guests-explain.md new file mode 100644 index 000000000..90bb0f166 --- /dev/null +++ b/.changeset/swift-guests-explain.md @@ -0,0 +1,15 @@ +--- +'@powersync/react': minor +--- + +- 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 +// 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/.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 6b0a13e1d..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 @@ -1,24 +1,25 @@ -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 { ArrayComparator } from '@powersync/web'; +import React from 'react'; export type LoginFormParams = { email: string; password: string; }; -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 DEFAULT_QUERY = /* sql */ ` + SELECT + * + FROM + lists +`; +const TableDisplay = React.memo(({ data }: { data: any[] }) => { const queryDataGridResult = React.useMemo(() => { - const firstItem = querySQLResult?.[0]; - + const firstItem = data?.[0]; return { columns: firstItem ? Object.keys(firstItem).map((field) => ({ @@ -26,9 +27,47 @@ 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, [], { + /** + * 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. + */ + comparator: new ArrayComparator({ + compareBy: (item) => JSON.stringify(item) + }) + }); return ( @@ -57,33 +96,12 @@ export default function SQLConsolePage() { if (queryInput) { setQuery(queryInput); } - }} - > + }}> Execute Query - - {queryDataGridResult ? ( - - {queryDataGridResult.columns ? ( - ({ ...r, id: r.id ?? index })) ?? []} - columns={queryDataGridResult.columns} - initialState={{ - pagination: { - paginationModel: { - pageSize: 20 - } - } - }} - pageSizeOptions={[20]} - disableRowSelectionOnClick - /> - ) : null} - - ) : null} + ); 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/providers/SystemProvider.tsx b/demos/react-supabase-todolist/src/components/providers/SystemProvider.tsx index 9bbbb4f11..8a3f3c209 100644 --- a/demos/react-supabase-todolist/src/components/providers/SystemProvider.tsx +++ b/demos/react-supabase-todolist/src/components/providers/SystemProvider.tsx @@ -1,9 +1,9 @@ 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 { createBaseLogger, DifferentialWatchedQuery, LogLevel, PowerSyncDatabase } from '@powersync/web'; import React, { Suspense } from 'react'; import { NavigationPanelContextProvider } from '../navigation/NavigationPanelContext'; @@ -17,10 +17,46 @@ export const db = new PowerSyncDatabase({ } }); +export type EnhancedListRecord = ListRecord & { total_tasks: number; completed_tasks: number }; + +export type QueryStore = { + lists: DifferentialWatchedQuery; +}; + +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 + .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 + }; + }); + React.useEffect(() => { const logger = createBaseLogger(); logger.useDefaults(); // eslint-disable-line @@ -30,7 +66,7 @@ export const SystemProvider = ({ children }: { children: React.ReactNode }) => { powerSync.init(); const l = connector.registerListener({ - initialized: () => { }, + initialized: () => {}, sessionStarted: () => { powerSync.connect(connector); } @@ -47,11 +83,13 @@ export const SystemProvider = ({ children }: { children: React.ReactNode }) => { return ( }> - - - {children} - - + + + + {children} + + + ); }; diff --git a/demos/react-supabase-todolist/src/components/widgets/ListItemWidget.tsx b/demos/react-supabase-todolist/src/components/widgets/ListItemWidget.tsx index bdb68b82d..e2ac183ef 100644 --- a/demos/react-supabase-todolist/src/components/widgets/ListItemWidget.tsx +++ b/demos/react-supabase-todolist/src/components/widgets/ListItemWidget.tsx @@ -1,73 +1,90 @@ -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 { 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) => { +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..d2f12b9fa 100644 --- a/demos/react-supabase-todolist/src/components/widgets/TodoListsWidget.tsx +++ b/demos/react-supabase-todolist/src/components/widgets/TodoListsWidget.tsx @@ -1,9 +1,7 @@ -import { usePowerSync, useQuery } from '@powersync/react'; import { List } from '@mui/material'; -import { useNavigate } from 'react-router-dom'; +import { useWatchedQuerySubscription } from '@powersync/react'; +import { useQueryStore } from '../providers/SystemProvider'; 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,30 +12,10 @@ const description = (total: number, completed: number = 0) => { }; export function TodoListsWidget(props: TodoListsWidgetProps) { - const powerSync = usePowerSync(); - const navigate = useNavigate(); + const queries = useQueryStore(); + const { data: listRecords, isLoading } = useWatchedQuerySubscription(queries!.lists); - const { data: listRecords, isLoading } = useQuery(` - 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 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]); - }); - }; - - if (isLoading) { + if (isLoading && listRecords.length == 0) { return
Loading...
; } @@ -46,13 +24,10 @@ export function TodoListsWidget(props: TodoListsWidgetProps) { {listRecords.map((r) => ( deleteList(r.id)} - onPress={() => { - navigate(TODO_LISTS_ROUTE + '/' + r.id); - }} /> ))} 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/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/README.md b/demos/yjs-react-supabase-text-collab/README.md index 7d6e916b7..34810d4ae 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.local \ +--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 @@ -108,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 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/powersync.yaml b/demos/yjs-react-supabase-text-collab/powersync.yaml new file mode 100644 index 000000000..bedd899f4 --- /dev/null +++ b/demos/yjs-react-supabase-text-collab/powersync.yaml @@ -0,0 +1,47 @@ +# 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 + 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/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..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,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 { 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,70 @@ 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 + .query({ + sql: /* sql */ ` + SELECT + * + FROM + document_updates + WHERE + document_id = ? + AND editor_id != ? + `, + parameters: [documentId, this.id] + }) + .differentialWatch(); + + 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.registerListener({ + onStateChange: async () => { + for (const added of updateQuery.state.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 +117,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/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/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/database.sql b/demos/yjs-react-supabase-text-collab/supabase/migrations/20250618064101_configure_powersync.sql similarity index 82% rename from demos/yjs-react-supabase-text-collab/database.sql rename to demos/yjs-react-supabase-text-collab/supabase/migrations/20250618064101_configure_powersync.sql index ecc41e43a..61209693f 100644 --- a/demos/yjs-react-supabase-text-collab/database.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 d8b77a6e1..0dfb4c242 100644 --- a/demos/yjs-react-supabase-text-collab/sync-rules.yaml +++ b/demos/yjs-react-supabase-text-collab/sync-rules.yaml @@ -1,10 +1,12 @@ +# 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: - 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 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/package.json b/package.json index 5ae008c68..2e82c5855 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", @@ -33,12 +33,14 @@ "@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", "prettier": "^3.2.5", + "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/common/src/client/AbstractPowerSyncDatabase.ts b/packages/common/src/client/AbstractPowerSyncDatabase.ts index 590747969..691bb2bbb 100644 --- a/packages/common/src/client/AbstractPowerSyncDatabase.ts +++ b/packages/common/src/client/AbstractPowerSyncDatabase.ts @@ -18,9 +18,10 @@ 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 { 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,6 +35,9 @@ import { type PowerSyncConnectionOptions, type RequiredAdditionalConnectionOptions } from './sync/stream/AbstractStreamingSyncImplementation.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. */ @@ -71,7 +75,7 @@ export interface PowerSyncDatabaseOptionsWithSettings extends BasePowerSyncDatab database: SQLOpenOptions; } -export interface SQLWatchOptions { +export interface SQLOnChangeOptions { signal?: AbortSignal; tables?: string[]; /** The minimum interval between queries. */ @@ -83,6 +87,18 @@ export interface SQLWatchOptions { * by not removing PowerSync table name prefixes */ rawTableNames?: boolean; + /** + * 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. + */ + comparator?: WatchedQueryComparator; } export interface WatchOnChangeEvent { @@ -102,6 +118,8 @@ export interface WatchOnChangeHandler { export interface PowerSyncDBListener extends StreamingSyncImplementationListener { initialized: () => void; schemaChanged: (schema: Schema) => void; + closing: () => Promise | void; + closed: () => Promise | void; } export interface PowerSyncCloseOptions { @@ -123,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'), @@ -520,6 +536,8 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver cb.closing?.()); + const { disconnect } = options; if (disconnect) { await this.disconnect(); @@ -528,6 +546,7 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver cb.closed?.()); } /** @@ -870,6 +889,62 @@ 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. + * + * @example + * ```javascript + * + * // 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. + * ``` + */ + customQuery(query: WatchCompatibleQuery): Query { + return new CustomQuery({ + db: this, + query + }); + } + /** * Execute a read query every time the source tables are modified. * Use {@link SQLWatchOptions.throttleMs} to specify the minimum interval between queries. @@ -887,39 +962,44 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver ({ + sql: sql, + parameters: parameters ?? [] + }), + execute: () => this.executeReadOnly(sql, parameters) + }, + reportFetching: false, + throttleMs: options?.throttleMs ?? DEFAULT_WATCH_THROTTLE_MS + } + }); - const watchQuery = async (abortSignal: AbortSignal) => { - 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 - }, - { - ...(options ?? {}), - tables: resolvedTables, - // Override the abort signal since we intercept it - signal: abortSignal - } - ); - } catch (error) { - onError?.(error); + const dispose = watchedQuery.registerListener({ + onData: (data) => { + if (!data) { + // This should not happen. We only use null for the initial data. + return; + } + onResult(data); + }, + onError: (error) => { + onError(error); } - }; + }); - runOnSchemaChange(watchQuery, this, options); + options?.signal?.addEventListener('abort', () => { + dispose(); + watchedQuery.close(); + }); } /** @@ -993,7 +1073,7 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver; + onChange(options?: SQLOnChangeOptions): AsyncIterable; /** * See {@link onChangeWithCallback}. * @@ -1008,11 +1088,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; @@ -1037,7 +1117,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'); @@ -1064,6 +1144,10 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver { try { 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..ddc3b057f --- /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 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/sync/bucket/BucketStorageAdapter.ts b/packages/common/src/client/sync/bucket/BucketStorageAdapter.ts index ecd6c7ef4..3c24c059c 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 85d493ef5..5e0eb4bc5 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/client/watched/GetAllQuery.ts b/packages/common/src/client/watched/GetAllQuery.ts new file mode 100644 index 000000000..116fe1fd4 --- /dev/null +++ b/packages/common/src/client/watched/GetAllQuery.ts @@ -0,0 +1,46 @@ +import { CompiledQuery } from '../../types/types.js'; +import { AbstractPowerSyncDatabase } from '../AbstractPowerSyncDatabase.js'; +import { WatchCompatibleQuery } from './WatchedQuery.js'; + +/** + * Options for {@link GetAllQuery}. + */ +export type GetAllQueryOptions = { + sql: string; + parameters?: ReadonlyArray; + /** + * Optional mapper function to convert raw rows into the desired RowType. + * @example + * ```javascript + * (rawRow) => ({ + * id: rawRow.id, + * created_at: new Date(rawRow.created_at), + * }) + * ``` + */ + mapper?: (rawRow: Record) => RowType; +}; + +/** + * 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 ?? [] + }; + } + + async execute(options: { db: AbstractPowerSyncDatabase }): Promise { + const { db } = options; + const { sql, parameters = [] } = this.compile(); + const rawResult = await db.getAll(sql, [...parameters]); + 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 new file mode 100644 index 000000000..7c8593148 --- /dev/null +++ b/packages/common/src/client/watched/WatchedQuery.ts @@ -0,0 +1,110 @@ +import { CompiledQuery } from '../../types/types.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). + * Loading becomes false once the first set of results from the watched query is available or an error occurs. + */ + 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). + */ + readonly isFetching: boolean; + /** + * The last error that occurred while executing the query. + */ + readonly error: Error | null; + /** + * The last time the query was updated. + */ + readonly lastUpdated: Date | null; + /** + * The last data returned by the query. + */ + readonly data: Data; +} + +/** + * Options provided to the `execute` method of a {@link WatchCompatibleQuery}. + */ +export interface WatchExecuteOptions { + sql: string; + parameters: any[]; + db: AbstractPowerSyncDatabase; +} + +/** + * Similar to {@link CompatibleQuery}, except the `execute` method + * does not enforce an Array result type. + */ +export interface WatchCompatibleQuery { + execute(options: WatchExecuteOptions): Promise; + compile(): CompiledQuery; +} + +export interface WatchedQueryOptions { + /** 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. + * Setting to false reduces the number of state changes if the fetch status + * is not relevant to the consumer. + */ + reportFetching?: boolean; +} + +export enum WatchedQueryListenerEvent { + ON_DATA = 'onData', + ON_ERROR = 'onError', + ON_STATE_CHANGE = 'onStateChange', + CLOSED = 'closed' +} + +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 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> { + /** + * Current state of the watched query. + */ + readonly state: WatchedQueryState; + + readonly closed: boolean; + + /** + * Subscribe to watched query events. + * @returns A function to unsubscribe from the events. + */ + registerListener(listener: WatchedQueryListener): () => void; + + /** + * Updates the underlying query options. + * This will trigger a re-evaluation of the query and update the state. + */ + updateSettings(options: Settings): Promise; + + /** + * Close the watched query and end all subscriptions. + */ + close(): Promise; +} 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..ceed509b2 --- /dev/null +++ b/packages/common/src/client/watched/processors/AbstractQueryProcessor.ts @@ -0,0 +1,200 @@ +import { AbstractPowerSyncDatabase } from '../../../client/AbstractPowerSyncDatabase.js'; +import { MetaBaseObserver } from '../../../utils/MetaBaseObserver.js'; +import { WatchedQuery, WatchedQueryListener, WatchedQueryOptions, WatchedQueryState } from '../WatchedQuery.js'; + +/** + * @internal + */ +export interface AbstractQueryProcessorOptions { + db: AbstractPowerSyncDatabase; + watchOptions: Settings; + placeholderData: Data; +} + +/** + * @internal + */ +export interface LinkQueryOptions { + abortSignal: AbortSignal; + settings: Settings; +} + +type MutableDeep = + 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; + +/** + * Performs underlying watching and yields a stream of results. + * @internal + */ +export abstract class AbstractQueryProcessor< + Data = unknown[], + Settings extends WatchedQueryOptions = WatchedQueryOptions + > + extends MetaBaseObserver> + implements WatchedQuery +{ + readonly state: WatchedQueryState; + + protected abortController: AbortController; + protected initialized: Promise; + protected _closed: boolean; + protected disposeListeners: (() => void) | null; + + get closed() { + return this._closed; + } + + constructor(protected options: AbstractQueryProcessorOptions) { + super(); + this.abortController = new AbortController(); + this._closed = false; + 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: this.options.placeholderData + }; + } + + protected get reportFetching() { + return this.options.watchOptions.reportFetching ?? true; + } + + /** + * Updates the underlying query. + */ + async updateSettings(settings: Settings) { + await this.initialized; + + if (!this.state.isLoading) { + await this.updateState({ + isLoading: true, + isFetching: this.reportFetching ? true : false + }); + } + + this.options.watchOptions = settings; + this.abortController.abort(); + this.abortController = new AbortController(); + await this.linkQuery({ + abortSignal: this.abortController.signal, + settings + }); + } + + /** + * 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 linkQuery(options: LinkQueryOptions): Promise; + + 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; + } + + Object.assign(this.state, { lastUpdated: new Date() } satisfies Partial>, update); + + if (typeof update.data !== 'undefined') { + await this.iterateAsyncListenersWithError(async (l) => l.onData?.(this.state.data)); + } + + await this.iterateAsyncListenersWithError(async (l) => l.onStateChange?.(this.state)); + } + + /** + * Configures base DB listeners and links the query to listeners. + */ + protected async init() { + const { db } = this.options; + + const disposeCloseListener = db.registerListener({ + closing: async () => { + await 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); + }); + } + }); + + this.disposeListeners = () => { + disposeCloseListener(); + disposeSchemaListener(); + }; + + // Initial setup + this.runWithReporting(async () => { + await this.updateSettings(this.options.watchOptions); + }); + } + + async close() { + await this.initialized; + this.abortController.abort(); + this.disposeListeners?.(); + this.disposeListeners = null; + this._closed = true; + this.iterateListeners((l) => l.closed?.()); + this.listeners.clear(); + } + + /** + * Runs a callback and reports errors to the error listeners. + */ + protected async runWithReporting(callback: () => Promise): Promise { + try { + await callback(); + } catch (error) { + // This will update the error on the state and iterate error listeners + await this.updateState({ error }); + } + } + + /** + * 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/DifferentialQueryProcessor.ts b/packages/common/src/client/watched/processors/DifferentialQueryProcessor.ts new file mode 100644 index 000000000..4be17f99c --- /dev/null +++ b/packages/common/src/client/watched/processors/DifferentialQueryProcessor.ts @@ -0,0 +1,314 @@ +import { WatchCompatibleQuery, WatchedQuery, WatchedQueryOptions, WatchedQueryState } from '../WatchedQuery.js'; +import { + AbstractQueryProcessor, + AbstractQueryProcessorOptions, + LinkQueryOptions, + MutableWatchedQueryState +} from './AbstractQueryProcessor.js'; + +/** + * Represents an updated row in a differential watched query. + * It contains both the current and previous state of the row. + */ +export interface WatchedQueryRowDifferential { + readonly current: RowType; + readonly previous: RowType; +} + +/** + * 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>; + /** + * 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 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>; +} + +/** + * 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; +} + +/** + * 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 differential incremental watched queries using. + */ +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>> { + /** + * The difference between the current and previous result set. + */ + readonly diff: WatchedQueryDifferential; +} + +type MutableDifferentialWatchedQueryState = MutableWatchedQueryState & { + data: RowType[]; + diff: WatchedQueryDifferential; +}; + +export interface DifferentialWatchedQuery + extends WatchedQuery>, DifferentialWatchedQuerySettings> { + readonly state: DifferentialWatchedQueryState; +} + +/** + * @internal + */ +export interface DifferentialQueryProcessorOptions + extends AbstractQueryProcessorOptions> { + 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: [], + removed: [], + updated: [], + 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 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 + } + }; + } + + /* + * @returns If the sets are equal + */ + protected differentiate( + current: RowType[], + previousMap: DataHashMap + ): { diff: WatchedQueryDifferential; map: DataHashMap; hasChanged: boolean } { + const { identify, compareBy } = this.differentiator; + + let hasChanged = false; + const currentMap = new Map(); + const removedTracker = new Set(previousMap.keys()); + + // Allow mutating to populate the data temporarily. + const diff = { + all: [] as RowType[], + added: [] as RowType[], + removed: [] as RowType[], + updated: [] as WatchedQueryRowDifferential[], + unchanged: [] as RowType[] + }; + + /** + * 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); + // update the map to preserve the reference + currentMap.set(key, previousItem); + } 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 + 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 + this.state.data.forEach((item) => { + currentMap.set(this.differentiator.identify(item), { + hash: this.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) { + Object.assign(partialStateUpdate, { + data: diff.all, + 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/OnChangeQueryProcessor.ts b/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts new file mode 100644 index 000000000..40d17e180 --- /dev/null +++ b/packages/common/src/client/watched/processors/OnChangeQueryProcessor.ts @@ -0,0 +1,106 @@ +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; +} + +export type ComparisonWatchedQuery = WatchedQuery>; + +/** + * @internal + */ +export interface OnChangeQueryProcessorOptions + extends AbstractQueryProcessorOptions> { + comparator?: WatchedQueryComparator; +} + +/** + * 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 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[]); + + 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> & { data?: Data } = {}; + + // 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; + } + + // Check if the result has changed + if (!this.checkEquality(result, this.state.data)) { + Object.assign(partialStateUpdate, { + data: result + }); + } + + 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/comparators.ts b/packages/common/src/client/watched/processors/comparators.ts new file mode 100644 index 000000000..6d2500d3c --- /dev/null +++ b/packages/common/src/client/watched/processors/comparators.ts @@ -0,0 +1,51 @@ +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; +}; + +/** + * Compares array results of watched queries for incrementally watched queries created in the standard mode. + */ +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 44203a2b9..40d4f7330 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -31,6 +31,14 @@ 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/DifferentialQueryProcessor.js'; +export * from './client/watched/processors/OnChangeQueryProcessor.js'; +export * from './client/watched/WatchedQuery.js'; + export * from './utils/AbortOperation.js'; export * from './utils/BaseObserver.js'; export * from './utils/DataStream.js'; diff --git a/packages/common/src/utils/BaseObserver.ts b/packages/common/src/utils/BaseObserver.ts index df56e9e61..fa8067226 100644 --- a/packages/common/src/utils/BaseObserver.ts +++ b/packages/common/src/utils/BaseObserver.ts @@ -1,20 +1,22 @@ export interface Disposable { - dispose: () => Promise; + dispose: () => Promise | void; } +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>(); constructor() {} + dispose(): void { + this.listeners.clear(); + } + /** * Register a listener for updates to the PowerSync client. */ 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 1c39a32e3..1a499a5fe 100644 --- a/packages/kysely-driver/tests/sqlite/watch.test.ts +++ b/packages/kysely-driver/tests/sqlite/watch.test.ts @@ -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(); } @@ -261,4 +261,33 @@ 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.customQuery(query).watch(); + + const latestDataPromise = new Promise>>((resolve) => { + const dispose = watch.registerListener({ + 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/node/package.json b/packages/node/package.json index 62c9a4cea..a6dd9aad8 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/packages/react-native/rollup.config.mjs b/packages/react-native/rollup.config.mjs index d2ee7e7fa..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({ sourceMap: sourcemap }) + terser({ sourceMap }) ], external: [ '@journeyapps/react-native-quick-sqlite', diff --git a/packages/react/README.md b/packages/react/README.md index e02344e50..748cf1259 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 @@ -271,3 +271,123 @@ export const TodoListDisplaySuspense = () => { ); }; ``` + +## Preventing Unnecessary 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 {data, error, isLoading} = useQuery(...) + + // ... Widget code + + return ( + // ... 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 dependent tables. This can be optimized by using Incremental queries. + + ) +} +``` + +### 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. + +```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. +Additionally, the internal array items maintain object references when 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'`, [], { + differentiator: { + identify: (item) => item.id, + compareBy: (item) => JSON.stringify(item) + } + }) + + // ... 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 preserved for unchanged rows) + // 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 } = useQuery(`SELECT * FROM cats WHERE breed = 'tabby'`, [], { + differentiator: { + identify: (item) => item.id, + compareBy: (item) => JSON.stringify(item) + } + 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) + + ) +} +``` diff --git a/packages/react/package.json b/packages/react/package.json index 21ffd769c..e18ff924f 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -34,10 +34,13 @@ }, "devDependencies": { "@powersync/common": "workspace:*", + "@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", "react-error-boundary": "^4.1.0" } } diff --git a/packages/react/src/QueryStore.ts b/packages/react/src/QueryStore.ts index a1954851e..6ab44a955 100644 --- a/packages/react/src/QueryStore.ts +++ b/packages/react/src/QueryStore.ts @@ -1,32 +1,69 @@ -import { AbstractPowerSyncDatabase } from '@powersync/common'; -import { Query, WatchedQuery } from './WatchedQuery'; -import { AdditionalOptions } from './hooks/useQuery'; +import { + AbstractPowerSyncDatabase, + WatchCompatibleQuery, + WatchedQuery, + WatchedQueryListenerEvent +} from '@powersync/common'; +import { DifferentialHookOptions } from './hooks/watched/watch-types'; -export function generateQueryKey(sqlStatement: string, parameters: any[], options: AdditionalOptions): string { +export function generateQueryKey( + sqlStatement: string, + parameters: ReadonlyArray, + options: DifferentialHookOptions +): 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: DifferentialHookOptions) { if (this.cache.has(key)) { - return this.cache.get(key); + return this.cache.get(key) as WatchedQuery; } - const q = new WatchedQuery(this.db, query, options); - const disposer = q.registerListener({ - disposed: () => { + const watch = options.differentiator + ? this.db.customQuery(query).differentialWatch({ + differentiator: options.differentiator, + reportFetching: options.reportFetching, + throttleMs: options.throttleMs + }) + : this.db.customQuery(query).watch({ + reportFetching: options.reportFetching, + throttleMs: options.throttleMs + }); + + this.cache.set(key, watch); + + const disposer = watch.registerListener({ + closed: () => { this.cache.delete(key); disposer?.(); } }); - this.cache.set(key, q); + 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 + const relevantCounts = [ + WatchedQueryListenerEvent.ON_DATA, + WatchedQueryListenerEvent.ON_STATE_CHANGE, + WatchedQueryListenerEvent.ON_ERROR + ].reduce((sum, event) => { + return sum + (counts[event] || 0); + }, 0); + + if (relevantCounts == 0) { + watch.close(); + this.cache.delete(key); + } + } + }); - return q; + return watch; } } diff --git a/packages/react/src/WatchedQuery.ts b/packages/react/src/WatchedQuery.ts deleted file mode 100644 index 250f91d6d..000000000 --- a/packages/react/src/WatchedQuery.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { - AbstractPowerSyncDatabase, - BaseListener, - BaseObserver, - CompilableQuery, - Disposable, - runOnSchemaChange -} from '@powersync/common'; -import { AdditionalOptions } from './hooks/useQuery'; - -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); - } - } - - 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/usePowerSyncQuery.ts b/packages/react/src/hooks/deprecated/usePowerSyncQuery.ts similarity index 94% rename from packages/react/src/hooks/usePowerSyncQuery.ts rename to packages/react/src/hooks/deprecated/usePowerSyncQuery.ts index 59feb1d65..9f490274c 100644 --- a/packages/react/src/hooks/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/usePowerSyncStatus.ts b/packages/react/src/hooks/deprecated/usePowerSyncStatus.ts similarity index 93% rename from packages/react/src/hooks/usePowerSyncStatus.ts rename to packages/react/src/hooks/deprecated/usePowerSyncStatus.ts index 0798db66a..34a3ee91e 100644 --- a/packages/react/src/hooks/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/usePowerSyncWatchedQuery.ts b/packages/react/src/hooks/deprecated/usePowerSyncWatchedQuery.ts similarity index 96% rename from packages/react/src/hooks/usePowerSyncWatchedQuery.ts rename to packages/react/src/hooks/deprecated/usePowerSyncWatchedQuery.ts index 7521f6b8a..581823afc 100644 --- a/packages/react/src/hooks/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/suspense/SuspenseQueryResult.ts b/packages/react/src/hooks/suspense/SuspenseQueryResult.ts new file mode 100644 index 000000000..d8b0f7b7e --- /dev/null +++ b/packages/react/src/hooks/suspense/SuspenseQueryResult.ts @@ -0,0 +1,4 @@ +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/suspense-utils.ts b/packages/react/src/hooks/suspense/suspense-utils.ts new file mode 100644 index 000000000..f80621587 --- /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.registerListener({ + 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.registerListener({ + 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..10adf3e4e --- /dev/null +++ b/packages/react/src/hooks/suspense/useSingleSuspenseQuery.ts @@ -0,0 +1,85 @@ +import { CompilableQuery, WatchedQuery } from '@powersync/common'; +import React from 'react'; +import { generateQueryKey, getQueryStore } from '../../QueryStore'; +import { usePowerSync } from '../PowerSyncContext'; +import { AdditionalOptions } from '../watched/watch-types'; +import { constructCompatibleQuery } from '../watched/watch-utils'; +import { createSuspendingPromise, useTemporaryHold } from './suspense-utils'; +import { SuspenseQueryResult } from './SuspenseQueryResult'; + +/** + * 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.isLoading === false) { + 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.isLoading === false) { + // Happy path data return + return { + data: data ?? watchedQuery?.state.data ?? [], + refresh: async (signal) => { + try { + const compiledQuery = parsedQuery.compile(); + const result = await parsedQuery.execute({ + sql: compiledQuery.sql, + parameters: [...compiledQuery.parameters], + db: powerSync + }); + if (signal.aborted) { + return; // Abort if the signal is already aborted + } + setData(result); + setError(null); + } catch (e) { + setError(e); + } + } + }; + } else { + // Notify suspense is required + throw createSuspendingPromise(watchedQuery!); + } +}; diff --git a/packages/react/src/hooks/suspense/useSuspenseQuery.ts b/packages/react/src/hooks/suspense/useSuspenseQuery.ts new file mode 100644 index 000000000..d5bf0a59c --- /dev/null +++ b/packages/react/src/hooks/suspense/useSuspenseQuery.ts @@ -0,0 +1,76 @@ +import { CompilableQuery } from '@powersync/common'; +import { AdditionalOptions, DifferentialHookOptions } from '../watched/watch-types'; +import { ReadonlySuspenseQueryResult, SuspenseQueryResult } from './SuspenseQueryResult'; +import { 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. + * @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 + * {lists.map((l) => ( + * {JSON.stringify(l)} + * ))} + * ; + * } + * + * export const DisplayComponent = () => { + * return ( + * Loading content...}> + * + * + * ); + * } + * + * 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 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 & DifferentialHookOptions = {} +) { + switch (options?.runQueryOnce) { + case true: + return useSingleSuspenseQuery(query, parameters, options); + default: + return useWatchedSuspenseQuery(query, parameters, options); + } +} diff --git a/packages/react/src/hooks/suspense/useWatchedQuerySuspenseSubscription.ts b/packages/react/src/hooks/suspense/useWatchedQuerySuspenseSubscription.ts new file mode 100644 index 000000000..c56e79fc5 --- /dev/null +++ b/packages/react/src/hooks/suspense/useWatchedQuerySuspenseSubscription.ts @@ -0,0 +1,66 @@ +import { WatchedQuery } from '@powersync/common'; +import React from 'react'; +import { createSuspendingPromise, useTemporaryHold } from './suspense-utils'; + +/** + * 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 = < + ResultType = unknown, + Query extends WatchedQuery = WatchedQuery +>( + query: Query +): Query['state'] => { + 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.registerListener({ + 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 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 new file mode 100644 index 000000000..c5171a01d --- /dev/null +++ b/packages/react/src/hooks/suspense/useWatchedSuspenseQuery.ts @@ -0,0 +1,36 @@ +import { CompilableQuery } from '@powersync/common'; +import { generateQueryKey, getQueryStore } from '../../QueryStore'; +import { usePowerSync } from '../PowerSyncContext'; +import { AdditionalOptions } from '../watched/watch-types'; +import { constructCompatibleQuery } from '../watched/watch-utils'; +import { useWatchedQuerySuspenseSubscription } from './useWatchedQuerySuspenseSubscription'; + +/** + * @internal This is not exported in the index.ts + */ +export const useWatchedSuspenseQuery = ( + query: string | CompilableQuery, + parameters: any[] = [], + options: AdditionalOptions = {} +) => { + 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); + + return useWatchedQuerySuspenseSubscription(watchedQuery); +}; diff --git a/packages/react/src/hooks/useQuery.ts b/packages/react/src/hooks/useQuery.ts deleted file mode 100644 index b7400eaea..000000000 --- a/packages/react/src/hooks/useQuery.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { parseQuery, type CompilableQuery, type ParsedQuery, type SQLWatchOptions } from '@powersync/common'; -import React from 'react'; -import { usePowerSync } from './PowerSyncContext'; - -export interface AdditionalOptions extends Omit { - runQueryOnce?: boolean; -} - -export type QueryResult = { - data: T[]; - /** - * 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; -}; - -/** - * 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 [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] - ); - - 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); - } - }; - - React.useEffect(() => { - const abortController = new AbortController(); - const updateData = async () => { - await fetchTables(abortController.signal); - await fetchData(abortController.signal); - }; - - updateData(); - - const l = powerSync.registerListener({ - schemaChanged: updateData - }); - - return () => { - abortController.abort(); - l?.(); - }; - }, [powerSync, memoizedParams, sqlStatement]); - - 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 - } - ); - } - - return () => { - abortController.current?.abort(); - }; - }, [powerSync, sqlStatement, memoizedParams, memoizedOptions, tables]); - - return { isLoading, isFetching: isFetching || shouldFetch, data, error, refresh: fetchData }; -}; 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/hooks/useSuspenseQuery.ts b/packages/react/src/hooks/useSuspenseQuery.ts deleted file mode 100644 index 87da1fbfa..000000000 --- a/packages/react/src/hooks/useSuspenseQuery.ts +++ /dev/null @@ -1,93 +0,0 @@ -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'>; - -/** - * 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 => { - const powerSync = usePowerSync(); - if (!powerSync) { - 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); - - // 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 q = store.getQuery( - key, - { rawQuery: query, sqlStatement: parsedQuery.sqlStatement, queryParameters: parsedQuery.parameters }, - options - ); - - const addedHoldTo = React.useRef(undefined); - const releaseTemporaryHold = React.useRef<(() => void) | undefined>(undefined); - - if (addedHoldTo.current !== q) { - releaseTemporaryHold.current?.(); - releaseTemporaryHold.current = q.addTemporaryHold(); - addedHoldTo.current = q; - } - - const [_counter, setUpdateCounter] = React.useState(0); - - React.useEffect(() => { - const dispose = q.registerListener({ - onUpdate: () => { - setUpdateCounter((counter) => { - return counter + 1; - }); - } - }); - - releaseTemporaryHold.current?.(); - releaseTemporaryHold.current = undefined; - - return dispose; - }, []); - - if (q.currentError != null) { - throw q.currentError; - } else if (q.currentData != null) { - return { data: q.currentData, refresh: () => q.fetchData() }; - } else { - throw q.readyPromise; - } -}; diff --git a/packages/react/src/hooks/watched/useQuery.ts b/packages/react/src/hooks/watched/useQuery.ts new file mode 100644 index 000000000..9ee2b8bb4 --- /dev/null +++ b/packages/react/src/hooks/watched/useQuery.ts @@ -0,0 +1,85 @@ +import { type CompilableQuery } from '@powersync/common'; +import { usePowerSync } from '../PowerSyncContext'; +import { useSingleQuery } from './useSingleQuery'; +import { useWatchedQuery } from './useWatchedQuery'; +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 + * {lists.map((l) => ( + * {JSON.stringify(l)} + * ))} + * + * } + * + * 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 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 & 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) { + case true: + return useSingleQuery({ + query: parsedQuery, + powerSync, + queryChanged + }); + default: + return useWatchedQuery({ + query: parsedQuery, + powerSync, + queryChanged, + options: { + reportFetching: options.reportFetching, + // Maintains backwards compatibility with previous versions + // Differentiation is opt-in by default + // We emit new data for each table change by default. + differentiator: options.differentiator + } + }); + } +} diff --git a/packages/react/src/hooks/watched/useSingleQuery.ts b/packages/react/src/hooks/watched/useSingleQuery.ts new file mode 100644 index 000000000..f56ec8d15 --- /dev/null +++ b/packages/react/src/hooks/watched/useSingleQuery.ts @@ -0,0 +1,61 @@ +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 compiledQuery = query.compile(); + const result = await query.execute({ + sql: compiledQuery.sql, + parameters: [...compiledQuery.parameters], + db: powerSync + }); + 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..dfe24a17e --- /dev/null +++ b/packages/react/src/hooks/watched/useWatchedQuery.ts @@ -0,0 +1,53 @@ +import React from 'react'; +import { useWatchedQuerySubscription } from './useWatchedQuerySubscription'; +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: DifferentialHookOptions } +): QueryResult | ReadonlyQueryResult => { + const { query, powerSync, queryChanged, options: hookOptions } = options; + + const createWatchedQuery = React.useCallback(() => { + 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); + + React.useEffect(() => { + watchedQuery.close(); + setWatchedQuery(createWatchedQuery); + }, [powerSync]); + + // 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({ + query, + throttleMs: hookOptions.throttleMs, + reportFetching: hookOptions.reportFetching + }); + } + }, [queryChanged]); + + return useWatchedQuerySubscription(watchedQuery); +}; diff --git a/packages/react/src/hooks/watched/useWatchedQuerySubscription.ts b/packages/react/src/hooks/watched/useWatchedQuerySubscription.ts new file mode 100644 index 000000000..c8cfa9252 --- /dev/null +++ b/packages/react/src/hooks/watched/useWatchedQuerySubscription.ts @@ -0,0 +1,39 @@ +import { WatchedQuery } 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 } = useWatchedQuerySubscription(listsQuery); + * + * return + * {lists.map((l) => ( + * {JSON.stringify(l)} + * ))} + * ; + * } + * + */ +export const useWatchedQuerySubscription = < + ResultType = unknown, + Query extends WatchedQuery = WatchedQuery +>( + query: Query +): Query['state'] => { + const [output, setOutputState] = React.useState(query.state); + + React.useEffect(() => { + const dispose = query.registerListener({ + onStateChange: (state) => { + setOutputState({ ...state }); + } + }); + + return () => { + dispose(); + }; + }, [query]); + + 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..f03555fbd --- /dev/null +++ b/packages/react/src/hooks/watched/watch-types.ts @@ -0,0 +1,47 @@ +import { SQLOnChangeOptions, WatchedQueryDifferentiator } from '@powersync/common'; + +export interface HookWatchOptions extends Omit { + reportFetching?: boolean; +} + +export interface AdditionalOptions extends HookWatchOptions { + runQueryOnce?: boolean; +} + +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. + */ + 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). + */ + readonly isFetching: boolean; + readonly error: Error | undefined; + /** + * Function used to run the query again. + */ + 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; +}; 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..fd72b2394 --- /dev/null +++ b/packages/react/src/hooks/watched/watch-utils.ts @@ -0,0 +1,78 @@ +import { AbstractPowerSyncDatabase, CompilableQuery, CompiledQuery, 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) => { + let _compiled: CompiledQuery; + 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); + + 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, powerSync]); + + const queryChanged = checkQueryChanged(parsedQuery, options); + + return { + parsedQuery, + queryChanged + }; +}; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 6199b10ea..6bdc019aa 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,7 +1,11 @@ +export { usePowerSyncQuery } from './hooks/deprecated/usePowerSyncQuery'; +export { usePowerSyncStatus } from './hooks/deprecated/usePowerSyncStatus'; +export { usePowerSyncWatchedQuery } from './hooks/deprecated/usePowerSyncWatchedQuery'; export * from './hooks/PowerSyncContext'; -export { usePowerSyncQuery } from './hooks/usePowerSyncQuery'; +export { SuspenseQueryResult } from './hooks/suspense/SuspenseQueryResult'; +export { useSuspenseQuery } from './hooks/suspense/useSuspenseQuery'; +export { useWatchedQuerySuspenseSubscription } from './hooks/suspense/useWatchedQuerySuspenseSubscription'; 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 { useQuery } from './hooks/watched/useQuery'; +export { useWatchedQuerySubscription } from './hooks/watched/useWatchedQuerySubscription'; +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/profile.test.tsx b/packages/react/tests/profile.test.tsx new file mode 100644 index 000000000..0c060751f --- /dev/null +++ b/packages/react/tests/profile.test.tsx @@ -0,0 +1,498 @@ +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'; +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 + .query({ + sql: 'SELECT * FROM lists ORDER BY name ASC;' + }) + .differentialWatch({ + reportFetching: false + }); + + const times: number[] = []; + diffSpy(query, times); + + const baseResult = await testInserts({ + db, + incrementalInsertsCount, + initialDataCount, + useMemoize: false, + getQueryData: () => { + const { data } = useWatchedQuerySubscription(query); + return [...data]; + } + }); + + 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;'); + + const query = db.query({ sql: 'SELECT * FROM lists ORDER BY name ASC;' }).differentialWatch({ + reportFetching: false + }); + + const times: number[] = []; + diffSpy(query, times); + + const baseResult = await testInserts({ + db, + incrementalInsertsCount, + initialDataCount, + useMemoize: true, + getQueryData: () => { + const { data } = useWatchedQuerySubscription(query); + return [...data]; + } + }); + + 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: Infinity }, () => { + 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(',')); + }); + + // 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/packages/react/tests/useQuery.test.tsx b/packages/react/tests/useQuery.test.tsx index b92bbaa38..76c8107a8 100644 --- a/packages/react/tests/useQuery.test.tsx +++ b/packages/react/tests/useQuery.test.tsx @@ -1,21 +1,30 @@ 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 pDefer from 'p-defer'; 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'])) -}; +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' }, + schema: new commonSdk.Schema({ + lists: new commonSdk.Table({ + name: commonSdk.column.text + }) + }) + }); -vi.mock('./PowerSyncContext', () => ({ - useContext: vi.fn(() => mockPowerSync) -})); + onTestFinished(async () => { + await db.disconnectAndClear(); + await db.close(); + }); + + return db; +}; describe('useQuery', () => { beforeEach(() => { @@ -33,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 }); @@ -42,30 +54,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 +91,54 @@ 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 } ); }); 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 +149,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 +160,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 } ); @@ -192,9 +192,162 @@ describe('useQuery', () => { expect(currentResult.data).toEqual([]); expect(currentResult.error).toEqual(Error('error')); }, - { timeout: 100 } + { timeout: 500, interval: 100 } ); }); - // TODO: Add tests for powersync.onChangeWithCallback path + 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'], { + differentiator: { + identify: (item) => item.id, + compareBy: (item) => JSON.stringify(item) + } + }), + { 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; + 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); + }); + + // The number of calls should be incremented after we make a change + 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(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); + }); + + // 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 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 + 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(); + + // 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<{ id: string; name: string }>({ sql: `SELECT * FROM lists`, parameters: [] }) + .differentialWatch(); + + 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); + }); }); diff --git a/packages/react/tests/useSuspenseQuery.test.tsx b/packages/react/tests/useSuspenseQuery.test.tsx index f83c164d2..d24d9a7bc 100644 --- a/packages/react/tests/useSuspenseQuery.test.tsx +++ b/packages/react/tests/useSuspenseQuery.test.tsx @@ -1,37 +1,23 @@ -import * as commonSdk from '@powersync/common'; +import { AbstractPowerSyncDatabase, WatchedQuery, WatchedQueryListenerEvent } 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'; - -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) -})); +import { useSuspenseQuery } from '../src/hooks/suspense/useSuspenseQuery'; +import { useWatchedQuerySuspenseSubscription } from '../src/hooks/suspense/useWatchedQuerySuspenseSubscription'; +import { openPowerSync } from './useQuery.test'; describe('useSuspenseQuery', () => { const loadingFallback = 'Loading'; const errorFallback = 'Error'; + let powersync: AbstractPowerSyncDatabase; + const wrapper = ({ children }) => ( - + - {children} + {children} ); @@ -66,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 () => { @@ -76,50 +62,106 @@ describe('useSuspenseQuery', () => { }); it('should suspend on initial load', async () => { - mockPowerSync.getAll = vi.fn(() => { - return new Promise(() => {}); + // spy on watched query generation + const baseImplementation = powersync.customQuery; + let watch: WatchedQuery | null = null; + + const spy = vi.spyOn(powersync, 'customQuery').mockImplementation((options) => { + const builder = baseImplementation.call(powersync, options); + const baseBuild = builder.differentialWatch; + + // The hooks use the `watch` method if no differentiator is set + vi.spyOn(builder, 'watch').mockImplementation((buildOptions) => { + watch = baseBuild.call(builder, buildOptions); + return watch!; + }); + + return builder!; }); const wrapper = ({ children }) => ( - - {children} + + + {children} +
Not suspending
+
); - renderHook(() => useSuspenseQuery('SELECT * from lists'), { wrapper }); + await powersync.execute("INSERT INTO lists (id, name) VALUES (uuid(), 'aname')"); + const { unmount } = renderHook(() => useSuspenseQuery('SELECT * from lists'), { wrapper }); + + expect(screen.queryByText('Not suspending')).toBeFalsy(); await waitForSuspend(); - expect(mockPowerSync.getAll).toHaveBeenCalledTimes(1); - }); + // The component should render after suspending + await waitFor( + async () => { + expect(screen.queryByText('Not suspending')).toBeTruthy(); + }, + { timeout: 500, interval: 100 } + ); - it('should run the query once if runQueryOnce flag is set', async () => { - let resolvePromise: (_: string[]) => void = () => {}; + expect(watch).toBeDefined(); + expect(watch!.closed).false; + expect(watch!.state.data.length).eq(1); + expect(watch!.listenerMeta.counts[WatchedQueryListenerEvent.ON_STATE_CHANGE]).greaterThanOrEqual(2); // should have a temporary hold and state listener - mockPowerSync.getAll = vi.fn(() => { - return new Promise((resolve) => { - resolvePromise = resolve; - }); - }); + // wait for the temporary hold to elapse + await waitFor( + async () => { + expect(watch!.listenerMeta.counts[WatchedQueryListenerEvent.ON_STATE_CHANGE]).eq(1); + }, + { timeout: 10_000, interval: 500 } + ); - const { result } = renderHook(() => useSuspenseQuery('SELECT * from lists', [], { runQueryOnce: true }), { - wrapper - }); + // now unmount the hook, this should remove listeners from the watch and close the query + unmount(); - await waitForSuspend(); + // wait for the temporary hold to elapse + await waitFor( + async () => { + expect(watch!.listenerMeta.counts[WatchedQueryListenerEvent.ON_STATE_CHANGE]).undefined; + expect(watch?.closed).true; + }, + { timeout: 10_000, interval: 500 } + ); + }); - resolvePromise(defaultQueryResult); + it('should run the query once if runQueryOnce flag is set', async () => { + await powersync.execute("INSERT INTO lists (id, name) VALUES (uuid(), 'list1')"); - await waitForCompletedSuspend(); + const { result } = renderHook( + () => useSuspenseQuery<{ name: string }>('SELECT * from lists', [], { runQueryOnce: true }), + { + wrapper + } + ); + + // Wait for the data to be presented + let lastData; await waitFor( async () => { const currentResult = result.current; - expect(currentResult?.data).toEqual(['list1', 'list2']); - expect(mockPowerSync.onChangeWithCallback).not.toHaveBeenCalled(); - expect(mockPowerSync.getAll).toHaveBeenCalledTimes(1); + lastData = currentResult?.data; + expect(lastData?.[0]).toBeDefined(); + expect(lastData?.[0].name).toBe('list1'); }, - { timeout: 100 } + { timeout: 1000 } ); + + await waitForCompletedSuspend(); + + // Do another insert, this should not trigger a re-render + await powersync.execute("INSERT INTO lists (id, name) VALUES (uuid(), 'list2')"); + + // 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)); + + expect(result.current.data).toEqual(lastData); + // sanity + expect(result.current.data?.length).toBe(1); }); it('should rerun the query when refresh is used', async () => { @@ -127,59 +169,41 @@ describe('useSuspenseQuery', () => { wrapper }); + // First ensure we do suspend, then wait for suspending to complete await waitForSuspend(); let refresh; - await waitFor( async () => { const currentResult = result.current; - refresh = currentResult.refresh; - expect(mockPowerSync.getAll).toHaveBeenCalledTimes(1); + console.log(currentResult); + refresh = currentResult?.refresh; + expect(refresh).toBeDefined(); }, - { timeout: 100 } + { timeout: 1000 } ); await waitForCompletedSuspend(); + expect(refresh).toBeDefined(); + const spy = vi.spyOn(powersync, 'getAll'); + const callCount = spy.mock.calls.length; await refresh(); - expect(mockPowerSync.getAll).toHaveBeenCalledTimes(2); + expect(spy).toHaveBeenCalledTimes(callCount + 1); }); it('should set error when error occurs', async () => { - let rejectPromise: (err: string) => void = () => {}; + renderHook(() => useSuspenseQuery('SELECT * from fakelists', []), { wrapper }); - mockPowerSync.getAll = vi.fn(() => { - return new Promise((_resolve, reject) => { - rejectPromise = reject; - }); - }); - - renderHook(() => useSuspenseQuery('SELECT * from lists', []), { wrapper }); - - await waitForSuspend(); - - 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 }), { + renderHook(() => useSuspenseQuery('SELECT * from fakelists', [], { runQueryOnce: true }), { wrapper }); - await waitForSuspend(); - - rejectPromise('failure'); await waitForCompletedSuspend(); await waitForError(); }); @@ -214,15 +238,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 } ); @@ -230,4 +252,51 @@ 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 + .query({ + sql: `SELECT * FROM lists`, + parameters: [] + }) + .watch(); + + 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); + }); }); diff --git a/packages/react/vitest.config.ts b/packages/react/vitest.config.ts index f96ac5630..bf0bd4de1 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: true, + instances: [ + { + browser: 'chromium' + } + ] + } } }; diff --git a/packages/vue/package.json b/packages/vue/package.json index 3bddfedba..ec5c011ad 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..9768d40b6 100644 --- a/packages/vue/src/composables/useQuery.ts +++ b/packages/vue/src/composables/useQuery.ts @@ -1,28 +1,19 @@ -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; + 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. */ @@ -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..5a5ab36f2 --- /dev/null +++ b/packages/vue/src/composables/useSingleQuery.ts @@ -0,0 +1,118 @@ +import { + type CompilableQuery, + ParsedQuery, + 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 { + runQueryOnce?: boolean; + differentiator?: WatchedQueryDifferentiator; +} + +export type WatchedQueryResult = { + 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. + */ + 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). + */ + readonly isFetching: Ref; + readonly 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..e922a6ea6 --- /dev/null +++ b/packages/vue/src/composables/useWatchedQuery.ts @@ -0,0 +1,98 @@ +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'; + +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 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 = watch.registerListener({ + 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(); + watch.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..80323a05c 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: true, + instances: [ + { + browser: 'chromium' + } + ] + } } }; diff --git a/packages/web/src/db/adapters/LockedAsyncDatabaseAdapter.ts b/packages/web/src/db/adapters/LockedAsyncDatabaseAdapter.ts index 35c8dfa0d..25e0afa56 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/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..b82ba3b71 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; @@ -267,12 +267,13 @@ describe('Multiple Instances', { sequential: true }, () => { // Close the second client, leaving only the first one 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(); }); }); diff --git a/packages/web/tests/watch.test.ts b/packages/web/tests/watch.test.ts index 6aea2211d..945c1325f 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, ArrayComparator, GetAllQuery, 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); }); @@ -323,4 +324,530 @@ describe('Watch Tests', { sequential: true }, () => { expect(receivedWithManagedOverflowCount).greaterThan(2); expect(receivedWithManagedOverflowCount).toBeLessThanOrEqual(4); }); + + it('should stream watch results', async () => { + const watch = powersync + .query({ + sql: 'SELECT * FROM assets', + parameters: [] + }) + .watch(); + + const getNextState = () => + new Promise>((resolve) => { + const dispose = watch.registerListener({ + onStateChange: (state) => { + dispose(); + resolve(state); + } + }); + }); + + let state = watch.state; + expect(state.isFetching).true; + expect(state.isLoading).true; + + state = await getNextState(); + expect(state.isFetching).false; + expect(state.isLoading).false; + + const nextStatePromise = getNextState(); + await powersync.execute('INSERT INTO assets(id, make, customer_id) VALUES (uuid(), ?, ?)', ['test', uuid()]); + state = await nextStatePromise; + expect(state!.isFetching).true; + + state = await getNextState(); + expect(state.isFetching).false; + expect(state.data).toHaveLength(1); + }); + + it('should only report updates for relevant changes', async () => { + const watch = powersync + .query<{ make: string }>({ + sql: 'SELECT * FROM assets where make = ?', + parameters: ['test'] + }) + .watch({ + comparator: new ArrayComparator({ + compareBy: (item) => JSON.stringify(item) + }) + }); + + let notificationCount = 0; + const dispose = watch.registerListener({ + onData: () => { + notificationCount++; + } + }); + onTestFinished(dispose); + + // Should only trigger for this operation + await powersync.execute('INSERT INTO assets(id, make, customer_id) VALUES (uuid(), ?, ?)', ['test', uuid()]); + + // 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()]); + + // 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 not report fetching status', async () => { + const watch = powersync + .query({ + sql: 'SELECT * FROM assets where make = ?', + parameters: ['test'] + }) + .watch({ + reportFetching: false + }); + + expect(watch.state.isFetching).false; + + let notificationCount = 0; + const dispose = watch.registerListener({ + onStateChange: () => { + notificationCount++; + } + }); + 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()]); + + // 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 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 + .query<{ make: string }>({ + sql: 'SELECT * FROM assets where make = ?', + parameters: ['test'] + }) + .watch({ + 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({ + 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'); + }); + + it('should report differential query results', async () => { + const watch = powersync + .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( + /* sql */ ` + INSERT INTO + assets (id, make, customer_id) + VALUES + (uuid (), ?, ?) + `, + ['test1', uuid()] + ); + + await vi.waitFor( + () => { + expect(watch.state.diff.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.diff.added).toHaveLength(1); + expect(watch.state.diff.added[0]?.make).equals('test2'); + + expect(watch.state.diff.removed).toHaveLength(0); + expect(watch.state.diff.all).toHaveLength(2); + }, + { timeout: 1000 } + ); + + await powersync.execute( + /* sql */ ` + DELETE FROM assets + WHERE + make = ? + `, + ['test2'] + ); + + await vi.waitFor( + () => { + 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.diff.removed).toHaveLength(1); + expect(watch.state.diff.removed[0]?.make).equals('test2'); + }, + { timeout: 1000 } + ); + }); + + it('should report differential query results with a custom differentiator', async () => { + const watch = powersync + .query({ + sql: /* sql */ ` + SELECT + * + FROM + assets + `, + mapper: (raw) => { + return { + id: raw.id as string, + make: raw.make as string + }; + } + }) + .differentialWatch({ + differentiator: { + identify: (item) => item.id, + compareBy: (item) => JSON.stringify(item) + } + }); + + // Create sample data + await powersync.execute( + /* sql */ ` + INSERT INTO + assets (id, make, customer_id) + VALUES + (uuid (), ?, ?) + `, + ['test1', uuid()] + ); + + await vi.waitFor( + () => { + expect(watch.state.diff.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.diff.added).toHaveLength(1); + expect(watch.state.diff.added[0]?.make).equals('test2'); + + expect(watch.state.diff.removed).toHaveLength(0); + expect(watch.state.diff.all).toHaveLength(2); + }, + { timeout: 1000 } + ); + }); + + it('should preserve object references in result set', async () => { + // Sort the results by the `make` column in ascending order + const watch = powersync + .query({ + sql: /* sql */ ` + SELECT + * + FROM + assets + ORDER BY + make ASC; + `, + mapper: (raw) => { + return { + id: raw.id as string, + make: raw.make as string + }; + } + }) + .differentialWatch({ + differentiator: { + identify: (item) => item.id, + compareBy: (item) => JSON.stringify(item) + } + }); + + // 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.map((i) => i.make)).deep.equals(['a', 'b', 'd']); + }, + { timeout: 1000 } + ); + + const initialData = watch.state.data; + + await powersync.execute( + /* sql */ ` + INSERT INTO + assets (id, make, customer_id) + VALUES + (uuid (), ?, uuid ()) + `, + ['c'] + ); + + await vi.waitFor( + () => { + expect(watch.state.data).toHaveLength(4); + }, + { timeout: 1000 } + ); + + 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[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 + * 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. + */ + + // Create sample data + await powersync.execute( + /* sql */ ` + INSERT INTO + assets (id, make, customer_id) + VALUES + (uuid (), ?, ?) + `, + ['test1', uuid()] + ); + + const watch = 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).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.diff.added).toHaveLength(1); + expect(watch.state.diff.added[0]?.make).equals('test2'); + + expect(watch.state.diff.removed).toHaveLength(0); + expect(watch.state.diff.all).toHaveLength(2); + }, + { timeout: 1000 } + ); + + await powersync.execute( + /* sql */ ` + DELETE FROM assets + WHERE + make = ? + `, + ['test2'] + ); + + await vi.waitFor( + () => { + 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.diff.removed).toHaveLength(1); + expect(watch.state.diff.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 + .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[0]?.make).equals('test1'); + }, + { timeout: 1000, interval: 100 } + ); + + await powersync.execute( + /* sql */ ` + UPDATE assets + SET + make = ? + WHERE + make = ? + `, + ['test2', 'test1'] + ); + + await vi.waitFor( + () => { + 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.diff.removed).toHaveLength(0); + expect(watch.state.diff.all).toHaveLength(1); + }, + { timeout: 1000 } + ); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2b71ae97d..74ee8c528 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 @@ -32,12 +32,18 @@ importers: prettier: specifier: ^3.2.5 version: 3.5.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.5.3) typescript: 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: @@ -119,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) @@ -140,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) @@ -164,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)) @@ -212,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 @@ -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.3.1(react@18.3.1))(react@18.3.1)(sass@1.89.1) + version: 14.2.3(@babel/core@7.26.10)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.89.1) react: specifier: ^18.2.0 version: 18.3.1 @@ -865,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)) @@ -938,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) @@ -959,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) @@ -983,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)) @@ -998,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)) @@ -1040,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 @@ -1077,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) @@ -1104,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) @@ -1125,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)) @@ -1143,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)) @@ -1158,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)) @@ -1219,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) @@ -1797,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: @@ -1854,18 +1860,27 @@ 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.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@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 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) @@ -1964,6 +1979,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 @@ -5515,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==} @@ -9280,12 +9301,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: @@ -9295,11 +9316,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 @@ -9309,20 +9330,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==} @@ -10474,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'} @@ -11448,6 +11473,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==} @@ -12653,6 +12681,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'} @@ -14194,6 +14226,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 @@ -14321,6 +14356,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'} @@ -14433,6 +14472,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: @@ -14748,6 +14792,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==} @@ -15198,6 +15245,9 @@ packages: engines: {node: '>=18.18'} hasBin: true + micro-memoize@4.1.3: + resolution: {integrity: sha512-DzRMi8smUZXT7rCGikRwldEh6eO6qzKiPPopcr1+2EY3AYKpy5fu159PKWwIS9A6IWnrvPKDMcuFtyrroZa8Bw==} + micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} @@ -15520,6 +15570,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: @@ -15614,6 +15667,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'} @@ -15767,6 +15824,10 @@ packages: resolution: {integrity: sha512-OXdegQq03OmXEjt2hZP33W2YPs/E5BcFQks46+G2gAxs4gHOIVD1u7EqlYLYSKsaIpyKCK9Gbk0ta1/gjRSMRQ==} engines: {node: '>=6'} + 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'} @@ -16157,6 +16218,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} @@ -16881,6 +16946,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'} @@ -17141,9 +17215,16 @@ packages: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} + railroad-diagrams@1.0.0: + resolution: {integrity: sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==} + rambda@9.4.2: resolution: {integrity: sha512-++euMfxnl7OgaEKwXh9QqThOjMeta2HH001N1v4mYQzBjJBnmXBh2BCK6dZAbICFVXOFUVD3xFG0R3ZPU0mxXw==} + randexp@0.4.6: + resolution: {integrity: sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==} + engines: {node: '>=0.12'} + randombytes@2.1.0: resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} @@ -17928,6 +18009,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'} @@ -18538,6 +18623,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'} @@ -18751,6 +18840,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'} @@ -19033,6 +19125,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==} @@ -19050,6 +19145,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'} @@ -19756,8 +19855,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 @@ -19908,16 +20007,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: @@ -25000,13 +25099,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 @@ -25696,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': @@ -26252,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 @@ -26261,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 @@ -26272,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 @@ -27030,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)': @@ -28471,7 +28572,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) @@ -28488,22 +28605,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) @@ -31366,36 +31467,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)': - 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 - 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 - 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)': + '@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@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 + '@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 @@ -31405,53 +31486,46 @@ snapshots: - 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))': + '@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 - 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))': - 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': @@ -32283,7 +32357,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 @@ -32965,6 +33039,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: @@ -34117,6 +34195,8 @@ snapshots: dependencies: path-type: 4.0.0 + discontinuous-range@1.0.0: {} + dlv@1.1.3: {} dns-packet@5.6.1: @@ -34144,7 +34224,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: @@ -35430,7 +35510,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 @@ -35590,29 +35670,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 @@ -35620,29 +35700,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 @@ -35683,7 +35763,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) @@ -35711,7 +35791,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)) @@ -35747,7 +35827,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)) @@ -36034,6 +36114,8 @@ snapshots: find-root@1.1.0: {} + find-up-simple@1.0.1: {} + find-up@2.1.0: dependencies: locate-path: 2.0.0 @@ -36740,7 +36822,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 @@ -37932,7 +38014,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 @@ -38280,6 +38362,8 @@ snapshots: js-tokens@4.0.0: {} + js-tokens@9.0.1: {} + js-yaml@3.14.1: dependencies: argparse: 1.0.10 @@ -38461,6 +38545,8 @@ snapshots: ms: 2.1.3 semver: 7.5.4 + jsox@1.2.123: {} + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.9 @@ -38595,6 +38681,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 @@ -38915,6 +39005,8 @@ snapshots: loupe@3.1.3: {} + loupe@3.1.4: {} + lower-case@2.0.2: dependencies: tslib: 2.8.1 @@ -39957,6 +40049,8 @@ snapshots: - supports-color - utf-8-validate + micro-memoize@4.1.3: {} + micromark-core-commonmark@2.0.3: dependencies: decode-named-character-reference: 1.1.0 @@ -40445,6 +40539,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.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): dependencies: framer-motion: 6.5.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -40551,6 +40647,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 @@ -40567,7 +40670,7 @@ snapshots: nested-error-stacks@2.0.1: {} - next@14.2.3(@babel/core@7.26.10)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.89.1): + next@14.2.3(@babel/core@7.26.10)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.89.1): dependencies: '@next/env': 14.2.3 '@swc/helpers': 0.5.5 @@ -40577,7 +40680,7 @@ snapshots: postcss: 8.4.31 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - styled-jsx: 5.1.1(@babel/core@7.26.10)(react@18.3.1) + styled-jsx: 5.1.1(@babel/core@7.26.10)(babel-plugin-macros@3.1.0)(react@18.3.1) optionalDependencies: '@next/swc-darwin-arm64': 14.2.3 '@next/swc-darwin-x64': 14.2.3 @@ -40721,6 +40824,10 @@ snapshots: long-timeout: 0.1.1 sorted-array-functions: 1.3.0 + node-sql-parser@4.18.0: + dependencies: + big-integer: 1.6.52 + node-stream-zip@1.15.0: {} nopt@6.0.0: @@ -41131,6 +41238,10 @@ snapshots: registry-url: 6.0.1 semver: 7.7.2 + package-up@5.0.0: + dependencies: + find-up-simple: 1.0.1 + pacote@20.0.0: dependencies: '@npmcli/git': 6.0.3 @@ -41904,6 +42015,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.6.0(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.5.3): + dependencies: + jsox: 1.2.123 + node-sql-parser: 4.18.0 + prettier: 3.5.3 + sql-formatter: 15.6.2 + tslib: 2.8.1 + prettier@2.8.8: {} prettier@3.5.3: {} @@ -42191,8 +42321,15 @@ snapshots: quick-lru@5.1.1: {} + railroad-diagrams@1.0.0: {} + rambda@9.4.2: {} + randexp@0.4.6: + dependencies: + discontinuous-range: 1.0.0 + ret: 0.1.15 + randombytes@2.1.0: dependencies: safe-buffer: 5.2.1 @@ -42960,7 +43097,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 @@ -43552,6 +43689,8 @@ snapshots: onetime: 7.0.0 signal-exit: 4.1.0 + ret@0.1.15: {} + retry@0.12.0: {} retry@0.13.1: {} @@ -44286,6 +44425,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: @@ -44517,6 +44661,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 @@ -44550,12 +44698,13 @@ snapshots: hey-listen: 1.0.8 tslib: 2.8.1 - styled-jsx@5.1.1(@babel/core@7.26.10)(react@18.3.1): + styled-jsx@5.1.1(@babel/core@7.26.10)(babel-plugin-macros@3.1.0)(react@18.3.1): dependencies: client-only: 0.0.1 react: 18.3.1 optionalDependencies: '@babel/core': 7.26.10 + babel-plugin-macros: 3.1.0 stylehacks@6.1.1(postcss@8.5.4): dependencies: @@ -44980,6 +45129,8 @@ snapshots: tiny-invariant@1.3.3: {} + tiny-jsonc@1.0.2: {} + tiny-warning@1.0.3: {} tinybench@2.9.0: {} @@ -44993,6 +45144,8 @@ snapshots: tinypool@1.1.0: {} + tinypool@1.1.1: {} + tinyrainbow@2.0.0: {} tinyspy@4.0.3: {} @@ -45790,15 +45943,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 @@ -45807,6 +45961,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: @@ -46005,16 +46161,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 @@ -46025,17 +46181,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 @@ -46045,6 +46202,8 @@ snapshots: - sugarss - supports-color - terser + - tsx + - yaml vlq@1.0.1: {} @@ -46925,7 +47084,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 @@ -46934,7 +47093,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: {}