From b783c34cfdd5e905f288868277b589db029cbdc5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 19 Aug 2026 16:28:14 +0000 Subject: [PATCH 01/25] feat: replace y-webrtc with PartyKit for real-time collaboration - Add PartyKit server (party/document.ts) for Yjs room handling - Add API routes for PartyKit server to load/save documents - Add y-partykit client hook replacing WebrtcProvider - Add service role Supabase client for server-side persistence - Update editor to accept generic collaboration provider - Add PARTYKIT.md documentation - Update .env.example with PartyKit configuration Benefits: - Server-mediated sync (no more WebRTC connection failures) - Single persistence point (eliminates duplicate saves from clients) - Better scalability (no mesh connections between clients) The database schema remains unchanged for easy rollback. Co-authored-by: Matia Raspopovic --- .env.example | 21 +- PARTYKIT.md | 167 +++ package-lock.json | 1072 ++++++++++++++++++- package.json | 2 + party/document.ts | 159 +++ partykit.json | 6 + src/app/_components/editor/editor.tsx | 8 +- src/app/api/partykit/load/route.ts | 114 ++ src/app/api/partykit/save/route.ts | 87 ++ src/app/documents/[documentId]/page.tsx | 6 +- src/hooks/use-collaborative-doc-partykit.ts | 99 ++ src/utils/supabase/service-role.ts | 17 + 12 files changed, 1744 insertions(+), 14 deletions(-) create mode 100644 PARTYKIT.md create mode 100644 party/document.ts create mode 100644 partykit.json create mode 100644 src/app/api/partykit/load/route.ts create mode 100644 src/app/api/partykit/save/route.ts create mode 100644 src/hooks/use-collaborative-doc-partykit.ts create mode 100644 src/utils/supabase/service-role.ts diff --git a/.env.example b/.env.example index adfe836..a477347 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,21 @@ # When adding additional environment variables, the schema in "/src/env.js" # should be updated accordingly. -# Example: -# SERVERVAR="foo" -# NEXT_PUBLIC_CLIENTVAR="bar" +# Supabase +NEXT_PUBLIC_SUPABASE_URL="https://your-project.supabase.co" +NEXT_PUBLIC_SUPABASE_ANON_KEY="your-anon-key" +SUPABASE_SERVICE_ROLE_KEY="your-service-role-key" + +# PartyKit (Real-time collaboration) +# For local development: localhost:1999 +# For production: your-project.partykit.dev +NEXT_PUBLIC_PARTYKIT_HOST="localhost:1999" + +# Shared secret for PartyKit server to authenticate with the app +# Generate with: openssl rand -base64 32 +PARTYKIT_SECRET="your-partykit-secret" + +# App URL (for PartyKit server to call back to the app) +# For local development: http://localhost:3000 +# For production: https://your-app.vercel.app +APP_URL="http://localhost:3000" diff --git a/PARTYKIT.md b/PARTYKIT.md new file mode 100644 index 0000000..91ec7e0 --- /dev/null +++ b/PARTYKIT.md @@ -0,0 +1,167 @@ +# PartyKit Integration + +This document describes the PartyKit integration for real-time collaborative editing. + +## Overview + +PartyKit replaces the previous y-webrtc peer-to-peer sync with a server-mediated WebSocket architecture. This provides: + +- **Reliable sync**: No more WebRTC connection failures through firewalls +- **Single persistence point**: Only the PartyKit server writes to the database (no more duplicate saves from multiple clients) +- **Better scalability**: Server handles coordination instead of mesh connections between clients + +## Architecture + +``` +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ Client A │ │ Client B │ │ Client C │ +└──────┬──────┘ └──────┬──────┘ └──────┬──────┘ + │ │ │ + └───────────────────┼───────────────────┘ + │ WebSocket + ▼ + ┌────────────────────────┐ + │ PartyKit Server │ + │ (per-document room) │ + └───────────┬────────────┘ + │ HTTP + ▼ + ┌────────────────────────┐ + │ Next.js API Routes │ + │ /api/partykit/* │ + └───────────┬────────────┘ + │ + ▼ + ┌────────────────────────┐ + │ Supabase │ + └────────────────────────┘ +``` + +## Setup + +### 1. Install PartyKit CLI + +```bash +npm install -g partykit +``` + +### 2. Login to PartyKit + +```bash +npx partykit login +``` + +### 3. Configure Environment Variables + +Add to your `.env` file: + +```env +# PartyKit host (for client-side) +NEXT_PUBLIC_PARTYKIT_HOST=localhost:1999 # dev +# NEXT_PUBLIC_PARTYKIT_HOST=chptr-collab.partykit.dev # prod + +# Shared secret for server-to-server auth +PARTYKIT_SECRET=your-secret-here # Generate with: openssl rand -base64 32 + +# Supabase service role key (for PartyKit API routes) +SUPABASE_SERVICE_ROLE_KEY=your-service-role-key + +# App URL for PartyKit server callbacks +APP_URL=http://localhost:3000 # dev +# APP_URL=https://your-app.vercel.app # prod +``` + +### 4. Local Development + +Run both the Next.js dev server and PartyKit dev server: + +```bash +# Terminal 1: Next.js +npm run dev + +# Terminal 2: PartyKit +npx partykit dev +``` + +PartyKit dev server runs on `localhost:1999` by default. + +### 5. Deploy PartyKit + +```bash +npx partykit deploy +``` + +This deploys to PartyKit's free tier at `chptr-collab.partykit.dev`. + +### 6. Configure PartyKit Environment Variables + +After deploying, set the environment variables for the PartyKit server: + +```bash +npx partykit env add APP_URL +# Enter: https://your-app.vercel.app + +npx partykit env add PARTYKIT_SECRET +# Enter: your-secret-here (same as in your Next.js .env) +``` + +## Files + +| File | Purpose | +|------|---------| +| `partykit.json` | PartyKit configuration | +| `party/document.ts` | PartyKit server (Yjs room handler) | +| `src/hooks/use-collaborative-doc-partykit.ts` | Client-side hook | +| `src/app/api/partykit/load/route.ts` | API to load document state | +| `src/app/api/partykit/save/route.ts` | API to save document state | +| `src/utils/supabase/service-role.ts` | Supabase client with service role | + +## How It Works + +### Client Connection + +1. Client opens document page +2. `useCollaborativeDocPartykit` hook creates a Y.Doc and YPartyKitProvider +3. Provider connects to PartyKit server via WebSocket +4. Provider syncs document state and awareness (cursors) + +### Server Lifecycle + +1. First client connects → PartyKit spins up room for that document ID +2. Room calls `/api/partykit/load` to fetch document state from Supabase +3. Room applies state to its Y.Doc +4. As clients make edits, Y.Doc updates are broadcast to all connected clients +5. Room debounces saves (1 second) and calls `/api/partykit/save` +6. Last client disconnects → room shuts down (but save completes first) + +### Persistence + +The PartyKit server saves the full Y.Doc state as a snapshot. This: + +- Replaces the previous append-only change log approach +- Clears old changes from `document_changes` table after each save +- Eliminates the need for client-side compaction + +## Costs + +PartyKit runs on Cloudflare Workers. Estimated costs: + +| Users | Monthly Cost | +|-------|--------------| +| 0-50 | $0 (free tier) | +| 50-500 | ~$5 | +| 500-2000 | ~$10-25 | +| 2000+ | ~$25-100 | + +## Rollback + +To revert to y-webrtc: + +1. In `src/app/documents/[documentId]/page.tsx`: + - Change import back to `use-collaborative-doc-crdt` + - Change hook call back to `useCollaborativeDocCrdt` + +2. In `src/app/_components/editor/editor.tsx`: + - Change provider type back to `WebrtcProvider` + +The database schema is unchanged, so rollback is seamless. diff --git a/package-lock.json b/package-lock.json index e964e37..6a8097c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,6 +45,7 @@ "lucide-react": "^1.14.0", "next": "15.0.7", "next-themes": "^0.4.3", + "partykit": "^0.0.115", "react": "^18.3.1", "react-dom": "^18.3.1", "react-hook-form": "^7.53.2", @@ -58,6 +59,7 @@ "tailwindcss-animate": "^1.0.7", "unified": "^11.0.5", "vaul": "^1.1.2", + "y-partykit": "^0.0.33", "y-webrtc": "^10.3.0", "yjs": "^13.6.27", "zod": "^3.23.8", @@ -329,6 +331,114 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20240718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20240718.0.tgz", + "integrity": "sha512-BsPZcSCgoGnufog2GIgdPuiKicYTNyO/Dp++HbpLRH+yQdX3x4aWx83M+a0suTl1xv76dO4g9aw7SIB6OSgIyQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20240718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20240718.0.tgz", + "integrity": "sha512-nlr4gaOO5gcJerILJQph3+2rnas/nx/lYsuaot1ntHu4LAPBoQo1q/Pucj2cSIav4UiMzTbDmoDwPlls4Kteog==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20240718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20240718.0.tgz", + "integrity": "sha512-LJ/k3y47pBcjax0ee4K+6ZRrSsqWlfU4lbU8Dn6u5tSC9yzwI4YFNXDrKWInB0vd7RT3w4Yqq1S6ZEbfRrqVUg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20240718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20240718.0.tgz", + "integrity": "sha512-zBEZvy88EcAMGRGfuVtS00Yl7lJdUM9sH7i651OoL+q0Plv9kphlCC0REQPwzxrEYT1qibSYtWcD9IxQGgx2/g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20240718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20240718.0.tgz", + "integrity": "sha512-YpCRvvT47XanFum7C3SedOZKK6BfVhqmwdAAVAQFyc4gsCdegZo0JkUkdloC/jwuWlbCACOG2HTADHOqyeolzQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workers-types": { + "version": "4.20240718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20240718.0.tgz", + "integrity": "sha512-7RqxXIM9HyhjfZ9ztXjITuc7mL0w4s+zXgypqKmMuvuObC3DgXutJ3bOYbQ+Ss5QbywrzWSNMlmGdL/ldg/yZg==", + "license": "MIT OR Apache-2.0" + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@emnapi/runtime": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.3.1.tgz", @@ -345,6 +455,374 @@ "integrity": "sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==", "license": "MIT" }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", @@ -468,6 +946,15 @@ "license": "MIT", "optional": true }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/@fastify/error": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.0.0.tgz", @@ -4522,10 +5009,9 @@ } }, "node_modules/acorn": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", - "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", - "dev": true, + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -4544,6 +5030,18 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/ai": { "version": "6.0.103", "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.103.tgz", @@ -4863,6 +5361,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/as-table": { + "version": "1.0.55", + "resolved": "https://registry.npmjs.org/as-table/-/as-table-1.0.55.tgz", + "integrity": "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==", + "license": "MIT", + "dependencies": { + "printable-characters": "^1.0.42" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -5211,6 +5718,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/capnp-ts": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/capnp-ts/-/capnp-ts-0.7.0.tgz", + "integrity": "sha512-XKxXAC3HVPv7r674zP0VC3RTXz+/JKhfyw94ljvF80yynK6VkTnqE3jMuN8b3dUVmmc43TjyxjW4KTsmB3c86g==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.1", + "tslib": "^2.2.0" + } + }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -5322,6 +5839,23 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/clipboardy": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-4.0.0.tgz", + "integrity": "sha512-5mOlNS0mhX0707P2I0aZ2V/cmHUEO/fL7VFLqszkhUsxt7RwnmrInf/eEQKlf5GzvYeHIjT+Ov1HRfNmymlG0w==", + "license": "MIT", + "dependencies": { + "execa": "^8.0.1", + "is-wsl": "^3.1.0", + "is64bit": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -5416,6 +5950,12 @@ "dev": true, "license": "MIT" }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "license": "MIT" + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -5444,7 +5984,6 @@ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", "license": "MIT", - "optional": true, "engines": { "node": ">= 0.6" } @@ -5517,6 +6056,12 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/data-uri-to-buffer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-2.0.2.tgz", + "integrity": "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==", + "license": "MIT" + }, "node_modules/data-view-buffer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz", @@ -5653,6 +6198,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "license": "MIT" + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -6032,6 +6583,44 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -6624,6 +7213,41 @@ "node": ">=18.0.0" } }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exit-hook": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz", + "integrity": "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/express": { "version": "4.21.1", "resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz", @@ -7173,6 +7797,28 @@ "node": ">=6" } }, + "node_modules/get-source": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/get-source/-/get-source-2.0.12.tgz", + "integrity": "sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==", + "license": "Unlicense", + "dependencies": { + "data-uri-to-buffer": "^2.0.0", + "source-map": "^0.6.1" + } + }, + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-symbol-description": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz", @@ -7238,6 +7884,12 @@ "node": ">=10.13.0" } }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "license": "BSD-2-Clause" + }, "node_modules/glob/node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -7744,6 +8396,15 @@ "node": ">= 0.8" } }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, "node_modules/iceberg-js": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", @@ -8018,6 +8679,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -8077,6 +8753,24 @@ "node": ">=0.10.0" } }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-map": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", @@ -8205,6 +8899,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-string": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", @@ -8308,6 +9014,36 @@ "url": "https://github.com/sponsors/mesqueeb" } }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is64bit": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is64bit/-/is64bit-2.0.0.tgz", + "integrity": "sha512-jv+8jaWCl0g2lSBkNSVXdzfBA0npK1HGC2KtWM9FumFRoGS94g3NbCCLVnCYHLjp4GrW2KZeeSTMo5ddtznmGw==", + "license": "MIT", + "dependencies": { + "system-architecture": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", @@ -8596,6 +9332,12 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -8920,6 +9662,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -9551,6 +10299,44 @@ "node": ">= 0.6" } }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/miniflare": { + "version": "3.20240718.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-3.20240718.0.tgz", + "integrity": "sha512-TKgSeyqPBeT8TBLxbDJOKPWlq/wydoJRHjAyDdgxbw59N6wbP8JucK6AU1vXCfu21eKhrEin77ssXOpbfekzPA==", + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "acorn": "^8.8.0", + "acorn-walk": "^8.2.0", + "capnp-ts": "^0.7.0", + "exit-hook": "^2.2.1", + "glob-to-regexp": "^0.4.1", + "stoppable": "^1.1.0", + "undici": "^5.28.4", + "workerd": "1.20240718.0", + "ws": "^8.17.1", + "youch": "^3.2.2", + "zod": "^3.22.3" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=16.13" + } + }, "node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -9585,6 +10371,24 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, "node_modules/motion-dom": { "version": "12.16.0", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.16.0.tgz", @@ -9606,6 +10410,15 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/mustache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "license": "MIT", + "bin": { + "mustache": "bin/mustache" + } + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -9768,6 +10581,33 @@ "node": ">=0.10.0" } }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -9895,6 +10735,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/ohash": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-1.1.6.tgz", + "integrity": "sha512-TBu7PtV8YkAZn0tSxobKY2n2aAQva936lhRrj6957aDaCf9IEtqsKbgMzXE/F/sjqYOwmrukeORHNLe5glk7Cg==", + "license": "MIT" + }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", @@ -9928,6 +10774,21 @@ "wrappy": "1" } }, + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/oniguruma-parser": { "version": "0.12.1", "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz", @@ -10060,6 +10921,27 @@ "node": ">= 0.8" } }, + "node_modules/partykit": { + "version": "0.0.115", + "resolved": "https://registry.npmjs.org/partykit/-/partykit-0.0.115.tgz", + "integrity": "sha512-WHmJIZsAzRWrm1lrtU7wcl0tpD4rg2vgmn4+hXGPjU4mnXgFxyjVq2ZzO547xRXa1IbETOP6J9INl/ergR99bA==", + "license": "MIT", + "dependencies": { + "@cloudflare/workers-types": "4.20240718.0", + "clipboardy": "4.0.0", + "esbuild": "0.21.5", + "miniflare": "3.20240718.0", + "ts-dedent": "^2.2.0", + "unenv": "2.0.0-rc.0", + "yoga-wasm-web": "0.3.3" + }, + "bin": { + "partykit": "dist/bin.mjs" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -10118,6 +11000,12 @@ "license": "MIT", "optional": true }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -10194,6 +11082,23 @@ "node": ">= 6" } }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/pkg-types/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, "node_modules/possible-typed-array-names": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", @@ -10464,6 +11369,12 @@ } } }, + "node_modules/printable-characters": { + "version": "1.0.42", + "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz", + "integrity": "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==", + "license": "Unlicense" + }, "node_modules/process-warning": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.0.tgz", @@ -11808,6 +12719,15 @@ "atomic-sleep": "^1.0.0" } }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -11837,6 +12757,16 @@ "node": ">= 10.x" } }, + "node_modules/stacktracey": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/stacktracey/-/stacktracey-2.2.0.tgz", + "integrity": "sha512-ETyQEz+CzXiLjEbyJqpbp+/T79RQD/6wqFucRBIlVNZfYq2Ay7wbretD4cxpbymZlaPWx58aIhPEY1Cr8DlVvg==", + "license": "Unlicense", + "dependencies": { + "as-table": "^1.0.36", + "get-source": "^2.0.12" + } + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -11847,6 +12777,16 @@ "node": ">= 0.8" } }, + "node_modules/stoppable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stoppable/-/stoppable-1.1.0.tgz", + "integrity": "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==", + "license": "MIT", + "engines": { + "node": ">=4", + "npm": ">=6" + } + }, "node_modules/streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", @@ -12083,6 +13023,18 @@ "node": ">=4" } }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -12198,6 +13150,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/system-architecture": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/system-architecture/-/system-architecture-0.1.0.tgz", + "integrity": "sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tabbable": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", @@ -12392,6 +13356,15 @@ "typescript": ">=4.2.0" } }, + "node_modules/ts-dedent": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.3.0.tgz", + "integrity": "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==", + "license": "MIT", + "engines": { + "node": ">=6.10" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -12554,6 +13527,12 @@ "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", "license": "MIT" }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "license": "MIT" + }, "node_modules/unbox-primitive": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", @@ -12570,6 +13549,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, "node_modules/undici-types": { "version": "6.19.8", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", @@ -12577,6 +13568,19 @@ "dev": true, "license": "MIT" }, + "node_modules/unenv": { + "version": "2.0.0-rc.0", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.0.tgz", + "integrity": "sha512-H0kl2w8jFL/FAk0xvjVing4bS3jd//mbg1QChDnn58l9Sc5RtduaKmLAL8n+eBw5jJo8ZjYV7CrEGage5LAOZQ==", + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "mlly": "^1.7.4", + "ohash": "^1.1.4", + "pathe": "^1.1.2", + "ufo": "^1.5.4" + } + }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", @@ -13039,6 +14043,26 @@ "node": ">=0.10.0" } }, + "node_modules/workerd": { + "version": "1.20240718.0", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20240718.0.tgz", + "integrity": "sha512-w7lOLRy0XecQTg/ujTLWBiJJuoQvzB3CdQ6/8Wgex3QxFhV9Pbnh3UbwIuUfMw3OCCPQc4o7y+1P+mISAgp6yg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20240718.0", + "@cloudflare/workerd-darwin-arm64": "1.20240718.0", + "@cloudflare/workerd-linux-64": "1.20240718.0", + "@cloudflare/workerd-linux-arm64": "1.20240718.0", + "@cloudflare/workerd-windows-64": "1.20240718.0" + } + }, "node_modules/wrap-ansi": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", @@ -13145,7 +14169,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", "license": "MIT", - "optional": true, "engines": { "node": ">=10.0.0" }, @@ -13162,6 +14185,26 @@ } } }, + "node_modules/y-partykit": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/y-partykit/-/y-partykit-0.0.33.tgz", + "integrity": "sha512-TqM2H/C1fBZbFjL9kpBLC3EWWaizBUYdFzW5DMGmcZYfQv5tYYIBTqUW4eCZOXHkhNLTEA+s2ydSKGYi8u1+sg==", + "license": "ISC", + "dependencies": { + "lib0": "^0.2.94", + "lodash.debounce": "^4.0.8" + }, + "peerDependencies": { + "react": "*", + "y-protocols": "^1.0.6", + "yjs": "^13.6.16" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + }, "node_modules/y-prosemirror": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/y-prosemirror/-/y-prosemirror-1.3.7.tgz", @@ -13275,6 +14318,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yoga-wasm-web": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/yoga-wasm-web/-/yoga-wasm-web-0.3.3.tgz", + "integrity": "sha512-N+d4UJSJbt/R3wqY7Coqs5pcV0aUj2j9IaQ3rNj9bVCLld8tTGKRa2USARjnvZJWVx1NDmQev8EknoczaOQDOA==", + "license": "MIT" + }, + "node_modules/youch": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/youch/-/youch-3.3.4.tgz", + "integrity": "sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg==", + "license": "MIT", + "dependencies": { + "cookie": "^0.7.1", + "mustache": "^4.2.0", + "stacktracey": "^2.1.8" + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/package.json b/package.json index 3c15019..e8f446d 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "lucide-react": "^1.14.0", "next": "15.0.7", "next-themes": "^0.4.3", + "partykit": "^0.0.115", "react": "^18.3.1", "react-dom": "^18.3.1", "react-hook-form": "^7.53.2", @@ -66,6 +67,7 @@ "tailwindcss-animate": "^1.0.7", "unified": "^11.0.5", "vaul": "^1.1.2", + "y-partykit": "^0.0.33", "y-webrtc": "^10.3.0", "yjs": "^13.6.27", "zod": "^3.23.8", diff --git a/party/document.ts b/party/document.ts new file mode 100644 index 0000000..01e3f87 --- /dev/null +++ b/party/document.ts @@ -0,0 +1,159 @@ +import type * as Party from "partykit/server"; +import { onConnect, type YPartyKitOptions } from "y-partykit"; +import * as Y from "yjs"; + +function base64ToUint8Array(base64: string): Uint8Array { + const binary = atob(base64.trim()); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +function uint8ArrayToBase64(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary); +} + +export default class DocumentParty implements Party.Server { + ydoc: Y.Doc; + isLoaded: boolean = false; + pendingSave: boolean = false; + saveTimeout: ReturnType | null = null; + + constructor(readonly room: Party.Room) { + this.ydoc = new Y.Doc(); + } + + get appUrl(): string { + return this.room.env.APP_URL as string || "http://localhost:3000"; + } + + get partykitSecret(): string { + return this.room.env.PARTYKIT_SECRET as string || ""; + } + + async onStart(): Promise { + await this.loadDocument(); + + this.ydoc.on("update", (_update: Uint8Array, origin: unknown) => { + if (origin === "load") return; + this.scheduleSave(); + }); + } + + async loadDocument(): Promise { + const documentId = this.room.id; + + try { + const response = await fetch(`${this.appUrl}/api/partykit/load`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Partykit-Secret": this.partykitSecret, + }, + body: JSON.stringify({ documentId }), + }); + + if (!response.ok) { + if (response.status === 404) { + console.log(`[PartyKit] Document ${documentId} not found, starting fresh`); + this.isLoaded = true; + return; + } + throw new Error(`Failed to load document: ${response.status}`); + } + + const data = await response.json() as { + snapshot: string | null; + changes: Array<{ updateData: string }>; + }; + + const updates: Uint8Array[] = []; + + if (data.snapshot) { + updates.push(base64ToUint8Array(data.snapshot)); + } + + for (const change of data.changes || []) { + updates.push(base64ToUint8Array(change.updateData)); + } + + if (updates.length > 0) { + const merged = Y.mergeUpdates(updates); + Y.applyUpdate(this.ydoc, merged, "load"); + } + + this.isLoaded = true; + console.log(`[PartyKit] Loaded document ${documentId} with ${updates.length} updates`); + } catch (error) { + console.error(`[PartyKit] Failed to load document ${documentId}:`, error); + this.isLoaded = true; + } + } + + scheduleSave(): void { + if (this.pendingSave) return; + this.pendingSave = true; + + if (this.saveTimeout) { + clearTimeout(this.saveTimeout); + } + + this.saveTimeout = setTimeout(() => { + this.pendingSave = false; + this.saveTimeout = null; + void this.saveDocument(); + }, 1000); + } + + async saveDocument(): Promise { + const documentId = this.room.id; + const stateUpdate = Y.encodeStateAsUpdate(this.ydoc); + const stateBase64 = uint8ArrayToBase64(stateUpdate); + + try { + const response = await fetch(`${this.appUrl}/api/partykit/save`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Partykit-Secret": this.partykitSecret, + }, + body: JSON.stringify({ + documentId, + snapshot: stateBase64, + }), + }); + + if (!response.ok) { + throw new Error(`Failed to save document: ${response.status}`); + } + + console.log(`[PartyKit] Saved document ${documentId}`); + } catch (error) { + console.error(`[PartyKit] Failed to save document ${documentId}:`, error); + } + } + + onConnect(conn: Party.Connection): void | Promise { + const options: YPartyKitOptions = { + callback: { handler: () => {} }, + }; + + return onConnect(conn, this.room, options); + } + + async onClose(): Promise { + if (this.saveTimeout) { + clearTimeout(this.saveTimeout); + this.saveTimeout = null; + } + if (this.pendingSave) { + await this.saveDocument(); + } + } +} diff --git a/partykit.json b/partykit.json new file mode 100644 index 0000000..66e9bf5 --- /dev/null +++ b/partykit.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://www.partykit.io/schema.json", + "name": "chptr-collab", + "main": "party/document.ts", + "compatibilityDate": "2024-01-01" +} diff --git a/src/app/_components/editor/editor.tsx b/src/app/_components/editor/editor.tsx index 77f493a..45a6080 100644 --- a/src/app/_components/editor/editor.tsx +++ b/src/app/_components/editor/editor.tsx @@ -12,7 +12,6 @@ import { SuggestionMenuController, useCreateBlockNote } from "@blocknote/react"; import { Alert } from "./custom-blocks/Alert"; import { AiPromptInput } from "./custom-blocks/AiPromptInput"; import type * as Y from "yjs"; -import type { WebrtcProvider } from "y-webrtc"; import { renderCursor } from "./cursor-renderer"; import { supportedLanguages, @@ -27,11 +26,16 @@ import { useDocumentEditorStore } from "./document-editor-store"; type Theme = "light" | "dark" | "system"; +interface CollaborationProvider { + awareness: unknown; + destroy?: () => void; +} + interface EditorProps { userName: string; userColor: string; ydoc: Y.Doc; - provider: WebrtcProvider; + provider: CollaborationProvider; } const schema = BlockNoteSchema.create({ diff --git a/src/app/api/partykit/load/route.ts b/src/app/api/partykit/load/route.ts new file mode 100644 index 0000000..d4eb2ad --- /dev/null +++ b/src/app/api/partykit/load/route.ts @@ -0,0 +1,114 @@ +import { NextResponse } from "next/server"; +import { createServiceRoleClient } from "~/utils/supabase/service-role"; + +interface SnapshotRow { + snapshot_data: string | null; + snapshot_cutoff_created_at: string | null; +} + +interface ChangeRow { + update_data: string | null; + created_at: string; +} + +function byteaResponseToBase64(raw: string | null | undefined): string { + const trimmed = (raw ?? "").trim(); + if (!trimmed) return ""; + if ( + trimmed.startsWith("\\x") || + trimmed.startsWith("0x") || + trimmed.startsWith("0X") + ) { + const hex = trimmed.replace(/^\\x|^0x|^0X/i, "").replace(/\s/g, ""); + return Buffer.from(hex, "hex").toString("base64"); + } + if (/^[0-9a-fA-F]+$/.test(trimmed) && trimmed.length % 2 === 0) { + return Buffer.from(trimmed, "hex").toString("base64"); + } + return trimmed; +} + +export async function POST(request: Request) { + const partykitSecret = request.headers.get("X-Partykit-Secret"); + const expectedSecret = process.env.PARTYKIT_SECRET; + + if (!expectedSecret || partykitSecret !== expectedSecret) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const { documentId } = (await request.json()) as { documentId: string }; + + if (!documentId) { + return NextResponse.json( + { error: "Missing documentId" }, + { status: 400 } + ); + } + + const supabase = createServiceRoleClient(); + + const { data: doc, error: docError } = await supabase + .from("documents") + .select("id") + .eq("id", documentId) + .single(); + + if (docError || !doc) { + return NextResponse.json({ error: "Document not found" }, { status: 404 }); + } + + const { data: rawSnapshotRow, error: snapshotError } = await supabase + .from("document_snapshots") + .select("snapshot_data, snapshot_cutoff_created_at") + .eq("document_id", documentId) + .single(); + + if (snapshotError && snapshotError.code !== "PGRST116") { + console.error("[PartyKit Load] Snapshot error:", snapshotError); + } + + const snapshotRow = rawSnapshotRow as SnapshotRow | null; + const snapshot: string | null = snapshotRow?.snapshot_data + ? byteaResponseToBase64(snapshotRow.snapshot_data) + : null; + const snapshotCutoffCreatedAt: string | null = + snapshotRow?.snapshot_cutoff_created_at ?? null; + + let changesQuery = supabase + .from("document_changes") + .select("update_data, created_at") + .eq("document_id", documentId) + .order("created_at", { ascending: true }); + + if (snapshotCutoffCreatedAt) { + changesQuery = changesQuery.gt("created_at", snapshotCutoffCreatedAt); + } + + const { data: rawChangesRows, error: changesError } = await changesQuery; + + if (changesError) { + console.error("[PartyKit Load] Changes error:", changesError); + return NextResponse.json( + { error: "Failed to load changes" }, + { status: 500 } + ); + } + + const changesRows = (rawChangesRows ?? []) as ChangeRow[]; + const changes = changesRows.map((row) => ({ + updateData: byteaResponseToBase64(row.update_data), + })); + + return NextResponse.json({ + snapshot, + changes, + }); + } catch (error) { + console.error("[PartyKit Load] Error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/partykit/save/route.ts b/src/app/api/partykit/save/route.ts new file mode 100644 index 0000000..97eed11 --- /dev/null +++ b/src/app/api/partykit/save/route.ts @@ -0,0 +1,87 @@ +import { NextResponse } from "next/server"; +import { createServiceRoleClient } from "~/utils/supabase/service-role"; + +function base64ToByteaHex(base64: string): string { + const buf = Buffer.from(base64, "base64"); + return "\\x" + buf.toString("hex"); +} + +export async function POST(request: Request) { + const partykitSecret = request.headers.get("X-Partykit-Secret"); + const expectedSecret = process.env.PARTYKIT_SECRET; + + if (!expectedSecret || partykitSecret !== expectedSecret) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const { documentId, snapshot } = (await request.json()) as { + documentId: string; + snapshot: string; + }; + + if (!documentId || !snapshot) { + return NextResponse.json( + { error: "Missing documentId or snapshot" }, + { status: 400 } + ); + } + + const supabase = createServiceRoleClient(); + + const { data: doc, error: docError } = await supabase + .from("documents") + .select("id") + .eq("id", documentId) + .single(); + + if (docError || !doc) { + return NextResponse.json({ error: "Document not found" }, { status: 404 }); + } + + const { error: upsertError } = await supabase + .from("document_snapshots") + .upsert( + { + document_id: documentId, + snapshot_data: base64ToByteaHex(snapshot), + snapshot_cutoff_created_at: new Date().toISOString(), + }, + { onConflict: "document_id" } + ); + + if (upsertError) { + console.error("[PartyKit Save] Upsert error:", upsertError); + return NextResponse.json( + { error: "Failed to save snapshot" }, + { status: 500 } + ); + } + + const { error: deleteError } = await supabase + .from("document_changes") + .delete() + .eq("document_id", documentId); + + if (deleteError) { + console.error("[PartyKit Save] Delete error (non-fatal):", deleteError); + } + + const { error: updateError } = await supabase + .from("documents") + .update({ last_updated: new Date().toISOString() }) + .eq("id", documentId); + + if (updateError) { + console.error("[PartyKit Save] Update timestamp error (non-fatal):", updateError); + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("[PartyKit Save] Error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/app/documents/[documentId]/page.tsx b/src/app/documents/[documentId]/page.tsx index 55b4f51..80d4e05 100644 --- a/src/app/documents/[documentId]/page.tsx +++ b/src/app/documents/[documentId]/page.tsx @@ -7,7 +7,7 @@ import { TRPCClientError } from "@trpc/client"; import { Alert, AlertDescription, AlertTitle } from "~/app/_components/alert"; import { DocumentLoadingSkeleton } from "~/app/_components/document-loading-skeleton"; import { MotionFade } from "~/app/_components/motion-fade"; -import { useCollaborativeDocCrdt } from "~/hooks/use-collaborative-doc-crdt"; +import { useCollaborativeDocPartykit } from "~/hooks/use-collaborative-doc-partykit"; import { useNewDocumentFlag } from "~/hooks/use-new-document-flag"; import { useUserProfile } from "~/hooks/use-user-profile"; import { getAvatarColorHex } from "~/lib/avatar-colors"; @@ -80,8 +80,8 @@ export default function DocumentPage() { // Fetch user profile (non-blocking: editor shows with placeholder until loaded) const { data: userProfile } = useUserProfile(); - // CRDT-based collaborative doc - handles fetching and saving internally - const { ydoc, provider, isReady, isLoading, error } = useCollaborativeDocCrdt( + // PartyKit-based collaborative doc - handles fetching and saving on server + const { ydoc, provider, isReady, isLoading, error } = useCollaborativeDocPartykit( { documentId, isNew, diff --git a/src/hooks/use-collaborative-doc-partykit.ts b/src/hooks/use-collaborative-doc-partykit.ts new file mode 100644 index 0000000..d7cdc35 --- /dev/null +++ b/src/hooks/use-collaborative-doc-partykit.ts @@ -0,0 +1,99 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import * as Y from "yjs"; +import YPartyKitProvider from "y-partykit/provider"; + +interface UseCollaborativeDocPartykitOptions { + documentId: string; + isNew?: boolean; +} + +interface UseCollaborativeDocPartykitResult { + ydoc: Y.Doc | null; + provider: YPartyKitProvider | null; + isReady: boolean; + isLoading: boolean; + error: Error | null; +} + +const PARTYKIT_HOST = process.env.NEXT_PUBLIC_PARTYKIT_HOST ?? "localhost:1999"; + +export function useCollaborativeDocPartykit({ + documentId, + isNew = false, +}: UseCollaborativeDocPartykitOptions): UseCollaborativeDocPartykitResult { + const [state, setState] = useState<{ + ydoc: Y.Doc; + provider: YPartyKitProvider; + } | null>(null); + const [isReady, setIsReady] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const cleanupRef = useRef<(() => void) | null>(null); + const lastDocumentIdRef = useRef(null); + const initializedRef = useRef(false); + + useEffect(() => { + if (lastDocumentIdRef.current === documentId && initializedRef.current) { + return; + } + + cleanupRef.current?.(); + cleanupRef.current = null; + initializedRef.current = false; + + setIsLoading(true); + setError(null); + setIsReady(false); + + const ydoc = new Y.Doc(); + + const provider = new YPartyKitProvider(PARTYKIT_HOST, documentId, ydoc, { + connect: true, + }); + + provider.on("sync", (synced: boolean) => { + if (synced) { + setIsReady(true); + setIsLoading(false); + } + }); + + provider.on("connection-error", (err: Error) => { + console.error("[PartyKit] Connection error:", err); + setError(err); + setIsLoading(false); + }); + + lastDocumentIdRef.current = documentId; + initializedRef.current = true; + setState({ ydoc, provider }); + + cleanupRef.current = () => { + initializedRef.current = false; + try { + provider.destroy(); + } catch {} + try { + ydoc.destroy(); + } catch {} + setState(null); + setIsReady(false); + }; + + return () => { + cleanupRef.current?.(); + cleanupRef.current = null; + }; + }, [documentId, isNew]); + + return { + ydoc: state?.ydoc ?? null, + provider: state?.provider ?? null, + isReady, + isLoading, + error, + }; +} diff --git a/src/utils/supabase/service-role.ts b/src/utils/supabase/service-role.ts new file mode 100644 index 0000000..98b65b0 --- /dev/null +++ b/src/utils/supabase/service-role.ts @@ -0,0 +1,17 @@ +import { createClient as createSupabaseClient } from "@supabase/supabase-js"; + +export function createServiceRoleClient() { + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; + const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + + if (!supabaseUrl || !serviceRoleKey) { + throw new Error("Missing SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY"); + } + + return createSupabaseClient(supabaseUrl, serviceRoleKey, { + auth: { + autoRefreshToken: false, + persistSession: false, + }, + }); +} From b86c9994d1a06c50f1adf40ad81a4fc6adf548ec Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 21 Aug 2026 16:26:04 +0000 Subject: [PATCH 02/25] feat: use user JWT for RLS-enforced auth instead of service role key - Client now passes Supabase JWT to PartyKit on connection - PartyKit verifies JWT expiry before allowing connection - API routes use user's JWT to create Supabase client - RLS is automatically enforced - no bypassing with service role - Remove service-role.ts (no longer needed) - Update documentation Security model: - User authenticates with Supabase, gets JWT - JWT passed to PartyKit via WebSocket query params - PartyKit forwards JWT to API routes - API routes create Supabase client with user's JWT - RLS enforces document permissions This eliminates the need for SUPABASE_SERVICE_ROLE_KEY. Co-authored-by: Matia Raspopovic --- .env.example | 1 - PARTYKIT.md | 62 +++++++----- party/document.ts | 79 ++++++++++++--- src/app/api/partykit/load/route.ts | 22 ++++- src/app/api/partykit/save/route.ts | 19 +++- src/hooks/use-collaborative-doc-partykit.ts | 103 ++++++++++++++------ src/utils/supabase/from-token.ts | 22 +++++ src/utils/supabase/service-role.ts | 17 ---- 8 files changed, 232 insertions(+), 93 deletions(-) create mode 100644 src/utils/supabase/from-token.ts delete mode 100644 src/utils/supabase/service-role.ts diff --git a/.env.example b/.env.example index a477347..bd1dc88 100644 --- a/.env.example +++ b/.env.example @@ -12,7 +12,6 @@ # Supabase NEXT_PUBLIC_SUPABASE_URL="https://your-project.supabase.co" NEXT_PUBLIC_SUPABASE_ANON_KEY="your-anon-key" -SUPABASE_SERVICE_ROLE_KEY="your-service-role-key" # PartyKit (Real-time collaboration) # For local development: localhost:1999 diff --git a/PARTYKIT.md b/PARTYKIT.md index 91ec7e0..dafd1f9 100644 --- a/PARTYKIT.md +++ b/PARTYKIT.md @@ -9,34 +9,48 @@ PartyKit replaces the previous y-webrtc peer-to-peer sync with a server-mediated - **Reliable sync**: No more WebRTC connection failures through firewalls - **Single persistence point**: Only the PartyKit server writes to the database (no more duplicate saves from multiple clients) - **Better scalability**: Server handles coordination instead of mesh connections between clients +- **RLS respected**: User authentication is verified on every connection ## Architecture ``` ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ Client A │ │ Client B │ │ Client C │ +│ (w/ JWT) │ │ (w/ JWT) │ │ (w/ JWT) │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │ │ └───────────────────┼───────────────────┘ - │ WebSocket + │ WebSocket + JWT ▼ ┌────────────────────────┐ │ PartyKit Server │ - │ (per-document room) │ + │ - Verifies JWT │ + │ - Manages Y.Doc │ └───────────┬────────────┘ - │ HTTP + │ HTTP + JWT ▼ ┌────────────────────────┐ │ Next.js API Routes │ │ /api/partykit/* │ └───────────┬────────────┘ - │ + │ RLS enforced ▼ ┌────────────────────────┐ │ Supabase │ └────────────────────────┘ ``` +## Security Model + +1. **Client authenticates with Supabase** and receives a JWT +2. **Client connects to PartyKit** with JWT in query params +3. **PartyKit verifies** the JWT is not expired +4. **PartyKit calls API routes** with the user's JWT +5. **API routes create Supabase client** using that JWT +6. **RLS automatically enforced** - users can only access documents they have permission to + +No service role key is used. The user's own credentials flow through the entire system. + ## Setup ### 1. Install PartyKit CLI @@ -63,9 +77,6 @@ NEXT_PUBLIC_PARTYKIT_HOST=localhost:1999 # dev # Shared secret for server-to-server auth PARTYKIT_SECRET=your-secret-here # Generate with: openssl rand -base64 32 -# Supabase service role key (for PartyKit API routes) -SUPABASE_SERVICE_ROLE_KEY=your-service-role-key - # App URL for PartyKit server callbacks APP_URL=http://localhost:3000 # dev # APP_URL=https://your-app.vercel.app # prod @@ -110,37 +121,36 @@ npx partykit env add PARTYKIT_SECRET | File | Purpose | |------|---------| | `partykit.json` | PartyKit configuration | -| `party/document.ts` | PartyKit server (Yjs room handler) | -| `src/hooks/use-collaborative-doc-partykit.ts` | Client-side hook | -| `src/app/api/partykit/load/route.ts` | API to load document state | -| `src/app/api/partykit/save/route.ts` | API to save document state | -| `src/utils/supabase/service-role.ts` | Supabase client with service role | +| `party/document.ts` | PartyKit server (Yjs room handler, JWT verification) | +| `src/hooks/use-collaborative-doc-partykit.ts` | Client-side hook (gets session, passes JWT) | +| `src/app/api/partykit/load/route.ts` | API to load document state (uses user's JWT) | +| `src/app/api/partykit/save/route.ts` | API to save document state (uses user's JWT) | +| `src/utils/supabase/from-token.ts` | Creates Supabase client from JWT | ## How It Works ### Client Connection -1. Client opens document page +1. Client gets Supabase session (includes access_token) 2. `useCollaborativeDocPartykit` hook creates a Y.Doc and YPartyKitProvider -3. Provider connects to PartyKit server via WebSocket +3. Provider connects to PartyKit server with JWT in query params 4. Provider syncs document state and awareness (cursors) ### Server Lifecycle -1. First client connects → PartyKit spins up room for that document ID -2. Room calls `/api/partykit/load` to fetch document state from Supabase -3. Room applies state to its Y.Doc -4. As clients make edits, Y.Doc updates are broadcast to all connected clients -5. Room debounces saves (1 second) and calls `/api/partykit/save` -6. Last client disconnects → room shuts down (but save completes first) - -### Persistence +1. First client connects with JWT → PartyKit verifies JWT not expired +2. Room calls `/api/partykit/load` with user's JWT +3. API route creates Supabase client with that JWT → RLS enforced +4. If user has access, document loads; otherwise, connection rejected +5. As clients make edits, Y.Doc updates are broadcast to all connected clients +6. Room debounces saves (1 second) and calls `/api/partykit/save` with JWT +7. Last client disconnects → room shuts down (but save completes first) -The PartyKit server saves the full Y.Doc state as a snapshot. This: +### Permission Enforcement -- Replaces the previous append-only change log approach -- Clears old changes from `document_changes` table after each save -- Eliminates the need for client-side compaction +- **Load**: If user can't read the document, the Supabase query returns nothing/error +- **Save**: If user can't write to the document, the Supabase upsert fails +- **Connect**: If load fails due to permissions, the connection is closed with code 4003 ## Costs diff --git a/party/document.ts b/party/document.ts index 01e3f87..55ca124 100644 --- a/party/document.ts +++ b/party/document.ts @@ -19,34 +19,50 @@ function uint8ArrayToBase64(bytes: Uint8Array): string { return btoa(binary); } +function decodeJwtPayload(token: string): { sub?: string; exp?: number } | null { + try { + const parts = token.split("."); + if (parts.length !== 3) return null; + const payload = atob(parts[1]!.replace(/-/g, "+").replace(/_/g, "/")); + return JSON.parse(payload) as { sub?: string; exp?: number }; + } catch { + return null; + } +} + +function isTokenExpired(token: string): boolean { + const payload = decodeJwtPayload(token); + if (!payload?.exp) return true; + return Date.now() >= payload.exp * 1000; +} + export default class DocumentParty implements Party.Server { ydoc: Y.Doc; isLoaded: boolean = false; pendingSave: boolean = false; saveTimeout: ReturnType | null = null; + authorizedToken: string | null = null; constructor(readonly room: Party.Room) { this.ydoc = new Y.Doc(); } get appUrl(): string { - return this.room.env.APP_URL as string || "http://localhost:3000"; + return (this.room.env.APP_URL as string) || "http://localhost:3000"; } get partykitSecret(): string { - return this.room.env.PARTYKIT_SECRET as string || ""; + return (this.room.env.PARTYKIT_SECRET as string) || ""; } async onStart(): Promise { - await this.loadDocument(); - this.ydoc.on("update", (_update: Uint8Array, origin: unknown) => { if (origin === "load") return; this.scheduleSave(); }); } - async loadDocument(): Promise { + async loadDocument(token: string): Promise { const documentId = this.room.id; try { @@ -55,20 +71,27 @@ export default class DocumentParty implements Party.Server { headers: { "Content-Type": "application/json", "X-Partykit-Secret": this.partykitSecret, + Authorization: `Bearer ${token}`, }, body: JSON.stringify({ documentId }), }); if (!response.ok) { if (response.status === 404) { - console.log(`[PartyKit] Document ${documentId} not found, starting fresh`); + console.log( + `[PartyKit] Document ${documentId} not found, starting fresh` + ); this.isLoaded = true; - return; + return true; + } + if (response.status === 401 || response.status === 403) { + console.log(`[PartyKit] Unauthorized access to document ${documentId}`); + return false; } throw new Error(`Failed to load document: ${response.status}`); } - const data = await response.json() as { + const data = (await response.json()) as { snapshot: string | null; changes: Array<{ updateData: string }>; }; @@ -89,10 +112,13 @@ export default class DocumentParty implements Party.Server { } this.isLoaded = true; - console.log(`[PartyKit] Loaded document ${documentId} with ${updates.length} updates`); + console.log( + `[PartyKit] Loaded document ${documentId} with ${updates.length} updates` + ); + return true; } catch (error) { console.error(`[PartyKit] Failed to load document ${documentId}:`, error); - this.isLoaded = true; + return false; } } @@ -112,6 +138,11 @@ export default class DocumentParty implements Party.Server { } async saveDocument(): Promise { + if (!this.authorizedToken) { + console.error("[PartyKit] No authorized token available for save"); + return; + } + const documentId = this.room.id; const stateUpdate = Y.encodeStateAsUpdate(this.ydoc); const stateBase64 = uint8ArrayToBase64(stateUpdate); @@ -122,6 +153,7 @@ export default class DocumentParty implements Party.Server { headers: { "Content-Type": "application/json", "X-Partykit-Secret": this.partykitSecret, + Authorization: `Bearer ${this.authorizedToken}`, }, body: JSON.stringify({ documentId, @@ -139,7 +171,32 @@ export default class DocumentParty implements Party.Server { } } - onConnect(conn: Party.Connection): void | Promise { + async onConnect(conn: Party.Connection): Promise { + const url = new URL(conn.uri, "http://dummy"); + const token = url.searchParams.get("token"); + + if (!token) { + console.log("[PartyKit] Connection rejected: no token provided"); + conn.close(4001, "Unauthorized: no token"); + return; + } + + if (isTokenExpired(token)) { + console.log("[PartyKit] Connection rejected: token expired"); + conn.close(4001, "Unauthorized: token expired"); + return; + } + + if (!this.isLoaded) { + const success = await this.loadDocument(token); + if (!success) { + conn.close(4003, "Forbidden: no access to document"); + return; + } + } + + this.authorizedToken = token; + const options: YPartyKitOptions = { callback: { handler: () => {} }, }; diff --git a/src/app/api/partykit/load/route.ts b/src/app/api/partykit/load/route.ts index d4eb2ad..31a4675 100644 --- a/src/app/api/partykit/load/route.ts +++ b/src/app/api/partykit/load/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { createServiceRoleClient } from "~/utils/supabase/service-role"; +import { createClientFromToken } from "~/utils/supabase/from-token"; interface SnapshotRow { snapshot_data: string | null; @@ -36,6 +36,16 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } + const authHeader = request.headers.get("Authorization"); + const token = authHeader?.replace("Bearer ", ""); + + if (!token) { + return NextResponse.json( + { error: "Missing authorization token" }, + { status: 401 } + ); + } + try { const { documentId } = (await request.json()) as { documentId: string }; @@ -46,7 +56,7 @@ export async function POST(request: Request) { ); } - const supabase = createServiceRoleClient(); + const supabase = createClientFromToken(token); const { data: doc, error: docError } = await supabase .from("documents") @@ -55,7 +65,13 @@ export async function POST(request: Request) { .single(); if (docError || !doc) { - return NextResponse.json({ error: "Document not found" }, { status: 404 }); + if (docError?.code === "PGRST116") { + return NextResponse.json({ error: "Document not found" }, { status: 404 }); + } + return NextResponse.json( + { error: "Access denied or document not found" }, + { status: 403 } + ); } const { data: rawSnapshotRow, error: snapshotError } = await supabase diff --git a/src/app/api/partykit/save/route.ts b/src/app/api/partykit/save/route.ts index 97eed11..d8c00c2 100644 --- a/src/app/api/partykit/save/route.ts +++ b/src/app/api/partykit/save/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { createServiceRoleClient } from "~/utils/supabase/service-role"; +import { createClientFromToken } from "~/utils/supabase/from-token"; function base64ToByteaHex(base64: string): string { const buf = Buffer.from(base64, "base64"); @@ -14,6 +14,16 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } + const authHeader = request.headers.get("Authorization"); + const token = authHeader?.replace("Bearer ", ""); + + if (!token) { + return NextResponse.json( + { error: "Missing authorization token" }, + { status: 401 } + ); + } + try { const { documentId, snapshot } = (await request.json()) as { documentId: string; @@ -27,7 +37,7 @@ export async function POST(request: Request) { ); } - const supabase = createServiceRoleClient(); + const supabase = createClientFromToken(token); const { data: doc, error: docError } = await supabase .from("documents") @@ -36,7 +46,10 @@ export async function POST(request: Request) { .single(); if (docError || !doc) { - return NextResponse.json({ error: "Document not found" }, { status: 404 }); + return NextResponse.json( + { error: "Access denied or document not found" }, + { status: 403 } + ); } const { error: upsertError } = await supabase diff --git a/src/hooks/use-collaborative-doc-partykit.ts b/src/hooks/use-collaborative-doc-partykit.ts index d7cdc35..94f66dc 100644 --- a/src/hooks/use-collaborative-doc-partykit.ts +++ b/src/hooks/use-collaborative-doc-partykit.ts @@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from "react"; import * as Y from "yjs"; import YPartyKitProvider from "y-partykit/provider"; +import { createClient } from "~/utils/supabase/client"; interface UseCollaborativeDocPartykitOptions { documentId: string; @@ -17,7 +18,8 @@ interface UseCollaborativeDocPartykitResult { error: Error | null; } -const PARTYKIT_HOST = process.env.NEXT_PUBLIC_PARTYKIT_HOST ?? "localhost:1999"; +const PARTYKIT_HOST = + process.env.NEXT_PUBLIC_PARTYKIT_HOST ?? "localhost:1999"; export function useCollaborativeDocPartykit({ documentId, @@ -48,41 +50,78 @@ export function useCollaborativeDocPartykit({ setError(null); setIsReady(false); - const ydoc = new Y.Doc(); - - const provider = new YPartyKitProvider(PARTYKIT_HOST, documentId, ydoc, { - connect: true, - }); - - provider.on("sync", (synced: boolean) => { - if (synced) { - setIsReady(true); + const setup = async () => { + try { + const supabase = createClient(); + const { + data: { session }, + error: sessionError, + } = await supabase.auth.getSession(); + + if (sessionError) { + throw new Error(`Failed to get session: ${sessionError.message}`); + } + + if (!session?.access_token) { + throw new Error("Not authenticated"); + } + + const ydoc = new Y.Doc(); + + const provider = new YPartyKitProvider(PARTYKIT_HOST, documentId, ydoc, { + connect: true, + params: { + token: session.access_token, + }, + }); + + provider.on("sync", (synced: boolean) => { + if (synced) { + setIsReady(true); + setIsLoading(false); + } + }); + + provider.on("connection-error", (err: Error) => { + console.error("[PartyKit] Connection error:", err); + setError(err); + setIsLoading(false); + }); + + provider.on("connection-close", (event: CloseEvent) => { + if (event.code === 4001) { + setError(new Error("Unauthorized: Please sign in")); + setIsLoading(false); + } else if (event.code === 4003) { + setError(new Error("You don't have access to this document")); + setIsLoading(false); + } + }); + + lastDocumentIdRef.current = documentId; + initializedRef.current = true; + setState({ ydoc, provider }); + + cleanupRef.current = () => { + initializedRef.current = false; + try { + provider.destroy(); + } catch {} + try { + ydoc.destroy(); + } catch {} + setState(null); + setIsReady(false); + }; + } catch (err) { + console.error("[PartyKit] Setup error:", err); + setError(err instanceof Error ? err : new Error(String(err))); setIsLoading(false); } - }); - - provider.on("connection-error", (err: Error) => { - console.error("[PartyKit] Connection error:", err); - setError(err); - setIsLoading(false); - }); - - lastDocumentIdRef.current = documentId; - initializedRef.current = true; - setState({ ydoc, provider }); - - cleanupRef.current = () => { - initializedRef.current = false; - try { - provider.destroy(); - } catch {} - try { - ydoc.destroy(); - } catch {} - setState(null); - setIsReady(false); }; + void setup(); + return () => { cleanupRef.current?.(); cleanupRef.current = null; diff --git a/src/utils/supabase/from-token.ts b/src/utils/supabase/from-token.ts new file mode 100644 index 0000000..2d99247 --- /dev/null +++ b/src/utils/supabase/from-token.ts @@ -0,0 +1,22 @@ +import { createClient as createSupabaseClient } from "@supabase/supabase-js"; + +export function createClientFromToken(accessToken: string) { + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; + const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + + if (!supabaseUrl || !supabaseAnonKey) { + throw new Error("Missing SUPABASE_URL or SUPABASE_ANON_KEY"); + } + + return createSupabaseClient(supabaseUrl, supabaseAnonKey, { + global: { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + auth: { + autoRefreshToken: false, + persistSession: false, + }, + }); +} diff --git a/src/utils/supabase/service-role.ts b/src/utils/supabase/service-role.ts deleted file mode 100644 index 98b65b0..0000000 --- a/src/utils/supabase/service-role.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { createClient as createSupabaseClient } from "@supabase/supabase-js"; - -export function createServiceRoleClient() { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; - const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; - - if (!supabaseUrl || !serviceRoleKey) { - throw new Error("Missing SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY"); - } - - return createSupabaseClient(supabaseUrl, serviceRoleKey, { - auth: { - autoRefreshToken: false, - persistSession: false, - }, - }); -} From c4aef6878c6eb1ad0f6b065c512e136ea4414eb9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 21 Aug 2026 16:29:54 +0000 Subject: [PATCH 03/25] fix: add npm scripts for partykit dev/deploy Running 'npx partykit dev' downloads a fresh version that can't see local node_modules. Using 'npm run dev:partykit' uses the locally installed version which can resolve y-partykit properly. Co-authored-by: Matia Raspopovic --- package.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/package.json b/package.json index e8f446d..eaf7a17 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "build": "next build", "check": "next lint && tsc --noEmit", "dev": "next dev --turbo", + "dev:partykit": "partykit dev", + "deploy:partykit": "partykit deploy", "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache", "format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache", "lint": "next lint", From 0072dfdf2449722229039553a4fc1f29a1bcc1fb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 21 Aug 2026 16:30:23 +0000 Subject: [PATCH 04/25] docs: update PARTYKIT.md to use npm scripts Co-authored-by: Matia Raspopovic --- PARTYKIT.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/PARTYKIT.md b/PARTYKIT.md index dafd1f9..af6a22a 100644 --- a/PARTYKIT.md +++ b/PARTYKIT.md @@ -90,16 +90,18 @@ Run both the Next.js dev server and PartyKit dev server: # Terminal 1: Next.js npm run dev -# Terminal 2: PartyKit -npx partykit dev +# Terminal 2: PartyKit (use npm script, not npx!) +npm run dev:partykit ``` +**Important:** Use `npm run dev:partykit` instead of `npx partykit dev`. The npx version downloads a fresh PartyKit that can't see your project's dependencies. + PartyKit dev server runs on `localhost:1999` by default. ### 5. Deploy PartyKit ```bash -npx partykit deploy +npm run deploy:partykit ``` This deploys to PartyKit's free tier at `chptr-collab.partykit.dev`. @@ -116,6 +118,8 @@ npx partykit env add PARTYKIT_SECRET # Enter: your-secret-here (same as in your Next.js .env) ``` +Note: For `env` commands, `npx partykit` is fine since it doesn't need to bundle code. + ## Files | File | Purpose | From 198fe32b9558bc0cda81d1632d9508ad110b9b85 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 21 Aug 2026 16:43:20 +0000 Subject: [PATCH 05/25] refactor: simplify database schema for PartyKit - Add new document_state table (single table for full Y.Doc state) - Remove dependency on document_changes and document_snapshots - Simplify API routes to load/save full state - No more compaction needed - Update documentation with new schema Migration: Run migrations/partykit_document_state.sql Co-authored-by: Matia Raspopovic --- PARTYKIT.md | 23 +++++++- migrations/partykit_document_state.sql | 53 +++++++++++++++++ party/document.ts | 33 +++-------- src/app/api/partykit/load/route.ts | 81 ++++++++------------------ src/app/api/partykit/save/route.ts | 30 ++++------ 5 files changed, 119 insertions(+), 101 deletions(-) create mode 100644 migrations/partykit_document_state.sql diff --git a/PARTYKIT.md b/PARTYKIT.md index af6a22a..d3e25cc 100644 --- a/PARTYKIT.md +++ b/PARTYKIT.md @@ -120,6 +120,22 @@ npx partykit env add PARTYKIT_SECRET Note: For `env` commands, `npx partykit` is fine since it doesn't need to bundle code. +## Database Schema + +The PartyKit integration uses a simplified single-table schema: + +```sql +CREATE TABLE document_state ( + document_id UUID PRIMARY KEY REFERENCES documents(id), + state_data BYTEA NOT NULL, -- Full Y.Doc state + updated_at TIMESTAMPTZ DEFAULT NOW() +); +``` + +**To set up:** Run the migration in `migrations/partykit_document_state.sql` + +This replaces the old `document_changes` + `document_snapshots` tables with a single table. No more compaction needed since we always store the full state. + ## Files | File | Purpose | @@ -127,9 +143,10 @@ Note: For `env` commands, `npx partykit` is fine since it doesn't need to bundle | `partykit.json` | PartyKit configuration | | `party/document.ts` | PartyKit server (Yjs room handler, JWT verification) | | `src/hooks/use-collaborative-doc-partykit.ts` | Client-side hook (gets session, passes JWT) | -| `src/app/api/partykit/load/route.ts` | API to load document state (uses user's JWT) | -| `src/app/api/partykit/save/route.ts` | API to save document state (uses user's JWT) | +| `src/app/api/partykit/load/route.ts` | API to load document state | +| `src/app/api/partykit/save/route.ts` | API to save document state | | `src/utils/supabase/from-token.ts` | Creates Supabase client from JWT | +| `migrations/partykit_document_state.sql` | Database migration | ## How It Works @@ -178,4 +195,4 @@ To revert to y-webrtc: 2. In `src/app/_components/editor/editor.tsx`: - Change provider type back to `WebrtcProvider` -The database schema is unchanged, so rollback is seamless. +**Note:** The PartyKit integration uses a new `document_state` table. The old `document_changes` and `document_snapshots` tables are still present but not used. If you have existing documents that were created with the old system, you may need to migrate the data or keep both systems available. diff --git a/migrations/partykit_document_state.sql b/migrations/partykit_document_state.sql new file mode 100644 index 0000000..f1d0ebc --- /dev/null +++ b/migrations/partykit_document_state.sql @@ -0,0 +1,53 @@ +-- PartyKit document state table +-- Replaces document_changes + document_snapshots with a single table +-- PartyKit server is the only writer, storing full Y.Doc state + +-- Create new simplified table +CREATE TABLE IF NOT EXISTS document_state ( + document_id UUID NOT NULL PRIMARY KEY REFERENCES documents(id) ON DELETE CASCADE, + state_data BYTEA NOT NULL, -- Full Y.Doc state (Y.encodeStateAsUpdate) + updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL +); + +-- Index for querying by update time (useful for cleanup/analytics) +CREATE INDEX IF NOT EXISTS idx_document_state_updated_at ON document_state (updated_at); + +-- RLS policies +ALTER TABLE document_state ENABLE ROW LEVEL SECURITY; + +-- Policy: Users can read state for documents they have permission to access +CREATE POLICY "Users can read document state" ON document_state FOR +SELECT USING ( + EXISTS ( + SELECT 1 + FROM document_permissions + WHERE document_permissions.document_id = document_state.document_id + AND document_permissions.user_id = auth.uid() + ) +); + +-- Policy: Users can insert state for documents they have permission to access +CREATE POLICY "Users can insert document state" ON document_state FOR +INSERT WITH CHECK ( + EXISTS ( + SELECT 1 + FROM document_permissions + WHERE document_permissions.document_id = document_state.document_id + AND document_permissions.user_id = auth.uid() + ) +); + +-- Policy: Users can update state for documents they have permission to access +CREATE POLICY "Users can update document state" ON document_state FOR +UPDATE USING ( + EXISTS ( + SELECT 1 + FROM document_permissions + WHERE document_permissions.document_id = document_state.document_id + AND document_permissions.user_id = auth.uid() + ) +); + +-- Optional: Drop old tables if migrating (uncomment when ready) +-- DROP TABLE IF EXISTS document_changes; +-- DROP TABLE IF EXISTS document_snapshots; diff --git a/party/document.ts b/party/document.ts index 55ca124..f432017 100644 --- a/party/document.ts +++ b/party/document.ts @@ -78,9 +78,7 @@ export default class DocumentParty implements Party.Server { if (!response.ok) { if (response.status === 404) { - console.log( - `[PartyKit] Document ${documentId} not found, starting fresh` - ); + console.log(`[PartyKit] Document ${documentId} not found, starting fresh`); this.isLoaded = true; return true; } @@ -91,30 +89,17 @@ export default class DocumentParty implements Party.Server { throw new Error(`Failed to load document: ${response.status}`); } - const data = (await response.json()) as { - snapshot: string | null; - changes: Array<{ updateData: string }>; - }; + const data = (await response.json()) as { state: string | null }; - const updates: Uint8Array[] = []; - - if (data.snapshot) { - updates.push(base64ToUint8Array(data.snapshot)); - } - - for (const change of data.changes || []) { - updates.push(base64ToUint8Array(change.updateData)); - } - - if (updates.length > 0) { - const merged = Y.mergeUpdates(updates); - Y.applyUpdate(this.ydoc, merged, "load"); + if (data.state) { + const stateBytes = base64ToUint8Array(data.state); + Y.applyUpdate(this.ydoc, stateBytes, "load"); + console.log(`[PartyKit] Loaded document ${documentId} with existing state`); + } else { + console.log(`[PartyKit] Document ${documentId} has no saved state, starting fresh`); } this.isLoaded = true; - console.log( - `[PartyKit] Loaded document ${documentId} with ${updates.length} updates` - ); return true; } catch (error) { console.error(`[PartyKit] Failed to load document ${documentId}:`, error); @@ -157,7 +142,7 @@ export default class DocumentParty implements Party.Server { }, body: JSON.stringify({ documentId, - snapshot: stateBase64, + state: stateBase64, }), }); diff --git a/src/app/api/partykit/load/route.ts b/src/app/api/partykit/load/route.ts index 31a4675..921e1bd 100644 --- a/src/app/api/partykit/load/route.ts +++ b/src/app/api/partykit/load/route.ts @@ -1,30 +1,23 @@ import { NextResponse } from "next/server"; import { createClientFromToken } from "~/utils/supabase/from-token"; -interface SnapshotRow { - snapshot_data: string | null; - snapshot_cutoff_created_at: string | null; -} - -interface ChangeRow { - update_data: string | null; - created_at: string; -} - -function byteaResponseToBase64(raw: string | null | undefined): string { - const trimmed = (raw ?? "").trim(); - if (!trimmed) return ""; - if ( - trimmed.startsWith("\\x") || - trimmed.startsWith("0x") || - trimmed.startsWith("0X") - ) { - const hex = trimmed.replace(/^\\x|^0x|^0X/i, "").replace(/\s/g, ""); +function byteaToBase64(raw: string | null | undefined): string | null { + if (!raw) return null; + const trimmed = raw.trim(); + if (!trimmed) return null; + + // Handle PostgreSQL bytea hex format (\x...) + if (trimmed.startsWith("\\x")) { + const hex = trimmed.slice(2); return Buffer.from(hex, "hex").toString("base64"); } + + // Handle raw hex if (/^[0-9a-fA-F]+$/.test(trimmed) && trimmed.length % 2 === 0) { return Buffer.from(trimmed, "hex").toString("base64"); } + + // Assume already base64 return trimmed; } @@ -58,6 +51,7 @@ export async function POST(request: Request) { const supabase = createClientFromToken(token); + // Check if user has access to the document const { data: doc, error: docError } = await supabase .from("documents") .select("id") @@ -74,52 +68,27 @@ export async function POST(request: Request) { ); } - const { data: rawSnapshotRow, error: snapshotError } = await supabase - .from("document_snapshots") - .select("snapshot_data, snapshot_cutoff_created_at") + // Load document state + const { data: stateRow, error: stateError } = await supabase + .from("document_state") + .select("state_data") .eq("document_id", documentId) .single(); - if (snapshotError && snapshotError.code !== "PGRST116") { - console.error("[PartyKit Load] Snapshot error:", snapshotError); - } - - const snapshotRow = rawSnapshotRow as SnapshotRow | null; - const snapshot: string | null = snapshotRow?.snapshot_data - ? byteaResponseToBase64(snapshotRow.snapshot_data) - : null; - const snapshotCutoffCreatedAt: string | null = - snapshotRow?.snapshot_cutoff_created_at ?? null; - - let changesQuery = supabase - .from("document_changes") - .select("update_data, created_at") - .eq("document_id", documentId) - .order("created_at", { ascending: true }); - - if (snapshotCutoffCreatedAt) { - changesQuery = changesQuery.gt("created_at", snapshotCutoffCreatedAt); - } - - const { data: rawChangesRows, error: changesError } = await changesQuery; - - if (changesError) { - console.error("[PartyKit Load] Changes error:", changesError); + if (stateError && stateError.code !== "PGRST116") { + console.error("[PartyKit Load] State error:", stateError); return NextResponse.json( - { error: "Failed to load changes" }, + { error: "Failed to load document state" }, { status: 500 } ); } - const changesRows = (rawChangesRows ?? []) as ChangeRow[]; - const changes = changesRows.map((row) => ({ - updateData: byteaResponseToBase64(row.update_data), - })); + // Convert bytea to base64 + const state = stateRow + ? byteaToBase64((stateRow as { state_data: string }).state_data) + : null; - return NextResponse.json({ - snapshot, - changes, - }); + return NextResponse.json({ state }); } catch (error) { console.error("[PartyKit Load] Error:", error); return NextResponse.json( diff --git a/src/app/api/partykit/save/route.ts b/src/app/api/partykit/save/route.ts index d8c00c2..943560a 100644 --- a/src/app/api/partykit/save/route.ts +++ b/src/app/api/partykit/save/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import { createClientFromToken } from "~/utils/supabase/from-token"; -function base64ToByteaHex(base64: string): string { +function base64ToBytea(base64: string): string { const buf = Buffer.from(base64, "base64"); return "\\x" + buf.toString("hex"); } @@ -25,20 +25,21 @@ export async function POST(request: Request) { } try { - const { documentId, snapshot } = (await request.json()) as { + const { documentId, state } = (await request.json()) as { documentId: string; - snapshot: string; + state: string; }; - if (!documentId || !snapshot) { + if (!documentId || !state) { return NextResponse.json( - { error: "Missing documentId or snapshot" }, + { error: "Missing documentId or state" }, { status: 400 } ); } const supabase = createClientFromToken(token); + // Check if user has access to the document const { data: doc, error: docError } = await supabase .from("documents") .select("id") @@ -52,13 +53,14 @@ export async function POST(request: Request) { ); } + // Upsert document state const { error: upsertError } = await supabase - .from("document_snapshots") + .from("document_state") .upsert( { document_id: documentId, - snapshot_data: base64ToByteaHex(snapshot), - snapshot_cutoff_created_at: new Date().toISOString(), + state_data: base64ToBytea(state), + updated_at: new Date().toISOString(), }, { onConflict: "document_id" } ); @@ -66,20 +68,12 @@ export async function POST(request: Request) { if (upsertError) { console.error("[PartyKit Save] Upsert error:", upsertError); return NextResponse.json( - { error: "Failed to save snapshot" }, + { error: "Failed to save document state" }, { status: 500 } ); } - const { error: deleteError } = await supabase - .from("document_changes") - .delete() - .eq("document_id", documentId); - - if (deleteError) { - console.error("[PartyKit Save] Delete error (non-fatal):", deleteError); - } - + // Update document's last_updated timestamp const { error: updateError } = await supabase .from("documents") .update({ last_updated: new Date().toISOString() }) From 85fb8febe80e8abb711217afb643e4aa6ea31a91 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 21 Aug 2026 17:12:52 +0000 Subject: [PATCH 06/25] fix: create document in database before connecting to PartyKit When isNew=true, the hook now calls /api/documents/create to create the document row and owner permission before connecting to PartyKit. This ensures: 1. Document exists when PartyKit tries to load/save 2. RLS permissions are set up correctly 3. getDocumentById works for document metadata Flow: 1. User clicks 'create document' 2. Hook detects isNew=true 3. Calls POST /api/documents/create with documentId 4. API creates document + permission via create_document_with_owner RPC 5. Then connects to PartyKit Co-authored-by: Matia Raspopovic --- src/app/api/documents/create/route.ts | 69 +++++++++++++++++++++ src/hooks/use-collaborative-doc-partykit.ts | 24 +++++++ 2 files changed, 93 insertions(+) create mode 100644 src/app/api/documents/create/route.ts diff --git a/src/app/api/documents/create/route.ts b/src/app/api/documents/create/route.ts new file mode 100644 index 0000000..2cf326b --- /dev/null +++ b/src/app/api/documents/create/route.ts @@ -0,0 +1,69 @@ +import { NextResponse } from "next/server"; +import { createClientFromToken } from "~/utils/supabase/from-token"; + +export async function POST(request: Request) { + const authHeader = request.headers.get("Authorization"); + const token = authHeader?.replace("Bearer ", ""); + + if (!token) { + return NextResponse.json( + { error: "Missing authorization token" }, + { status: 401 } + ); + } + + try { + const { documentId } = (await request.json()) as { documentId: string }; + + if (!documentId) { + return NextResponse.json( + { error: "Missing documentId" }, + { status: 400 } + ); + } + + const supabase = createClientFromToken(token); + + // Check if document already exists + const { data: existingDoc, error: checkError } = await supabase + .from("documents") + .select("id") + .eq("id", documentId) + .single(); + + if (checkError && checkError.code !== "PGRST116") { + console.error("[Create Document] Check error:", checkError); + return NextResponse.json( + { error: "Failed to check document" }, + { status: 500 } + ); + } + + // Document already exists, that's fine + if (existingDoc) { + return NextResponse.json({ success: true, created: false }); + } + + // Create document with owner permission using the RPC + const { error: createError } = await supabase.rpc("create_document_with_owner", { + p_document_id: documentId, + p_name: "Untitled", + }); + + if (createError) { + console.error("[Create Document] RPC error:", createError); + return NextResponse.json( + { error: "Failed to create document" }, + { status: 500 } + ); + } + + return NextResponse.json({ success: true, created: true }); + } catch (error) { + console.error("[Create Document] Error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/hooks/use-collaborative-doc-partykit.ts b/src/hooks/use-collaborative-doc-partykit.ts index 94f66dc..0365655 100644 --- a/src/hooks/use-collaborative-doc-partykit.ts +++ b/src/hooks/use-collaborative-doc-partykit.ts @@ -21,6 +21,25 @@ interface UseCollaborativeDocPartykitResult { const PARTYKIT_HOST = process.env.NEXT_PUBLIC_PARTYKIT_HOST ?? "localhost:1999"; +async function createDocumentIfNew( + documentId: string, + accessToken: string +): Promise { + const response = await fetch("/api/documents/create", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ documentId }), + }); + + if (!response.ok) { + const data = (await response.json().catch(() => ({}))) as { error?: string }; + throw new Error(data.error ?? "Failed to create document"); + } +} + export function useCollaborativeDocPartykit({ documentId, isNew = false, @@ -66,6 +85,11 @@ export function useCollaborativeDocPartykit({ throw new Error("Not authenticated"); } + // If this is a new document, create it in the database first + if (isNew) { + await createDocumentIfNew(documentId, session.access_token); + } + const ydoc = new Y.Doc(); const provider = new YPartyKitProvider(PARTYKIT_HOST, documentId, ydoc, { From 35412c280441a88a1c7098585ad8039d87db3937 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 21 Aug 2026 19:09:46 +0000 Subject: [PATCH 07/25] refactor: simplify document creation flow with isNew flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Load API now handles document creation: - isNew=true + doc doesn't exist → create doc + owner permission - isNew=false + doc doesn't exist → 404 - doc exists → check permission, return state Save API simplified: - Just upserts state (doc already exists from Load) - Checks permission via RLS PartyKit server: - Passes isNew flag from client to Load API - Returns appropriate error codes (4003=forbidden, 4004=not found) Client hook: - Passes isNew flag to PartyKit via query params - Handles new 4004 error code for 'not found' Removed: - /api/documents/create route (no longer needed) This gives instant UX - document is created on first Load call, user can start editing immediately. Co-authored-by: Matia Raspopovic --- party/document.ts | 34 ++++----- src/app/api/documents/create/route.ts | 69 ----------------- src/app/api/partykit/load/route.ts | 82 ++++++++++++++++----- src/app/api/partykit/save/route.ts | 19 ++--- src/hooks/use-collaborative-doc-partykit.ts | 29 ++------ 5 files changed, 93 insertions(+), 140 deletions(-) delete mode 100644 src/app/api/documents/create/route.ts diff --git a/party/document.ts b/party/document.ts index f432017..8269516 100644 --- a/party/document.ts +++ b/party/document.ts @@ -62,7 +62,7 @@ export default class DocumentParty implements Party.Server { }); } - async loadDocument(token: string): Promise { + async loadDocument(token: string, isNew: boolean): Promise<{ success: boolean; errorCode?: number }> { const documentId = this.room.id; try { @@ -73,20 +73,12 @@ export default class DocumentParty implements Party.Server { "X-Partykit-Secret": this.partykitSecret, Authorization: `Bearer ${token}`, }, - body: JSON.stringify({ documentId }), + body: JSON.stringify({ documentId, isNew }), }); if (!response.ok) { - if (response.status === 404) { - console.log(`[PartyKit] Document ${documentId} not found, starting fresh`); - this.isLoaded = true; - return true; - } - if (response.status === 401 || response.status === 403) { - console.log(`[PartyKit] Unauthorized access to document ${documentId}`); - return false; - } - throw new Error(`Failed to load document: ${response.status}`); + console.log(`[PartyKit] Load failed for ${documentId}: ${response.status}`); + return { success: false, errorCode: response.status }; } const data = (await response.json()) as { state: string | null }; @@ -96,14 +88,14 @@ export default class DocumentParty implements Party.Server { Y.applyUpdate(this.ydoc, stateBytes, "load"); console.log(`[PartyKit] Loaded document ${documentId} with existing state`); } else { - console.log(`[PartyKit] Document ${documentId} has no saved state, starting fresh`); + console.log(`[PartyKit] Document ${documentId} starting with empty state`); } this.isLoaded = true; - return true; + return { success: true }; } catch (error) { console.error(`[PartyKit] Failed to load document ${documentId}:`, error); - return false; + return { success: false, errorCode: 500 }; } } @@ -159,6 +151,7 @@ export default class DocumentParty implements Party.Server { async onConnect(conn: Party.Connection): Promise { const url = new URL(conn.uri, "http://dummy"); const token = url.searchParams.get("token"); + const isNew = url.searchParams.get("isNew") === "true"; if (!token) { console.log("[PartyKit] Connection rejected: no token provided"); @@ -172,10 +165,15 @@ export default class DocumentParty implements Party.Server { return; } + // Only load on first connection to this room if (!this.isLoaded) { - const success = await this.loadDocument(token); - if (!success) { - conn.close(4003, "Forbidden: no access to document"); + const result = await this.loadDocument(token, isNew); + if (!result.success) { + const code = result.errorCode === 404 ? 4004 : 4003; + const message = result.errorCode === 404 + ? "Document not found" + : "Access denied"; + conn.close(code, message); return; } } diff --git a/src/app/api/documents/create/route.ts b/src/app/api/documents/create/route.ts deleted file mode 100644 index 2cf326b..0000000 --- a/src/app/api/documents/create/route.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { NextResponse } from "next/server"; -import { createClientFromToken } from "~/utils/supabase/from-token"; - -export async function POST(request: Request) { - const authHeader = request.headers.get("Authorization"); - const token = authHeader?.replace("Bearer ", ""); - - if (!token) { - return NextResponse.json( - { error: "Missing authorization token" }, - { status: 401 } - ); - } - - try { - const { documentId } = (await request.json()) as { documentId: string }; - - if (!documentId) { - return NextResponse.json( - { error: "Missing documentId" }, - { status: 400 } - ); - } - - const supabase = createClientFromToken(token); - - // Check if document already exists - const { data: existingDoc, error: checkError } = await supabase - .from("documents") - .select("id") - .eq("id", documentId) - .single(); - - if (checkError && checkError.code !== "PGRST116") { - console.error("[Create Document] Check error:", checkError); - return NextResponse.json( - { error: "Failed to check document" }, - { status: 500 } - ); - } - - // Document already exists, that's fine - if (existingDoc) { - return NextResponse.json({ success: true, created: false }); - } - - // Create document with owner permission using the RPC - const { error: createError } = await supabase.rpc("create_document_with_owner", { - p_document_id: documentId, - p_name: "Untitled", - }); - - if (createError) { - console.error("[Create Document] RPC error:", createError); - return NextResponse.json( - { error: "Failed to create document" }, - { status: 500 } - ); - } - - return NextResponse.json({ success: true, created: true }); - } catch (error) { - console.error("[Create Document] Error:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 } - ); - } -} diff --git a/src/app/api/partykit/load/route.ts b/src/app/api/partykit/load/route.ts index 921e1bd..cea88db 100644 --- a/src/app/api/partykit/load/route.ts +++ b/src/app/api/partykit/load/route.ts @@ -5,19 +5,16 @@ function byteaToBase64(raw: string | null | undefined): string | null { if (!raw) return null; const trimmed = raw.trim(); if (!trimmed) return null; - - // Handle PostgreSQL bytea hex format (\x...) + if (trimmed.startsWith("\\x")) { const hex = trimmed.slice(2); return Buffer.from(hex, "hex").toString("base64"); } - - // Handle raw hex + if (/^[0-9a-fA-F]+$/.test(trimmed) && trimmed.length % 2 === 0) { return Buffer.from(trimmed, "hex").toString("base64"); } - - // Assume already base64 + return trimmed; } @@ -40,7 +37,11 @@ export async function POST(request: Request) { } try { - const { documentId } = (await request.json()) as { documentId: string }; + const body = (await request.json()) as { + documentId: string; + isNew?: boolean; + }; + const { documentId, isNew } = body; if (!documentId) { return NextResponse.json( @@ -51,24 +52,54 @@ export async function POST(request: Request) { const supabase = createClientFromToken(token); - // Check if user has access to the document - const { data: doc, error: docError } = await supabase + // Check if document exists + const { data: existingDoc, error: docError } = await supabase .from("documents") .select("id") .eq("id", documentId) .single(); - if (docError || !doc) { - if (docError?.code === "PGRST116") { - return NextResponse.json({ error: "Document not found" }, { status: 404 }); - } + if (docError && docError.code !== "PGRST116") { + console.error("[PartyKit Load] Document check error:", docError); return NextResponse.json( - { error: "Access denied or document not found" }, - { status: 403 } + { error: "Failed to check document" }, + { status: 500 } ); } - // Load document state + // Document doesn't exist + if (!existingDoc) { + if (isNew) { + // Create document with user as owner + const { error: createError } = await supabase.rpc( + "create_document_with_owner", + { + p_document_id: documentId, + p_name: "Untitled", + } + ); + + if (createError) { + console.error("[PartyKit Load] Create error:", createError); + return NextResponse.json( + { error: "Failed to create document" }, + { status: 500 } + ); + } + + // Return empty state for new document + return NextResponse.json({ state: null }); + } else { + // Not a new document request, document doesn't exist + return NextResponse.json( + { error: "Document not found" }, + { status: 404 } + ); + } + } + + // Document exists - check if user has permission by trying to read state + // RLS will enforce permission check const { data: stateRow, error: stateError } = await supabase .from("document_state") .select("state_data") @@ -76,6 +107,21 @@ export async function POST(request: Request) { .single(); if (stateError && stateError.code !== "PGRST116") { + // If we get an error other than "not found", it might be permission denied + // But RLS errors typically manifest differently, so let's check document_permissions + const { data: permission, error: permError } = await supabase + .from("document_permissions") + .select("id") + .eq("document_id", documentId) + .single(); + + if (permError || !permission) { + return NextResponse.json( + { error: "Access denied" }, + { status: 403 } + ); + } + console.error("[PartyKit Load] State error:", stateError); return NextResponse.json( { error: "Failed to load document state" }, @@ -83,8 +129,8 @@ export async function POST(request: Request) { ); } - // Convert bytea to base64 - const state = stateRow + // Return state (may be null if no state saved yet) + const state = stateRow ? byteaToBase64((stateRow as { state_data: string }).state_data) : null; diff --git a/src/app/api/partykit/save/route.ts b/src/app/api/partykit/save/route.ts index 943560a..53fa319 100644 --- a/src/app/api/partykit/save/route.ts +++ b/src/app/api/partykit/save/route.ts @@ -39,16 +39,17 @@ export async function POST(request: Request) { const supabase = createClientFromToken(token); - // Check if user has access to the document - const { data: doc, error: docError } = await supabase - .from("documents") + // Check if user has permission to this document + // RLS on document_permissions will enforce this + const { data: permission, error: permError } = await supabase + .from("document_permissions") .select("id") - .eq("id", documentId) + .eq("document_id", documentId) .single(); - if (docError || !doc) { + if (permError || !permission) { return NextResponse.json( - { error: "Access denied or document not found" }, + { error: "Access denied" }, { status: 403 } ); } @@ -74,15 +75,11 @@ export async function POST(request: Request) { } // Update document's last_updated timestamp - const { error: updateError } = await supabase + await supabase .from("documents") .update({ last_updated: new Date().toISOString() }) .eq("id", documentId); - if (updateError) { - console.error("[PartyKit Save] Update timestamp error (non-fatal):", updateError); - } - return NextResponse.json({ success: true }); } catch (error) { console.error("[PartyKit Save] Error:", error); diff --git a/src/hooks/use-collaborative-doc-partykit.ts b/src/hooks/use-collaborative-doc-partykit.ts index 0365655..e9405bb 100644 --- a/src/hooks/use-collaborative-doc-partykit.ts +++ b/src/hooks/use-collaborative-doc-partykit.ts @@ -21,25 +21,6 @@ interface UseCollaborativeDocPartykitResult { const PARTYKIT_HOST = process.env.NEXT_PUBLIC_PARTYKIT_HOST ?? "localhost:1999"; -async function createDocumentIfNew( - documentId: string, - accessToken: string -): Promise { - const response = await fetch("/api/documents/create", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${accessToken}`, - }, - body: JSON.stringify({ documentId }), - }); - - if (!response.ok) { - const data = (await response.json().catch(() => ({}))) as { error?: string }; - throw new Error(data.error ?? "Failed to create document"); - } -} - export function useCollaborativeDocPartykit({ documentId, isNew = false, @@ -85,17 +66,14 @@ export function useCollaborativeDocPartykit({ throw new Error("Not authenticated"); } - // If this is a new document, create it in the database first - if (isNew) { - await createDocumentIfNew(documentId, session.access_token); - } - const ydoc = new Y.Doc(); + // Pass isNew flag to PartyKit const provider = new YPartyKitProvider(PARTYKIT_HOST, documentId, ydoc, { connect: true, params: { token: session.access_token, + isNew: isNew ? "true" : "false", }, }); @@ -119,6 +97,9 @@ export function useCollaborativeDocPartykit({ } else if (event.code === 4003) { setError(new Error("You don't have access to this document")); setIsLoading(false); + } else if (event.code === 4004) { + setError(new Error("Document not found")); + setIsLoading(false); } }); From 1b11a00c3f5c23c0382b01cb69fd724ce8af44e2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 21 Aug 2026 19:23:28 +0000 Subject: [PATCH 08/25] ux: skip loading skeleton for new documents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New documents now show nothing while connecting (~300ms) instead of the loading skeleton. This feels more instant since the editor just appears rather than skeleton → editor transition. Existing documents still show the loading skeleton as before. Co-authored-by: Matia Raspopovic --- src/app/documents/[documentId]/page.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/app/documents/[documentId]/page.tsx b/src/app/documents/[documentId]/page.tsx index 80d4e05..6e64a74 100644 --- a/src/app/documents/[documentId]/page.tsx +++ b/src/app/documents/[documentId]/page.tsx @@ -103,7 +103,7 @@ export default function DocumentPage() { ); } - // 2. Show loading skeleton (CRDT loading or provider not ready). Do not block on profile. + // 2. Show loading skeleton for existing documents only if (!isNew && (isLoading || !isReady || !ydoc || !provider)) { return ( @@ -112,8 +112,12 @@ export default function DocumentPage() { ); } - // 3. Still waiting for ydoc/provider (e.g. optimistic new-doc case) + // 3. For new documents, show nothing while connecting (feels instant) + // For existing documents that somehow got here, show skeleton if (!isReady || !ydoc || !provider) { + if (isNew) { + return null; + } return ( From 9c6b3d43aeb24fb7eea9005a31a4df8ae661a3b2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 21 Aug 2026 19:31:46 +0000 Subject: [PATCH 09/25] feat: implement delayed loading skeleton (250ms) to avoid flicker - Only show DocumentLoadingSkeleton after 250ms delay - Fast loads (< 250ms) show nothing, feels instant - Slow loads (> 250ms) show skeleton for user feedback - Unified loading logic for both new and existing documents Co-authored-by: Matia Raspopovic --- src/app/documents/[documentId]/page.tsx | 54 +++++++++++++++---------- 1 file changed, 33 insertions(+), 21 deletions(-) diff --git a/src/app/documents/[documentId]/page.tsx b/src/app/documents/[documentId]/page.tsx index 6e64a74..9ac177d 100644 --- a/src/app/documents/[documentId]/page.tsx +++ b/src/app/documents/[documentId]/page.tsx @@ -2,7 +2,7 @@ import dynamic from "next/dynamic"; import { useParams } from "next/navigation"; -import { useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import { TRPCClientError } from "@trpc/client"; import { Alert, AlertDescription, AlertTitle } from "~/app/_components/alert"; import { DocumentLoadingSkeleton } from "~/app/_components/document-loading-skeleton"; @@ -12,6 +12,8 @@ import { useNewDocumentFlag } from "~/hooks/use-new-document-flag"; import { useUserProfile } from "~/hooks/use-user-profile"; import { getAvatarColorHex } from "~/lib/avatar-colors"; +const SKELETON_DELAY_MS = 250; + const DOCUMENT_ERROR = { NOT_FOUND: { title: "Doc not found", @@ -88,6 +90,25 @@ export default function DocumentPage() { }, ); + // Delayed skeleton: only show after SKELETON_DELAY_MS to avoid flicker on fast loads + const [showSkeleton, setShowSkeleton] = useState(false); + const isStillLoading = isLoading || !isReady || !ydoc || !provider; + + useEffect(() => { + if (!isStillLoading) { + // Loading complete, reset skeleton state + setShowSkeleton(false); + return; + } + + // Start timer to show skeleton after delay + const timer = setTimeout(() => { + setShowSkeleton(true); + }, SKELETON_DELAY_MS); + + return () => clearTimeout(timer); + }, [isStillLoading]); + // === RENDERING LOGIC === // 1. Handle errors — show alert and stop; don't proceed to loading or editor @@ -103,29 +124,20 @@ export default function DocumentPage() { ); } - // 2. Show loading skeleton for existing documents only - if (!isNew && (isLoading || !isReady || !ydoc || !provider)) { - return ( - - - - ); - } - - // 3. For new documents, show nothing while connecting (feels instant) - // For existing documents that somehow got here, show skeleton - if (!isReady || !ydoc || !provider) { - if (isNew) { - return null; + // 2. Still loading — show skeleton only after delay to avoid flicker + if (isStillLoading) { + if (showSkeleton) { + return ( + + + + ); } - return ( - - - - ); + // Before delay: show nothing (feels instant for fast loads) + return null; } - // 4. Ready to render + // 3. Ready to render const userName = userProfile ? [userProfile.first_name, userProfile.last_name] .filter((p): p is string => typeof p === "string" && p.trim().length > 0) From 45d213952ac3b247e1cd269bf648261300b56581 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 21 Aug 2026 20:37:20 +0000 Subject: [PATCH 10/25] docs: add comprehensive PartyKit architecture documentation - System architecture with detailed diagrams - Data flow for connection, load, and save operations - Security model and JWT flow explanation - All user flows (new doc, existing doc, multi-tab) - Edge cases (duplicate tab, token expiry, network issues, etc.) - UX optimizations (delayed skeleton) - Caveats and current limitations - Future considerations for multi-user collaboration - Data migration guide Co-authored-by: Matia Raspopovic --- PARTYKIT_ARCHITECTURE.md | 887 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 887 insertions(+) create mode 100644 PARTYKIT_ARCHITECTURE.md diff --git a/PARTYKIT_ARCHITECTURE.md b/PARTYKIT_ARCHITECTURE.md new file mode 100644 index 0000000..709210c --- /dev/null +++ b/PARTYKIT_ARCHITECTURE.md @@ -0,0 +1,887 @@ +# PartyKit Architecture + +This document provides a comprehensive overview of the PartyKit-based real-time collaboration architecture, including user flows, edge cases, and future considerations. + +## Table of Contents + +- [Overview](#overview) +- [System Architecture](#system-architecture) +- [Data Flow](#data-flow) +- [Database Schema](#database-schema) +- [Security Model](#security-model) +- [User Flows](#user-flows) +- [Edge Cases](#edge-cases) +- [UX Optimizations](#ux-optimizations) +- [Caveats and Limitations](#caveats-and-limitations) +- [Future Considerations: Multi-User Collaboration](#future-considerations-multi-user-collaboration) +- [Data Migration](#data-migration) + +--- + +## Overview + +### Why PartyKit? + +The previous architecture used `y-webrtc` for peer-to-peer sync between clients. This had several limitations: + +| Problem | Impact | +|---------|--------| +| **Mesh topology** | N clients = N×(N-1)/2 connections. 5 users × 3 tabs = 105 WebRTC connections | +| **Firewall failures** | WebRTC P2P fails through corporate/strict firewalls with no fallback | +| **Redundant persistence** | Every client independently saves to database (N clients = N save streams) | +| **Complex compaction** | Append-only log + snapshots + background compaction logic | +| **Public signaling** | Relied on public STUN/TURN servers for connection establishment | + +### PartyKit Solution + +PartyKit provides a **server-mediated WebSocket architecture** running on Cloudflare's edge network: + +| Benefit | Description | +|---------|-------------| +| **Star topology** | N clients = N connections (to central server) | +| **Universal connectivity** | WebSocket works through all firewalls | +| **Single writer** | Only PartyKit server persists to database | +| **Simple schema** | One table, full state, no compaction | +| **Free tier** | Cloudflare Workers free tier covers small-medium usage | + +--- + +## System Architecture + +### High-Level Overview + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ CLIENT LAYER │ +├─────────────┬─────────────┬─────────────┬─────────────┬─────────────────┤ +│ Browser A │ Browser B │ Mobile C │ Browser D │ ... │ +│ (Tab 1) │ (Tab 2) │ (App) │ (User 2) │ │ +│ │ │ │ │ │ +│ ┌─────────┐ │ ┌─────────┐ │ ┌─────────┐ │ ┌─────────┐ │ │ +│ │ Y.Doc │ │ │ Y.Doc │ │ │ Y.Doc │ │ │ Y.Doc │ │ Local Yjs │ +│ │ (local) │ │ │ (local) │ │ │ (local) │ │ │ (local) │ │ documents │ +│ └────┬────┘ │ └────┬────┘ │ └────┬────┘ │ └────┬────┘ │ │ +│ │ JWT │ │ JWT │ │ JWT │ │ JWT │ │ +└──────┼──────┴──────┼──────┴──────┼──────┴──────┼──────┴─────────────────┘ + │ │ │ │ + └─────────────┴──────┬──────┴─────────────┘ + │ + WebSocket + JWT + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ PARTYKIT SERVER LAYER │ +│ (Cloudflare Workers Edge) │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ PartyKit Room (per document) │ │ +│ │ │ │ +│ │ ┌──────────────┐ ┌──────────────┐ ┌───────────────────────┐ │ │ +│ │ │ Y.Doc │ │ Awareness │ │ Connection Pool │ │ │ +│ │ │ (source of │ │ (cursors, │ │ (all connected │ │ │ +│ │ │ truth) │ │ presence) │ │ clients) │ │ │ +│ │ └──────────────┘ └──────────────┘ └───────────────────────┘ │ │ +│ │ │ │ +│ │ ┌──────────────────────────────────────────────────────────┐ │ │ +│ │ │ Debounced Save Timer (1 second) │ │ │ +│ │ │ - Batches rapid edits into single DB write │ │ │ +│ │ └──────────────────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +└────────────────────────────────────┬────────────────────────────────────┘ + │ + HTTP + JWT + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ NEXT.JS API LAYER │ +│ (Vercel Serverless) │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────┐ ┌─────────────────────────┐ │ +│ │ /api/partykit/load │ │ /api/partykit/save │ │ +│ │ │ │ │ │ +│ │ - Receives JWT │ │ - Receives JWT │ │ +│ │ - Creates Supabase │ │ - Creates Supabase │ │ +│ │ client with JWT │ │ client with JWT │ │ +│ │ - Queries document │ │ - Upserts document │ │ +│ │ - RLS enforced │ │ - RLS enforced │ │ +│ └────────────┬────────────┘ └────────────┬────────────┘ │ +│ │ │ │ +└────────────────┼──────────────────────────────┼─────────────────────────┘ + │ │ + └──────────────┬───────────────┘ + │ + SQL + RLS + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ DATABASE LAYER │ +│ (Supabase) │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ documents │ │ +│ │ - id, title, created_at, updated_at │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ │ FK │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ document_state │ │ +│ │ - document_id (PK, FK) │ │ +│ │ - state_data (BYTEA) ← Full Y.Doc encoded state │ │ +│ │ - updated_at │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ document_permissions │ │ +│ │ - document_id, user_id, permission_level │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ Row Level Security (RLS) enforced on all tables │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### Component Responsibilities + +| Component | Responsibility | +|-----------|----------------| +| **Client (Browser)** | Local Y.Doc, UI rendering, user input, JWT management | +| **YPartyKitProvider** | WebSocket connection, Yjs sync protocol, awareness | +| **PartyKit Room** | Central Y.Doc, broadcast updates, debounced persistence | +| **Next.js API** | JWT→Supabase client, RLS-enforced DB operations | +| **Supabase** | Document storage, permissions, RLS enforcement | + +--- + +## Data Flow + +### Connection & Initial Load + +``` +┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ +│ Client │ │ PartyKit │ │ Next.js │ │ Supabase │ +└────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ + │ │ │ │ + │ 1. Get Supabase Session │ │ + │────────────────────────────────────────────────────────────────►│ + │◄────────────────────────────────────────────────────────────────│ + │ { access_token (JWT) } │ │ + │ │ │ │ + │ 2. WebSocket Connect │ │ + │ ?token=JWT&isNew=false │ │ + │────────────────────►│ │ │ + │ │ │ │ + │ │ 3. Verify JWT │ │ + │ │ (not expired) │ │ + │ │ │ │ + │ │ 4. POST /api/partykit/load │ + │ │ Authorization: Bearer JWT │ + │ │ { documentId } │ │ + │ │────────────────────►│ │ + │ │ │ │ + │ │ │ 5. Query with JWT │ + │ │ │────────────────────►│ + │ │ │◄────────────────────│ + │ │ │ { state_data } │ + │ │◄────────────────────│ │ + │ │ { state } │ │ + │ │ │ │ + │ 6. Yjs Sync │ │ │ + │◄───────────────────►│ │ │ + │ (document state) │ │ │ + │ │ │ │ +``` + +### Edit & Save Flow + +``` +┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ +│ Client │ │ PartyKit │ │ Next.js │ │ Supabase │ +└────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ + │ │ │ │ + │ 1. User types │ │ │ + │ (local Y.Doc │ │ │ + │ updates) │ │ │ + │ │ │ │ + │ 2. Yjs Update │ │ │ + │────────────────────►│ │ │ + │ │ │ │ + │ │ 3. Apply to │ │ + │ │ server Y.Doc │ │ + │ │ │ │ + │ │ 4. Broadcast to │ │ + │◄────────────────────│ other clients │ │ + │ │────────────────────►│ (other clients) │ + │ │ │ │ + │ │ 5. Start/reset │ │ + │ │ debounce timer │ │ + │ │ (1 second) │ │ + │ │ │ │ + │ │ ... 1s ... │ │ + │ │ │ │ + │ │ 6. POST /api/partykit/save │ + │ │ Authorization: Bearer JWT │ + │ │ { documentId, state } │ + │ │────────────────────►│ │ + │ │ │ │ + │ │ │ 7. Upsert with JWT │ + │ │ │────────────────────►│ + │ │ │◄────────────────────│ + │ │◄────────────────────│ { success } │ + │ │ │ │ +``` + +--- + +## Database Schema + +### New Schema (PartyKit) + +```sql +-- Single table for full document state +CREATE TABLE document_state ( + document_id UUID PRIMARY KEY REFERENCES documents(id) ON DELETE CASCADE, + state_data BYTEA NOT NULL, -- Full Y.Doc encoded state + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- RLS Policies +CREATE POLICY "Users can read document_state if they have document permission" + ON document_state FOR SELECT + USING ( + EXISTS ( + SELECT 1 FROM document_permissions + WHERE document_permissions.document_id = document_state.document_id + AND document_permissions.user_id = auth.uid() + ) + ); + +CREATE POLICY "Users can write document_state if they have write permission" + ON document_state FOR ALL + USING ( + EXISTS ( + SELECT 1 FROM document_permissions + WHERE document_permissions.document_id = document_state.document_id + AND document_permissions.user_id = auth.uid() + AND document_permissions.permission_level IN ('owner', 'editor') + ) + ); +``` + +### Schema Comparison + +| Aspect | Old (y-webrtc) | New (PartyKit) | +|--------|----------------|----------------| +| **Tables** | `document_changes` + `document_snapshots` | `document_state` | +| **Rows per doc** | Many (1 per change) + 1 snapshot | 1 | +| **Compaction** | Required (when changes > 100) | Not needed | +| **Storage** | Incremental updates | Full state | +| **Complexity** | High (compaction logic) | Low | + +--- + +## Security Model + +### JWT Flow Through the System + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ SECURITY FLOW │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. USER AUTHENTICATES │ +│ ┌──────────┐ ┌──────────┐ │ +│ │ User │────►│ Supabase │ User logs in with email/password │ +│ │ │◄────│ Auth │ Receives JWT (access_token) │ +│ └──────────┘ └──────────┘ │ +│ │ │ +│ │ JWT contains: { sub: user_id, exp: expiry, ... } │ +│ ▼ │ +│ 2. CLIENT CONNECTS TO PARTYKIT │ +│ ┌──────────┐ ┌──────────┐ │ +│ │ Client │────►│ PartyKit │ WebSocket: ?token=JWT&isNew=... │ +│ │ │ │ Server │ │ +│ └──────────┘ └──────────┘ │ +│ │ │ +│ │ Decodes JWT, checks exp > now │ +│ │ (Does NOT verify signature - trusts │ +│ │ that Supabase will reject invalid JWTs) │ +│ ▼ │ +│ 3. PARTYKIT CALLS API WITH USER'S JWT │ +│ ┌──────────┐ ┌──────────┐ │ +│ │ PartyKit │────►│ Next.js │ Authorization: Bearer │ +│ │ Server │ │ API │ │ +│ └──────────┘ └──────────┘ │ +│ │ │ +│ │ Creates Supabase client WITH user's JWT │ +│ │ (not service role key) │ +│ ▼ │ +│ 4. SUPABASE ENFORCES RLS │ +│ ┌──────────┐ ┌──────────┐ │ +│ │ Next.js │────►│ Supabase │ Query runs as the user │ +│ │ API │◄────│ DB │ RLS policies check auth.uid() │ +│ └──────────┘ └──────────┘ │ +│ │ +│ ═══════════════════════════════════════════════════════════════════ │ +│ RESULT: User can only access documents they have permission to. │ +│ No service role key. No elevated privileges. │ +│ ═══════════════════════════════════════════════════════════════════ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### Error Codes + +| WebSocket Close Code | Meaning | Client Behavior | +|---------------------|---------|-----------------| +| `4001` | Token missing | Redirect to login | +| `4002` | Token expired | Refresh token, reconnect | +| `4003` | Permission denied (can't load) | Show "Access denied" error | +| `4004` | Document not found | Show "Document not found" error | + +--- + +## User Flows + +### Flow 1: Creating a New Document + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ NEW DOCUMENT CREATION FLOW │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. User clicks "New Document" │ +│ ┌──────────┐ │ +│ │ Client │ - Generate UUID for new document │ +│ │ │ - Set isNew=true flag in memory │ +│ │ │ - Navigate to /documents/{new-uuid} │ +│ └──────────┘ │ +│ │ │ +│ │ Instant navigation (no API call yet) │ +│ ▼ │ +│ 2. DocumentPage renders │ +│ ┌──────────┐ │ +│ │ Client │ - Hook detects isNew=true │ +│ │ │ - Renders blank screen (no skeleton) │ +│ │ │ - Connects to PartyKit with isNew=true │ +│ └──────────┘ │ +│ │ │ +│ ▼ │ +│ 3. PartyKit receives connection │ +│ ┌──────────┐ │ +│ │ PartyKit │ - Verifies JWT │ +│ │ Server │ - Calls /api/partykit/load with isNew=true │ +│ └──────────┘ │ +│ │ │ +│ ▼ │ +│ 4. Load API handles new document │ +│ ┌──────────┐ │ +│ │ Next.js │ - Checks if document exists → NO │ +│ │ API │ - Since isNew=true: │ +│ │ │ - Calls create_document_with_owner RPC │ +│ │ │ - Creates document + owner permission atomically │ +│ │ │ - Returns { state: null } (empty doc) │ +│ └──────────┘ │ +│ │ │ +│ ▼ │ +│ 5. Editor ready │ +│ ┌──────────┐ │ +│ │ Client │ - Y.Doc initialized (empty) │ +│ │ │ - Editor renders │ +│ │ │ - User can start typing immediately │ +│ └──────────┘ │ +│ │ │ +│ ▼ │ +│ 6. First edit triggers save │ +│ ┌──────────┐ │ +│ │ PartyKit │ - Debounce timer starts │ +│ │ Server │ - After 1s, calls /api/partykit/save │ +│ │ │ - document_state row created │ +│ └──────────┘ │ +│ │ +│ ═══════════════════════════════════════════════════════════════════ │ +│ RESULT: User sees empty editor instantly. Document created on first │ +│ connection. State persisted on first edit. │ +│ ═══════════════════════════════════════════════════════════════════ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### Flow 2: Opening an Existing Document + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ EXISTING DOCUMENT OPEN FLOW │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. User navigates to /documents/{existing-uuid} │ +│ ┌──────────┐ │ +│ │ Client │ - isNew=false (not from "New Document" flow) │ +│ │ │ - Renders nothing initially (< 250ms) │ +│ │ │ - If > 250ms: show loading skeleton │ +│ └──────────┘ │ +│ │ │ +│ ▼ │ +│ 2. Connect to PartyKit │ +│ ┌──────────┐ ┌──────────┐ │ +│ │ Client │────►│ PartyKit │ WebSocket with JWT, isNew=false │ +│ └──────────┘ └──────────┘ │ +│ │ │ +│ ▼ │ +│ 3. Load document state │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ PartyKit │────►│ Next.js │────►│ Supabase │ │ +│ │ │◄────│ API │◄────│ │ │ +│ └──────────┘ └──────────┘ └──────────┘ │ +│ │ │ +│ │ Apply state to Y.Doc, sync to client │ +│ ▼ │ +│ 4. Editor renders with content │ +│ ┌──────────┐ │ +│ │ Client │ - Y.Doc populated with existing content │ +│ │ │ - Editor renders │ +│ │ │ - User can continue editing │ +│ └──────────┘ │ +│ │ +│ ═══════════════════════════════════════════════════════════════════ │ +│ TYPICAL LOAD TIME: < 100ms (fast network) │ +│ SKELETON APPEARS: Only if load takes > 250ms │ +│ ═══════════════════════════════════════════════════════════════════ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### Flow 3: Multi-Tab / Multi-Device (Same User) + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ MULTI-TAB SYNCHRONIZATION │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ Tab 1 │ │ Tab 2 │ │ Mobile │ │ +│ │ (laptop) │ │ (laptop) │ │ (phone) │ │ +│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ +│ │ │ │ │ +│ │ All same user, all same document │ │ +│ │ │ │ │ +│ └─────────────────────┼─────────────────────┘ │ +│ │ │ +│ ┌──────┴──────┐ │ +│ │ PartyKit │ │ +│ │ Room │ │ +│ │ │ │ +│ │ Y.Doc (1) │ Single source of truth │ +│ └──────┬──────┘ │ +│ │ │ +│ User types in Tab 1: │ │ +│ ─────────────────────────────┼──────────────────────────────── │ +│ Tab 1 → PartyKit → Tab 2 │ │ +│ → Mobile │ │ +│ │ │ +│ Changes sync in ~10-50ms (WebSocket latency) │ +│ │ │ +│ Only ONE save to database │ │ +│ (from PartyKit, not clients) │ │ +│ │ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Edge Cases + +### Edge Case 1: New Document, No Edits, Duplicate Tab + +**Scenario:** User creates a new document, doesn't type anything, then duplicates the tab or opens the same URL in another tab. + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ │ +│ Timeline: │ +│ ───────────────────────────────────────────────────────────────── │ +│ │ +│ T0: User clicks "New Document" │ +│ → Tab 1 opens /documents/{uuid} │ +│ → isNew=true flag set │ +│ → Connects to PartyKit │ +│ → Load API creates document (via RPC) │ +│ → Empty editor shown │ +│ │ +│ T1: User duplicates tab (Cmd+D) without typing │ +│ → Tab 2 opens same URL │ +│ → isNew=false (flag only in Tab 1's memory) │ +│ → Connects to PartyKit │ +│ → Load API finds document exists → returns state (empty) │ +│ → Empty editor shown │ +│ │ +│ T2: User types in Tab 1 │ +│ → Update syncs to Tab 2 via PartyKit │ +│ → Both tabs show same content │ +│ │ +│ RESULT: Works correctly. Document exists after first connection. │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### Edge Case 2: Token Expiration During Edit Session + +**Scenario:** User's JWT expires while they are actively editing. + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ │ +│ Current Behavior: │ +│ ───────────────────────────────────────────────────────────────── │ +│ │ +│ 1. User editing for extended period │ +│ 2. JWT expires (typically 1 hour) │ +│ 3. Next save attempt fails (API rejects expired JWT) │ +│ 4. PartyKit logs error but keeps Y.Doc in memory │ +│ 5. Client still has local Y.Doc with all changes │ +│ │ +│ Mitigation: │ +│ ───────────────────────────────────────────────────────────────── │ +│ │ +│ - Supabase client auto-refreshes tokens in background │ +│ - Client hook could detect token refresh and reconnect │ +│ - PartyKit could close connection on save failure, prompting │ +│ client to reconnect with fresh token │ +│ │ +│ Current Risk: Low (most edit sessions < 1 hour) │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### Edge Case 3: Network Disconnection + +**Scenario:** User loses internet connection while editing. + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ │ +│ Behavior: │ +│ ───────────────────────────────────────────────────────────────── │ +│ │ +│ 1. Network drops │ +│ 2. WebSocket disconnects │ +│ 3. YPartyKitProvider attempts reconnection (exponential backoff) │ +│ 4. User can continue typing (local Y.Doc still works) │ +│ 5. Edits queue locally │ +│ 6. Network returns │ +│ 7. WebSocket reconnects │ +│ 8. Y.Doc syncs accumulated changes │ +│ 9. PartyKit debounces and saves │ +│ │ +│ Data Safety: │ +│ ───────────────────────────────────────────────────────────────── │ +│ │ +│ - Local edits preserved in Y.Doc (memory) │ +│ - NOT persisted to disk during offline │ +│ - If user closes browser while offline, changes lost │ +│ │ +│ Note: True offline support would require IndexedDB persistence │ +│ (see Future Considerations) │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### Edge Case 4: Large Document Load Time + +**Scenario:** Document has extensive content, resulting in large Y.Doc state. + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ │ +│ Factors Affecting Load Time: │ +│ ───────────────────────────────────────────────────────────────── │ +│ │ +│ 1. state_data size (BYTEA column) │ +│ 2. Network latency (user ↔ Supabase region) │ +│ 3. Y.Doc deserialization time │ +│ │ +│ Estimated Load Times: │ +│ ───────────────────────────────────────────────────────────────── │ +│ │ +│ | Document Size | State Size | Fast Network | Slow Network | │ +│ |----------------|------------|--------------|--------------| │ +│ | Small (1 page) | ~5 KB | < 50ms | < 200ms | │ +│ | Medium (10 pg) | ~50 KB | < 100ms | < 500ms | │ +│ | Large (100 pg) | ~500 KB | < 300ms | 1-2s | │ +│ | Huge (1000 pg) | ~5 MB | 1-2s | 5-10s | │ +│ │ +│ Mitigation (Current): │ +│ ───────────────────────────────────────────────────────────────── │ +│ │ +│ - Delayed loading skeleton (shows after 250ms) │ +│ - Fast loads: no flicker │ +│ - Slow loads: skeleton provides feedback │ +│ │ +│ Mitigation (Future): │ +│ ───────────────────────────────────────────────────────────────── │ +│ │ +│ - Lazy loading (load visible blocks first) │ +│ - Document chunking │ +│ - CDN caching for frequently accessed docs │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### Edge Case 5: Document Deleted While Being Edited + +**Scenario:** User A is editing a document. User B (or an admin) deletes it. + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ │ +│ Current Behavior: │ +│ ───────────────────────────────────────────────────────────────── │ +│ │ +│ 1. User A is editing document │ +│ 2. Document deleted from database │ +│ 3. User A continues editing (local Y.Doc) │ +│ 4. Next save attempt fails: │ +│ - document_state FK constraint fails │ +│ - OR RLS blocks access (permission row deleted) │ +│ 5. PartyKit logs error │ +│ 6. User A's local changes exist but cannot be saved │ +│ │ +│ Recommended Future Handling: │ +│ ───────────────────────────────────────────────────────────────── │ +│ │ +│ - Detect save failure due to deletion │ +│ - Notify user: "This document has been deleted" │ +│ - Offer to create a new document with current content │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## UX Optimizations + +### Delayed Loading Skeleton + +To avoid "flicker" on fast loads while still providing feedback on slow loads: + +```typescript +const SKELETON_DELAY_MS = 250; + +const [showSkeleton, setShowSkeleton] = useState(false); +const isStillLoading = isLoading || !isReady || !ydoc || !provider; + +useEffect(() => { + if (!isStillLoading) { + setShowSkeleton(false); + return; + } + const timer = setTimeout(() => setShowSkeleton(true), SKELETON_DELAY_MS); + return () => clearTimeout(timer); +}, [isStillLoading]); + +// Render: +// - Fast load (< 250ms): blank → editor (no skeleton) +// - Slow load (> 250ms): blank → skeleton → editor +``` + +### Instant New Document Feel + +New document creation feels instant because: + +1. No API call before navigation (UUID generated client-side) +2. No loading skeleton shown (returns `null` while connecting) +3. Document created during first WebSocket connection +4. Empty editor appears as soon as connection established + +--- + +## Caveats and Limitations + +### 1. No True Offline Support + +**Current:** Local Y.Doc exists only in memory. If browser closes during network outage, unsaved changes are lost. + +**Mitigation:** Could add IndexedDB persistence layer (y-indexeddb) for offline resilience. + +### 2. Single Room = Single JWT + +**Current:** PartyKit room uses the JWT of the first client that connected (the "initializer"). Subsequent clients connect but room still uses initializer's JWT for saves. + +**Implication:** If initializer's permissions change (e.g., demoted from editor to viewer), saves may fail. + +**Mitigation:** Could rotate JWT to most recently connected client with write permissions, or require each save to use a still-connected client's JWT. + +### 3. JWT Not Cryptographically Verified by PartyKit + +**Current:** PartyKit only checks that JWT is not expired (decodes payload, checks `exp`). It does not verify the signature. + +**Why This Is OK:** The real security enforcement happens at Supabase when the API route uses the JWT to create a client. Invalid/forged JWTs will fail at that layer. + +**Risk:** A malicious actor could potentially connect to PartyKit with a forged JWT, but any actual database operations would fail. + +### 4. Debounce Delay Before Persistence + +**Current:** Changes are debounced for 1 second before saving. If PartyKit server crashes within that window, those changes are lost. + +**Risk:** Very low (Cloudflare Workers are highly reliable), but theoretically possible. + +**Mitigation:** Could reduce debounce time or implement optimistic persistence. + +### 5. No Conflict Resolution UI + +**Current:** Yjs handles conflicts automatically using CRDT semantics. No user-facing conflict resolution. + +**Implication:** In rare cases, Yjs's automatic resolution might not match user intent (e.g., both users editing same sentence). + +**Mitigation:** For most text editing, Yjs's approach is acceptable. Heavy concurrent editing of the same section could use operational transform or last-writer-wins at block level. + +--- + +## Future Considerations: Multi-User Collaboration + +### Sharing Flow Design + +When collaboration is enabled, the recommended flow: + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ FUTURE: SHARING A DOCUMENT │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Scenario: User A wants to share with User B │ +│ │ +│ 1. User A clicks "Share" button │ +│ │ +│ 2. Check if document has initial save: │ +│ ┌────────────────────────────────────────────────────────────┐ │ +│ │ IF document_state row exists: │ │ +│ │ → Proceed to share dialog │ │ +│ │ │ │ +│ │ IF document_state row does NOT exist: │ │ +│ │ → Force save current Y.Doc state first │ │ +│ │ → Then proceed to share dialog │ │ +│ └────────────────────────────────────────────────────────────┘ │ +│ │ +│ 3. User A enters User B's email │ +│ │ +│ 4. Create permission record: │ +│ INSERT INTO document_permissions │ +│ (document_id, user_id, permission_level) │ +│ VALUES ({doc}, {user_b}, 'editor') │ +│ │ +│ 5. User B can now access the document │ +│ │ +│ Note: Shared link without explicit permission shows │ +│ "Access denied" or "Document not found" (no auto-share) │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### Awareness Features (Presence, Cursors) + +YPartyKitProvider already supports Yjs Awareness: + +```typescript +// Already available via provider.awareness +provider.awareness.setLocalStateField('user', { + name: userName, + color: userColor, +}); + +// BlockNote can show other users' cursors automatically +// when configured with awareness +``` + +Future work: +- Show avatars of connected users +- Show cursor positions in document +- Show "User X is editing..." indicators + +### Permission Levels + +Current schema supports: + +| Level | Can Read | Can Edit | Can Delete | Can Share | +|-------|----------|----------|------------|-----------| +| `viewer` | ✓ | ✗ | ✗ | ✗ | +| `editor` | ✓ | ✓ | ✗ | ✗ | +| `owner` | ✓ | ✓ | ✓ | ✓ | + +RLS policies should enforce these based on `permission_level`. + +### Rate Limiting / Abuse Prevention + +Considerations for production: + +1. **Connection limits**: Max connections per document +2. **Save rate limiting**: Max saves per minute per document +3. **Document size limits**: Max state_data size +4. **User connection limits**: Max documents per user + +### Webhooks / Real-time Notifications + +Future: Notify users when: +- Someone shares a document with them +- Someone joins a document they're editing +- Significant changes made to shared document + +--- + +## Data Migration + +### Migrating from Old Schema + +If you have existing documents using the old `document_changes` + `document_snapshots` schema: + +```sql +-- Migration script to convert existing documents to new schema +INSERT INTO document_state (document_id, state_data, updated_at) +SELECT + ds.document_id, + ds.snapshot_data as state_data, -- Use latest snapshot + ds.created_at as updated_at +FROM document_snapshots ds +WHERE NOT EXISTS ( + SELECT 1 FROM document_state + WHERE document_id = ds.document_id +); + +-- Note: This uses snapshots only. For full accuracy, you would need to: +-- 1. Load snapshot +-- 2. Apply all changes since snapshot +-- 3. Encode full Y.Doc state +-- 4. Insert into document_state +``` + +### Rollback Procedure + +To revert to y-webrtc: + +1. Restore old hook import in `page.tsx` +2. Restore `WebrtcProvider` type in `editor.tsx` +3. Keep `document_state` table (no harm) +4. Old `document_changes` and `document_snapshots` tables still exist + +--- + +## Summary + +| Aspect | Implementation | +|--------|----------------| +| **Sync Protocol** | Yjs over WebSocket via PartyKit | +| **Topology** | Star (all clients → PartyKit → database) | +| **Persistence** | Server-side only, debounced 1s | +| **Schema** | Single `document_state` table | +| **Security** | JWT flows through entire system, RLS enforced | +| **New Doc UX** | Instant (no skeleton, create on connect) | +| **Existing Doc UX** | Delayed skeleton (250ms threshold) | +| **Multi-tab** | Fully supported via PartyKit sync | +| **Offline** | Limited (local Y.Doc only, no IndexedDB) | +| **Cost** | Free tier for small usage | + +This architecture provides a solid foundation for single-user multi-device editing, with clear paths to enable multi-user collaboration when needed. From d18e51928e9f9ebee04725c53ea7f15d732bf0ec Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 21 Aug 2026 20:52:37 +0000 Subject: [PATCH 11/25] fix: reset skeleton state on document change, increase delay to 500ms - Reset showSkeleton to false when documentId changes - Add documentId to useEffect dependency array - Increase SKELETON_DELAY_MS from 250ms to 500ms Co-authored-by: Matia Raspopovic --- src/app/documents/[documentId]/page.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/app/documents/[documentId]/page.tsx b/src/app/documents/[documentId]/page.tsx index 9ac177d..30e51c3 100644 --- a/src/app/documents/[documentId]/page.tsx +++ b/src/app/documents/[documentId]/page.tsx @@ -12,7 +12,7 @@ import { useNewDocumentFlag } from "~/hooks/use-new-document-flag"; import { useUserProfile } from "~/hooks/use-user-profile"; import { getAvatarColorHex } from "~/lib/avatar-colors"; -const SKELETON_DELAY_MS = 250; +const SKELETON_DELAY_MS = 500; const DOCUMENT_ERROR = { NOT_FOUND: { @@ -95,9 +95,10 @@ export default function DocumentPage() { const isStillLoading = isLoading || !isReady || !ydoc || !provider; useEffect(() => { + // Reset skeleton state when document changes or loading completes + setShowSkeleton(false); + if (!isStillLoading) { - // Loading complete, reset skeleton state - setShowSkeleton(false); return; } @@ -107,7 +108,7 @@ export default function DocumentPage() { }, SKELETON_DELAY_MS); return () => clearTimeout(timer); - }, [isStillLoading]); + }, [isStillLoading, documentId]); // === RENDERING LOGIC === From 58668d1ba3206608f8733c2190f9851f1b80743d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 22:23:40 +0000 Subject: [PATCH 12/25] feat: add migration script for old CRDT schema to PartyKit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/migrate-to-partykit.ts migrates document_changes + document_snapshots → document_state - Supports dry-run mode, single document migration, and batch processing - Reuses existing Yjs reconstruction logic - Provides detailed progress and statistics - Updated .env.example with SUPABASE_SERVICE_ROLE_KEY placeholder - Updated PARTYKIT_ARCHITECTURE.md with migration instructions Co-authored-by: Matia Raspopovic --- .env.example | 4 + PARTYKIT_ARCHITECTURE.md | 50 ++-- scripts/migrate-to-partykit.ts | 428 +++++++++++++++++++++++++++++++++ 3 files changed, 464 insertions(+), 18 deletions(-) create mode 100644 scripts/migrate-to-partykit.ts diff --git a/.env.example b/.env.example index bd1dc88..3853409 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,10 @@ NEXT_PUBLIC_SUPABASE_URL="https://your-project.supabase.co" NEXT_PUBLIC_SUPABASE_ANON_KEY="your-anon-key" +# Service role key (for migrations only - DO NOT use in client-side code) +# Get from: Supabase Dashboard → Settings → API → service_role key +# SUPABASE_SERVICE_ROLE_KEY="your-service-role-key" + # PartyKit (Real-time collaboration) # For local development: localhost:1999 # For production: your-project.partykit.dev diff --git a/PARTYKIT_ARCHITECTURE.md b/PARTYKIT_ARCHITECTURE.md index 709210c..9941456 100644 --- a/PARTYKIT_ARCHITECTURE.md +++ b/PARTYKIT_ARCHITECTURE.md @@ -836,28 +836,42 @@ Future: Notify users when: ### Migrating from Old Schema -If you have existing documents using the old `document_changes` + `document_snapshots` schema: +A migration script is provided to convert existing documents from the old `document_changes` + `document_snapshots` schema to the new `document_state` schema. -```sql --- Migration script to convert existing documents to new schema -INSERT INTO document_state (document_id, state_data, updated_at) -SELECT - ds.document_id, - ds.snapshot_data as state_data, -- Use latest snapshot - ds.created_at as updated_at -FROM document_snapshots ds -WHERE NOT EXISTS ( - SELECT 1 FROM document_state - WHERE document_id = ds.document_id -); +**Location:** `scripts/migrate-to-partykit.ts` + +**What it does:** +1. Scans for all documents with data in the old tables +2. For each document: + - Loads the snapshot (if exists) + - Loads all changes after the snapshot cutoff (the "tail") + - Reconstructs the full Y.Doc by applying snapshot + tail + - Encodes the full state and inserts into `document_state` +3. Provides detailed progress and error reporting + +**Usage:** --- Note: This uses snapshots only. For full accuracy, you would need to: --- 1. Load snapshot --- 2. Apply all changes since snapshot --- 3. Encode full Y.Doc state --- 4. Insert into document_state +```bash +# First, do a dry run to see what would be migrated +SUPABASE_SERVICE_ROLE_KEY="your-key" npx tsx scripts/migrate-to-partykit.ts --dry-run + +# Run the actual migration +SUPABASE_SERVICE_ROLE_KEY="your-key" npx tsx scripts/migrate-to-partykit.ts + +# Migrate a specific document +SUPABASE_SERVICE_ROLE_KEY="your-key" npx tsx scripts/migrate-to-partykit.ts --document-id= ``` +**Requirements:** +- `NEXT_PUBLIC_SUPABASE_URL` - Your Supabase project URL +- `SUPABASE_SERVICE_ROLE_KEY` - Service role key (from Supabase Dashboard → Settings → API) + +**Notes:** +- The script uses the service role key to bypass RLS and access all documents +- Already-migrated documents are skipped (safe to re-run) +- Old tables are not modified - you can run both systems side-by-side +- The script processes documents in batches of 50 for efficiency + ### Rollback Procedure To revert to y-webrtc: diff --git a/scripts/migrate-to-partykit.ts b/scripts/migrate-to-partykit.ts new file mode 100644 index 0000000..486d722 --- /dev/null +++ b/scripts/migrate-to-partykit.ts @@ -0,0 +1,428 @@ +#!/usr/bin/env npx tsx +/** + * Migration Script: Old CRDT Schema → PartyKit Schema + * + * Migrates documents from the old schema (document_changes + document_snapshots) + * to the new PartyKit schema (document_state). + * + * Usage: + * # Dry run (no changes made) + * npx tsx scripts/migrate-to-partykit.ts --dry-run + * + * # Actual migration + * npx tsx scripts/migrate-to-partykit.ts + * + * # Migrate specific document + * npx tsx scripts/migrate-to-partykit.ts --document-id= + * + * Required environment variables: + * NEXT_PUBLIC_SUPABASE_URL - Your Supabase project URL + * SUPABASE_SERVICE_ROLE_KEY - Service role key (from Supabase Dashboard → Settings → API) + * + * The service role key bypasses RLS to access all documents. + * Keep it secret and never commit it to version control. + */ + +import { createClient } from '@supabase/supabase-js' +import * as Y from 'yjs' + +// ============================================================================ +// Configuration +// ============================================================================ + +const BATCH_SIZE = 50 // Documents to process per batch +const CHANGES_PAGE_SIZE = 5000 // Max changes to fetch per query + +// ============================================================================ +// Types +// ============================================================================ + +type DocumentSnapshotRow = { + document_id: string + snapshot_data: string + snapshot_cutoff_created_at: string +} + +type DocumentChangeRow = { + update_data: string + created_at: string +} + +type MigrationResult = { + documentId: string + success: boolean + error?: string + hadSnapshot: boolean + changesCount: number + stateSize: number +} + +// ============================================================================ +// Utility Functions (from document-changes.ts) +// ============================================================================ + +function byteaResponseToBase64(raw: string | null | undefined): string { + const trimmed = (raw ?? '').trim() + if (!trimmed) return '' + if (trimmed.startsWith('\\x') || trimmed.startsWith('0x') || trimmed.startsWith('0X')) { + const hex = trimmed.replace(/^\\x|^0x|^0X/i, '').replace(/\s/g, '') + return Buffer.from(hex, 'hex').toString('base64') + } + if (/^[0-9a-fA-F]+$/.test(trimmed) && trimmed.length % 2 === 0) { + return Buffer.from(trimmed, 'hex').toString('base64') + } + return trimmed +} + +function base64ToUint8Array(base64: string): Uint8Array { + const binary = Buffer.from(base64.trim(), 'base64') + return new Uint8Array(binary) +} + +function uint8ArrayToBase64(bytes: Uint8Array): string { + return Buffer.from(bytes).toString('base64') +} + +function base64ToByteaHex(base64: string): string { + const buf = Buffer.from(base64, 'base64') + return '\\x' + buf.toString('hex') +} + +// ============================================================================ +// Core Migration Logic +// ============================================================================ + +function reconstructYDocState( + snapshotBase64: string | null, + tailRows: DocumentChangeRow[] +): Uint8Array { + const ydoc = new Y.Doc() + + // Apply snapshot if exists + if (snapshotBase64) { + const snapshotBytes = base64ToUint8Array(snapshotBase64) + Y.applyUpdate(ydoc, snapshotBytes) + } + + // Apply all tail changes + for (const row of tailRows) { + const updateBytes = base64ToUint8Array(byteaResponseToBase64(row.update_data)) + Y.applyUpdate(ydoc, updateBytes) + } + + // Encode full state + return Y.encodeStateAsUpdate(ydoc) +} + +async function migrateDocument( + supabase: ReturnType, + documentId: string, + dryRun: boolean +): Promise { + const result: MigrationResult = { + documentId, + success: false, + hadSnapshot: false, + changesCount: 0, + stateSize: 0, + } + + try { + // 1. Check if already migrated + const { data: existingState } = await supabase + .from('document_state') + .select('document_id') + .eq('document_id', documentId) + .single() + + if (existingState) { + result.success = true + result.error = 'Already migrated (skipped)' + return result + } + + // 2. Fetch snapshot (if exists) + const { data: snapshotRow, error: snapshotError } = await supabase + .from('document_snapshots') + .select('snapshot_data, snapshot_cutoff_created_at') + .eq('document_id', documentId) + .single() + + if (snapshotError && snapshotError.code !== 'PGRST116') { + throw new Error(`Failed to fetch snapshot: ${snapshotError.message}`) + } + + const snapshot = snapshotRow as DocumentSnapshotRow | null + const snapshotBase64 = snapshot?.snapshot_data + ? byteaResponseToBase64(snapshot.snapshot_data) + : null + const cutoffAfter = snapshot?.snapshot_cutoff_created_at ?? null + + result.hadSnapshot = !!snapshotBase64 + + // 3. Fetch all changes (tail after snapshot cutoff) + const tailRows: DocumentChangeRow[] = [] + let offset = 0 + let hasMore = true + + while (hasMore) { + let query = supabase + .from('document_changes') + .select('update_data, created_at') + .eq('document_id', documentId) + .order('created_at', { ascending: true }) + .range(offset, offset + CHANGES_PAGE_SIZE - 1) + + if (cutoffAfter) { + query = query.gt('created_at', cutoffAfter) + } + + const { data: page, error } = await query + if (error) { + throw new Error(`Failed to fetch changes: ${error.message}`) + } + + const rows = (page ?? []) as DocumentChangeRow[] + tailRows.push(...rows) + hasMore = rows.length === CHANGES_PAGE_SIZE + offset += CHANGES_PAGE_SIZE + } + + result.changesCount = tailRows.length + + // 4. Check if there's any data to migrate + if (!snapshotBase64 && tailRows.length === 0) { + result.success = true + result.error = 'No data to migrate (empty document)' + return result + } + + // 5. Reconstruct full Y.Doc state + const fullState = reconstructYDocState(snapshotBase64, tailRows) + result.stateSize = fullState.length + + // 6. Insert into document_state (unless dry run) + if (!dryRun) { + const { error: insertError } = await supabase + .from('document_state') + .insert({ + document_id: documentId, + state_data: base64ToByteaHex(uint8ArrayToBase64(fullState)), + updated_at: new Date().toISOString(), + }) + + if (insertError) { + throw new Error(`Failed to insert state: ${insertError.message}`) + } + } + + result.success = true + return result + + } catch (err) { + result.error = err instanceof Error ? err.message : String(err) + return result + } +} + +async function getAllDocumentIds( + supabase: ReturnType +): Promise { + const documentIds = new Set() + + // Get documents with snapshots + const { data: snapshotDocs, error: snapshotError } = await supabase + .from('document_snapshots') + .select('document_id') + + if (snapshotError) { + throw new Error(`Failed to fetch snapshot document IDs: ${snapshotError.message}`) + } + + for (const row of snapshotDocs ?? []) { + documentIds.add(row.document_id) + } + + // Get documents with changes (paginated for large datasets) + let offset = 0 + let hasMore = true + const PAGE_SIZE = 1000 + + while (hasMore) { + const { data: changeDocs, error: changeError } = await supabase + .from('document_changes') + .select('document_id') + .range(offset, offset + PAGE_SIZE - 1) + + if (changeError) { + throw new Error(`Failed to fetch change document IDs: ${changeError.message}`) + } + + const rows = changeDocs ?? [] + for (const row of rows) { + documentIds.add(row.document_id) + } + + hasMore = rows.length === PAGE_SIZE + offset += PAGE_SIZE + } + + return Array.from(documentIds) +} + +// ============================================================================ +// Main Entry Point +// ============================================================================ + +async function main() { + console.log('╔════════════════════════════════════════════════════════════════╗') + console.log('║ PartyKit Migration: document_changes → document_state ║') + console.log('╚════════════════════════════════════════════════════════════════╝') + console.log() + + // Parse arguments + const args = process.argv.slice(2) + const dryRun = args.includes('--dry-run') + const specificDocArg = args.find(a => a.startsWith('--document-id=')) + const specificDocId = specificDocArg?.split('=')[1] + + if (dryRun) { + console.log('🔍 DRY RUN MODE - No changes will be made\n') + } + + // Validate environment variables + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL + const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY + + if (!supabaseUrl) { + console.error('❌ Missing NEXT_PUBLIC_SUPABASE_URL environment variable') + process.exit(1) + } + + if (!serviceRoleKey) { + console.error('❌ Missing SUPABASE_SERVICE_ROLE_KEY environment variable') + console.error('') + console.error(' To get your service role key:') + console.error(' 1. Go to your Supabase Dashboard') + console.error(' 2. Navigate to Settings → API') + console.error(' 3. Copy the "service_role" key (NOT the anon key)') + console.error('') + console.error(' Then run:') + console.error(' SUPABASE_SERVICE_ROLE_KEY="your-key" npx tsx scripts/migrate-to-partykit.ts') + process.exit(1) + } + + // Create Supabase client with service role (bypasses RLS) + const supabase = createClient(supabaseUrl, serviceRoleKey, { + auth: { + autoRefreshToken: false, + persistSession: false, + }, + }) + + console.log(`📡 Connected to: ${supabaseUrl}`) + console.log() + + // Get document IDs to migrate + let documentIds: string[] + + if (specificDocId) { + console.log(`📄 Migrating specific document: ${specificDocId}`) + documentIds = [specificDocId] + } else { + console.log('🔍 Scanning for documents to migrate...') + documentIds = await getAllDocumentIds(supabase) + console.log(` Found ${documentIds.length} documents with old CRDT data`) + } + + console.log() + + if (documentIds.length === 0) { + console.log('✅ No documents to migrate!') + return + } + + // Process documents in batches + const results: MigrationResult[] = [] + let processed = 0 + + for (let i = 0; i < documentIds.length; i += BATCH_SIZE) { + const batch = documentIds.slice(i, i + BATCH_SIZE) + + const batchResults = await Promise.all( + batch.map(docId => migrateDocument(supabase, docId, dryRun)) + ) + + results.push(...batchResults) + processed += batch.length + + // Progress update + const percent = Math.round((processed / documentIds.length) * 100) + const succeeded = results.filter(r => r.success && !r.error?.includes('skipped')).length + const skipped = results.filter(r => r.error?.includes('skipped') || r.error?.includes('empty')).length + const failed = results.filter(r => !r.success).length + + process.stdout.write( + `\r⏳ Progress: ${processed}/${documentIds.length} (${percent}%) | ` + + `✅ ${succeeded} migrated | ⏭️ ${skipped} skipped | ❌ ${failed} failed` + ) + } + + console.log('\n') + + // Summary + console.log('═══════════════════════════════════════════════════════════════════') + console.log(' MIGRATION SUMMARY') + console.log('═══════════════════════════════════════════════════════════════════') + + const succeeded = results.filter(r => r.success && !r.error?.includes('skipped') && !r.error?.includes('empty')) + const alreadyMigrated = results.filter(r => r.error?.includes('Already migrated')) + const emptyDocs = results.filter(r => r.error?.includes('empty document')) + const failed = results.filter(r => !r.success) + + console.log(`✅ Successfully migrated: ${succeeded.length}`) + console.log(`⏭️ Already migrated: ${alreadyMigrated.length}`) + console.log(`📭 Empty (no data): ${emptyDocs.length}`) + console.log(`❌ Failed: ${failed.length}`) + console.log() + + if (succeeded.length > 0) { + const totalSize = succeeded.reduce((sum, r) => sum + r.stateSize, 0) + const avgSize = Math.round(totalSize / succeeded.length) + const totalChanges = succeeded.reduce((sum, r) => sum + r.changesCount, 0) + const withSnapshots = succeeded.filter(r => r.hadSnapshot).length + + console.log('📊 Statistics for migrated documents:') + console.log(` • Total state data: ${(totalSize / 1024).toFixed(1)} KB`) + console.log(` • Average state size: ${(avgSize / 1024).toFixed(1)} KB`) + console.log(` • Total changes processed: ${totalChanges}`) + console.log(` • Documents with snapshots: ${withSnapshots}`) + console.log() + } + + if (failed.length > 0) { + console.log('❌ Failed documents:') + for (const f of failed.slice(0, 10)) { + console.log(` • ${f.documentId}: ${f.error}`) + } + if (failed.length > 10) { + console.log(` ... and ${failed.length - 10} more`) + } + console.log() + } + + if (dryRun) { + console.log('🔍 This was a DRY RUN. No changes were made.') + console.log(' Run without --dry-run to perform the actual migration.') + } else if (failed.length === 0) { + console.log('🎉 Migration completed successfully!') + } else { + console.log('⚠️ Migration completed with errors. Review failed documents above.') + process.exit(1) + } +} + +main().catch((err) => { + console.error('Fatal error:', err) + process.exit(1) +}) From 961809e95d110ec97b1ad3a01b9ffbd6b8e148e6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 22:33:43 +0000 Subject: [PATCH 13/25] fix: load .env files automatically in migration script - Added dotenv to load .env.local and .env files - Matches Next.js env loading behavior (.env.local takes precedence) Co-authored-by: Matia Raspopovic --- package-lock.json | 14 ++++++++++++++ package.json | 1 + scripts/migrate-to-partykit.ts | 10 +++++++++- 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 6a8097c..bff43df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -74,6 +74,7 @@ "@types/sanitize-html": "^2.16.1", "@typescript-eslint/eslint-plugin": "^8.1.0", "@typescript-eslint/parser": "^8.1.0", + "dotenv": "^17.4.2", "eslint": "^8.57.0", "eslint-config-next": "^15.0.1", "postcss": "^8.4.39", @@ -6343,6 +6344,19 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", diff --git a/package.json b/package.json index eaf7a17..1ff5b4e 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "@types/sanitize-html": "^2.16.1", "@typescript-eslint/eslint-plugin": "^8.1.0", "@typescript-eslint/parser": "^8.1.0", + "dotenv": "^17.4.2", "eslint": "^8.57.0", "eslint-config-next": "^15.0.1", "postcss": "^8.4.39", diff --git a/scripts/migrate-to-partykit.ts b/scripts/migrate-to-partykit.ts index 486d722..84e138d 100644 --- a/scripts/migrate-to-partykit.ts +++ b/scripts/migrate-to-partykit.ts @@ -15,7 +15,7 @@ * # Migrate specific document * npx tsx scripts/migrate-to-partykit.ts --document-id= * - * Required environment variables: + * Environment variables (loaded from .env.local, .env, or environment): * NEXT_PUBLIC_SUPABASE_URL - Your Supabase project URL * SUPABASE_SERVICE_ROLE_KEY - Service role key (from Supabase Dashboard → Settings → API) * @@ -23,6 +23,14 @@ * Keep it secret and never commit it to version control. */ +import * as dotenv from 'dotenv' +import * as path from 'path' + +// Load environment variables from .env files (same order as Next.js) +// .env.local takes precedence over .env +dotenv.config({ path: path.resolve(process.cwd(), '.env.local') }) +dotenv.config({ path: path.resolve(process.cwd(), '.env') }) + import { createClient } from '@supabase/supabase-js' import * as Y from 'yjs' From 3aa945e30ab11816e881fa539338efbe1ae83cb2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 22:36:12 +0000 Subject: [PATCH 14/25] fix: use built-in env file parser instead of dotenv - Removed dotenv dependency - Added simple .env file parser using only Node.js built-ins - Works with npx tsx without needing npm install first Co-authored-by: Matia Raspopovic --- package-lock.json | 14 ------------- package.json | 1 - scripts/migrate-to-partykit.ts | 37 +++++++++++++++++++++++++++++++--- 3 files changed, 34 insertions(+), 18 deletions(-) diff --git a/package-lock.json b/package-lock.json index bff43df..6a8097c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -74,7 +74,6 @@ "@types/sanitize-html": "^2.16.1", "@typescript-eslint/eslint-plugin": "^8.1.0", "@typescript-eslint/parser": "^8.1.0", - "dotenv": "^17.4.2", "eslint": "^8.57.0", "eslint-config-next": "^15.0.1", "postcss": "^8.4.39", @@ -6344,19 +6343,6 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, - "node_modules/dotenv": { - "version": "17.4.2", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", - "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", diff --git a/package.json b/package.json index 1ff5b4e..eaf7a17 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,6 @@ "@types/sanitize-html": "^2.16.1", "@typescript-eslint/eslint-plugin": "^8.1.0", "@typescript-eslint/parser": "^8.1.0", - "dotenv": "^17.4.2", "eslint": "^8.57.0", "eslint-config-next": "^15.0.1", "postcss": "^8.4.39", diff --git a/scripts/migrate-to-partykit.ts b/scripts/migrate-to-partykit.ts index 84e138d..dc644cc 100644 --- a/scripts/migrate-to-partykit.ts +++ b/scripts/migrate-to-partykit.ts @@ -23,13 +23,44 @@ * Keep it secret and never commit it to version control. */ -import * as dotenv from 'dotenv' +import * as fs from 'fs' import * as path from 'path' +// Simple .env file parser (no external dependencies) +function loadEnvFile(filePath: string): void { + try { + const content = fs.readFileSync(filePath, 'utf-8') + for (const line of content.split('\n')) { + const trimmed = line.trim() + // Skip empty lines and comments + if (!trimmed || trimmed.startsWith('#')) continue + + const eqIndex = trimmed.indexOf('=') + if (eqIndex === -1) continue + + const key = trimmed.slice(0, eqIndex).trim() + let value = trimmed.slice(eqIndex + 1).trim() + + // Remove surrounding quotes if present + if ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1) + } + + // Only set if not already defined in environment + if (process.env[key] === undefined) { + process.env[key] = value + } + } + } catch { + // File doesn't exist, that's fine + } +} + // Load environment variables from .env files (same order as Next.js) // .env.local takes precedence over .env -dotenv.config({ path: path.resolve(process.cwd(), '.env.local') }) -dotenv.config({ path: path.resolve(process.cwd(), '.env') }) +loadEnvFile(path.resolve(process.cwd(), '.env.local')) +loadEnvFile(path.resolve(process.cwd(), '.env')) import { createClient } from '@supabase/supabase-js' import * as Y from 'yjs' From 11b4a6c685c910b7f4baf97956a7ecdbadcf9975 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 22:37:20 +0000 Subject: [PATCH 15/25] fix: use dotenv with npm script for migration - Reverted to using dotenv for env file loading - Added npm run migrate:partykit script - Using npm script ensures project dependencies are available Co-authored-by: Matia Raspopovic --- package-lock.json | 14 +++++++++++ package.json | 2 ++ scripts/migrate-to-partykit.ts | 43 +++++----------------------------- 3 files changed, 22 insertions(+), 37 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6a8097c..bff43df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -74,6 +74,7 @@ "@types/sanitize-html": "^2.16.1", "@typescript-eslint/eslint-plugin": "^8.1.0", "@typescript-eslint/parser": "^8.1.0", + "dotenv": "^17.4.2", "eslint": "^8.57.0", "eslint-config-next": "^15.0.1", "postcss": "^8.4.39", @@ -6343,6 +6344,19 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", diff --git a/package.json b/package.json index eaf7a17..9d0b929 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "dev": "next dev --turbo", "dev:partykit": "partykit dev", "deploy:partykit": "partykit deploy", + "migrate:partykit": "tsx scripts/migrate-to-partykit.ts", "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache", "format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache", "lint": "next lint", @@ -84,6 +85,7 @@ "@types/sanitize-html": "^2.16.1", "@typescript-eslint/eslint-plugin": "^8.1.0", "@typescript-eslint/parser": "^8.1.0", + "dotenv": "^17.4.2", "eslint": "^8.57.0", "eslint-config-next": "^15.0.1", "postcss": "^8.4.39", diff --git a/scripts/migrate-to-partykit.ts b/scripts/migrate-to-partykit.ts index dc644cc..69b6cab 100644 --- a/scripts/migrate-to-partykit.ts +++ b/scripts/migrate-to-partykit.ts @@ -7,13 +7,13 @@ * * Usage: * # Dry run (no changes made) - * npx tsx scripts/migrate-to-partykit.ts --dry-run + * npm run migrate:partykit -- --dry-run * * # Actual migration - * npx tsx scripts/migrate-to-partykit.ts + * npm run migrate:partykit * * # Migrate specific document - * npx tsx scripts/migrate-to-partykit.ts --document-id= + * npm run migrate:partykit -- --document-id= * * Environment variables (loaded from .env.local, .env, or environment): * NEXT_PUBLIC_SUPABASE_URL - Your Supabase project URL @@ -23,44 +23,13 @@ * Keep it secret and never commit it to version control. */ -import * as fs from 'fs' +import * as dotenv from 'dotenv' import * as path from 'path' -// Simple .env file parser (no external dependencies) -function loadEnvFile(filePath: string): void { - try { - const content = fs.readFileSync(filePath, 'utf-8') - for (const line of content.split('\n')) { - const trimmed = line.trim() - // Skip empty lines and comments - if (!trimmed || trimmed.startsWith('#')) continue - - const eqIndex = trimmed.indexOf('=') - if (eqIndex === -1) continue - - const key = trimmed.slice(0, eqIndex).trim() - let value = trimmed.slice(eqIndex + 1).trim() - - // Remove surrounding quotes if present - if ((value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'"))) { - value = value.slice(1, -1) - } - - // Only set if not already defined in environment - if (process.env[key] === undefined) { - process.env[key] = value - } - } - } catch { - // File doesn't exist, that's fine - } -} - // Load environment variables from .env files (same order as Next.js) // .env.local takes precedence over .env -loadEnvFile(path.resolve(process.cwd(), '.env.local')) -loadEnvFile(path.resolve(process.cwd(), '.env')) +dotenv.config({ path: path.resolve(process.cwd(), '.env.local') }) +dotenv.config({ path: path.resolve(process.cwd(), '.env') }) import { createClient } from '@supabase/supabase-js' import * as Y from 'yjs' From 6cab4b2f14ae123fcd6b15a6b993d1c4a61dee79 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 22:40:48 +0000 Subject: [PATCH 16/25] fix: add tsx as dev dependency for migration script Co-authored-by: Matia Raspopovic --- package-lock.json | 504 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + 2 files changed, 505 insertions(+) diff --git a/package-lock.json b/package-lock.json index bff43df..0f67a48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -81,6 +81,7 @@ "prettier": "^3.3.2", "prettier-plugin-tailwindcss": "^0.6.5", "tailwindcss": "^3.4.3", + "tsx": "^4.23.12", "typescript": "^5.5.3" } }, @@ -728,6 +729,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/netbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", @@ -744,6 +762,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/openbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", @@ -760,6 +795,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", @@ -13404,6 +13456,458 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.23.12", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", + "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index 9d0b929..bd511f2 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ "prettier": "^3.3.2", "prettier-plugin-tailwindcss": "^0.6.5", "tailwindcss": "^3.4.3", + "tsx": "^4.23.12", "typescript": "^5.5.3" }, "ct3aMetadata": { From 13e276d57203707d77cbddea8aba2aca677c14c6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 01:35:02 +0000 Subject: [PATCH 17/25] fix: use y-partykit load option to properly load document state - Use y-partykit's load option instead of managing separate Y.Doc - Pre-fetch document before calling onConnect to handle errors - Return loaded Y.Doc via load callback for y-partykit to use - This fixes migrated documents showing as empty Co-authored-by: Matia Raspopovic --- party/document.ts | 102 ++++++++++++++++++++-------------------------- 1 file changed, 44 insertions(+), 58 deletions(-) diff --git a/party/document.ts b/party/document.ts index 8269516..e5bc793 100644 --- a/party/document.ts +++ b/party/document.ts @@ -36,16 +36,16 @@ function isTokenExpired(token: string): boolean { return Date.now() >= payload.exp * 1000; } +type LoadResult = + | { success: true; ydoc: Y.Doc | null } + | { success: false; errorCode: number; errorMessage: string }; + export default class DocumentParty implements Party.Server { - ydoc: Y.Doc; - isLoaded: boolean = false; - pendingSave: boolean = false; - saveTimeout: ReturnType | null = null; authorizedToken: string | null = null; + loadedDoc: Y.Doc | null = null; + isLoaded: boolean = false; - constructor(readonly room: Party.Room) { - this.ydoc = new Y.Doc(); - } + constructor(readonly room: Party.Room) {} get appUrl(): string { return (this.room.env.APP_URL as string) || "http://localhost:3000"; @@ -55,14 +55,7 @@ export default class DocumentParty implements Party.Server { return (this.room.env.PARTYKIT_SECRET as string) || ""; } - async onStart(): Promise { - this.ydoc.on("update", (_update: Uint8Array, origin: unknown) => { - if (origin === "load") return; - this.scheduleSave(); - }); - } - - async loadDocument(token: string, isNew: boolean): Promise<{ success: boolean; errorCode?: number }> { + async fetchDocument(token: string, isNew: boolean): Promise { const documentId = this.room.id; try { @@ -78,50 +71,39 @@ export default class DocumentParty implements Party.Server { if (!response.ok) { console.log(`[PartyKit] Load failed for ${documentId}: ${response.status}`); - return { success: false, errorCode: response.status }; + return { + success: false, + errorCode: response.status === 404 ? 4004 : 4003, + errorMessage: response.status === 404 ? "Document not found" : "Access denied", + }; } const data = (await response.json()) as { state: string | null }; if (data.state) { + const ydoc = new Y.Doc(); const stateBytes = base64ToUint8Array(data.state); - Y.applyUpdate(this.ydoc, stateBytes, "load"); - console.log(`[PartyKit] Loaded document ${documentId} with existing state`); + Y.applyUpdate(ydoc, stateBytes); + console.log(`[PartyKit] Loaded document ${documentId} with existing state (${stateBytes.length} bytes)`); + return { success: true, ydoc }; } else { console.log(`[PartyKit] Document ${documentId} starting with empty state`); + return { success: true, ydoc: null }; } - - this.isLoaded = true; - return { success: true }; } catch (error) { console.error(`[PartyKit] Failed to load document ${documentId}:`, error); - return { success: false, errorCode: 500 }; + return { success: false, errorCode: 4003, errorMessage: "Failed to load document" }; } } - scheduleSave(): void { - if (this.pendingSave) return; - this.pendingSave = true; - - if (this.saveTimeout) { - clearTimeout(this.saveTimeout); - } - - this.saveTimeout = setTimeout(() => { - this.pendingSave = false; - this.saveTimeout = null; - void this.saveDocument(); - }, 1000); - } - - async saveDocument(): Promise { + async saveDocument(ydoc: Y.Doc): Promise { if (!this.authorizedToken) { console.error("[PartyKit] No authorized token available for save"); return; } const documentId = this.room.id; - const stateUpdate = Y.encodeStateAsUpdate(this.ydoc); + const stateUpdate = Y.encodeStateAsUpdate(ydoc); const stateBase64 = uint8ArrayToBase64(stateUpdate); try { @@ -165,35 +147,39 @@ export default class DocumentParty implements Party.Server { return; } - // Only load on first connection to this room + // Store the token for saving + this.authorizedToken = token; + + // Load document on first connection if (!this.isLoaded) { - const result = await this.loadDocument(token, isNew); + const result = await this.fetchDocument(token, isNew); + if (!result.success) { - const code = result.errorCode === 404 ? 4004 : 4003; - const message = result.errorCode === 404 - ? "Document not found" - : "Access denied"; - conn.close(code, message); + conn.close(result.errorCode, result.errorMessage); return; } + + this.loadedDoc = result.ydoc; + this.isLoaded = true; } - this.authorizedToken = token; + const loadedDoc = this.loadedDoc; const options: YPartyKitOptions = { - callback: { handler: () => {} }, + gc: false, + load: async () => { + // Return the pre-loaded document + return loadedDoc; + }, + callback: { + handler: async (ydoc: Y.Doc) => { + await this.saveDocument(ydoc); + }, + debounceWait: 1000, + debounceMaxWait: 5000, + }, }; return onConnect(conn, this.room, options); } - - async onClose(): Promise { - if (this.saveTimeout) { - clearTimeout(this.saveTimeout); - this.saveTimeout = null; - } - if (this.pendingSave) { - await this.saveDocument(); - } - } } From e0ae525a9f788ec331930e9cba4c18c8661db919 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 27 Aug 2026 02:06:56 +0000 Subject: [PATCH 18/25] feat: implement robust connection recovery for token expiration - Listen for Supabase auth state changes (TOKEN_REFRESHED) - Automatically reconnect with fresh token when old one expires - Retry up to 3 times on connection errors before showing error - Keep Y.Doc intact during reconnection (no data loss) - Add isReconnecting state for UI feedback - Show 'Reconnecting...' indicator instead of error during recovery - Show error banner only after all retries exhausted - Handle SIGNED_OUT event gracefully Co-authored-by: Matia Raspopovic --- src/app/documents/[documentId]/page.tsx | 75 +++-- src/hooks/use-collaborative-doc-partykit.ts | 286 +++++++++++++++----- 2 files changed, 281 insertions(+), 80 deletions(-) diff --git a/src/app/documents/[documentId]/page.tsx b/src/app/documents/[documentId]/page.tsx index 30e51c3..7f8885e 100644 --- a/src/app/documents/[documentId]/page.tsx +++ b/src/app/documents/[documentId]/page.tsx @@ -2,7 +2,8 @@ import dynamic from "next/dynamic"; import { useParams } from "next/navigation"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { Loader2 } from "lucide-react"; import { TRPCClientError } from "@trpc/client"; import { Alert, AlertDescription, AlertTitle } from "~/app/_components/alert"; import { DocumentLoadingSkeleton } from "~/app/_components/document-loading-skeleton"; @@ -83,12 +84,17 @@ export default function DocumentPage() { const { data: userProfile } = useUserProfile(); // PartyKit-based collaborative doc - handles fetching and saving on server - const { ydoc, provider, isReady, isLoading, error } = useCollaborativeDocPartykit( - { + const { ydoc, provider, isReady, isLoading, isReconnecting, error } = + useCollaborativeDocPartykit({ documentId, isNew, - }, - ); + }); + + // Track if we've ever successfully connected (to know when to show reconnecting vs loading) + const hasConnectedRef = useRef(false); + if (isReady) { + hasConnectedRef.current = true; + } // Delayed skeleton: only show after SKELETON_DELAY_MS to avoid flicker on fast loads const [showSkeleton, setShowSkeleton] = useState(false); @@ -110,10 +116,15 @@ export default function DocumentPage() { return () => clearTimeout(timer); }, [isStillLoading, documentId]); + // Reset hasConnected when document changes + useEffect(() => { + hasConnectedRef.current = false; + }, [documentId]); + // === RENDERING LOGIC === - // 1. Handle errors — show alert and stop; don't proceed to loading or editor - if (error) { + // 1. Handle errors — but not if we're reconnecting with a previously working editor + if (error && !isReconnecting && !hasConnectedRef.current) { const { title, message } = getDocumentErrorContent(error); return ( @@ -125,8 +136,8 @@ export default function DocumentPage() { ); } - // 2. Still loading — show skeleton only after delay to avoid flicker - if (isStillLoading) { + // 2. Still loading (initial load) — show skeleton only after delay + if (isStillLoading && !hasConnectedRef.current) { if (showSkeleton) { return ( @@ -138,7 +149,7 @@ export default function DocumentPage() { return null; } - // 3. Ready to render + // 3. Ready to render (or reconnecting with existing editor) const userName = userProfile ? [userProfile.first_name, userProfile.last_name] .filter((p): p is string => typeof p === "string" && p.trim().length > 0) @@ -149,14 +160,46 @@ export default function DocumentPage() { userProfile?.default_avatar_background_color, ); + // If we have an error after being connected, show it as a dismissible banner + const showErrorBanner = error && hasConnectedRef.current && !isReconnecting; + return ( - + {/* Reconnecting indicator */} + {isReconnecting && ( +
+
+ + Reconnecting... +
+
+ )} + + {/* Error banner (after reconnect failed) */} + {showErrorBanner && ( +
+
+ Connection lost. Your changes are saved locally. + +
+
+ )} + + {ydoc && provider ? ( + + ) : ( + + )}
); } diff --git a/src/hooks/use-collaborative-doc-partykit.ts b/src/hooks/use-collaborative-doc-partykit.ts index e9405bb..0cbaa79 100644 --- a/src/hooks/use-collaborative-doc-partykit.ts +++ b/src/hooks/use-collaborative-doc-partykit.ts @@ -1,9 +1,10 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, useCallback } from "react"; import * as Y from "yjs"; import YPartyKitProvider from "y-partykit/provider"; import { createClient } from "~/utils/supabase/client"; +import type { SupabaseClient } from "@supabase/supabase-js"; interface UseCollaborativeDocPartykitOptions { documentId: string; @@ -15,48 +16,65 @@ interface UseCollaborativeDocPartykitResult { provider: YPartyKitProvider | null; isReady: boolean; isLoading: boolean; + isReconnecting: boolean; error: Error | null; } const PARTYKIT_HOST = process.env.NEXT_PUBLIC_PARTYKIT_HOST ?? "localhost:1999"; +const MAX_RETRIES = 3; +const RETRY_DELAY_MS = 1000; + export function useCollaborativeDocPartykit({ documentId, isNew = false, }: UseCollaborativeDocPartykitOptions): UseCollaborativeDocPartykitResult { - const [state, setState] = useState<{ - ydoc: Y.Doc; - provider: YPartyKitProvider; - } | null>(null); + const [ydoc, setYdoc] = useState(null); + const [provider, setProvider] = useState(null); const [isReady, setIsReady] = useState(false); const [isLoading, setIsLoading] = useState(true); + const [isReconnecting, setIsReconnecting] = useState(false); const [error, setError] = useState(null); - const cleanupRef = useRef<(() => void) | null>(null); - const lastDocumentIdRef = useRef(null); - const initializedRef = useRef(false); - - useEffect(() => { - if (lastDocumentIdRef.current === documentId && initializedRef.current) { - return; - } + const supabaseRef = useRef(null); + const ydocRef = useRef(null); + const providerRef = useRef(null); + const retryCountRef = useRef(0); + const isConnectingRef = useRef(false); + const mountedRef = useRef(true); + const documentIdRef = useRef(documentId); + const hasConnectedOnceRef = useRef(false); - cleanupRef.current?.(); - cleanupRef.current = null; - initializedRef.current = false; + const connect = useCallback( + async (isRetry = false) => { + // Prevent concurrent connection attempts + if (isConnectingRef.current) return; + isConnectingRef.current = true; - setIsLoading(true); - setError(null); - setIsReady(false); + // Update UI state + if (isRetry) { + setIsReconnecting(true); + setError(null); + } else { + setIsLoading(true); + setIsReconnecting(false); + setError(null); + setIsReady(false); + } - const setup = async () => { try { - const supabase = createClient(); + // Get fresh session + if (!supabaseRef.current) { + supabaseRef.current = createClient(); + } + const { data: { session }, error: sessionError, - } = await supabase.auth.getSession(); + } = await supabaseRef.current.auth.getSession(); + + if (!mountedRef.current) return; if (sessionError) { throw new Error(`Failed to get session: ${sessionError.message}`); @@ -66,78 +84,218 @@ export function useCollaborativeDocPartykit({ throw new Error("Not authenticated"); } - const ydoc = new Y.Doc(); + // Destroy old provider (but keep Y.Doc for reconnects) + if (providerRef.current) { + try { + providerRef.current.destroy(); + } catch {} + providerRef.current = null; + } - // Pass isNew flag to PartyKit - const provider = new YPartyKitProvider(PARTYKIT_HOST, documentId, ydoc, { - connect: true, - params: { - token: session.access_token, - isNew: isNew ? "true" : "false", - }, - }); + // Create Y.Doc only on first connect, reuse on reconnect + let doc = ydocRef.current; + if (!doc) { + doc = new Y.Doc(); + ydocRef.current = doc; + } - provider.on("sync", (synced: boolean) => { - if (synced) { + // Create new provider with fresh token + const newProvider = new YPartyKitProvider( + PARTYKIT_HOST, + documentIdRef.current, + doc, + { + connect: true, + params: { + token: session.access_token, + // Only pass isNew=true on first connection, not on reconnects + isNew: isNew && !hasConnectedOnceRef.current ? "true" : "false", + }, + } + ); + + providerRef.current = newProvider; + + // Handle successful sync + const handleSync = (synced: boolean) => { + if (synced && mountedRef.current) { setIsReady(true); setIsLoading(false); + setIsReconnecting(false); + setError(null); + retryCountRef.current = 0; + hasConnectedOnceRef.current = true; } - }); + }; - provider.on("connection-error", (err: Error) => { + // Handle connection errors + const handleConnectionError = (err: Error) => { console.error("[PartyKit] Connection error:", err); - setError(err); - setIsLoading(false); - }); + // Don't set error immediately - let connection-close handle retry logic + }; + + // Handle connection close + const handleConnectionClose = async (event: CloseEvent) => { + if (!mountedRef.current) return; + + console.log(`[PartyKit] Connection closed: code=${event.code}, reason=${event.reason}`); - provider.on("connection-close", (event: CloseEvent) => { + // Token expired or auth error - try to reconnect with fresh token if (event.code === 4001) { - setError(new Error("Unauthorized: Please sign in")); + if (retryCountRef.current < MAX_RETRIES) { + retryCountRef.current++; + console.log(`[PartyKit] Token expired, retrying (${retryCountRef.current}/${MAX_RETRIES})...`); + isConnectingRef.current = false; + setTimeout(() => { + void connect(true); + }, RETRY_DELAY_MS); + return; + } + setError(new Error("Session expired. Please refresh the page.")); setIsLoading(false); - } else if (event.code === 4003) { + setIsReconnecting(false); + return; + } + + // Permission denied - not recoverable + if (event.code === 4003) { setError(new Error("You don't have access to this document")); setIsLoading(false); - } else if (event.code === 4004) { + setIsReconnecting(false); + return; + } + + // Document not found - not recoverable + if (event.code === 4004) { setError(new Error("Document not found")); setIsLoading(false); + setIsReconnecting(false); + return; } - }); - lastDocumentIdRef.current = documentId; - initializedRef.current = true; - setState({ ydoc, provider }); - - cleanupRef.current = () => { - initializedRef.current = false; - try { - provider.destroy(); - } catch {} - try { - ydoc.destroy(); - } catch {} - setState(null); - setIsReady(false); + // Other close events (network issues, server restart, etc.) + // YPartyKitProvider has built-in reconnect, but if we keep getting + // close events, we might need to refresh the token + if (event.code !== 1000 && retryCountRef.current < MAX_RETRIES) { + retryCountRef.current++; + console.log(`[PartyKit] Connection lost, retrying (${retryCountRef.current}/${MAX_RETRIES})...`); + isConnectingRef.current = false; + setTimeout(() => { + void connect(true); + }, RETRY_DELAY_MS * retryCountRef.current); + return; + } }; + + newProvider.on("sync", handleSync); + newProvider.on("connection-error", handleConnectionError); + newProvider.on("connection-close", handleConnectionClose); + + // Update state + setYdoc(doc); + setProvider(newProvider); + isConnectingRef.current = false; } catch (err) { + isConnectingRef.current = false; + + if (!mountedRef.current) return; + console.error("[PartyKit] Setup error:", err); + + // Retry on setup errors + if (retryCountRef.current < MAX_RETRIES) { + retryCountRef.current++; + console.log(`[PartyKit] Setup failed, retrying (${retryCountRef.current}/${MAX_RETRIES})...`); + setTimeout(() => { + void connect(true); + }, RETRY_DELAY_MS * retryCountRef.current); + return; + } + setError(err instanceof Error ? err : new Error(String(err))); setIsLoading(false); + setIsReconnecting(false); } - }; + }, + [isNew] + ); + + useEffect(() => { + mountedRef.current = true; + documentIdRef.current = documentId; + retryCountRef.current = 0; + hasConnectedOnceRef.current = false; - void setup(); + // Reset state for new document + setYdoc(null); + setProvider(null); + setIsReady(false); + setIsLoading(true); + setIsReconnecting(false); + setError(null); + + // Clean up old Y.Doc when document changes + if (ydocRef.current) { + try { + ydocRef.current.destroy(); + } catch {} + ydocRef.current = null; + } + + // Clean up old provider + if (providerRef.current) { + try { + providerRef.current.destroy(); + } catch {} + providerRef.current = null; + } + + // Initialize Supabase client + supabaseRef.current = createClient(); + + // Start connection + void connect(); + + // Listen for auth state changes (token refresh) + const { + data: { subscription }, + } = supabaseRef.current.auth.onAuthStateChange((event) => { + if (event === "TOKEN_REFRESHED" && mountedRef.current) { + console.log("[PartyKit] Token refreshed, reconnecting with new token..."); + retryCountRef.current = 0; + void connect(true); + } else if (event === "SIGNED_OUT" && mountedRef.current) { + setError(new Error("Signed out")); + setIsReady(false); + } + }); return () => { - cleanupRef.current?.(); - cleanupRef.current = null; + mountedRef.current = false; + subscription.unsubscribe(); + + if (providerRef.current) { + try { + providerRef.current.destroy(); + } catch {} + providerRef.current = null; + } + + if (ydocRef.current) { + try { + ydocRef.current.destroy(); + } catch {} + ydocRef.current = null; + } }; - }, [documentId, isNew]); + }, [documentId, connect]); return { - ydoc: state?.ydoc ?? null, - provider: state?.provider ?? null, + ydoc, + provider, isReady, isLoading, + isReconnecting, error, }; } From 7b69b5052516b808bd422ea4bfdd2d8c748805de Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 27 Aug 2026 02:44:22 +0000 Subject: [PATCH 19/25] feat: add save retry limits to prevent infinite retry loops - Track consecutive save failures (max 5) - Disable saving after max failures reached - Auto-reset failure count after 1 minute of no failures - Re-enable saving when a new client connects (fresh token) - Log failure progression for debugging Co-authored-by: Matia Raspopovic --- party/document.ts | 56 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/party/document.ts b/party/document.ts index e5bc793..1c55174 100644 --- a/party/document.ts +++ b/party/document.ts @@ -40,10 +40,18 @@ type LoadResult = | { success: true; ydoc: Y.Doc | null } | { success: false; errorCode: number; errorMessage: string }; +const MAX_SAVE_FAILURES = 5; +const SAVE_FAILURE_RESET_MS = 60000; // Reset failure count after 1 minute of no failures + export default class DocumentParty implements Party.Server { authorizedToken: string | null = null; loadedDoc: Y.Doc | null = null; isLoaded: boolean = false; + + // Save retry tracking + saveFailureCount = 0; + lastSaveFailureTime: number | null = null; + savingDisabled = false; constructor(readonly room: Party.Room) {} @@ -97,12 +105,29 @@ export default class DocumentParty implements Party.Server { } async saveDocument(ydoc: Y.Doc): Promise { + const documentId = this.room.id; + + // Check if saving is disabled due to too many failures + if (this.savingDisabled) { + console.log(`[PartyKit] Saving disabled for ${documentId} due to repeated failures`); + return; + } + + // Reset failure count if enough time has passed since last failure + if ( + this.lastSaveFailureTime && + Date.now() - this.lastSaveFailureTime > SAVE_FAILURE_RESET_MS + ) { + this.saveFailureCount = 0; + this.lastSaveFailureTime = null; + } + if (!this.authorizedToken) { console.error("[PartyKit] No authorized token available for save"); + this.recordSaveFailure(documentId); return; } - const documentId = this.room.id; const stateUpdate = Y.encodeStateAsUpdate(ydoc); const stateBase64 = uint8ArrayToBase64(stateUpdate); @@ -124,9 +149,30 @@ export default class DocumentParty implements Party.Server { throw new Error(`Failed to save document: ${response.status}`); } + // Success - reset failure tracking + this.saveFailureCount = 0; + this.lastSaveFailureTime = null; console.log(`[PartyKit] Saved document ${documentId}`); } catch (error) { console.error(`[PartyKit] Failed to save document ${documentId}:`, error); + this.recordSaveFailure(documentId); + } + } + + private recordSaveFailure(documentId: string): void { + this.saveFailureCount++; + this.lastSaveFailureTime = Date.now(); + + if (this.saveFailureCount >= MAX_SAVE_FAILURES) { + this.savingDisabled = true; + console.error( + `[PartyKit] Saving disabled for ${documentId} after ${MAX_SAVE_FAILURES} consecutive failures. ` + + `A new client connection will re-enable saving.` + ); + } else { + console.log( + `[PartyKit] Save failure ${this.saveFailureCount}/${MAX_SAVE_FAILURES} for ${documentId}` + ); } } @@ -150,6 +196,14 @@ export default class DocumentParty implements Party.Server { // Store the token for saving this.authorizedToken = token; + // Reset save failure tracking on new connection (new token might work) + if (this.savingDisabled) { + console.log(`[PartyKit] Re-enabling saving for ${this.room.id} due to new connection`); + this.savingDisabled = false; + this.saveFailureCount = 0; + this.lastSaveFailureTime = null; + } + // Load document on first connection if (!this.isLoaded) { const result = await this.fetchDocument(token, isNew); From 0e52b58f7b8fa72764560658015c97e01bb22597 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 27 Aug 2026 03:13:55 +0000 Subject: [PATCH 20/25] feat: disable editor when offline with clear UI feedback - Add useOnlineStatus hook to detect browser online/offline state - Show amber 'You're offline' banner when disconnected - Disable editor (read-only + reduced opacity) when offline - Clear messaging that editing is disabled until reconnect - Auto-recover when back online - Updated error banner messaging for consistency Co-authored-by: Matia Raspopovic --- src/app/_components/editor/editor.tsx | 8 ++++++- src/app/documents/[documentId]/page.tsx | 29 +++++++++++++++++++++---- src/hooks/use-online-status.ts | 25 +++++++++++++++++++++ 3 files changed, 57 insertions(+), 5 deletions(-) create mode 100644 src/hooks/use-online-status.ts diff --git a/src/app/_components/editor/editor.tsx b/src/app/_components/editor/editor.tsx index 45a6080..e8ab326 100644 --- a/src/app/_components/editor/editor.tsx +++ b/src/app/_components/editor/editor.tsx @@ -36,6 +36,7 @@ interface EditorProps { userColor: string; ydoc: Y.Doc; provider: CollaborationProvider; + editable?: boolean; } const schema = BlockNoteSchema.create({ @@ -51,6 +52,7 @@ export default function Editor({ userColor, ydoc, provider, + editable = true, }: EditorProps) { const { theme } = useTheme(); const [currentTheme, setCurrentTheme] = useState(theme as Theme); @@ -172,11 +174,15 @@ export default function Editor({ }; return ( -
+
+ {/* Offline indicator - highest priority */} + {showOfflineBanner && ( +
+
+ + You're offline. Editing is disabled until you reconnect. +
+
+ )} + {/* Reconnecting indicator */} - {isReconnecting && ( + {isReconnecting && !showOfflineBanner && (
@@ -176,10 +196,10 @@ export default function DocumentPage() { )} {/* Error banner (after reconnect failed) */} - {showErrorBanner && ( + {showErrorBanner && !showOfflineBanner && (
- Connection lost. Your changes are saved locally. + Connection lost. Editing is disabled.