diff --git a/.gitignore b/.gitignore index cbf8f9efd..6cb58e867 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ node_modules lib dist +.nuxt +.output *.tsbuildinfo .vscode .DS_STORE diff --git a/.nvmrc b/.nvmrc index 805b5a4e0..9a2a0e219 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v20.9.0 +v20 diff --git a/demos/nuxt-supabase-todolist/.env.template b/demos/nuxt-supabase-todolist/.env.template new file mode 100644 index 000000000..e1ee4c53a --- /dev/null +++ b/demos/nuxt-supabase-todolist/.env.template @@ -0,0 +1,23 @@ +# Self-hosted Environment Configuration +# Copy this template: `cp .env.template .env` +# Edit .env and enter your Supabase and PowerSync project details. + +NUXT_PUBLIC_SUPABASE_URL=http://localhost:54321 +NUXT_PUBLIC_SUPABASE_ANON_KEY= +# PowerSync Configuration +NUXT_PUBLIC_POWERSYNC_URL=http://localhost:6000 + + +# If using Powersync Cloud or a Cloud source database uses these +# Supabase Configuration +# VITE_SUPABASE_URL=https://.supabase.co +# VITE_SUPABASE_ANON_KEY= + +# PowerSync Configuration +# VITE_POWERSYNC_URL=https://.powersync.journeyapps.com + +# Self-hosted PowerSync Configuration +PS_POSTGRESQL_URI=postgresql://postgres:postgres@supabase_db_powersync:5432/postgres +PS_SUPABASE_JWT_SECRET=super-secret-jwt-token-with-at-least-32-characters-long +PS_API_TOKEN=super-secret +PS_PORT=6000 \ No newline at end of file diff --git a/demos/nuxt-supabase-todolist/.nuxtrc b/demos/nuxt-supabase-todolist/.nuxtrc new file mode 100644 index 000000000..3c8c6a114 --- /dev/null +++ b/demos/nuxt-supabase-todolist/.nuxtrc @@ -0,0 +1 @@ +imports.autoImport=true \ No newline at end of file diff --git a/demos/nuxt-supabase-todolist/README.md b/demos/nuxt-supabase-todolist/README.md new file mode 100644 index 000000000..ff4a3129f --- /dev/null +++ b/demos/nuxt-supabase-todolist/README.md @@ -0,0 +1,95 @@ +# PowerSync + Supabase Nuxt Demo: Todo List + +This is a demo application showcasing PowerSync integration with Nuxt 4 and Supabase. It demonstrates real-time data synchronization for a simple todo list application using PowerSync's official Nuxt module. + +## Setup Instructions + +Note that this setup guide has minor deviations from the [Supabase + PowerSync integration guide](https://docs.powersync.com/integration-guides/supabase-+-powersync). Below we refer to sections in this guide where relevant. + +### 1. Install dependencies + +In the repo root directory, use [pnpm](https://pnpm.io/installation) to install dependencies: + +```bash +pnpm install +pnpm build:packages +``` + +### 2. Create project on Supabase and set up Postgres + +This demo app uses Supabase as its Postgres database and backend: + +1. [Create a new project on the Supabase dashboard](https://supabase.com/dashboard/projects). +2. Go to the Supabase SQL Editor for your new project and execute the SQL statements in [`db/seed.sql`](db/seed.sql) to create the database schema, PowerSync replication role, and publication needed for PowerSync. + +**Important:** When connecting PowerSync to your Supabase database, you'll use the `powersync_role` credentials instead of the default Supabase connection string. This role has the necessary replication privileges and bypasses Row Level Security (RLS). + +### 3. Auth setup + +This app uses Supabase's email/password authentication. + +1. Go to "Authentication" -> "Providers" in your Supabase dashboard +2. Ensure "Email" provider is enabled +3. You can disable email confirmation for development by going to "Authentication" -> "Email Auth" and disabling "Confirm email" + +You'll need to create a user account when you first access the application. + +### 4. Set up PowerSync + +You can use either PowerSync Cloud or self-host PowerSync: + +- **PowerSync Cloud**: [Create a new project on the PowerSync dashboard](https://powersync.journeyapps.com/) and connect it to your Supabase database using the `powersync_role` credentials created in step 2. +- **Self-hosting**: Follow the [self-hosting guide](https://docs.powersync.com/self-hosting/getting-started) to deploy your own PowerSync instance. + +The sync rules for this demo are provided in [`sync-rules.yaml`](sync-rules.yaml) in this directory. + +### 5. Set up local environment variables + +Create a `.env` file in this directory with the following variables: + +```bash +NUXT_PUBLIC_SUPABASE_URL=your_supabase_url +NUXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key +NUXT_PUBLIC_POWERSYNC_URL=your_powersync_instance_url +``` + +Replace the values with your actual credentials: +- Get `NUXT_PUBLIC_SUPABASE_URL` and `NUXT_PUBLIC_SUPABASE_ANON_KEY` from your Supabase project settings under "Project Settings" -> "API" +- Get `NUXT_PUBLIC_POWERSYNC_URL` from your PowerSync instance (Cloud dashboard or your self-hosted instance URL) + +### 6. Run the demo app + +In this directory, run the following to start the development server: + +```bash +pnpm dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to try out the demo. + +## Project Structure + +``` +├── powersync/ +│ ├── AppSchema.ts # PowerSync schema definition +│ └── SuperbaseConnector.ts # Supabase connector implementation +├── plugins/ +│ └── powersync.client.ts # PowerSync plugin setup +├── pages/ +│ ├── index.vue # Main todo list page +│ ├── login.vue # Login page +│ └── confirm.vue # Auth confirmation page +├── components/ +│ └── AppHeader.vue # Header component +├── db/ +│ └── seed.sql # Database setup SQL +├── sync-rules.yaml # PowerSync sync rules +└── nuxt.config.ts # Nuxt configuration +``` + +## Learn More + +- [PowerSync Documentation](https://docs.powersync.com/) +- [Supabase Documentation](https://supabase.com/docs) +- [Nuxt Documentation](https://nuxt.com/) + diff --git a/demos/nuxt-supabase-todolist/app.config.ts b/demos/nuxt-supabase-todolist/app.config.ts new file mode 100644 index 000000000..786e6c408 --- /dev/null +++ b/demos/nuxt-supabase-todolist/app.config.ts @@ -0,0 +1,20 @@ +export default defineAppConfig({ + ui: { + colors: { + primary: 'indigo', + neutral: 'stone', + }, + input: { + variants: { + variant: { + subtle: 'ring-default bg-elevated/50', + }, + }, + }, + header: { + slots: { + root: 'border-none', + }, + }, + }, +}) diff --git a/demos/nuxt-supabase-todolist/app.vue b/demos/nuxt-supabase-todolist/app.vue new file mode 100644 index 000000000..4423fb90a --- /dev/null +++ b/demos/nuxt-supabase-todolist/app.vue @@ -0,0 +1,58 @@ + + + diff --git a/demos/nuxt-supabase-todolist/assets/css/main.css b/demos/nuxt-supabase-todolist/assets/css/main.css new file mode 100644 index 000000000..10337b802 --- /dev/null +++ b/demos/nuxt-supabase-todolist/assets/css/main.css @@ -0,0 +1,8 @@ +@import "tailwindcss"; +@import "@nuxt/ui"; + +:root { + --ui-header-height: 40px; + + --ui-container: 100%; +} \ No newline at end of file diff --git a/demos/nuxt-supabase-todolist/components/AppHeader.vue b/demos/nuxt-supabase-todolist/components/AppHeader.vue new file mode 100644 index 000000000..a3dd70ace --- /dev/null +++ b/demos/nuxt-supabase-todolist/components/AppHeader.vue @@ -0,0 +1,43 @@ + + + diff --git a/demos/nuxt-supabase-todolist/db/seed.sql b/demos/nuxt-supabase-todolist/db/seed.sql new file mode 100644 index 000000000..740e901f1 --- /dev/null +++ b/demos/nuxt-supabase-todolist/db/seed.sql @@ -0,0 +1,28 @@ +-- Past this into your Superbase SQL Editor + +-- TODO change this if changing the DB connection name +-- connect postgres; +-- Create tables + +CREATE TABLE IF NOT EXISTS public.tasks( + id uuid NOT NULL DEFAULT gen_random_uuid(), + created_at timestamp with time zone NOT NULL DEFAULT now(), + completed_at timestamp with time zone NULL, + description text NOT NULL, + completed boolean NOT NULL DEFAULT FALSE, + user_id uuid NOT NULL, + CONSTRAINT tasks_pkey PRIMARY KEY (id) +); + +-- Create a role/user with replication privileges for PowerSync +CREATE ROLE powersync_role WITH REPLICATION BYPASSRLS LOGIN PASSWORD 'postgres_12345'; +-- Set up permissions for the newly created role +-- Read-only (SELECT) access is required +GRANT SELECT ON ALL TABLES IN SCHEMA public TO powersync_role; + +-- Optionally, grant SELECT on all future tables (to cater for schema additions) +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO powersync_role; + + +-- Create publication for PowerSync tables +CREATE PUBLICATION powersync FOR ALL TABLES; diff --git a/demos/nuxt-supabase-todolist/eslint.config.mjs b/demos/nuxt-supabase-todolist/eslint.config.mjs new file mode 100644 index 000000000..848ec061a --- /dev/null +++ b/demos/nuxt-supabase-todolist/eslint.config.mjs @@ -0,0 +1,9 @@ +// @ts-check +import withNuxt from './.nuxt/eslint.config.mjs' + +export default withNuxt({ + rules: { + '@typescript-eslint/no-explicit-any': 'off', + 'nuxt/nuxt-config-keys-order': 'off', + }, +}) diff --git a/demos/nuxt-supabase-todolist/layouts/default.vue b/demos/nuxt-supabase-todolist/layouts/default.vue new file mode 100644 index 000000000..b5dc6a84b --- /dev/null +++ b/demos/nuxt-supabase-todolist/layouts/default.vue @@ -0,0 +1,9 @@ + diff --git a/demos/nuxt-supabase-todolist/nuxt.config.ts b/demos/nuxt-supabase-todolist/nuxt.config.ts new file mode 100644 index 000000000..7ddb25845 --- /dev/null +++ b/demos/nuxt-supabase-todolist/nuxt.config.ts @@ -0,0 +1,76 @@ +import wasm from 'vite-plugin-wasm' +import topLevelAwait from 'vite-plugin-top-level-await' + +export default defineNuxtConfig({ + + modules: [ + '@powersync/nuxt', + '@nuxt/eslint', + '@nuxt/ui', + '@nuxtjs/supabase', + ], + ssr: false, + + devtools: { + enabled: true, + }, + + css: ['~/assets/css/main.css'], + + runtimeConfig: { + public: { + powersyncUrl: process.env.NUXT_PUBLIC_POWERSYNC_URL, + }, + }, + + // enable hot reloading when we make changes to our module + watch: ['../src/*', './**/*'], + + compatibilityDate: '2024-07-05', + + vite: { + plugins: [topLevelAwait()], + optimizeDeps: { + exclude: ['@journeyapps/wa-sqlite', '@powersync/web', '@powersync/common', '@powersync/vue', '@powersync/kysely-driver'], + include: [ + '@powersync/web > js-logger', + '@supabase/postgrest-js', + ], + }, + + worker: { + format: 'es', + plugins: () => [wasm(), topLevelAwait()], + }, + }, + + unocss: { + autoImport: false, + }, + + eslint: { + config: { + stylistic: true, + }, + }, + + powersync: { + useDiagnostics: true, + }, + + supabase: { + url: process.env.NUXT_PUBLIC_SUPABASE_URL, + key: process.env.NUXT_PUBLIC_SUPABASE_ANON_KEY, + redirectOptions: { + login: '/login', + callback: '/confirm', + // include: ['/protected'], + exclude: ['/unprotected', '/public/*'], + }, + clientOptions: { + auth: { + persistSession: true, + }, + }, + }, +}) diff --git a/demos/nuxt-supabase-todolist/package.json b/demos/nuxt-supabase-todolist/package.json new file mode 100644 index 000000000..8bed03ba8 --- /dev/null +++ b/demos/nuxt-supabase-todolist/package.json @@ -0,0 +1,23 @@ +{ + "private": true, + "name": "powersync-nuxt-supabase-todolist", + "scripts": { + "dev": "nuxt dev", + "build": "nuxt build", + "start": "nuxt preview" + }, + "dependencies": { + "@nuxt/ui": "^4.0.0", + "@nuxtjs/supabase": "2.0.1", + "@powersync/nuxt": "workspace:*" + }, + "devDependencies": { + "@nuxt/devtools": "^1.0.6", + "@nuxt/eslint": "^1.9.0", + "@supabase/supabase-js": "2.75.0", + "nuxt": "4.1.3", + "vite-plugin-top-level-await": "^1.6.0", + "vite-plugin-wasm": "^3.5.0", + "vue": "^3.5.20" + } +} diff --git a/demos/nuxt-supabase-todolist/pages/confirm.vue b/demos/nuxt-supabase-todolist/pages/confirm.vue new file mode 100644 index 000000000..2ea1d5fc7 --- /dev/null +++ b/demos/nuxt-supabase-todolist/pages/confirm.vue @@ -0,0 +1,17 @@ + + + diff --git a/demos/nuxt-supabase-todolist/pages/index.vue b/demos/nuxt-supabase-todolist/pages/index.vue new file mode 100644 index 000000000..9f8360486 --- /dev/null +++ b/demos/nuxt-supabase-todolist/pages/index.vue @@ -0,0 +1,153 @@ + + + diff --git a/demos/nuxt-supabase-todolist/pages/login.vue b/demos/nuxt-supabase-todolist/pages/login.vue new file mode 100644 index 000000000..8a5532c6e --- /dev/null +++ b/demos/nuxt-supabase-todolist/pages/login.vue @@ -0,0 +1,118 @@ + + + diff --git a/demos/nuxt-supabase-todolist/plugins/powersync.client.ts b/demos/nuxt-supabase-todolist/plugins/powersync.client.ts new file mode 100644 index 000000000..4cd194cd3 --- /dev/null +++ b/demos/nuxt-supabase-todolist/plugins/powersync.client.ts @@ -0,0 +1,29 @@ +import { + AppSchemaWithDiagnostics, +} from '~/powersync/AppSchema' +import { SupabaseConnector } from '~/powersync/SuperbaseConnector' +import { SyncClientImplementation } from '@powersync/web' + + +export default defineNuxtPlugin({ + async setup(nuxtApp) { + const db = new NuxtPowerSyncDatabase({ + database: { + dbFilename: 'a-db-name.sqlite', + }, + schema: AppSchemaWithDiagnostics, + }) + + const connector = new SupabaseConnector() + + await db.init() + + await db.connect(connector, { + clientImplementation: SyncClientImplementation.RUST, + }) + + const plugin = createPowerSyncPlugin({ database: db }) + + nuxtApp.vueApp.use(plugin) + }, +}) diff --git a/demos/nuxt-supabase-todolist/powersync/AppSchema.ts b/demos/nuxt-supabase-todolist/powersync/AppSchema.ts new file mode 100644 index 000000000..1a9ffb3c0 --- /dev/null +++ b/demos/nuxt-supabase-todolist/powersync/AppSchema.ts @@ -0,0 +1,28 @@ +import { column, Schema, Table } from '@powersync/web' + +const { diagnosticsSchema } = usePowerSyncInspector() + +export const TASKS_TABLE = 'tasks' + +const tasks = new Table( + { + created_at: column.text, + completed_at: column.text, + description: column.text, + completed: column.integer, + user_id: column.text, + }, + { indexes: { user: ['user_id'] } }, +) + +export const AppSchema = new Schema({ + tasks, +}) + +export const AppSchemaWithDiagnostics = new Schema([ + ...AppSchema.tables, + ...diagnosticsSchema.tables, +]) + +export type Database = (typeof AppSchema)['types'] +export type TaskRecord = Database['tasks'] diff --git a/demos/nuxt-supabase-todolist/powersync/SuperbaseConnector.ts b/demos/nuxt-supabase-todolist/powersync/SuperbaseConnector.ts new file mode 100644 index 000000000..00635de72 --- /dev/null +++ b/demos/nuxt-supabase-todolist/powersync/SuperbaseConnector.ts @@ -0,0 +1,156 @@ +import type { PowerSyncCredentials, + AbstractPowerSyncDatabase, + CrudEntry, + PowerSyncBackendConnector } from '@powersync/web' +import { + BaseObserver, + UpdateType } from '@powersync/web' + +import type { Session, SupabaseClient } from '@supabase/supabase-js' +/// Postgres Response codes that we cannot recover from by retrying. +const FATAL_RESPONSE_CODES = [ + // Class 22 — Data Exception + // Examples include data type mismatch. + new RegExp('^22...$'), + // Class 23 — Integrity Constraint Violation. + // Examples include NOT NULL, FOREIGN KEY and UNIQUE violations. + new RegExp('^23...$'), + // INSUFFICIENT PRIVILEGE - typically a row-level security violation + new RegExp('^42501$'), +] + +export type SupabaseConnectorListener = { + initialized: () => void + sessionStarted: (session: Session) => void +} + +export class SupabaseConnector extends BaseObserver implements PowerSyncBackendConnector { + readonly client: SupabaseClient + + ready: boolean + + currentSession: Session | null + + constructor() { + super() + + this.client = useSupabaseClient() + this.currentSession = null + this.ready = false + } + + async init() { + if (this.ready) { + return + } + + const sessionResponse = await this.client.auth.getSession() + this.updateSession(sessionResponse.data.session) + + this.ready = true + this.iterateListeners(cb => cb.initialized?.()) + } + + async login(username: string, password: string) { + const { + data: { session }, + error, + } = await this.client.auth.signInWithPassword({ + email: username, + password: password, + }) + + if (error) { + throw error + } + + this.updateSession(session) + } + + async fetchCredentials() { + const { + data: { session }, + error, + } = await this.client.auth.getSession() + + if (!session || error) { + throw new Error(`Could not fetch Supabase credentials: ${error}`) + } + + console.debug('session expires at', session.expires_at) + + const config = useRuntimeConfig() + return { + endpoint: config.public.powersyncUrl, + token: session.access_token ?? '', + } satisfies PowerSyncCredentials + } + + async uploadData(database: AbstractPowerSyncDatabase): Promise { + const transaction = await database.getNextCrudTransaction() + + if (!transaction) { + return + } + + let lastOp: CrudEntry | null = null + try { + // Note: If transactional consistency is important, use database functions + // or edge functions to process the entire transaction in a single call. + for (const op of transaction.crud) { + lastOp = op + const table = this.client.from(op.table) + let result: any + switch (op.op) { + case UpdateType.PUT: { + const record = { ...op.opData, id: op.id } + result = await table.upsert(record) + break + } + case UpdateType.PATCH: + result = await table.update(op.opData).eq('id', op.id) + break + case UpdateType.DELETE: + result = await table.delete().eq('id', op.id) + break + } + + if (result.error) { + console.error(result.error) + result.error.message = `Could not update Supabase. Received error: ${result.error.message}` + throw result.error + } + } + + await transaction.complete() + } + catch (ex: any) { + console.debug(ex) + if (typeof ex.code == 'string' && FATAL_RESPONSE_CODES.some(regex => regex.test(ex.code))) { + /** + * Instead of blocking the queue with these errors, + * discard the (rest of the) transaction. + * + * Note that these errors typically indicate a bug in the application. + * If protecting against data loss is important, save the failing records + * elsewhere instead of discarding, and/or notify the user. + */ + console.error('Data upload error - discarding:', lastOp, ex) + await transaction.complete() + } + else { + // Error may be retryable - e.g. network error or temporary server error. + // Throwing an error here causes this call to be retried after a delay. + throw ex + } + } + } + + updateSession(session: Session | null) { + this.currentSession = session + if (!session) { + return + } + this.iterateListeners(cb => cb.sessionStarted?.(session)) + } +} diff --git a/demos/nuxt-supabase-todolist/sync-rules.yaml b/demos/nuxt-supabase-todolist/sync-rules.yaml new file mode 100644 index 000000000..cab7d8cd0 --- /dev/null +++ b/demos/nuxt-supabase-todolist/sync-rules.yaml @@ -0,0 +1,9 @@ +config: + edition: 2 + +bucket_definitions: + tasks: + parameters: + - SELECT request.user_id() AS user_id + data: + - SELECT * FROM tasks WHERE tasks.user_id = bucket.user_id \ No newline at end of file diff --git a/demos/nuxt-supabase-todolist/tsconfig.json b/demos/nuxt-supabase-todolist/tsconfig.json new file mode 100644 index 000000000..eb97e3f0e --- /dev/null +++ b/demos/nuxt-supabase-todolist/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.nuxt/tsconfig.json" +} \ No newline at end of file diff --git a/package.json b/package.json index fd438ab2e..833d29263 100644 --- a/package.json +++ b/package.json @@ -49,5 +49,10 @@ "rollup-plugin-dts": "^6.2.1", "typescript": "^5.7.2", "vitest": "^3.2.4" + }, + "pnpm": { + "overrides": { + "cookie": "1.0.2" + } } } diff --git a/packages/nuxt/README b/packages/nuxt/README new file mode 100644 index 000000000..1f7b1edde --- /dev/null +++ b/packages/nuxt/README @@ -0,0 +1,667 @@ + + +
+ PowerSync Logo +

