diff --git a/README.md b/README.md index f37d9cda3..3147f0a63 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,7 @@ Demo applications are located in the [`demos/`](./demos/) directory. Also see ou - [demos/react-supabase-todolist](./demos/react-supabase-todolist/README.md): A React to-do list example app using the PowerSync Web SDK and a Supabase backend. - [demos/react-supabase-todolist-tanstackdb](./demos/react-supabase-todolist-tanstackdb/README.md): A React to-do list example app using the PowerSync Web SDK and a Supabase backend + [TanStackDB](https://tanstack.com/db/latest) collections. +- [demos/react-supabase-time-based-sync](./demos/react-supabase-time-based-sync/README.md): A React demo using Sync Streams to subscribe to date-filtered data dynamically, with a Supabase backend. - [demos/react-multi-client](./demos/react-multi-client/README.md): A React widget that illustrates how data flows from one PowerSync client to another. - [demos/yjs-react-supabase-text-collab](./demos/yjs-react-supabase-text-collab/README.md): A React real-time text editing collaboration example app powered by [Yjs](https://github.com/yjs/yjs) CRDTs and [Tiptap](https://tiptap.dev/), using the PowerSync Web SDK and a Supabase backend. - [demos/vue-supabase-todolist](./demos/vue-supabase-todolist/README.md): A Vue to-do list example app using the PowerSync Web SDK and a Supabase backend. diff --git a/demos/react-supabase-time-based-sync/.env.local.template b/demos/react-supabase-time-based-sync/.env.local.template new file mode 100644 index 000000000..546d659d8 --- /dev/null +++ b/demos/react-supabase-time-based-sync/.env.local.template @@ -0,0 +1,9 @@ +# Copy this template: `cp .env.local.template .env.local` +# Values below point to local Supabase + local PowerSync. +# The anon key is the well-known default for all local Supabase instances. +VITE_SUPABASE_URL=http://127.0.0.1:54321 +VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0 +VITE_POWERSYNC_URL=http://127.0.0.1:8080 + +# PowerSync Service port +PS_PORT=8080 diff --git a/demos/react-supabase-time-based-sync/.gitignore b/demos/react-supabase-time-based-sync/.gitignore new file mode 100644 index 000000000..2101efeea --- /dev/null +++ b/demos/react-supabase-time-based-sync/.gitignore @@ -0,0 +1,25 @@ +# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files + +# dependencies +node_modules/ + +# Metrom +.metro-health-check* + +# debug +npm-debug.* + +# local env files +.env*.local +.env + +# typescript +*.tsbuildinfo + +# IDE +.vscode +.fleet +.idea + +ios/ +android/ \ No newline at end of file diff --git a/demos/react-supabase-time-based-sync/README.md b/demos/react-supabase-time-based-sync/README.md new file mode 100644 index 000000000..d88ab0793 --- /dev/null +++ b/demos/react-supabase-time-based-sync/README.md @@ -0,0 +1,121 @@ +# PowerSync + Supabase: Time-Based Sync (Local-First) + +This demo shows how to use [PowerSync Sync Streams](https://docs.powersync.com/sync/sync-streams) to dynamically control which data is synced to the client based on a date. The backend contains a set of issues with `created_at` / `updated_at` as **`TIMESTAMPTZ`** in Postgres. Each selected date creates its own sync stream subscription with a `date` parameter. Toggling dates on or off adds or removes stream subscriptions and PowerSync syncs the matching issues. TTL is set to 0 so data is removed immediately when dates are deselected. + +This lets you model patterns like “sync the last N days of data” or “sync only the time ranges the user cares about” without re-deploying sync rules. + +The stream definition lives in `powersync/sync-config.yaml`: + +```yaml +streams: + issues_by_date: + query: | + SELECT * FROM issues + WHERE substring(updated_at, 1, 10) = subscription.parameter('date') +``` + +Postgres `TIMESTAMPTZ` values are handled like text for the first 10 characters (the `YYYY-MM-DD` prefix) in both the sync stream query and on the client replica. + +The client implementation is in `src/app/views/issues/page.tsx`. It builds an array of stream options from the selected dates and passes them directly to `useQuery` via the `streams` option: + +```tsx +import { useQuery } from '@powersync/react'; + +const streams = selectedDates.map((date) => ({ + name: 'issues_by_date', + parameters: { date }, + ttl: 0 +})); + +const { data: issues } = useQuery( + 'SELECT * FROM issues ORDER BY updated_at DESC', + [], + { streams } +); +``` + +`useQuery` manages the stream subscriptions internally — subscribing to new streams and unsubscribing from removed ones as the array changes. + +The demo runs against local Supabase (`supabase start`) and self-hosted PowerSync (via the PowerSync CLI). It uses anonymous Supabase auth — there is no login or registration flow. + +## Prerequisites + +- [Docker](https://docs.docker.com/get-docker/) (running) +- [Supabase CLI](https://supabase.com/docs/guides/local-development/cli/getting-started) +- [PowerSync CLI](https://docs.powersync.com/tools/cli) + +## Local development (recommended) + +1. Switch into this demo: + + ```bash + cd demos/react-supabase-time-based-sync + ``` + +2. Install dependencies: + + ```bash + pnpm install + ``` + +3. Create env file: + + ```bash + cp .env.local.template .env.local + ``` + + The template already contains the well-known local Supabase anon key, so no manual changes are needed. + +4. Start local Supabase + local PowerSync: + + > Ensure the [PowerSync CLI](https://docs.powersync.com/tools/cli) is installed before running the following command. + + ```bash + pnpm local:up + ``` + + This does three things: + - starts Supabase Docker services + - starts PowerSync using the checked-in `powersync/service.yaml` + - loads sync streams from `powersync/sync-config.yaml` + +5. Start the app: + + ```bash + pnpm dev + ``` + +Open [http://localhost:5173](http://localhost:5173). + +## Database setup and seed data + +The schema and seed data are in `supabase/migrations/20260312000000_init_issues.sql`. + +When Supabase starts for the first time, the migration creates: + +- the `issues` table (`created_at` / `updated_at` are `TIMESTAMPTZ`) +- RLS policies for authenticated users (including anonymous sessions) +- realtime publication for `issues` +- sample issues used by the time-based sync filters + +Run `supabase db reset` to re-apply migrations from scratch (required if you previously applied this migration when `created_at` / `updated_at` were `TEXT`). + +```bash +supabase db reset +``` + +## Notes + +- The app signs in with `signInAnonymously()` automatically in the connector. +- No login/register routes are used in this demo. +- To stop local services: + + ```bash + pnpm local:down + ``` + +## Learn More + +- [PowerSync CLI docs](https://docs.powersync.com/tools/cli) +- [PowerSync Sync Streams](https://docs.powersync.com/sync/sync-streams) +- [Supabase anonymous sign-ins](https://supabase.com/docs/guides/auth/auth-anonymous) diff --git a/demos/react-supabase-time-based-sync/package.json b/demos/react-supabase-time-based-sync/package.json new file mode 100644 index 000000000..3b2c6fa67 --- /dev/null +++ b/demos/react-supabase-time-based-sync/package.json @@ -0,0 +1,37 @@ +{ + "name": "react-supabase-time-based-sync", + "version": "0.1.0", + "private": true, + "description": "PowerSync React demo for time-based sync using sync streams (edition 3)", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.node.json && vite build", + "preview": "vite preview", + "start": "pnpm build && pnpm preview", + "local:up": "supabase start && powersync docker start", + "local:down": "powersync docker stop && supabase stop" + }, + "dependencies": { + "@powersync/react": "^1.9.1", + "@powersync/web": "^1.37.0", + "@emotion/react": "11.11.4", + "@emotion/styled": "11.11.5", + "@journeyapps/wa-sqlite": "^1.5.0", + "@mui/icons-material": "^5.15.12", + "@mui/material": "^5.15.12", + "@supabase/supabase-js": "^2.39.7", + "formik": "^2.4.6", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.22.3" + }, + "devDependencies": { + "@swc/core": "~1.6.0", + "@types/node": "^20.11.25", + "@types/react": "^18.2.64", + "@types/react-dom": "^18.2.21", + "@vitejs/plugin-react": "^4.2.1", + "typescript": "^5.4.2", + "vite": "^5.1.5" + } +} diff --git a/demos/react-supabase-time-based-sync/pnpm-workspace.yaml b/demos/react-supabase-time-based-sync/pnpm-workspace.yaml new file mode 100644 index 000000000..d05a7e7dc --- /dev/null +++ b/demos/react-supabase-time-based-sync/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - . diff --git a/demos/react-supabase-time-based-sync/powersync/cli.yaml b/demos/react-supabase-time-based-sync/powersync/cli.yaml new file mode 100644 index 000000000..28e44add0 --- /dev/null +++ b/demos/react-supabase-time-based-sync/powersync/cli.yaml @@ -0,0 +1,6 @@ +type: self-hosted +api_url: http://localhost:8080 +api_key: dev-token +plugins: + docker: + project_name: powersync_react-supabase-time-based-sync diff --git a/demos/react-supabase-time-based-sync/powersync/docker/docker-compose.yaml b/demos/react-supabase-time-based-sync/powersync/docker/docker-compose.yaml new file mode 100644 index 000000000..701c38711 --- /dev/null +++ b/demos/react-supabase-time-based-sync/powersync/docker/docker-compose.yaml @@ -0,0 +1,35 @@ +# Composed PowerSync Docker stack (generated by powersync docker configure). +# Modules add entries to include and to services.powersync.depends_on. +# Relative paths: . = powersync/docker, .. = powersync. +# Include syntax requires Docker Compose v2.20.3+ + +include: [] + +services: + powersync: + restart: unless-stopped + image: journeyapps/powersync-service:latest + command: [ 'start', '-r', 'unified' ] + env_file: + - ../../.env.local + volumes: + - ../service.yaml:/config/service.yaml + - ../sync-config.yaml:/config/sync-config.yaml + environment: + POWERSYNC_CONFIG_PATH: /config/service.yaml + NODE_OPTIONS: --max-old-space-size=1000 + healthcheck: + test: + - 'CMD' + - 'node' + - '-e' + - "fetch('http://localhost:${PS_PORT:-8080}/probes/liveness').then(r => + r.ok ? process.exit(0) : process.exit(1)).catch(() => + process.exit(1))" + interval: 5s + timeout: 1s + retries: 15 + ports: + - '${PS_PORT:-8080}:${PS_PORT:-8080}' + depends_on: {} +name: powersync_react-supabase-time-based-sync diff --git a/demos/react-supabase-time-based-sync/powersync/service.yaml b/demos/react-supabase-time-based-sync/powersync/service.yaml new file mode 100644 index 000000000..eabe18f46 --- /dev/null +++ b/demos/react-supabase-time-based-sync/powersync/service.yaml @@ -0,0 +1,31 @@ +# yaml-language-server: $schema=https://unpkg.com/@powersync/service-schema@latest/json-schema/powersync-config.json +_type: self-hosted + +replication: + connections: + - type: postgresql + uri: postgresql://postgres:postgres@host.docker.internal:54322/postgres + sslmode: disable + +storage: + type: postgresql + uri: postgresql://postgres:postgres@host.docker.internal:54322/postgres + sslmode: disable + +sync_config: + path: ./sync-config.yaml + +port: 8080 + +client_auth: + jwks_uri: http://host.docker.internal:54321/auth/v1/.well-known/jwks.json + audience: + - authenticated + +telemetry: + prometheus_port: 9090 + disable_telemetry_sharing: true + +api: + tokens: + - dev-token diff --git a/demos/react-supabase-time-based-sync/powersync/sync-config.yaml b/demos/react-supabase-time-based-sync/powersync/sync-config.yaml new file mode 100644 index 000000000..9d3378e1c --- /dev/null +++ b/demos/react-supabase-time-based-sync/powersync/sync-config.yaml @@ -0,0 +1,8 @@ +config: + edition: 3 + +streams: + issues_by_date: + query: | + SELECT * FROM issues + WHERE substring(updated_at, 1, 10) = subscription.parameter('date') diff --git a/demos/react-supabase-time-based-sync/public/favicon.ico b/demos/react-supabase-time-based-sync/public/favicon.ico new file mode 100644 index 000000000..918ca54ee Binary files /dev/null and b/demos/react-supabase-time-based-sync/public/favicon.ico differ diff --git a/demos/react-supabase-time-based-sync/public/icons/icon-192x192.png b/demos/react-supabase-time-based-sync/public/icons/icon-192x192.png new file mode 100644 index 000000000..66a723429 Binary files /dev/null and b/demos/react-supabase-time-based-sync/public/icons/icon-192x192.png differ diff --git a/demos/react-supabase-time-based-sync/public/icons/icon-256x256.png b/demos/react-supabase-time-based-sync/public/icons/icon-256x256.png new file mode 100644 index 000000000..1b8b97bae Binary files /dev/null and b/demos/react-supabase-time-based-sync/public/icons/icon-256x256.png differ diff --git a/demos/react-supabase-time-based-sync/public/icons/icon-384x384.png b/demos/react-supabase-time-based-sync/public/icons/icon-384x384.png new file mode 100644 index 000000000..af8be4dc6 Binary files /dev/null and b/demos/react-supabase-time-based-sync/public/icons/icon-384x384.png differ diff --git a/demos/react-supabase-time-based-sync/public/icons/icon-512x512.png b/demos/react-supabase-time-based-sync/public/icons/icon-512x512.png new file mode 100644 index 000000000..eb291c7e4 Binary files /dev/null and b/demos/react-supabase-time-based-sync/public/icons/icon-512x512.png differ diff --git a/demos/react-supabase-time-based-sync/public/icons/icon.png b/demos/react-supabase-time-based-sync/public/icons/icon.png new file mode 100644 index 000000000..c254b17c6 Binary files /dev/null and b/demos/react-supabase-time-based-sync/public/icons/icon.png differ diff --git a/demos/react-supabase-time-based-sync/public/powersync-logo.svg b/demos/react-supabase-time-based-sync/public/powersync-logo.svg new file mode 100644 index 000000000..05e31b6ed --- /dev/null +++ b/demos/react-supabase-time-based-sync/public/powersync-logo.svg @@ -0,0 +1 @@ + diff --git a/demos/react-supabase-time-based-sync/public/supabase-logo.png b/demos/react-supabase-time-based-sync/public/supabase-logo.png new file mode 100644 index 000000000..ff8c18e18 Binary files /dev/null and b/demos/react-supabase-time-based-sync/public/supabase-logo.png differ diff --git a/demos/react-supabase-time-based-sync/src/app/globals.css b/demos/react-supabase-time-based-sync/src/app/globals.css new file mode 100644 index 000000000..5ceb26045 --- /dev/null +++ b/demos/react-supabase-time-based-sync/src/app/globals.css @@ -0,0 +1,12 @@ +:root { + --foreground-rgb: 255, 255, 255; + --background-start-rgb: 0, 0, 0; + --background-end-rgb: 0, 0, 0; +} + +body { + color: rgb(var(--foreground-rgb)); + min-height: 100vh; + margin: 0; + background: linear-gradient(to bottom, transparent, rgb(var(--background-end-rgb))) rgb(var(--background-start-rgb)); +} diff --git a/demos/react-supabase-time-based-sync/src/app/index.tsx b/demos/react-supabase-time-based-sync/src/app/index.tsx new file mode 100644 index 000000000..e6aa8fae0 --- /dev/null +++ b/demos/react-supabase-time-based-sync/src/app/index.tsx @@ -0,0 +1,18 @@ +import { createRoot } from 'react-dom/client'; +import { RouterProvider } from 'react-router-dom'; +import { SystemProvider } from '@/components/providers/SystemProvider'; +import { ThemeProviderContainer } from '@/components/providers/ThemeProviderContainer'; +import { router } from '@/app/router'; + +const root = createRoot(document.getElementById('app')!); +root.render(); + +export function App() { + return ( + + + + + + ); +} diff --git a/demos/react-supabase-time-based-sync/src/app/router.tsx b/demos/react-supabase-time-based-sync/src/app/router.tsx new file mode 100644 index 000000000..a4ecdba3b --- /dev/null +++ b/demos/react-supabase-time-based-sync/src/app/router.tsx @@ -0,0 +1,26 @@ +import { Outlet, createBrowserRouter, Navigate } from 'react-router-dom'; +import IssuesPage from '@/app/views/issues/page'; +import ViewsLayout from '@/app/views/layout'; + +export const ISSUES_ROUTE = '/views/issues'; +export const DEFAULT_ENTRY_ROUTE = ISSUES_ROUTE; + +export const router = createBrowserRouter([ + { + path: '/', + element: + }, + { + element: ( + + + + ), + children: [ + { + path: ISSUES_ROUTE, + element: + } + ] + } +]); diff --git a/demos/react-supabase-time-based-sync/src/app/views/issues/page.tsx b/demos/react-supabase-time-based-sync/src/app/views/issues/page.tsx new file mode 100644 index 000000000..62a2ca6b3 --- /dev/null +++ b/demos/react-supabase-time-based-sync/src/app/views/issues/page.tsx @@ -0,0 +1,79 @@ +import { NavigationPage } from '@/components/navigation/NavigationPage'; +import { IssueItemWidget } from '@/components/widgets/IssueItemWidget'; +import { ISSUES_TABLE, IssueRecord } from '@/library/powersync/AppSchema'; +import { Box, Chip, List, Stack, Typography, styled } from '@mui/material'; +import { useQuery } from '@powersync/react'; +import React from 'react'; + +const AVAILABLE_DATES = ['2026-01-15', '2026-01-14', '2026-01-10', '2026-01-07', '2026-01-05']; + +export default function IssuesPage() { + const [selectedDates, setSelectedDates] = React.useState(AVAILABLE_DATES); + + const toggleDate = (date: string) => { + setSelectedDates((prev) => + prev.includes(date) ? prev.filter((d) => d !== date) : [...prev, date].sort().reverse() + ); + }; + + const streams = selectedDates.map((date) => ({ name: 'issues_by_date', parameters: { date }, ttl: 0 })); + + const { data: issues } = useQuery( + `SELECT * FROM ${ISSUES_TABLE} ORDER BY updated_at DESC`, + [], + { streams } + ); + + return ( + + + + + Each selected date subscribes to its own sync stream. Deselecting a date unsubscribes + from that stream and local data is removed (TTL = 0). + + + Syncing dates: + + + {AVAILABLE_DATES.map((date) => ( + toggleDate(date)} + variant={selectedDates.includes(date) ? 'filled' : 'outlined'} + /> + ))} + + + {selectedDates.length} date{selectedDates.length !== 1 ? 's' : ''} selected + + + + + {issues.length === 0 ? ( + + No issues found. Toggle dates above to sync issues. + + ) : ( + + {issues.map((issue) => ( + + ))} + + )} + + + + ); +} + +namespace S { + export const ControlsBox = styled(Box)` + padding: 16px; + margin-bottom: 16px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.05); + `; +} diff --git a/demos/react-supabase-time-based-sync/src/app/views/layout.tsx b/demos/react-supabase-time-based-sync/src/app/views/layout.tsx new file mode 100644 index 000000000..d56ea535a --- /dev/null +++ b/demos/react-supabase-time-based-sync/src/app/views/layout.tsx @@ -0,0 +1,149 @@ +import { ISSUES_ROUTE } from '@/app/router'; +import { useNavigationPanel } from '@/components/navigation/NavigationPanelContext'; +import { useSupabase } from '@/components/providers/SystemProvider'; +import BugReportIcon from '@mui/icons-material/BugReport'; +import MenuIcon from '@mui/icons-material/Menu'; +import NorthIcon from '@mui/icons-material/North'; +import SignalWifiOffIcon from '@mui/icons-material/SignalWifiOff'; +import SouthIcon from '@mui/icons-material/South'; +import WifiIcon from '@mui/icons-material/Wifi'; +import { + AppBar, + Box, + Divider, + Drawer, + IconButton, + List, + ListItem, + ListItemButton, + ListItemIcon, + ListItemText, + Menu, + MenuItem, + Toolbar, + Typography, + styled +} from '@mui/material'; +import { usePowerSync, useStatus } from '@powersync/react'; +import React from 'react'; +import { useNavigate } from 'react-router-dom'; + +type NavigationItem = { + path: string; + title: string; + icon: () => JSX.Element; + beforeNavigate?: () => Promise; +}; + +export default function ViewsLayout({ children }: { children: React.ReactNode }) { + const powerSync = usePowerSync(); + const status = useStatus(); + const supabase = useSupabase(); + const navigate = useNavigate(); + + const [openDrawer, setOpenDrawer] = React.useState(false); + const { title } = useNavigationPanel(); + + const [connectionAnchor, setConnectionAnchor] = React.useState(null); + + const NAVIGATION_ITEMS: NavigationItem[] = React.useMemo( + () => [ + { + path: ISSUES_ROUTE, + title: 'Issues', + icon: () => + } + ], + [] + ); + + return ( + + + + setOpenDrawer(!openDrawer)}> + + + + {title} + + + + { + setConnectionAnchor(event.currentTarget); + }}> + {status?.connected ? : } + + setConnectionAnchor(null)}> + {status?.connected || status?.connecting ? ( + { + setConnectionAnchor(null); + powerSync.disconnect(); + }}> + Disconnect + + ) : supabase ? ( + { + setConnectionAnchor(null); + powerSync.connect(supabase); + }}> + Connect + + ) : null} + + + + setOpenDrawer(false)}> + + + + {NAVIGATION_ITEMS.map((item) => ( + + { + await item.beforeNavigate?.(); + navigate(item.path); + setOpenDrawer(false); + }}> + {item.icon()} + + + + ))} + + + {children} + + ); +} + +namespace S { + export const MainBox = styled(Box)` + flex-grow: 1; + `; + + export const TopBar = styled(AppBar)` + margin-bottom: 20px; + `; + + export const PowerSyncLogo = styled('img')` + max-width: 250px; + max-height: 250px; + object-fit: contain; + padding: 20px; + `; +} diff --git a/demos/react-supabase-time-based-sync/src/components/navigation/NavigationPage.tsx b/demos/react-supabase-time-based-sync/src/components/navigation/NavigationPage.tsx new file mode 100644 index 000000000..ac0cc3072 --- /dev/null +++ b/demos/react-supabase-time-based-sync/src/components/navigation/NavigationPage.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { useNavigationPanel } from './NavigationPanelContext'; +import { Box, styled } from '@mui/material'; + +/** + * Wraps a component with automatic navigation panel title management + */ +export const NavigationPage: React.FC> = ({ title, children }) => { + const navigationPanel = useNavigationPanel(); + + React.useEffect(() => { + navigationPanel.setTitle(title); + + return () => navigationPanel.setTitle(''); + }, [title, navigationPanel]); + + return {children}; +}; + +namespace S { + export const Container = styled(Box)` + margin: 10px; + `; +} diff --git a/demos/react-supabase-time-based-sync/src/components/navigation/NavigationPanelContext.tsx b/demos/react-supabase-time-based-sync/src/components/navigation/NavigationPanelContext.tsx new file mode 100644 index 000000000..4745475d3 --- /dev/null +++ b/demos/react-supabase-time-based-sync/src/components/navigation/NavigationPanelContext.tsx @@ -0,0 +1,21 @@ +import React from 'react'; + +export type NavigationPanelController = { + setTitle: (title: string) => void; + title: string; +}; + +export const NavigationPanelContext = React.createContext({ + setTitle: () => { + throw new Error(`No NavigationPanelContext has been provided`); + }, + title: '' +}); + +export const NavigationPanelContextProvider = ({ children }: { children: React.ReactNode }) => { + const [title, setTitle] = React.useState(''); + + return {children}; +}; + +export const useNavigationPanel = () => React.useContext(NavigationPanelContext); diff --git a/demos/react-supabase-time-based-sync/src/components/providers/SystemProvider.tsx b/demos/react-supabase-time-based-sync/src/components/providers/SystemProvider.tsx new file mode 100644 index 000000000..618fd172f --- /dev/null +++ b/demos/react-supabase-time-based-sync/src/components/providers/SystemProvider.tsx @@ -0,0 +1,54 @@ +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, SyncClientImplementation } from '@powersync/web'; +import React, { Suspense } from 'react'; +import { NavigationPanelContextProvider } from '../navigation/NavigationPanelContext'; + +const SupabaseContext = React.createContext(null); +export const useSupabase = () => React.useContext(SupabaseContext); + +export const db = new PowerSyncDatabase({ + schema: AppSchema, + database: { + dbFilename: 'time.db' + } +}); + +export const SystemProvider = ({ children }: { children: React.ReactNode }) => { + const [connector] = React.useState(() => new SupabaseConnector()); + const [powerSync] = React.useState(db); + + React.useEffect(() => { + const logger = createBaseLogger(); + logger.useDefaults(); + logger.setLevel(LogLevel.DEBUG); + // For console testing purposes + (window as any)._powersync = powerSync; + + powerSync.init(); + const l = connector.registerListener({ + initialized: () => {}, + sessionStarted: () => { + powerSync.connect(connector, { clientImplementation: SyncClientImplementation.RUST }); + } + }); + + connector.init(); + + return () => l?.(); + }, [powerSync, connector]); + + return ( + }> + + + {children} + + + + ); +}; + +export default SystemProvider; diff --git a/demos/react-supabase-time-based-sync/src/components/providers/ThemeProviderContainer.tsx b/demos/react-supabase-time-based-sync/src/components/providers/ThemeProviderContainer.tsx new file mode 100644 index 000000000..9b9c1d61f --- /dev/null +++ b/demos/react-supabase-time-based-sync/src/components/providers/ThemeProviderContainer.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { createTheme, ThemeProvider } from '@mui/material/styles'; + +export const ThemeProviderContainer: React.FC> = ({ children }) => { + const theme = React.useMemo(() => { + return createTheme({ + palette: { + mode: 'dark', + primary: { + main: '#c44eff' + } + }, + typography: { + fontFamily: 'Rubik, sans-serif' + } + }); + }, []); + + return {children}; +}; diff --git a/demos/react-supabase-time-based-sync/src/components/widgets/IssueItemWidget.tsx b/demos/react-supabase-time-based-sync/src/components/widgets/IssueItemWidget.tsx new file mode 100644 index 000000000..1421cc1b1 --- /dev/null +++ b/demos/react-supabase-time-based-sync/src/components/widgets/IssueItemWidget.tsx @@ -0,0 +1,86 @@ +import { ISSUES_TABLE, IssueRecord } from '@/library/powersync/AppSchema'; +import DeleteIcon from '@mui/icons-material/DeleteOutline'; +import { Box, Chip, IconButton, ListItem, ListItemText, Paper, styled } from '@mui/material'; +import { usePowerSync } from '@powersync/react'; +import React from 'react'; + +export type IssueItemWidgetProps = { + issue: IssueRecord; +}; + +const priorityColor = (priority: string | null): 'default' | 'info' | 'warning' | 'error' => { + switch (priority) { + case 'low': + return 'default'; + case 'medium': + return 'info'; + case 'high': + return 'warning'; + case 'critical': + return 'error'; + default: + return 'default'; + } +}; + +const formatUpdatedAt = (value: string | null): string | null => { + if (value == null || value === '') return null; + const d = new Date(value); + return Number.isNaN(d.getTime()) ? value : d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }); +}; + +const statusColor = (status: string | null): 'default' | 'success' | 'primary' => { + switch (status) { + case 'open': + return 'primary'; + case 'closed': + return 'success'; + default: + return 'default'; + } +}; + +export const IssueItemWidget: React.FC = React.memo(({ issue }) => { + const powerSync = usePowerSync(); + + const deleteIssue = React.useCallback(async () => { + await powerSync.execute(`DELETE FROM ${ISSUES_TABLE} WHERE id = ?`, [issue.id]); + }, [issue.id]); + + return ( + + + + + }> + + {issue.title} + + + + } + secondary={ + + {issue.description} + {issue.updated_at && ( + + Updated: {formatUpdatedAt(issue.updated_at)} + + )} + + } + /> + + + ); +}); + +namespace S { + export const MainPaper = styled(Paper)` + margin-bottom: 10px; + `; +} diff --git a/demos/react-supabase-time-based-sync/src/index.html b/demos/react-supabase-time-based-sync/src/index.html new file mode 100644 index 000000000..3bd908ca9 --- /dev/null +++ b/demos/react-supabase-time-based-sync/src/index.html @@ -0,0 +1,12 @@ + + + + + + + + + +
+ + diff --git a/demos/react-supabase-time-based-sync/src/library/powersync/AppSchema.ts b/demos/react-supabase-time-based-sync/src/library/powersync/AppSchema.ts new file mode 100644 index 000000000..537a9db56 --- /dev/null +++ b/demos/react-supabase-time-based-sync/src/library/powersync/AppSchema.ts @@ -0,0 +1,24 @@ +import { column, Schema, Table } from '@powersync/web'; + +export const ISSUES_TABLE = 'issues'; + +const issues = new Table( + { + title: column.text, + description: column.text, + status: column.text, + priority: column.text, + created_by: column.text, + // Postgres TIMESTAMPTZ — replicated to SQLite as text by PowerSync + created_at: column.text, + updated_at: column.text + }, + { indexes: { updated: ['updated_at'] } } +); + +export const AppSchema = new Schema({ + issues +}); + +export type Database = (typeof AppSchema)['types']; +export type IssueRecord = Database['issues']; diff --git a/demos/react-supabase-time-based-sync/src/library/powersync/SupabaseConnector.ts b/demos/react-supabase-time-based-sync/src/library/powersync/SupabaseConnector.ts new file mode 100644 index 000000000..5a1cadca3 --- /dev/null +++ b/demos/react-supabase-time-based-sync/src/library/powersync/SupabaseConnector.ts @@ -0,0 +1,165 @@ +import { + AbstractPowerSyncDatabase, + BaseObserver, + CrudEntry, + PowerSyncBackendConnector, + UpdateType, + type PowerSyncCredentials +} from '@powersync/web'; + +import { Session, SupabaseClient, createClient } from '@supabase/supabase-js'; + +export type SupabaseConfig = { + supabaseUrl: string; + supabaseAnonKey: string; + powersyncUrl: string; +}; + +/// Postgres Response codes that we cannot recover from by retrying. +const FATAL_RESPONSE_CODES = [ + // Class 22 — Data Exception + new RegExp('^22...$'), + // Class 23 — Integrity Constraint Violation + new RegExp('^23...$'), + // INSUFFICIENT PRIVILEGE - typically a row-level security violation + new RegExp('^42501$') +]; + +export type SupabaseConnectorListener = { + initialized: () => void; + sessionStarted: (session: Session) => void; +}; + +export class SupabaseConnector extends BaseObserver implements PowerSyncBackendConnector { + readonly client: SupabaseClient; + readonly config: SupabaseConfig; + + ready: boolean; + + currentSession: Session | null; + + constructor() { + super(); + this.config = { + supabaseUrl: import.meta.env.VITE_SUPABASE_URL, + powersyncUrl: import.meta.env.VITE_POWERSYNC_URL, + supabaseAnonKey: import.meta.env.VITE_SUPABASE_ANON_KEY + }; + + this.client = createClient(this.config.supabaseUrl, this.config.supabaseAnonKey, { + auth: { + persistSession: true + } + }); + this.currentSession = null; + this.ready = false; + } + + async init() { + if (this.ready) { + return; + } + + let sessionResponse = await this.client.auth.getSession(); + if (sessionResponse.error) { + throw sessionResponse.error; + } + + if (!sessionResponse.data.session) { + const anonymousSignIn = await this.client.auth.signInAnonymously(); + if (anonymousSignIn.error) { + throw anonymousSignIn.error; + } + sessionResponse = await this.client.auth.getSession(); + if (sessionResponse.error) { + throw sessionResponse.error; + } + } + + this.updateSession(sessionResponse.data.session); + + this.ready = true; + this.iterateListeners((cb) => cb.initialized?.()); + } + + async fetchCredentials() { + let { + data: { session }, + error + } = await this.client.auth.getSession(); + + if (!session && !error) { + const anonymousSignIn = await this.client.auth.signInAnonymously(); + if (anonymousSignIn.error) { + throw anonymousSignIn.error; + } + const retry = await this.client.auth.getSession(); + session = retry.data.session; + error = retry.error; + } + + if (!session || error) { + throw new Error(`Could not fetch Supabase credentials: ${error}`); + } + + console.debug('session expires at', session.expires_at); + + return { + endpoint: this.config.powersyncUrl, + token: session.access_token ?? '' + } satisfies PowerSyncCredentials; + } + + async uploadData(database: AbstractPowerSyncDatabase): Promise { + const transaction = await database.getNextCrudTransaction(); + + if (!transaction) { + return; + } + + let lastOp: CrudEntry | null = null; + try { + for (const op of transaction.crud) { + lastOp = op; + const table = this.client.from(op.table); + let result: any; + switch (op.op) { + case UpdateType.PUT: + const record = { ...op.opData, id: op.id }; + result = await table.upsert(record); + break; + case UpdateType.PATCH: + result = await table.update(op.opData).eq('id', op.id); + break; + case UpdateType.DELETE: + result = await table.delete().eq('id', op.id); + break; + } + + if (result.error) { + console.error(result.error); + result.error.message = `Could not update Supabase. Received error: ${result.error.message}`; + throw result.error; + } + } + + await transaction.complete(); + } catch (ex: any) { + console.debug(ex); + if (typeof ex.code == 'string' && FATAL_RESPONSE_CODES.some((regex) => regex.test(ex.code))) { + console.error('Data upload error - discarding:', lastOp, ex); + await transaction.complete(); + } else { + throw ex; + } + } + } + + updateSession(session: Session | null) { + this.currentSession = session; + if (!session) { + return; + } + this.iterateListeners((cb) => cb.sessionStarted?.(session)); + } +} diff --git a/demos/react-supabase-time-based-sync/src/library/powersync/vite-env.d.ts b/demos/react-supabase-time-based-sync/src/library/powersync/vite-env.d.ts new file mode 100644 index 000000000..e3e71b5ba --- /dev/null +++ b/demos/react-supabase-time-based-sync/src/library/powersync/vite-env.d.ts @@ -0,0 +1,11 @@ +/// + +interface ImportMetaEnv { + readonly VITE_SUPABASE_URL: string; + readonly VITE_SUPABASE_ANON_KEY: string; + readonly VITE_POWERSYNC_URL: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/demos/react-supabase-time-based-sync/supabase/config.toml b/demos/react-supabase-time-based-sync/supabase/config.toml new file mode 100644 index 000000000..45c22036e --- /dev/null +++ b/demos/react-supabase-time-based-sync/supabase/config.toml @@ -0,0 +1,85 @@ +# A string used to distinguish different Supabase projects on the same host. +project_id = "react-supabase-time-based-sync" + +[api] +enabled = true +port = 54321 +schemas = ["public", "storage", "graphql_public"] +extra_search_path = ["public", "extensions"] +max_rows = 1000 + +[db] +port = 54322 +shadow_port = 54320 +major_version = 15 + +[db.pooler] +enabled = false +port = 54329 +pool_mode = "transaction" +default_pool_size = 20 +max_client_conn = 100 + +[realtime] +enabled = true + +[studio] +enabled = true +port = 54323 +api_url = "http://127.0.0.1" + +[inbucket] +enabled = true +port = 54324 + +[storage] +enabled = true +file_size_limit = "50MiB" + +[auth] +enabled = true +site_url = "http://127.0.0.1:5173" +additional_redirect_urls = ["http://127.0.0.1:5173"] +jwt_expiry = 3600 +enable_refresh_token_rotation = true +refresh_token_reuse_interval = 10 +enable_signup = true +enable_anonymous_sign_ins = true + +[auth.email] +enable_signup = true +double_confirm_changes = true +enable_confirmations = false + +[auth.sms] +enable_signup = true +enable_confirmations = false +template = "Your code is {{ .Code }} ." + +[auth.sms.test_otp] + +[auth.sms.twilio] +enabled = false +account_sid = "" +message_service_sid = "" +auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" + +[auth.external.apple] +enabled = false +client_id = "" +secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" +redirect_uri = "" +url = "" + +[analytics] +enabled = false +port = 54327 +vector_port = 54328 +backend = "postgres" + +[experimental] +orioledb_version = "" +s3_host = "env(S3_HOST)" +s3_region = "env(S3_REGION)" +s3_access_key = "env(S3_ACCESS_KEY)" +s3_secret_key = "env(S3_SECRET_KEY)" diff --git a/demos/react-supabase-time-based-sync/supabase/migrations/20260312000000_init_issues.sql b/demos/react-supabase-time-based-sync/supabase/migrations/20260312000000_init_issues.sql new file mode 100644 index 000000000..c922085f5 --- /dev/null +++ b/demos/react-supabase-time-based-sync/supabase/migrations/20260312000000_init_issues.sql @@ -0,0 +1,66 @@ +CREATE TABLE IF NOT EXISTS public.issues ( + id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, + title TEXT NOT NULL, + description TEXT, + status TEXT NOT NULL DEFAULT 'open', + priority TEXT NOT NULL DEFAULT 'medium', + created_by UUID REFERENCES auth.users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +ALTER TABLE public.issues ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "Authenticated users can read issues" ON public.issues; +CREATE POLICY "Authenticated users can read issues" + ON public.issues FOR SELECT + TO authenticated + USING (true); + +DROP POLICY IF EXISTS "Authenticated users can insert issues" ON public.issues; +CREATE POLICY "Authenticated users can insert issues" + ON public.issues FOR INSERT + TO authenticated + WITH CHECK (true); + +DROP POLICY IF EXISTS "Authenticated users can update issues" ON public.issues; +CREATE POLICY "Authenticated users can update issues" + ON public.issues FOR UPDATE + TO authenticated + USING (true); + +DROP POLICY IF EXISTS "Authenticated users can delete issues" ON public.issues; +CREATE POLICY "Authenticated users can delete issues" + ON public.issues FOR DELETE + TO authenticated + USING (true); + +DO +$$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_publication_tables + WHERE pubname = 'supabase_realtime' + AND schemaname = 'public' + AND tablename = 'issues' + ) THEN + ALTER PUBLICATION supabase_realtime ADD TABLE public.issues; + END IF; +END +$$; + +INSERT INTO public.issues (title, description, status, priority, created_at, updated_at) +VALUES + ('App crashes on login', 'Users report a crash when tapping the login button on iOS 17.', 'open', 'critical', timestamptz '2026-01-15 10:30:00+00', timestamptz '2026-01-15 10:30:00+00'), + ('Update onboarding copy', 'Marketing wants to revise the welcome screen text.', 'open', 'low', timestamptz '2026-01-15 14:00:00+00', timestamptz '2026-01-15 14:00:00+00'), + ('Fix broken pagination', 'The "next page" button returns an empty list after page 5.', 'open', 'high', timestamptz '2026-01-14 09:00:00+00', timestamptz '2026-01-14 09:00:00+00'), + ('Add dark mode toggle', 'Users have requested a dark mode option in settings.', 'open', 'medium', timestamptz '2026-01-14 11:30:00+00', timestamptz '2026-01-14 11:30:00+00'), + ('Database migration failing', 'Migration 042 fails on Postgres 15 due to deprecated syntax.', 'open', 'critical', timestamptz '2026-01-10 08:00:00+00', timestamptz '2026-01-10 08:00:00+00'), + ('Improve search performance', 'Search queries over 10k rows take >2s. Add proper indexing.', 'open', 'high', timestamptz '2026-01-10 16:45:00+00', timestamptz '2026-01-10 16:45:00+00'), + ('Typo in error message', 'Error says "Somthing went wrong" instead of "Something went wrong".', 'closed', 'low', timestamptz '2026-01-10 12:00:00+00', timestamptz '2026-01-10 12:00:00+00'), + ('Add CSV export', 'Allow users to export their data as CSV from the dashboard.', 'open', 'medium', timestamptz '2026-01-07 10:00:00+00', timestamptz '2026-01-07 10:00:00+00'), + ('Session timeout too short', 'Users are logged out after 5 minutes of inactivity. Increase to 30.', 'closed', 'high', timestamptz '2026-01-07 15:30:00+00', timestamptz '2026-01-07 15:30:00+00'), + ('Add rate limiting', 'API endpoints have no rate limiting. Add 100 req/min per user.', 'open', 'high', timestamptz '2026-01-05 09:15:00+00', timestamptz '2026-01-05 09:15:00+00'), + ('Fix timezone display', 'Times are shown in UTC instead of the user''s local timezone.', 'closed', 'medium', timestamptz '2026-01-05 13:00:00+00', timestamptz '2026-01-05 13:00:00+00') +ON CONFLICT (id) DO NOTHING; diff --git a/demos/react-supabase-time-based-sync/supabase/migrations/20260312001000_create_powersync_publication.sql b/demos/react-supabase-time-based-sync/supabase/migrations/20260312001000_create_powersync_publication.sql new file mode 100644 index 000000000..1b1f9aeb8 --- /dev/null +++ b/demos/react-supabase-time-based-sync/supabase/migrations/20260312001000_create_powersync_publication.sql @@ -0,0 +1,23 @@ +DO +$$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_publication WHERE pubname = 'powersync') THEN + CREATE PUBLICATION powersync FOR ALL TABLES; + END IF; +END +$$; + +DO +$$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_publication_tables + WHERE pubname = 'powersync' + AND schemaname = 'public' + AND tablename = 'issues' + ) THEN + ALTER PUBLICATION powersync ADD TABLE public.issues; + END IF; +END +$$; diff --git a/demos/react-supabase-time-based-sync/tsconfig.json b/demos/react-supabase-time-based-sync/tsconfig.json new file mode 100644 index 000000000..2a27e9673 --- /dev/null +++ b/demos/react-supabase-time-based-sync/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/demos/react-supabase-time-based-sync/tsconfig.node.json b/demos/react-supabase-time-based-sync/tsconfig.node.json new file mode 100644 index 000000000..a8583534f --- /dev/null +++ b/demos/react-supabase-time-based-sync/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.mts"] +} diff --git a/demos/react-supabase-time-based-sync/vite.config.mts b/demos/react-supabase-time-based-sync/vite.config.mts new file mode 100644 index 000000000..fb5075fac --- /dev/null +++ b/demos/react-supabase-time-based-sync/vite.config.mts @@ -0,0 +1,30 @@ +import { fileURLToPath, URL } from 'url'; + +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +// https://vitejs.dev/config/ +export default defineConfig({ + root: 'src', + build: { + outDir: '../dist', + rollupOptions: { + input: 'src/index.html' + }, + emptyOutDir: true + }, + resolve: { + alias: [{ find: '@', replacement: fileURLToPath(new URL('./src', import.meta.url)) }] + }, + publicDir: '../public', + envDir: '..', // Use this dir for env vars, not 'src'. + optimizeDeps: { + // Don't optimize these packages as they contain web workers and WASM files. + // https://github.com/vitejs/vite/issues/11672#issuecomment-1415820673 + exclude: ['@powersync/web'] + }, + plugins: [react()], + worker: { + format: 'es' + } +});