From 17fec266437e25e75aad4118aa3c6708d57d5d64 Mon Sep 17 00:00:00 2001 From: LucDeCaf Date: Tue, 23 Dec 2025 12:21:37 +0200 Subject: [PATCH 1/8] Add Neon demo --- demos/neon-data-api-neon-auth/.env.example | 11 + demos/neon-data-api-neon-auth/.gitignore | 30 + demos/neon-data-api-neon-auth/.prettierignore | 4 + demos/neon-data-api-neon-auth/README.md | 211 + demos/neon-data-api-neon-auth/components.json | 21 + .../neon-data-api-neon-auth/drizzle.config.ts | 11 + .../drizzle/0000_known_leader.sql | 30 + .../drizzle/meta/0000_snapshot.json | 213 + .../drizzle/meta/_journal.json | 13 + demos/neon-data-api-neon-auth/index.html | 13 + demos/neon-data-api-neon-auth/package.json | 55 + .../public/favicon.ico | Bin 0 -> 15406 bytes .../scripts/preMigrate.ts | 31 + demos/neon-data-api-neon-auth/src/app.tsx | 81 + .../src/assets/react.svg | 1 + .../src/components/Footer.tsx | 32 + .../src/components/app/header.tsx | 30 + .../src/components/app/note-card.tsx | 21 + .../src/components/app/note-header.tsx | 89 + .../src/components/app/note-title.tsx | 106 + .../src/components/app/notes-list.tsx | 44 + .../src/components/app/paragraph.tsx | 83 + .../src/components/ui/button.tsx | 59 + .../src/components/ui/toggle.tsx | 45 + .../neon-data-api-neon-auth/src/db/schema.ts | 61 + demos/neon-data-api-neon-auth/src/index.css | 133 + demos/neon-data-api-neon-auth/src/main.tsx | 11 + .../src/routeTree.gen.ts | 95 + .../src/routes/__root.tsx | 35 + .../src/routes/index.tsx | 57 + .../src/routes/note.tsx | 241 + .../src/routes/signin.tsx | 184 + .../neon-data-api-neon-auth/src/vite-env.d.ts | 1 + .../neon-data-api-neon-auth/tsconfig.app.json | 30 + demos/neon-data-api-neon-auth/tsconfig.json | 16 + .../tsconfig.node.json | 24 + .../neon-data-api-neon-auth/types/database.ts | 208 + demos/neon-data-api-neon-auth/vercel.json | 3 + demos/neon-data-api-neon-auth/vite.config.ts | 30 + pnpm-lock.yaml | 8542 +++++++++++++++-- 40 files changed, 10188 insertions(+), 717 deletions(-) create mode 100644 demos/neon-data-api-neon-auth/.env.example create mode 100644 demos/neon-data-api-neon-auth/.gitignore create mode 100644 demos/neon-data-api-neon-auth/.prettierignore create mode 100644 demos/neon-data-api-neon-auth/README.md create mode 100644 demos/neon-data-api-neon-auth/components.json create mode 100644 demos/neon-data-api-neon-auth/drizzle.config.ts create mode 100644 demos/neon-data-api-neon-auth/drizzle/0000_known_leader.sql create mode 100644 demos/neon-data-api-neon-auth/drizzle/meta/0000_snapshot.json create mode 100644 demos/neon-data-api-neon-auth/drizzle/meta/_journal.json create mode 100644 demos/neon-data-api-neon-auth/index.html create mode 100644 demos/neon-data-api-neon-auth/package.json create mode 100644 demos/neon-data-api-neon-auth/public/favicon.ico create mode 100644 demos/neon-data-api-neon-auth/scripts/preMigrate.ts create mode 100644 demos/neon-data-api-neon-auth/src/app.tsx create mode 100644 demos/neon-data-api-neon-auth/src/assets/react.svg create mode 100644 demos/neon-data-api-neon-auth/src/components/Footer.tsx create mode 100644 demos/neon-data-api-neon-auth/src/components/app/header.tsx create mode 100644 demos/neon-data-api-neon-auth/src/components/app/note-card.tsx create mode 100644 demos/neon-data-api-neon-auth/src/components/app/note-header.tsx create mode 100644 demos/neon-data-api-neon-auth/src/components/app/note-title.tsx create mode 100644 demos/neon-data-api-neon-auth/src/components/app/notes-list.tsx create mode 100644 demos/neon-data-api-neon-auth/src/components/app/paragraph.tsx create mode 100644 demos/neon-data-api-neon-auth/src/components/ui/button.tsx create mode 100644 demos/neon-data-api-neon-auth/src/components/ui/toggle.tsx create mode 100644 demos/neon-data-api-neon-auth/src/db/schema.ts create mode 100644 demos/neon-data-api-neon-auth/src/index.css create mode 100644 demos/neon-data-api-neon-auth/src/main.tsx create mode 100644 demos/neon-data-api-neon-auth/src/routeTree.gen.ts create mode 100644 demos/neon-data-api-neon-auth/src/routes/__root.tsx create mode 100644 demos/neon-data-api-neon-auth/src/routes/index.tsx create mode 100644 demos/neon-data-api-neon-auth/src/routes/note.tsx create mode 100644 demos/neon-data-api-neon-auth/src/routes/signin.tsx create mode 100644 demos/neon-data-api-neon-auth/src/vite-env.d.ts create mode 100644 demos/neon-data-api-neon-auth/tsconfig.app.json create mode 100644 demos/neon-data-api-neon-auth/tsconfig.json create mode 100644 demos/neon-data-api-neon-auth/tsconfig.node.json create mode 100644 demos/neon-data-api-neon-auth/types/database.ts create mode 100644 demos/neon-data-api-neon-auth/vercel.json create mode 100644 demos/neon-data-api-neon-auth/vite.config.ts diff --git a/demos/neon-data-api-neon-auth/.env.example b/demos/neon-data-api-neon-auth/.env.example new file mode 100644 index 000000000..612890108 --- /dev/null +++ b/demos/neon-data-api-neon-auth/.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/neon-data-api-neon-auth/.gitignore b/demos/neon-data-api-neon-auth/.gitignore new file mode 100644 index 000000000..4b3abc819 --- /dev/null +++ b/demos/neon-data-api-neon-auth/.gitignore @@ -0,0 +1,30 @@ +# 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 \ No newline at end of file diff --git a/demos/neon-data-api-neon-auth/.prettierignore b/demos/neon-data-api-neon-auth/.prettierignore new file mode 100644 index 000000000..3657147fa --- /dev/null +++ b/demos/neon-data-api-neon-auth/.prettierignore @@ -0,0 +1,4 @@ +# Ignore artifacts: +build +coverage +src/routeTree.gen.ts \ No newline at end of file diff --git a/demos/neon-data-api-neon-auth/README.md b/demos/neon-data-api-neon-auth/README.md new file mode 100644 index 000000000..74ac61080 --- /dev/null +++ b/demos/neon-data-api-neon-auth/README.md @@ -0,0 +1,211 @@ +# 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 use PowerSync with Neon. The README provides only basic instructions for setting up the demo. Please refer to the [PowerSync documentation](https://neon.com/docs/powersync/get-started) for more information. + +**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 demos/react-neon-notes-tanstack +pnpm install +``` + +### 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) \ No newline at end of file diff --git a/demos/neon-data-api-neon-auth/components.json b/demos/neon-data-api-neon-auth/components.json new file mode 100644 index 000000000..13e1db0b7 --- /dev/null +++ b/demos/neon-data-api-neon-auth/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/neon-data-api-neon-auth/drizzle.config.ts b/demos/neon-data-api-neon-auth/drizzle.config.ts new file mode 100644 index 000000000..bb9afba2a --- /dev/null +++ b/demos/neon-data-api-neon-auth/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/neon-data-api-neon-auth/drizzle/0000_known_leader.sql b/demos/neon-data-api-neon-auth/drizzle/0000_known_leader.sql new file mode 100644 index 000000000..3b7cb867f --- /dev/null +++ b/demos/neon-data-api-neon-auth/drizzle/0000_known_leader.sql @@ -0,0 +1,30 @@ +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")); \ No newline at end of file diff --git a/demos/neon-data-api-neon-auth/drizzle/meta/0000_snapshot.json b/demos/neon-data-api-neon-auth/drizzle/meta/0000_snapshot.json new file mode 100644 index 000000000..b56bcda98 --- /dev/null +++ b/demos/neon-data-api-neon-auth/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/neon-data-api-neon-auth/drizzle/meta/_journal.json b/demos/neon-data-api-neon-auth/drizzle/meta/_journal.json new file mode 100644 index 000000000..e11847cdb --- /dev/null +++ b/demos/neon-data-api-neon-auth/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/neon-data-api-neon-auth/index.html b/demos/neon-data-api-neon-auth/index.html new file mode 100644 index 000000000..4c283f741 --- /dev/null +++ b/demos/neon-data-api-neon-auth/index.html @@ -0,0 +1,13 @@ + + + + + + + note. + + +
+ + + diff --git a/demos/neon-data-api-neon-auth/package.json b/demos/neon-data-api-neon-auth/package.json new file mode 100644 index 000000000..746c86120 --- /dev/null +++ b/demos/neon-data-api-neon-auth/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/neon-data-api-neon-auth/public/favicon.ico b/demos/neon-data-api-neon-auth/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..70dc7d8a98c44d980db688ab97d87dffe44367c3 GIT binary patch literal 15406 zcmeHOTPSsD7+&+w-2eYv%)J~2A?L}D7&oLuilUH6xp2w_(KIF3E=O@Olp9G&q#Psl zlbi}sNaRe;VRCrqd)7bSYOS@`viHK?zTND%-q!jK?|Po^UElZa^&Pe({UH4)efc7> ztCv*zlO+8nNs^b>_jJI|lJqyr3k&AzrQBmC}Ct@TwF}v z-rldW@9F80ZC_)5a&khEk&zS=6GIsp8J6+O%F1Nyp`jrvC@8RGKR7rj+m@G?%Wkq_ zpPHH~bB>CNQr@K%d-w|4+uN(WBCGZ#B_)b2Zmigcg@w`K;o+<7@y;$TE;P1CAn*A2 z*e0K4*o*$c)Gv(*!Yp`9}{?5)$8*EqMZqlqMagp}KH?0%qd0zSY%E<}UAYXu zqM|~<28YqnQ8}ErqW#0e!y4?WswyRvIUE1-@{+!O{i@(cM@JLpAY7b{Ut3$NV8ieI z{rwzTzB&^>D=UjI#(1AAEiKhxBjHSZj*Dw}csLy$9eo&ncXzi9wyWBY7>C~+BXPF> zLqbAmcX!taTF!Geen&@#DK-k6iI4RIGrx0E?~T8_>w zrguz56>ytk3a+13-2YFMc&-!SZhh|2%=_Lho@iZuMd6?GwVT{QRh~v61fY?}g^Sy}eCoX=ygTPf1B3^eKqQ{;#R2v57a> zAHf+ZzD_J6e{jP$y?RXI9s>4PPXBXc!P5QY7ve9-Q68EG&B&# zsM`P7SLy2NB8+LZh{k_vYDzvcspgJ*?8BpNdJ&2L`1rVCyh%t%pslSfJ#{%P5`Uca z)cYNC6pUpv5sAN^JK{}sbv0dIUz^bvD*o&F$Hm2wy)#}S`@g-t-6r1XUxbMK`8gl{ z8>{E6PK(GtDJh8@-J2DSKjIDcxG~ptL`36{Gc4-!I`-Ni690&Z2%4Xtw?|o1E((8) zS;QMh#;m#lAH{!SVuJAh2(FWplZ3gVRl)lC$jFHD96WDqY|QFoJoP;uc&i6&KhEF! zMAzAaJNAIxRrpy5cRKsPeGT8s=Q!9EXCd6FAHm=9`3=<>6n+*$F8dwaJ>v1e HTRiYDAdSaS literal 0 HcmV?d00001 diff --git a/demos/neon-data-api-neon-auth/scripts/preMigrate.ts b/demos/neon-data-api-neon-auth/scripts/preMigrate.ts new file mode 100644 index 000000000..24e0576c5 --- /dev/null +++ b/demos/neon-data-api-neon-auth/scripts/preMigrate.ts @@ -0,0 +1,31 @@ +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/neon-data-api-neon-auth/src/app.tsx b/demos/neon-data-api-neon-auth/src/app.tsx new file mode 100644 index 000000000..22456bad2 --- /dev/null +++ b/demos/neon-data-api-neon-auth/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/neon-data-api-neon-auth/src/assets/react.svg b/demos/neon-data-api-neon-auth/src/assets/react.svg new file mode 100644 index 000000000..6c87de9bb --- /dev/null +++ b/demos/neon-data-api-neon-auth/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/demos/neon-data-api-neon-auth/src/components/Footer.tsx b/demos/neon-data-api-neon-auth/src/components/Footer.tsx new file mode 100644 index 000000000..830d386a9 --- /dev/null +++ b/demos/neon-data-api-neon-auth/src/components/Footer.tsx @@ -0,0 +1,32 @@ +import { cn } from "@/lib/utils"; + +export function Footer({ className }: { className?: string }) { + return ( + + ); +} diff --git a/demos/neon-data-api-neon-auth/src/components/app/header.tsx b/demos/neon-data-api-neon-auth/src/components/app/header.tsx new file mode 100644 index 000000000..461e981e7 --- /dev/null +++ b/demos/neon-data-api-neon-auth/src/components/app/header.tsx @@ -0,0 +1,30 @@ +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/neon-data-api-neon-auth/src/components/app/note-card.tsx b/demos/neon-data-api-neon-auth/src/components/app/note-card.tsx new file mode 100644 index 000000000..c40afb2be --- /dev/null +++ b/demos/neon-data-api-neon-auth/src/components/app/note-card.tsx @@ -0,0 +1,21 @@ +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/neon-data-api-neon-auth/src/components/app/note-header.tsx b/demos/neon-data-api-neon-auth/src/components/app/note-header.tsx new file mode 100644 index 000000000..db9494634 --- /dev/null +++ b/demos/neon-data-api-neon-auth/src/components/app/note-header.tsx @@ -0,0 +1,89 @@ +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/neon-data-api-neon-auth/src/components/app/note-title.tsx b/demos/neon-data-api-neon-auth/src/components/app/note-title.tsx new file mode 100644 index 000000000..a153c6e4e --- /dev/null +++ b/demos/neon-data-api-neon-auth/src/components/app/note-title.tsx @@ -0,0 +1,106 @@ +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/neon-data-api-neon-auth/src/components/app/notes-list.tsx b/demos/neon-data-api-neon-auth/src/components/app/notes-list.tsx new file mode 100644 index 000000000..55f97bf8b --- /dev/null +++ b/demos/neon-data-api-neon-auth/src/components/app/notes-list.tsx @@ -0,0 +1,44 @@ +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/neon-data-api-neon-auth/src/components/app/paragraph.tsx b/demos/neon-data-api-neon-auth/src/components/app/paragraph.tsx new file mode 100644 index 000000000..dbee16281 --- /dev/null +++ b/demos/neon-data-api-neon-auth/src/components/app/paragraph.tsx @@ -0,0 +1,83 @@ +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 +
+
+
+