PowerSync Nuxt

+

Local-first apps made simple

+

Effortless offline-first development with PowerSync integration for Nuxt applications.

+
+ +[![npm version][npm-version-src]][npm-version-href] +[![npm downloads][npm-downloads-src]][npm-downloads-href] +[![License][license-src]][license-href] +[![Nuxt][nuxt-src]][nuxt-href] + +PowerSync Nuxt module integrated with the [Nuxt Devtools](https://github.com/nuxt/devtools). + +- [✨  Release Notes](/CHANGELOG.md) + +## Features + +- 🔍 **Built-in Diagnostics** - Direct access to PowerSync instance monitoring and real-time connection insights +- 🗄️ **Data Inspection** - Seamless local data browsing with powerful debugging and troubleshooting tools +- ⚡ **Useful Composables** - Ready-to-use Vue composables for rapid offline-first application development +- 📦 **All-in-One** - Exposes all `@powersync/vue` composables, making this the only required dependency + +## Installation + +This module exposes all `@powersync/vue` composables, so you only need to install `@powersync/nuxt`: + +```bash +# Using pnpm +pnpm add -D @powersync/nuxt vite-plugin-top-level-await vite-plugin-wasm + +# Using yarn +yarn add --dev @powersync/nuxt vite-plugin-top-level-await vite-plugin-wasm + +# Using npm +npm install --save-dev @powersync/nuxt vite-plugin-top-level-await vite-plugin-wasm +``` +> [!NOTE] +> This module works with `Nuxt 4` and should work with `Nuxt 3` but has not been tested. Support for Nuxt 2 is not guaranteed or planned. + +## Quick Start + +1. Add `@powersync/nuxt` to the `modules` section of `nuxt.config.ts`: + +```typescript +import wasm from 'vite-plugin-wasm' +import topLevelAwait from 'vite-plugin-top-level-await' + +export default defineNuxtConfig({ + modules: ['@powersync/nuxt'], + vite: { + plugins: [topLevelAwait()], + optimizeDeps: { + exclude: ['@journeyapps/wa-sqlite', '@powersync/web'], + include: ['@powersync/web > js-logger'], // <-- Include `js-logger` when it isn't installed and imported. + }, + worker: { + format: 'es', + plugins: () => [wasm(), topLevelAwait()], + }, + }, +}) +``` + +> [!WARNING] +> If you are using Tailwind in your project see [Known Issues section](#known-issues) + +2. Create a PowerSync plugin (e.g., `plugins/powersync.client.ts`): + +```typescript +import { NuxtPowerSyncDatabase } from '@powersync/nuxt' +import { createPowerSyncPlugin } from '@powersync/nuxt' +import { AppSchema } from '~/powersync/AppSchema' +import { PowerSyncConnector } from '~/powersync/PowerSyncConnector' + +export default defineNuxtPlugin({ + async setup(nuxtApp) { + const db = new NuxtPowerSyncDatabase({ + database: { + dbFilename: 'your-db-filename.sqlite', + }, + schema: AppSchema, + }) + + const connector = new PowerSyncConnector() + + await db.init() + await db.connect(connector) + + const plugin = createPowerSyncPlugin({ database: db }) + nuxtApp.vueApp.use(plugin) + }, +}) +``` + +At this point, you're all set to use the module composables. The module automatically exposes all `@powersync/vue` composables, so you can use them directly: + +- `usePowerSync()` - Access the PowerSync database instance +- `usePowerSyncQuery()` - Query the database reactively +- `usePowerSyncStatus()` - Monitor sync status +- `usePowerSyncWatchedQuery()` - Watch queries with automatic updates +- And more... (see [API Reference](#api-reference)) + +## Setting up PowerSync + +This guide will walk you through the steps to set up PowerSync in your Nuxt project. + +### Create your Schema + +Create a file called `AppSchema.ts` and add your schema to it. + +```typescript +import { column, Schema, Table } from '@powersync/web' + +const lists = new Table({ + created_at: column.text, + name: column.text, + owner_id: column.text, +}) + +const todos = new Table( + { + list_id: column.text, + created_at: column.text, + completed_at: column.text, + description: column.text, + created_by: column.text, + completed_by: column.text, + completed: column.integer, + }, + { indexes: { list: ['list_id'] } }, +) + +export const AppSchema = new Schema({ + todos, + lists, +}) + +// For types +export type Database = (typeof AppSchema)['types'] +export type TodoRecord = Database['todos'] +export type ListRecord = Database['lists'] +``` + +> **Tip**: Learn more about how to create your schema [here](https://docs.powersync.com/client-sdk-references/javascript-web#1-define-the-schema). + +### Create your Connector + +Create a file called `PowerSyncConnector.ts` and add your connector to it. + +```typescript +import { UpdateType, type PowerSyncBackendConnector } from '@powersync/web' + +export class PowerSyncConnector implements PowerSyncBackendConnector { + async fetchCredentials() { + // Implement fetchCredentials to obtain a JWT from your authentication service. + // See https://docs.powersync.com/installation/authentication-setup + // If you're using Supabase or Firebase, you can re-use the JWT from those clients, see + // - https://docs.powersync.com/installation/authentication-setup/supabase-auth + // - https://docs.powersync.com/installation/authentication-setup/firebase-auth + return { + endpoint: '[Your PowerSync instance URL or self-hosted endpoint]', + // Use a development token (see Authentication Setup https://docs.powersync.com/installation/authentication-setup/development-tokens) to get up and running quickly + token: 'An authentication token', + } + } + + async uploadData(db: any) { + // Implement uploadData to send local changes to your backend service. + // You can omit this method if you only want to sync data from the database to the client + + // See example implementation here: https://docs.powersync.com/client-sdk-references/javascript-web#3-integrate-with-your-backend + // see demos here: https://github.com/powersync-ja/powersync-js/tree/main/demos + return + } +} +``` + +> **Tip**: Learn more about how to create your connector [here](https://docs.powersync.com/client-sdk-references/javascript-web#3-integrate-with-your-backend). + +### Create your PowerSync Plugin + +Finally, putting everything together, create a [plugin](https://nuxt.com/docs/4.x/guide/directory-structure/app/plugins) called `powersync.client.ts` to setup PowerSync. + +```typescript +import { createPowerSyncPlugin } from '@powersync/nuxt' +import { NuxtPowerSyncDatabase } from '@powersync/nuxt' +import { AppSchema } from '~/powersync/AppSchema' +import { PowerSyncConnector } from '~/powersync/PowerSyncConnector' + +export default defineNuxtPlugin({ + async setup(nuxtApp) { + const db = new NuxtPowerSyncDatabase({ + database: { + dbFilename: 'a-db-name.sqlite', + }, + schema: AppSchema, + }) + + const connector = new PowerSyncConnector() + + await db.init() + await db.connect(connector) + + const plugin = createPowerSyncPlugin({ database: db }) + + nuxtApp.vueApp.use(plugin) + }, +}) +``` + +### Kysely ORM (Optional) + +You can use Kysely as your ORM to interact with the database. The module provides a `usePowerSyncKysely()` composable: + +```typescript +import { usePowerSyncKysely } from '@powersync/nuxt' +import { type Database } from '../powersync/AppSchema' + +// In your component or composable +const db = usePowerSyncKysely() + +// Use the db object to interact with the database +const users = await db.selectFrom('users').selectAll().execute() +``` + +### Enabling Diagnostics + +To enable the PowerSync Inspector with diagnostics capabilities: + +1. **Enable diagnostics in your config**: + +```typescript +export default defineNuxtConfig({ + modules: ['@powersync/nuxt'], + powersync: { + useDiagnostics: true, // <- Add this + }, + vite: { + plugins: [topLevelAwait()], + optimizeDeps: { + exclude: ['@journeyapps/wa-sqlite', '@powersync/web'], + include: ['@powersync/web > js-logger'], + }, + worker: { + format: 'es', + plugins: () => [wasm(), topLevelAwait()], + }, + }, +}) +``` + +When `useDiagnostics: true` is set, `NuxtPowerSyncDatabase` automatically: +- Sets up diagnostics recording +- Stores the connector internally (accessible via diagnostics) +- Configures logging for diagnostics + +2. **Extend your schema**: + +If you're using diagnostics, you need to extend your schema with the diagnostics schema to collect diagnostics data: + +```typescript +import { usePowerSyncInspector } from '@powersync/nuxt' +import { Schema } from '@powersync/web' + +const { diagnosticsSchema } = usePowerSyncInspector() + +// Combine with your app schema +const combinedSchema = new Schema([ + ...yourSchema.tables, + ...diagnosticsSchema.tables, +]) +``` + +3. **Accessing PowerSync Inspector**: + +Once diagnostics are enabled, you can access the [PowerSync Inspector](#powersync-inspector): + +- **Direct URL**: `http://localhost:3000/__powersync-inspector` +- **Via Nuxt Devtools**: Open Devtools and look for the PowerSync tab (Instable until proper multitab support for diagnostics in implemented) + + + +## PowerSync Inspector + +PowerSync Inspector is a tool that helps inspect and diagnose the state of your PowerSync client directly from your app in real-time. + +### Setup + +To setup the PowerSync inspector, you need to follow the steps in the [Enabling Diagnostics](#enabling-diagnostics) section. + +Once setup, the inspector can be accessed on the `http://localhost:3000/__powersync-inspector` route or via the [Nuxt Devtools](#nuxt-devtools). + +### Features + +#### Sync Status + +The `Sync Status` tab provides a real-time view of the sync status of your PowerSync client, including: +- Connection status +- Sync progress +- Upload queue statistics +- Error monitoring + +#### Data Inspector + +Browse and inspect your local database tables and data with powerful filtering and search capabilities. + +#### Config Inspector + +View and inspect your PowerSync configuration, connection options, and schema information. + +#### Logs + +Real-time logging of PowerSync operations with syntax highlighting and search functionality. + +#### Nuxt Devtools + +The inspector is also available in the Nuxt Devtools as a tab, providing seamless integration with your development workflow. + +> [!WARNING] +> Multitab support is still not fully supported when diagnostics are enabled which causes the inspector to malfunction in the devtool. see [Known Issues section](#known-issues) + + +## API Reference + +### Module Options + +#### `useDiagnostics` + +Enable diagnostics and the PowerSync Inspector. + +- **Type**: `boolean` +- **Default**: `false` +- **Description**: When set to `true`, enables diagnostics recording and makes the PowerSync Inspector available. + +```typescript +export default defineNuxtConfig({ + modules: ['@powersync/nuxt'], + powersync: { + useDiagnostics: true, + }, +}) +``` + +### PowerSync Vue Composables + +This module automatically exposes all composables from `@powersync/vue`, so you don't need to install `@powersync/vue` separately: + +- `createPowerSyncPlugin(options)` - Create the PowerSync Vue plugin +- `providePowerSync(database)` - Provide PowerSync database to the app +- `usePowerSync()` - Access the PowerSync database instance +- `usePowerSyncQuery(query, params?)` - Query the database reactively +- `usePowerSyncStatus()` - Monitor sync status +- `usePowerSyncWatchedQuery(query, params?)` - Watch queries with automatic updates +- `useQuery(query, params?)` - Query helper +- `useStatus()` - Status helper +- `useWatchedQuerySubscription(query, params?)` - Watch query subscription helper + +All of these composables are available globally in your Nuxt app - no imports needed! + +### Classes + +#### `NuxtPowerSyncDatabase` + +An extended PowerSync database class that includes diagnostic capabilities for use with the PowerSync Inspector. + +**Usage**: + +```typescript +import { NuxtPowerSyncDatabase } from '@powersync/nuxt' + +const db = new NuxtPowerSyncDatabase({ + database: { + dbFilename: 'your-db-filename.sqlite', + }, + schema: yourSchema, +}) +``` + +**Features**: + +- **Automatic Diagnostics**: When `useDiagnostics: true` is set in module config, automatically enables diagnostics recording +- **Connector Storage**: Stores connector internally for inspector access +- **Enhanced VFS**: Uses cooperative sync VFS for improved compatibility +- **Schema Management**: Integrates with dynamic schema management for inspector features +- **Logging**: Automatically configures logging when diagnostics are enabled + +**Note**: The class works with or without diagnostics enabled. When diagnostics are disabled, it behaves like a standard `PowerSyncDatabase`. + +### Module Composables + +#### `usePowerSyncKysely()` + +Provides a Kysely-wrapped PowerSync database for type-safe database queries. + +**Type Parameters**: +- `T` - Your database type (from your schema) + +**Returns**: Kysely database instance (not `{ db }`) + +**Usage**: + +```typescript +import { usePowerSyncKysely } from '@powersync/nuxt' +import { type Database } from '../powersync/AppSchema' + +// Returns db directly, not { db } +const db = usePowerSyncKysely() + +// Use Kysely query builder +const users = await db.selectFrom('users').selectAll().execute() +``` + +#### `useDiagnosticsLogger()` + +Provides a logger configured for PowerSync diagnostics. + +**Returns**: + +```typescript +{ + logger: ILogHandler + logsStorage: Storage + emitter: Emitter +} +``` + +**Usage**: + +```typescript +const { logger } = useDiagnosticsLogger() + +// Logger is automatically configured for diagnostics +// Use it in your PowerSync setup if needed +``` + + +#### `usePowerSyncInspector()` + +A composable for setting up PowerSync Inspector functionality. This composable provides utilities for schema management and diagnostics setup. + +**Returns**: + +```typescript +{ + diagnosticsSchema: Schema + RecordingStorageAdapter: Class + getCurrentSchemaManager: Function +} +``` + +**Properties**: + +- **`diagnosticsSchema`** - The schema for diagnostics data collection. Use this to extend your app schema with diagnostic tables. +- **`RecordingStorageAdapter`** - Used internally. Storage adapter class that records operations for diagnostic purposes. +- **`getCurrentSchemaManager()`** - Used internally. Gets the current schema manager instance for dynamic schema operations. + +**Usage**: + +```typescript +const { diagnosticsSchema } = usePowerSyncInspector() + +// Combine with your app schema +const combinedSchema = new Schema([ + ...yourAppSchema.tables, + ...diagnosticsSchema.tables, +]) +``` + +#### `usePowerSyncInspectorDiagnostics()` + +A comprehensive composable that provides real-time diagnostics data and sync status monitoring for your PowerSync client and local database. This composable can be used to create your own inspector. + +**Returns**: + +```typescript +{ + // Database & Connection + db: Ref + connector: ComputedRef + connectionOptions: ComputedRef + isDiagnosticSchemaSetup: Readonly> + + // Sync Status + syncStatus: Readonly> + hasSynced: Readonly> + isConnected: Readonly> + isSyncing: Readonly> + isDownloading: Readonly> + isUploading: Readonly> + lastSyncedAt: Readonly> + + // Progress & Statistics + totalDownloadProgress: Readonly> + uploadQueueStats: Readonly> + uploadQueueCount: Readonly> + uploadQueueSize: Readonly> + bucketRows: Readonly> + tableRows: Readonly> + totals: Readonly> + + // Error Handling + downloadError: Readonly> + uploadError: Readonly> + downloadProgressDetails: Readonly> + + // User Info + userID: Readonly> + + // Utilities + clearData: Function + formatBytes: Function +} +``` + +**Reactive Properties**: + +- **Connection Status**: `isConnected`, `hasSynced`, `isSyncing`, `isDownloading`, `isUploading`, `lastSyncedAt` +- **Progress Tracking**: `totalDownloadProgress`, `uploadQueueStats`, `uploadQueueCount`, `uploadQueueSize`, `downloadProgressDetails` +- **Data Inspection**: `bucketRows`, `tableRows`, `totals` +- **Error Monitoring**: `downloadError`, `uploadError` +- **Authentication**: `userID` + +**Methods**: + +- **`clearData()`** - Disconnects and clears all local PowerSync data, then reconnects. Useful for resetting the sync state during development or troubleshooting. +- **`formatBytes(bytes, decimals?)`** - Formats byte counts into readable file sizes (e.g., "1.5 MiB"). Default decimals is 2. + +**Usage Examples**: + +```vue + + + +``` + +## Known Issues + +1. Enabling diagnostics makes your app glitch when operating in a multi-tab environment. You can observe this issue when you open the inspector in the devtools, for example. + +2. PowerSync Inspector relies on `unocss` as a transitive dependency. It might clash with your existing setup, for example if you use Tailwind CSS. + +To fix this, you can add the following to your `nuxt.config.ts`: + +```typescript +export default defineNuxtConfig({ + unocss: { + icons: true, + blocklist: [/\$\{.*\}/], + content: { + pipeline: { + exclude: [ + './layouts/*/**', + './pages/*/**', + './components/*/**', + './composables/*/**', + './utils/*/**', + './types/*/**', + ], + }, + }, + }, +}) +``` + +## Development + +```bash +# Install dependencies +npm install + +# Generate type stubs +npm run dev:prepare + +# Develop with playground, with devtools client ui +npm run dev + +# Develop with playground, with bundled client ui +npm run play:prod + +# Run ESLint +npm run lint + +# Run Vitest +npm run test +npm run test:watch + +# Release new version +npm run release +``` + +## Local Testing + +If the playground is not enough for you, you can test the module locally by cloning this repo and pointing the nuxt app you want to test to the local module. + +Don't forget to add a watcher for the module for hot reloading. + +Example (in your nuxt app): + +```typescript +import { defineNuxtConfig } from 'nuxt/config' + +export default defineNuxtConfig({ + modules: ['../../my-location/@powersync/nuxt/src/*'], + watch: ['../../my-location/@powersync/nuxt/src/*'], +}) +``` + + +[npm-version-src]: https://img.shields.io/npm/v/@powersync/nuxt/latest.svg?style=flat&colorA=18181B&colorB=28CF8D +[npm-version-href]: https://npmjs.com/package/@powersync/nuxt + +[npm-downloads-src]: https://img.shields.io/npm/dm/@powersync/nuxt.svg?style=flat&colorA=18181B&colorB=28CF8D +[npm-downloads-href]: https://npmjs.com/package/@powersync/nuxt + +[license-src]: https://img.shields.io/npm/l/@powersync/nuxt.svg?style=flat&colorA=18181B&colorB=28CF8D +[license-href]: https://npmjs.com/package/@powersync/nuxt + +[nuxt-src]: https://img.shields.io/badge/Nuxt-18181B?logo=nuxt.js +[nuxt-href]: https://nuxt.com diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json new file mode 100644 index 000000000..d92ce5365 --- /dev/null +++ b/packages/nuxt/package.json @@ -0,0 +1,81 @@ +{ + "name": "@powersync/nuxt", + "version": "0.1.0", + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public" + }, + "description": "PowerSync Nuxt module", + "license": "Apache-2.0", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/powersync-ja/powersync-js.git" + }, + "author": "JOURNEYAPPS", + "bugs": { + "url": "https://github.com/powersync-ja/powersync-js/issues" + }, + "exports": { + ".": { + "types": "./dist/types.d.mts", + "import": "./dist/module.mjs" + } + }, + "main": "./dist/module.mjs", + "typesVersions": { + "*": { + ".": ["./dist/types.d.mts"] + } + }, + "files": ["dist"], + "scripts": { + "prebuild": "nuxt-module-build prepare", + "build": "nuxt-module-build build", + "build:prod": "nuxt-module-build build", + "clean": "rm -rf dist .nuxt", + "dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare", + "watch": "nuxt-module-build build --watch", + "test": "vitest run", + "test:watch": "vitest watch", + "test:exports": "attw --pack --profile=esm-only ." + }, + "homepage": "https://docs.powersync.com", + "dependencies": { + "@iconify-json/carbon": "^1.2.13", + "@nuxt/devtools-kit": "^2.6.2", + "@nuxt/devtools-ui-kit": "^2.6.2", + "@nuxt/kit": "^4.0.3", + "@tanstack/vue-table": "^8.21.3", + "@vueuse/nuxt": "^13.9.0", + "consola": "^3.4.2", + "defu": "^6.1.4", + "fuse.js": "^7.1.0", + "mitt": "^3.0.1", + "reka-ui": "^2.5.0", + "shiki": "^3.13.0", + "unocss": "^66.5.2", + "unstorage": "^1.17.1" + }, + "peerDependencies": { + "@journeyapps/wa-sqlite": "^1.2.6", + "@powersync/kysely-driver": "workspace:*", + "@powersync/vue": "workspace:*", + "@powersync/web": "workspace:*" + }, + "devDependencies": { + "@journeyapps/wa-sqlite": "^1.4.0", + "@nuxt/module-builder": "^1.0.2", + "@nuxt/schema": "^4.1.2", + "@nuxt/test-utils": "^3.19.2", + "@powersync/kysely-driver": "workspace:*", + "@powersync/vue": "workspace:*", + "@powersync/web": "workspace:*", + "nuxt": "^4.1.2", + "vite-plugin-top-level-await": "^1.6.0", + "vite-plugin-wasm": "^3.5.0", + "vitest": "^3.2.4", + "vue": "^3.5.20", + "vue-tsc": "^3.0.8" + } + } \ No newline at end of file diff --git a/packages/nuxt/src/devtools.ts b/packages/nuxt/src/devtools.ts new file mode 100644 index 000000000..984a4b447 --- /dev/null +++ b/packages/nuxt/src/devtools.ts @@ -0,0 +1,22 @@ +import type { Nuxt } from 'nuxt/schema' + +export function setupDevToolsUI(nuxt: Nuxt) { + const port = nuxt.options.devServer?.port || 3000 + const DEVTOOLS_UI_ROUTE = `http://localhost:${port}/__powersync-inspector` + + nuxt.hook('devtools:customTabs', (tabs) => { + tabs.push({ + // unique identifier + name: 'powersync-inspector', + // title to display in the tab + title: 'Powersync Inspector', + // any icon from Iconify, or a URL to an image + icon: 'https://cdn.prod.website-files.com/67eea61902e19994e7054ea0/67f910109a12edc930f8ffb6_powersync-icon.svg', + // iframe view + view: { + type: 'iframe', + src: DEVTOOLS_UI_ROUTE, + }, + }) + }) +} diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts new file mode 100644 index 000000000..d331f973d --- /dev/null +++ b/packages/nuxt/src/module.ts @@ -0,0 +1,137 @@ +import { + defineNuxtModule, + createResolver, + addPlugin, + addImports, + extendPages, + // installModule, + addLayout, + addComponentsDir, + installModule, +} from '@nuxt/kit' +import { defu } from 'defu' +import { setupDevToolsUI } from './devtools' +import { addImportsFrom } from './runtime/utils/addImportsFrom' + +type JSONValue + = | string + | number + | boolean + | null + | undefined + | JSONObject + | JSONArray +interface JSONObject { + [key: string]: JSONValue +} +type JSONArray = JSONValue[] + +// Module options TypeScript interface definition +export interface PowerSyncNuxtModuleOptions { + /** + * enable diagnostics + * + * @default "false" + */ + useDiagnostics?: boolean +} + +export default defineNuxtModule({ + meta: { + name: 'powersync-nuxt', + configKey: 'powersync', + }, + // Default configuration options of the Nuxt module + defaults: { + useDiagnostics: false, + }, + async setup(options, nuxt) { + const resolver = createResolver(import.meta.url) + + nuxt.options.runtimeConfig.public.powerSyncModuleOptions = defu( + + nuxt.options.runtimeConfig.public.powerSyncModuleOptions as any, + { + useDiagnostics: options.useDiagnostics, + }, + ) + + await installModule('@nuxt/devtools-ui-kit') + await installModule('@vueuse/nuxt') + + addPlugin(resolver.resolve('./runtime/plugin.client')) + + // expose the composables + addImports({ + name: 'NuxtPowerSyncDatabase', + from: resolver.resolve( + './runtime/utils/NuxtPowerSyncDatabase', + ), + }) + + addImports({ + name: 'usePowerSyncInspector', + from: resolver.resolve('./runtime/composables/usePowerSyncInspector'), + }) + + addImports({ + name: 'usePowerSyncInspectorDiagnostics', + from: resolver.resolve( + './runtime/composables/usePowerSyncInspectorDiagnostics', + ), + }) + + addImports({ + name: 'usePowerSyncKysely', + from: resolver.resolve('./runtime/composables/usePowerSyncKysely'), + }) + + addImports({ + name: 'useDiagnosticsLogger', + from: resolver.resolve('./runtime/composables/useDiagnosticsLogger'), + }) + + // From the runtime directory + addComponentsDir({ + path: resolver.resolve('runtime/components'), + }) + + addLayout( + resolver.resolve('./runtime/layouts/powersync-inspector-layout.vue'), + 'powersync-inspector-layout', + ) + + extendPages((pages) => { + pages.push({ + path: '/__powersync-inspector', + // file: resolver.resolve("#build/pages/__powersync-inspector.vue"), + file: resolver.resolve('./runtime/pages/__powersync-inspector.vue'), + name: 'Powersync Inspector', + }) + }) + + addImportsFrom([ + 'createPowerSyncPlugin', + 'providePowerSync', + 'usePowerSync', + 'usePowerSyncQuery', + 'usePowerSyncStatus', + 'usePowerSyncWatchedQuery', + 'useQuery', + 'useStatus', + 'useWatchedQuerySubscription', + ], '@powersync/vue') + + // Ensure the packages are transpiled + nuxt.options.build.transpile = nuxt.options.build.transpile || [] + nuxt.options.build.transpile.push('reka-ui', '@tanstack/vue-table', '@powersync/web', '@powersync/kysely-driver', '@journeyapps/wa-sqlite') + + nuxt.hooks.hook('prepare:types', ({ references }: { references: any[] }) => { + references.push({ types: '@powersync/web' }) + references.push({ types: '@powersync/kysely-driver' }) + references.push({ types: '@journeyapps/wa-sqlite' }) + }) + + setupDevToolsUI(nuxt) + }, +}) diff --git a/packages/nuxt/src/runtime/components/BucketsInspectorTab.vue b/packages/nuxt/src/runtime/components/BucketsInspectorTab.vue new file mode 100644 index 000000000..893393128 --- /dev/null +++ b/packages/nuxt/src/runtime/components/BucketsInspectorTab.vue @@ -0,0 +1,707 @@ + + + + + diff --git a/packages/nuxt/src/runtime/components/ConfigInspectorTab.vue b/packages/nuxt/src/runtime/components/ConfigInspectorTab.vue new file mode 100644 index 000000000..39d3b725b --- /dev/null +++ b/packages/nuxt/src/runtime/components/ConfigInspectorTab.vue @@ -0,0 +1,175 @@ + + + + + diff --git a/packages/nuxt/src/runtime/components/DataInspectorTab.vue b/packages/nuxt/src/runtime/components/DataInspectorTab.vue new file mode 100644 index 000000000..4f356c5fd --- /dev/null +++ b/packages/nuxt/src/runtime/components/DataInspectorTab.vue @@ -0,0 +1,857 @@ +