diff --git a/.prettierignore b/.prettierignore index 325716636..4356af16e 100644 --- a/.prettierignore +++ b/.prettierignore @@ -12,5 +12,7 @@ **/assets/** **/bin/** **/ios/** +**/coverage/** +**/routeTree.gen.ts pnpm-lock.yaml diff --git a/demos/react-neon-tanstack-query-notes/.env.example b/demos/react-neon-tanstack-query-notes/.env.example new file mode 100644 index 000000000..612890108 --- /dev/null +++ b/demos/react-neon-tanstack-query-notes/.env.example @@ -0,0 +1,11 @@ +# NEON_DATA_API_URL, required, the URL of your Neon Data API +VITE_NEON_DATA_API_URL=https://data-api-url +# NEON_AUTH_URL, required, your Neon Auth URL +VITE_NEON_AUTH_URL=https://neon-auth-url + +# DATABASE_URL, optional, only if you want to run drizzle migrations with bun db:migrate +DATABASE_URL=your-database-url + + +# PowerSync instance URL, for PowerSync Cloud obtain from dashboard. Otherwise, the URL of your self-hosted PowerSync Service +VITE_POWERSYNC_URL=https://foo.powersync.journeyapps.com \ No newline at end of file diff --git a/demos/react-neon-tanstack-query-notes/.gitignore b/demos/react-neon-tanstack-query-notes/.gitignore new file mode 100644 index 000000000..948934e7c --- /dev/null +++ b/demos/react-neon-tanstack-query-notes/.gitignore @@ -0,0 +1,33 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Environment variables +.env +.env.local +.env.development +.env.production + +# Ignored by parent .gitignore +!src/lib diff --git a/demos/react-neon-tanstack-query-notes/README.md b/demos/react-neon-tanstack-query-notes/README.md new file mode 100644 index 000000000..cf830c3df --- /dev/null +++ b/demos/react-neon-tanstack-query-notes/README.md @@ -0,0 +1,214 @@ +# note. + +This project demonstrates how to build a note-taking application using Neon's Data API (powered by PostgREST), Neon Auth for authentication and PowerSync for real-time updates and offline support. Instead of using traditional database access via a backend, or even a backend at all, this demo showcases how to leverage PowerSync for SQLite queries of replicated Postgres data with a very elegant JS SDK. + +**Note:** this demo was forked from [neon-data-api-neon-auth](https://github.com/neondatabase-labs/neon-data-api-neon-auth) to provide Neon users with a migration example of how to add PowerSync to an existing Neon project. This README provides only basic instructions for setting up the demo. Please refer to the [PowerSync / Neon integration guide](https://docs.powersync.com/integration-guides/neon-+-powersync) for more complete instructions. + +**PowerSync JS SDK** + +- SQLite queries of replicated dynamic subsets of Postgres data +- Real-time updates and offline support +- ORM support + +**Neon Data API (PostgREST-compatible)** + +- Instant REST API for your Postgres database +- Built-in filtering, pagination, and relationships +- Automatic OpenAPI documentation + +This demo is built with: + +- [Neon](https://neon.tech) — Serverless Postgres +- [Neon Auth](https://neon.com/docs/auth/overview) — Authentication with automatic JWT integration +- [Neon Data API](https://neon.com/docs/data-api/get-started) — Direct database access from the frontend, used for sending client mutations (that PowerSync queues in SQLite) to the backend +- [PowerSync Cloud](https://powersync.com) — Backend DB to SQLite Sync Engine +- [PowerSync JS SDK](https://powersync.com/docs/js-sdk/get-started) — Client SQLite interface to synced data +- [PowerSync TanStack Query](https://docs.powersync.com/client-sdk-references/javascript-web/javascript-spa-frameworks#tanstack-query) — Brings TanStack’s advanced asynchronous state management features to the PowerSync JS SDK +- [PowerSync Drizzle Driver](https://docs.powersync.com/client-sdk-references/javascript-web/javascript-orm/drizzle) - ORM driver for Drizzle + +## Prerequisites + +Before you begin, ensure you have: + +- [pnpm](https://pnpm.io/) (v9.0 or newer) installed +- A [Neon account](https://console.neon.tech/signup) (free tier works) +- A [PowerSync account](https://powersync.com) (free tier works, self hosting also available) + +## Getting Started + +### 1. Create a Neon Project with Auth and Data API + +1. Go to [pg.new](https://pg.new) to create a new Neon project +2. In the Neon Console, navigate to your project and enable: + - **Neon Auth** — Go to the **Auth** page in the left sidebar and follow the setup wizard + - **Data API** — Go to the **Data API** page in the left sidebar and enable it + +For detailed instructions, see: + +- [Getting started with Neon Auth](https://neon.com/docs/auth/overview) +- [Getting started with Data API](https://neon.com/docs/data-api/get-started) + +### 2. Clone and Install + +```bash +git clone https://github.com/powersync-ja/powersync-js.git +cd powersync-js +pnpm install +pnpm build:packages +cd demos/react-neon-tanstack-query-notes +``` + +### 3. Configure Environment Variables + +Create a `.env` file in the project root: + +```env +# Neon Data API URL +# Find this in Neon Console → Data API page → "Data API URL" +VITE_NEON_DATA_API_URL=https://your-project-id.data-api.neon.tech + +# Neon Auth Base URL +# Find this in Neon Console → Auth page → "Auth Base URL" +# Note this comment: https://github.com/neondatabase/neon-data-api-neon-auth/pull/10#discussion_r2614978813 +VITE_NEON_AUTH_URL=https://your-project-id.auth.neon.tech + +# Database Connection String (for migrations) +# Find this in Neon Console → Dashboard → Connection string (select "Pooled connection") +DATABASE_URL=postgresql://user:password@your-project-id.pooler.region.neon.tech/neondb?sslmode=require + +####### PowerSync Config ########## +# PowerSync instance URL, for PowerSync Cloud obtain from dashboard otherwise url of your self-hosted PowerSync Service +VITE_POWERSYNC_URL=https://foo.powersync.journeyapps.com +``` + +### 4. Set Up the Database + +Run the migration to create the tables and RLS policies: + +```bash +pnpm db:migrate +``` + +This will: + +- Grant appropriate permissions to the `authenticated` and `anonymous` database roles +- Create the `notes` and `paragraphs` tables with RLS policies + +### 5. Configure logical replication for PowerSync + +PowerSync uses logical replication to sync data from your Neon project to your PowerSync instance, which is then synced to your SQLite database in the client. To configure logical replication follow the instructions in the [PowerSync documentation](https://docs.powersync.com/installation/database-setup#neon). + +### 6. Connect PowerSync to your Neon project + +In the PowerSync dashboard, create a project, an instance and then create a database connection to your Neon database using the credentials from the "Connect" button in the Neon Console. + +### 7. Configure PowerSync auth and Sync Rules + +### Auth + +Navigate to "Client Auth" in the PowerSync dashboard and configure: + +- Select "Enable development tokens" +- Populate the "JWKS URI" with the value from the "JWKS URL" field in the Neon Console → Auth page +- Populate the "JWT Audience" with your project root URL (e.g., `https://ep-restless-resonance-adom1z4w.neonauth.c-2.us-east-1.aws.neon.tech/`) + +### Sync Rules + +Navigate to "Sync Rules" in the PowerSync dashboard and configure these sync rules: + +```yaml +config: + edition: 2 + +bucket_definitions: + by_user: + # Only sync rows belonging to the user + parameters: SELECT id as note_id FROM notes WHERE owner_id = request.user_id() + data: + - SELECT * FROM notes WHERE id = bucket.note_id + - SELECT * FROM paragraphs WHERE note_id = bucket.note_id + # Sync all shared notes to all users (not recommended for production) + shared_notes: + parameters: SELECT id as note_id from notes where shared = TRUE + data: + - SELECT * FROM notes WHERE id = bucket.note_id + - SELECT * FROM paragraphs WHERE note_id = bucket.note_id +``` + +### 8. Test Sync + +You can use the Sync Test to validate your Sync Rules, but since your app won't have any data at this point yet, you can skip this step for now. + +Click on "Sync Test" test in the PowerSync dashboard, and enter the UUID of a user in your Neon Auth database to generate a test JWT. Then, click "Launch Sync Diagnostics Client" to test the sync rules. + +### 9. Start the Development Server + +```bash +pnpm dev +``` + +Open [http://localhost:5173](http://localhost:5173) in your browser. + +## Deployment on Vercel + +### 1. Push to GitHub + +If you haven't already, push your code to a GitHub repository: + +```bash +git init +git add . +git commit -m "Initial commit" +git remote add origin https://github.com/YOUR_USERNAME/YOUR_REPO.git +git push -u origin main +``` + +### 2. Import Project in Vercel + +1. Go to [vercel.com](https://vercel.com) and sign in (or create an account) +2. Click **"Add New..."** → **"Project"** +3. Select **"Import Git Repository"** and choose your repository +4. Vercel will auto-detect the Vite framework + +### 3. Configure Environment Variables + +In the Vercel project settings, add these environment variables: + +| Variable | Value | Where to find it | +| ------------------------ | -------------------------------------------- | ----------------------------- | +| `VITE_NEON_DATA_API_URL` | `https://your-project-id.data-api.neon.tech` | Neon Console → Data API page | +| `VITE_NEON_AUTH_URL` | `https://your-project-id.auth.neon.tech` | Neon Console → Auth page | +| `VITE_POWERSYNC_URL` | `https://foo.powersync.journeyapps.com` | PowerSync Dashboard → Connect | + +> **Note:** You don't need `DATABASE_URL` on Vercel — migrations are run locally during development. + +### 4. Deploy + +Click **"Deploy"** and wait for the build to complete. Your app will be live at `your-project.vercel.app`. + +### 5. Update Allowed Origins (Important!) + +After deployment, update your Neon Auth settings to allow your Vercel domain: + +1. Go to Neon Console → Auth page +2. Add your Vercel URL (e.g., `https://your-project.vercel.app`) to the allowed origins + +## Development Notes + +### Schema Changes + +If you modify `src/db/schema.ts`, generate new migrations with: + +```bash +pnpm db:generate +pnpm db:migrate +``` + +The `db:generate` command creates SQL migration files in the `/drizzle` folder based on your schema changes. You only need this when changing the database schema. + +## Learn More + +- [Neon Data API Documentation](https://neon.com/docs/data-api/get-started) +- [Neon Data API Tutorial](https://neon.com/docs/data-api/demo) +- [Neon Auth Documentation](https://neon.com/docs/auth/overview) +- [PowerSync Documentation](https://docs.powersync.com) diff --git a/demos/react-neon-tanstack-query-notes/components.json b/demos/react-neon-tanstack-query-notes/components.json new file mode 100644 index 000000000..13e1db0b7 --- /dev/null +++ b/demos/react-neon-tanstack-query-notes/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/demos/react-neon-tanstack-query-notes/drizzle.config.ts b/demos/react-neon-tanstack-query-notes/drizzle.config.ts new file mode 100644 index 000000000..00723e58d --- /dev/null +++ b/demos/react-neon-tanstack-query-notes/drizzle.config.ts @@ -0,0 +1,11 @@ +import 'dotenv/config'; +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + out: './drizzle', + schema: './src/db/schema.ts', + dialect: 'postgresql', + dbCredentials: { + url: process.env.DATABASE_URL! + } +}); diff --git a/demos/react-neon-tanstack-query-notes/drizzle/0000_known_leader.sql b/demos/react-neon-tanstack-query-notes/drizzle/0000_known_leader.sql new file mode 100644 index 000000000..eda851135 --- /dev/null +++ b/demos/react-neon-tanstack-query-notes/drizzle/0000_known_leader.sql @@ -0,0 +1,161 @@ +CREATE TABLE "notes" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid () NOT NULL, + "owner_id" text DEFAULT auth.user_id () NOT NULL, + "title" text DEFAULT 'untitled note' NOT NULL, + "created_at" timestamp + with + time zone DEFAULT now (), + "updated_at" timestamp + with + time zone DEFAULT now (), + "shared" boolean DEFAULT false +); + +--> statement-breakpoint +ALTER TABLE "notes" ENABLE ROW LEVEL SECURITY; + +--> statement-breakpoint +CREATE TABLE "paragraphs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid () NOT NULL, + "note_id" uuid, + "content" text NOT NULL, + "created_at" timestamp + with + time zone DEFAULT now () +); + +--> statement-breakpoint +ALTER TABLE "paragraphs" ENABLE ROW LEVEL SECURITY; + +--> statement-breakpoint +ALTER TABLE "paragraphs" ADD CONSTRAINT "paragraphs_note_id_notes_id_fk" FOREIGN KEY ("note_id") REFERENCES "public"."notes" ("id") ON DELETE no action ON UPDATE no action; + +--> statement-breakpoint +CREATE INDEX "owner_idx" ON "notes" USING btree ("owner_id"); + +--> statement-breakpoint +CREATE POLICY "crud-authenticated-policy-select" ON "notes" AS PERMISSIVE FOR +SELECT + TO "authenticated" USING ( + ( + select + auth.user_id () = "notes"."owner_id" + ) + ); + +--> statement-breakpoint +CREATE POLICY "crud-authenticated-policy-insert" ON "notes" AS PERMISSIVE FOR INSERT TO "authenticated" +WITH + CHECK ( + ( + select + auth.user_id () = "notes"."owner_id" + ) + ); + +--> statement-breakpoint +CREATE POLICY "crud-authenticated-policy-update" ON "notes" AS PERMISSIVE FOR +UPDATE TO "authenticated" USING ( + ( + select + auth.user_id () = "notes"."owner_id" + ) +) +WITH + CHECK ( + ( + select + auth.user_id () = "notes"."owner_id" + ) + ); + +--> statement-breakpoint +CREATE POLICY "crud-authenticated-policy-delete" ON "notes" AS PERMISSIVE FOR DELETE TO "authenticated" USING ( + ( + select + auth.user_id () = "notes"."owner_id" + ) +); + +--> statement-breakpoint +CREATE POLICY "shared_policy" ON "notes" AS PERMISSIVE FOR +SELECT + TO "authenticated" USING ("notes"."shared" = true); + +--> statement-breakpoint +CREATE POLICY "crud-authenticated-policy-select" ON "paragraphs" AS PERMISSIVE FOR +SELECT + TO "authenticated" USING ( + ( + select + notes.owner_id = auth.user_id () + from + notes + where + notes.id = "paragraphs"."note_id" + ) + ); + +--> statement-breakpoint +CREATE POLICY "crud-authenticated-policy-insert" ON "paragraphs" AS PERMISSIVE FOR INSERT TO "authenticated" +WITH + CHECK ( + ( + select + notes.owner_id = auth.user_id () + from + notes + where + notes.id = "paragraphs"."note_id" + ) + ); + +--> statement-breakpoint +CREATE POLICY "crud-authenticated-policy-update" ON "paragraphs" AS PERMISSIVE FOR +UPDATE TO "authenticated" USING ( + ( + select + notes.owner_id = auth.user_id () + from + notes + where + notes.id = "paragraphs"."note_id" + ) +) +WITH + CHECK ( + ( + select + notes.owner_id = auth.user_id () + from + notes + where + notes.id = "paragraphs"."note_id" + ) + ); + +--> statement-breakpoint +CREATE POLICY "crud-authenticated-policy-delete" ON "paragraphs" AS PERMISSIVE FOR DELETE TO "authenticated" USING ( + ( + select + notes.owner_id = auth.user_id () + from + notes + where + notes.id = "paragraphs"."note_id" + ) +); + +--> statement-breakpoint +CREATE POLICY "shared_policy" ON "paragraphs" AS PERMISSIVE FOR +SELECT + TO "authenticated" USING ( + ( + select + notes.shared + from + notes + where + notes.id = "paragraphs"."note_id" + ) + ); diff --git a/demos/react-neon-tanstack-query-notes/drizzle/meta/0000_snapshot.json b/demos/react-neon-tanstack-query-notes/drizzle/meta/0000_snapshot.json new file mode 100644 index 000000000..b56bcda98 --- /dev/null +++ b/demos/react-neon-tanstack-query-notes/drizzle/meta/0000_snapshot.json @@ -0,0 +1,213 @@ +{ + "id": "2a1953eb-7b72-4e2d-8fbe-46646a405d4b", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.notes": { + "name": "notes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "auth.user_id()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'untitled note'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "shared": { + "name": "shared", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "indexes": { + "owner_idx": { + "name": "owner_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "crud-authenticated-policy-select": { + "name": "crud-authenticated-policy-select", + "as": "PERMISSIVE", + "for": "SELECT", + "to": ["authenticated"], + "using": "(select auth.user_id() = \"notes\".\"owner_id\")" + }, + "crud-authenticated-policy-insert": { + "name": "crud-authenticated-policy-insert", + "as": "PERMISSIVE", + "for": "INSERT", + "to": ["authenticated"], + "withCheck": "(select auth.user_id() = \"notes\".\"owner_id\")" + }, + "crud-authenticated-policy-update": { + "name": "crud-authenticated-policy-update", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": ["authenticated"], + "using": "(select auth.user_id() = \"notes\".\"owner_id\")", + "withCheck": "(select auth.user_id() = \"notes\".\"owner_id\")" + }, + "crud-authenticated-policy-delete": { + "name": "crud-authenticated-policy-delete", + "as": "PERMISSIVE", + "for": "DELETE", + "to": ["authenticated"], + "using": "(select auth.user_id() = \"notes\".\"owner_id\")" + }, + "shared_policy": { + "name": "shared_policy", + "as": "PERMISSIVE", + "for": "SELECT", + "to": ["authenticated"], + "using": "\"notes\".\"shared\" = true" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.paragraphs": { + "name": "paragraphs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "note_id": { + "name": "note_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "paragraphs_note_id_notes_id_fk": { + "name": "paragraphs_note_id_notes_id_fk", + "tableFrom": "paragraphs", + "tableTo": "notes", + "columnsFrom": ["note_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "crud-authenticated-policy-select": { + "name": "crud-authenticated-policy-select", + "as": "PERMISSIVE", + "for": "SELECT", + "to": ["authenticated"], + "using": "(select notes.owner_id = auth.user_id() from notes where notes.id = \"paragraphs\".\"note_id\")" + }, + "crud-authenticated-policy-insert": { + "name": "crud-authenticated-policy-insert", + "as": "PERMISSIVE", + "for": "INSERT", + "to": ["authenticated"], + "withCheck": "(select notes.owner_id = auth.user_id() from notes where notes.id = \"paragraphs\".\"note_id\")" + }, + "crud-authenticated-policy-update": { + "name": "crud-authenticated-policy-update", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": ["authenticated"], + "using": "(select notes.owner_id = auth.user_id() from notes where notes.id = \"paragraphs\".\"note_id\")", + "withCheck": "(select notes.owner_id = auth.user_id() from notes where notes.id = \"paragraphs\".\"note_id\")" + }, + "crud-authenticated-policy-delete": { + "name": "crud-authenticated-policy-delete", + "as": "PERMISSIVE", + "for": "DELETE", + "to": ["authenticated"], + "using": "(select notes.owner_id = auth.user_id() from notes where notes.id = \"paragraphs\".\"note_id\")" + }, + "shared_policy": { + "name": "shared_policy", + "as": "PERMISSIVE", + "for": "SELECT", + "to": ["authenticated"], + "using": "(select notes.shared from notes where notes.id = \"paragraphs\".\"note_id\")" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/demos/react-neon-tanstack-query-notes/drizzle/meta/_journal.json b/demos/react-neon-tanstack-query-notes/drizzle/meta/_journal.json new file mode 100644 index 000000000..e11847cdb --- /dev/null +++ b/demos/react-neon-tanstack-query-notes/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1745964454319, + "tag": "0000_known_leader", + "breakpoints": true + } + ] +} diff --git a/demos/react-neon-tanstack-query-notes/index.html b/demos/react-neon-tanstack-query-notes/index.html new file mode 100644 index 000000000..4c283f741 --- /dev/null +++ b/demos/react-neon-tanstack-query-notes/index.html @@ -0,0 +1,13 @@ + + + + + + + note. + + +
+ + + diff --git a/demos/react-neon-tanstack-query-notes/package.json b/demos/react-neon-tanstack-query-notes/package.json new file mode 100644 index 000000000..746c86120 --- /dev/null +++ b/demos/react-neon-tanstack-query-notes/package.json @@ -0,0 +1,55 @@ +{ + "name": "note", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview", + "predb:migrate": "tsx scripts/preMigrate.ts", + "db:migrate": "drizzle-kit migrate", + "db:generate": "drizzle-kit generate" + }, + "dependencies": { + "@journeyapps/wa-sqlite": "^1.4.1", + "@neondatabase/neon-js": "^0.1.0-alpha.6", + "@neondatabase/serverless": "^1.0.2", + "@powersync/drizzle-driver": "workspace:*", + "@powersync/react": "workspace:*", + "@powersync/tanstack-react-query": "workspace:*", + "@powersync/web": "workspace:*", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-toggle": "^1.1.10", + "@tailwindcss/vite": "^4.1.17", + "@tanstack/react-query": "^5.90.11", + "@tanstack/react-router": "^1.139.10", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "drizzle-kit": "^0.31.7", + "drizzle-orm": "^0.44.7", + "lucide-react": "^0.503.0", + "moment": "^2.30.1", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "tailwind-merge": "^3.4.0", + "tailwindcss": "^4.1.17", + "unique-names-generator": "^4.7.1" + }, + "devDependencies": { + "@tanstack/react-router-devtools": "^1.139.10", + "@tanstack/router-plugin": "^1.139.10", + "@types/node": "^22.19.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^4.7.0", + "dotenv": "^16.6.1", + "globals": "^16.5.0", + "tsx": "^4.20.6", + "tw-animate-css": "^1.4.0", + "typescript": "~5.7.3", + "vite": "^6.4.1", + "vite-plugin-wasm": "^3.5.0" + } +} diff --git a/demos/react-neon-tanstack-query-notes/public/favicon.ico b/demos/react-neon-tanstack-query-notes/public/favicon.ico new file mode 100644 index 000000000..70dc7d8a9 Binary files /dev/null and b/demos/react-neon-tanstack-query-notes/public/favicon.ico differ diff --git a/demos/react-neon-tanstack-query-notes/scripts/preMigrate.ts b/demos/react-neon-tanstack-query-notes/scripts/preMigrate.ts new file mode 100644 index 000000000..7e7ee5341 --- /dev/null +++ b/demos/react-neon-tanstack-query-notes/scripts/preMigrate.ts @@ -0,0 +1,38 @@ +import 'dotenv/config'; +import { neon } from '@neondatabase/serverless'; +import { drizzle } from 'drizzle-orm/neon-http'; +import { sql } from 'drizzle-orm'; + +async function main() { + const url = process.env.DATABASE_URL; + if (!url) { + console.error('[preMigrate] DATABASE_URL is not set'); + process.exit(1); + } + + // Use Neon's serverless HTTP client for consistency with the project + const http = neon(url); + const db = drizzle(http); + + await db.execute(sql` + GRANT + SELECT + , + UPDATE, + INSERT, + DELETE ON ALL TABLES IN SCHEMA public TO authenticated; + `); + + await db.execute(sql` + GRANT + SELECT + ON ALL TABLES IN SCHEMA public TO anonymous; + `); + + console.log('[preMigrate] Successfully executed GRANT statements'); +} + +main().catch((err) => { + console.error('[preMigrate] Error executing pre-migration grants:', err); + process.exit(1); +}); diff --git a/demos/react-neon-tanstack-query-notes/src/app.tsx b/demos/react-neon-tanstack-query-notes/src/app.tsx new file mode 100644 index 000000000..f82f8b4a2 --- /dev/null +++ b/demos/react-neon-tanstack-query-notes/src/app.tsx @@ -0,0 +1,81 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { RouterProvider, createRouter } from '@tanstack/react-router'; +import { StrictMode, useEffect } from 'react'; + +import { PowerSyncContext } from '@powersync/react'; +import { connectPowerSync, neonConnector, powersync } from '@/lib/powersync'; + +// Import the generated route tree +import { routeTree } from './routeTree.gen'; + +// Create a new router instance +const router = createRouter({ + routeTree, + defaultPreload: 'intent', + context: { + accessToken: null + } +}); + +// Create a client +const queryClient = new QueryClient(); + +// Register the router instance for type safety +declare module '@tanstack/react-router' { + interface Register { + router: typeof router; + } +} + +function App() { + return ( + + + + + + + + + ); +} + +function PowerSyncAuthBridge() { + useEffect(() => { + // Initialize the connector and PowerSync + const initConnector = async () => { + await powersync.init(); + await neonConnector.init(); + + // Expose for console debugging + (window as any).powersync = powersync; + }; + + // Listen for session changes + const unsubscribe = neonConnector.registerListener({ + initialized: () => { + // If already have a session after init, connect PowerSync + if (neonConnector.currentSession) { + connectPowerSync(); + } + }, + sessionStarted: () => { + connectPowerSync(); + } + }); + + initConnector(); + + return () => { + unsubscribe?.(); + }; + }, []); + + return null; +} + +function RouterWithAuth() { + return ; +} + +export default App; diff --git a/demos/react-neon-tanstack-query-notes/src/assets/react.svg b/demos/react-neon-tanstack-query-notes/src/assets/react.svg new file mode 100644 index 000000000..6c87de9bb --- /dev/null +++ b/demos/react-neon-tanstack-query-notes/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/demos/react-neon-tanstack-query-notes/src/components/Footer.tsx b/demos/react-neon-tanstack-query-notes/src/components/Footer.tsx new file mode 100644 index 000000000..04c8f22c8 --- /dev/null +++ b/demos/react-neon-tanstack-query-notes/src/components/Footer.tsx @@ -0,0 +1,17 @@ +import { cn } from '@/lib/utils'; + +export function Footer({ className }: { className?: string }) { + return ( + + ); +} diff --git a/demos/react-neon-tanstack-query-notes/src/components/app/header.tsx b/demos/react-neon-tanstack-query-notes/src/components/app/header.tsx new file mode 100644 index 000000000..99ebdd1f3 --- /dev/null +++ b/demos/react-neon-tanstack-query-notes/src/components/app/header.tsx @@ -0,0 +1,29 @@ +import { client } from '@/lib/auth'; +import { powersync, neonConnector } from '@/lib/powersync'; +import { useRouter } from '@tanstack/react-router'; + +export default function Header({ name }: { name: string }) { + const router = useRouter(); + return ( +
+
+

Welcome {name}

+ +
+

+ Your minimalist note-taking app that automatically records timestamps for each of your notes. +

+
+ ); +} diff --git a/demos/react-neon-tanstack-query-notes/src/components/app/note-card.tsx b/demos/react-neon-tanstack-query-notes/src/components/app/note-card.tsx new file mode 100644 index 000000000..af8db08de --- /dev/null +++ b/demos/react-neon-tanstack-query-notes/src/components/app/note-card.tsx @@ -0,0 +1,11 @@ +import { Link } from '@tanstack/react-router'; +import moment from 'moment'; + +export default function NoteCard({ id, title, createdAt }: { id: string; title: string; createdAt: string }) { + return ( + +
{title}
+

{moment(createdAt).fromNow()}

+ + ); +} diff --git a/demos/react-neon-tanstack-query-notes/src/components/app/note-header.tsx b/demos/react-neon-tanstack-query-notes/src/components/app/note-header.tsx new file mode 100644 index 000000000..2ddd8dd33 --- /dev/null +++ b/demos/react-neon-tanstack-query-notes/src/components/app/note-header.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { NoteTitle } from '@/components/app/note-title'; +import { Toggle } from '@/components/ui/toggle'; +import { queryKeys } from '@/lib/query-keys'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useQuery } from '@powersync/tanstack-react-query'; +import { Share2 } from 'lucide-react'; +import { powersyncDrizzle } from '@/lib/powersync'; +import { notes } from '@/lib/powersync-schema'; +import { toCompilableQuery } from '@powersync/drizzle-driver'; +import { eq } from 'drizzle-orm'; + +type Props = { + id: string; + title: string; + shared: boolean; + owner_id: string; + user_id: string; + onShareToggle?: (isShared: boolean) => void; +}; + +export default function NoteHeader({ id, title, shared, owner_id, user_id, onShareToggle }: Props) { + const query = powersyncDrizzle.select({ shared: notes.shared }).from(notes).where(eq(notes.id, id)); + const { data: sharedRows } = useQuery({ + queryKey: queryKeys.noteShared(id), + enabled: Boolean(id), + query: toCompilableQuery(query) + }); + + const hydratedShared = (() => { + const row = sharedRows?.[0]; + if (!row) { + return undefined; + } + + return typeof row.shared === 'boolean' ? row.shared : Boolean(row.shared); + })(); + + const isShared = hydratedShared ?? shared ?? false; + const queryClient = useQueryClient(); + + // Invalidate on mount to catch changes that occurred while unmounted + React.useEffect(() => { + queryClient.invalidateQueries({ queryKey: queryKeys.noteShared(id) }); + }, [queryClient, id]); + + const toggleShareMutation = useMutation({ + mutationFn: async (newSharedState: boolean) => { + await powersyncDrizzle + .update(notes) + .set({ shared: newSharedState, updated_at: new Date().toISOString() }) + .where(eq(notes.id, id)); + + return { shared: newSharedState }; + }, + onSuccess: (data) => { + if (onShareToggle) { + onShareToggle(data.shared); + } + } + }); + + return ( +
+ + {user_id === owner_id && ( + { + const newSharedState = !isShared; + toggleShareMutation.mutate(newSharedState); + }} + > + + + )} +
+ ); +} diff --git a/demos/react-neon-tanstack-query-notes/src/components/app/note-title.tsx b/demos/react-neon-tanstack-query-notes/src/components/app/note-title.tsx new file mode 100644 index 000000000..66c65c54d --- /dev/null +++ b/demos/react-neon-tanstack-query-notes/src/components/app/note-title.tsx @@ -0,0 +1,107 @@ +import { powersyncDrizzle } from '@/lib/powersync'; +import { queryKeys } from '@/lib/query-keys'; +import { useQueryClient } from '@tanstack/react-query'; +import { Copy } from 'lucide-react'; +import { type KeyboardEvent, useEffect, useRef, useState } from 'react'; +import { eq, type InferSelectModel } from 'drizzle-orm'; +import { notes } from '@/lib/powersync-schema'; + +type Note = InferSelectModel; + +export function NoteTitle({ + id, + title, + shared, + owner +}: { + id: string; + title: string; + shared: boolean; + owner: boolean; +}) { + const [isEditingTitle, setIsEditingTitle] = useState(false); + const [titleValue, setTitleValue] = useState(title); + const titleRef = useRef(null); + + // Focus title when editing + useEffect(() => { + if (isEditingTitle && titleRef.current) { + titleRef.current.focus(); + // Place cursor at the end of text + const selection = window.getSelection(); + const range = document.createRange(); + range.selectNodeContents(titleRef.current); + range.collapse(false); + selection?.removeAllRanges(); + selection?.addRange(range); + } + }, [isEditingTitle]); + + const handleTitleEdit = () => { + setIsEditingTitle(true); + }; + + const handleTitleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + saveTitle(); + } + }; + + const queryClient = useQueryClient(); + + const saveTitle = async () => { + if (titleRef.current && titleRef.current.textContent !== null && id) { + const newTitle = titleRef.current.textContent.trim(); + if (newTitle !== title) { + try { + await powersyncDrizzle + .update(notes) + .set({ title: newTitle, updated_at: new Date().toISOString() }) + .where(eq(notes.id, id)); + + queryClient.setQueryData(queryKeys.note(id), (old: Note) => ({ + ...old, + title: newTitle + })); + + queryClient.invalidateQueries({ queryKey: queryKeys.notes() }); + + setTitleValue(newTitle); + } catch (err) { + console.error('Failed to update title', err); + // Restore original title on error + if (titleRef.current) { + titleRef.current.textContent = titleValue; + } + } + } + } + setIsEditingTitle(false); + }; + + return ( +
+ Title: +

+ {titleValue} +

+ {shared && owner && ( + { + navigator.clipboard.writeText(`${window.location.origin}/note?id=${id}`); + }} + /> + )} +
+ ); +} diff --git a/demos/react-neon-tanstack-query-notes/src/components/app/notes-list.tsx b/demos/react-neon-tanstack-query-notes/src/components/app/notes-list.tsx new file mode 100644 index 000000000..284a332f8 --- /dev/null +++ b/demos/react-neon-tanstack-query-notes/src/components/app/notes-list.tsx @@ -0,0 +1,35 @@ +import NoteCard from '@/components/app/note-card'; +import type { Note } from '@/lib/api'; +import { useRouter } from '@tanstack/react-router'; +import { PlusCircleIcon } from 'lucide-react'; + +export default function NotesList({ notes }: { notes: Note[] }) { + const router = useRouter(); + + const addNote = async () => { + router.navigate({ + to: '/note', + search: { id: 'new-note' }, + replace: true + }); + }; + + return ( +
+
+

My notes

+ +
+
+ {notes?.map((note) => )} + {notes.length === 0 &&
No notes yet
} +
+
+ ); +} diff --git a/demos/react-neon-tanstack-query-notes/src/components/app/paragraph.tsx b/demos/react-neon-tanstack-query-notes/src/components/app/paragraph.tsx new file mode 100644 index 000000000..39c3e5855 --- /dev/null +++ b/demos/react-neon-tanstack-query-notes/src/components/app/paragraph.tsx @@ -0,0 +1,73 @@ +import { cn } from '@/lib/utils'; +import { useEffect, useRef } from 'react'; + +type ParagraphProps = { + id: string; + content: string; + timestamp: string; +}; + +type CurrentParagraphProps = { + content: string; + timestamp: string; + onChange: (e: React.ChangeEvent) => void; + onKeyDown: (e: React.KeyboardEvent) => void; +}; + +// Format timestamp for display (HH:MM:SS) +const formatTime = (timestamp: string): string => { + return new Date(timestamp).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false + }); +}; + +export function Paragraph({ content, timestamp }: Omit) { + return ( +
+
{formatTime(timestamp)}
+
{content}
+
+ ); +} + +export function CurrentParagraph({ content, timestamp, onChange, onKeyDown }: CurrentParagraphProps) { + const textareaRef = useRef(null); + + // biome-ignore lint/correctness/useExhaustiveDependencies: auto-resize textarea + useEffect(() => { + const textarea = textareaRef.current; + if (textarea) { + textarea.style.height = 'auto'; + textarea.style.height = `${textarea.scrollHeight}px`; + } + }, [content]); + + return ( +
+
+ {formatTime(timestamp)} +
+
+ unsaved +
+
+
+