diff --git a/.github/snippets/pr-preview.md b/.github/snippets/pr-preview.md new file mode 100644 index 0000000..e9e306e --- /dev/null +++ b/.github/snippets/pr-preview.md @@ -0,0 +1,15 @@ +Every pull request publishes a preview package via [pkg.pr.new](https://pkg.pr.new), so you can install and test changes before they are merged. + +```bash +# npm +npm install https://pkg.pr.new/@websideproject/nuxt-auto-api@ +npm install https://pkg.pr.new/@websideproject/nuxt-auto-admin@ + +# pnpm +pnpm add https://pkg.pr.new/@websideproject/nuxt-auto-api@ +pnpm add https://pkg.pr.new/@websideproject/nuxt-auto-admin@ + +# bun +bun add https://pkg.pr.new/@websideproject/nuxt-auto-api@ +bun add https://pkg.pr.new/@websideproject/nuxt-auto-admin@ +``` diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml index d7216fa..6f48c51 100644 --- a/.github/workflows/autofix.yml +++ b/.github/workflows/autofix.yml @@ -24,6 +24,9 @@ jobs: - name: Lint code run: bun run lint:fix + - name: Update README + run: bun run automd + - uses: autofix-ci/action@635ffb0c9798bd160680f18fd73371e355b85f27 with: - commit-message: 'chore: apply automated lint fixes' + commit-message: 'chore: apply automated fixes' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 19a264e..eb1e210 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,22 +66,22 @@ jobs: - name: Test run: bun run test - publish: - runs-on: ubuntu-latest - if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' - needs: [build, lint, typecheck, test] - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - name: Install dependencies - run: bun install --frozen-lockfile - - name: Prepare - run: bun run dev:prepare - - name: Build modules - run: bun run build - - name: Publish - run: bunx pkg-pr-new publish --compact --no-template './packages/nuxt-auto-api' './packages/nuxt-auto-admin' +# publish: +# runs-on: ubuntu-latest +# if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' +# needs: [build, lint, typecheck, test] +# steps: +# - uses: actions/checkout@v4 +# with: +# fetch-depth: 0 +# - uses: oven-sh/setup-bun@v2 +# with: +# bun-version: latest +# - name: Install dependencies +# run: bun install --frozen-lockfile +# - name: Prepare +# run: bun run dev:prepare +# - name: Build modules +# run: bun run build +# - name: Publish +# run: bunx pkg-pr-new publish --compact --no-template './packages/nuxt-auto-api' './packages/nuxt-auto-admin' diff --git a/README.md b/README.md index 8a04b07..f846ae0 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,28 @@ bun add -D @websideproject/nuxt-auto-api @websideproject/nuxt-auto-admin 📖 **[Full Documentation →](https://github.com/websideproject/nuxt-auto)** +## 🔍 PR Previews + + + +Every pull request publishes a preview package via [pkg.pr.new](https://pkg.pr.new), so you can install and test changes before they are merged. + +```bash +# npm +npm install https://pkg.pr.new/@websideproject/nuxt-auto-api@ +npm install https://pkg.pr.new/@websideproject/nuxt-auto-admin@ + +# pnpm +pnpm add https://pkg.pr.new/@websideproject/nuxt-auto-api@ +pnpm add https://pkg.pr.new/@websideproject/nuxt-auto-admin@ + +# bun +bun add https://pkg.pr.new/@websideproject/nuxt-auto-api@ +bun add https://pkg.pr.new/@websideproject/nuxt-auto-admin@ +``` + + + ## 🤝 Contributing diff --git a/apps/benchmark/Dockerfile b/apps/benchmark/Dockerfile new file mode 100644 index 0000000..bac63d2 --- /dev/null +++ b/apps/benchmark/Dockerfile @@ -0,0 +1,34 @@ +FROM node:20-slim + +WORKDIR /workspace + +# Copy the package +COPY packages/nuxt-auto-api ./packages/nuxt-auto-api + +# Build the package +WORKDIR /workspace/packages/nuxt-auto-api +RUN npm install +RUN npm run prepack + +# Copy the app +COPY benchmark/app /workspace/benchmark/app + +WORKDIR /workspace/benchmark/app + +# Install dependencies +RUN npm install + +# Build the application +RUN npm run build + +# Expose port +EXPOSE 3000 + +# Set environment variables +ENV HOST=0.0.0.0 +ENV PORT=3000 +ENV NODE_ENV=production + +# Run seed and then start the server +# using jiti to execute the typescript seed file directly +CMD mkdir -p .data && npm run db:push && npx jiti ./server/database/seed.ts && node .output/server/index.mjs diff --git a/apps/benchmark/Dockerfile.bun b/apps/benchmark/Dockerfile.bun new file mode 100644 index 0000000..9af1839 --- /dev/null +++ b/apps/benchmark/Dockerfile.bun @@ -0,0 +1,42 @@ +FROM oven/bun:1 + +WORKDIR /workspace + +# Install build dependencies for better-sqlite3 +RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/* + +# Copy the package +COPY packages/nuxt-auto-api ./packages/nuxt-auto-api + +# Remove better-sqlite3 from package dependencies to avoid build +WORKDIR /workspace/packages/nuxt-auto-api +RUN sed -i '/better-sqlite3/d' package.json +RUN bun install +RUN bun run prepack + +# Copy the app +COPY benchmark/app /workspace/benchmark/app + +WORKDIR /workspace/benchmark/app + +# Remove better-sqlite3 and swap db.ts +RUN sed -i '/better-sqlite3/d' package.json +RUN cp server/database/db.bun.ts server/database/db.ts + +# Install dependencies +RUN bun install + +# Build the application +ENV NITRO_PRESET=bun +RUN bun run build + +# Expose port +EXPOSE 3000 + +# Set environment variables +ENV HOST=0.0.0.0 +ENV PORT=3000 +ENV NODE_ENV=production + +# Run migrations, seed and then start the server +CMD mkdir -p .data && bun run ./server/database/init-bun.ts && bun run ./server/database/seed.ts && bun run .output/server/index.mjs diff --git a/apps/benchmark/Dockerfile.fastify b/apps/benchmark/Dockerfile.fastify new file mode 100644 index 0000000..ed25b0b --- /dev/null +++ b/apps/benchmark/Dockerfile.fastify @@ -0,0 +1,26 @@ +FROM node:20-slim + +WORKDIR /workspace/benchmark/fastify-app + +# Copy package.json +COPY benchmark/fastify-app/package.json . + +# Install dependencies +RUN npm install + +# Copy source +COPY benchmark/fastify-app . + +# Build +RUN npm run build + +# Expose port +EXPOSE 3000 + +# Set environment variables +ENV HOST=0.0.0.0 +ENV PORT=3000 +ENV NODE_ENV=production + +# Run migrations (create .data dir first), seed and start +CMD mkdir -p .data && npx drizzle-kit push && npx tsx src/database/seed.ts && npm start diff --git a/apps/benchmark/README.md b/apps/benchmark/README.md new file mode 100644 index 0000000..ba3ad87 --- /dev/null +++ b/apps/benchmark/README.md @@ -0,0 +1,17 @@ +# Benchmark + +This directory contains a benchmark for `nuxt-auto-api` vs manual handlers. + +## Usage + +1. Build and run with Docker Compose: + ```bash + docker compose up --build + ``` + +2. View k6 results in the console. + +## Structure + +- `app`: A minimal Nuxt app using `nuxt-auto-api` and manual handlers. +- `k6`: k6 load testing scripts. diff --git a/apps/benchmark/app/drizzle.config.ts b/apps/benchmark/app/drizzle.config.ts new file mode 100644 index 0000000..8b245da --- /dev/null +++ b/apps/benchmark/app/drizzle.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'drizzle-kit' + +export default defineConfig({ + schema: './server/database/schema.ts', + out: './drizzle', + dialect: 'sqlite', + dbCredentials: { + url: '.data/db.sqlite', + }, +}) \ No newline at end of file diff --git a/apps/benchmark/app/modules/base/auth.ts b/apps/benchmark/app/modules/base/auth.ts new file mode 100644 index 0000000..8c12b1c --- /dev/null +++ b/apps/benchmark/app/modules/base/auth.ts @@ -0,0 +1,84 @@ +import type { ResourceAuthConfig } from 'nuxt-auto-api/runtime/types' + +/** + * Authorization configuration for core playground resources + */ + +// Users resource +export const usersAuth: ResourceAuthConfig = { + permissions: { + // Anyone can read users + read: () => true, + // Only admins can create users + create: 'admin', + // Users can update themselves, admins can update anyone + update: (context) => { + if (context.permissions.includes('admin')) return true + if (context.user && context.params.id === String(context.user.id)) return true + return false + }, + // Only admins can delete users + delete: 'admin', + }, + // Object-level check: users can only see/edit themselves unless they're admin + objectLevel: async (object, context) => { + if (context.permissions.includes('admin')) return true + // Convert both to string for comparison since IDs might be different types + if (context.user && String(object.id) === String(context.user.id)) return true + return false + }, + // Field-level permissions + fields: { + // Only the user themselves or admins can see the email + email: { + read: (context) => { + if (context.permissions.includes('admin')) return true + if (context.user && context.params.id === String(context.user.id)) return true + return false + }, + }, + // Only admins can change role + role: { + write: 'admin', + }, + }, +} + +// Posts resource - object-level authorization demo +export const postsAuth: ResourceAuthConfig = { + permissions: { + // Anyone can read posts + read: () => true, + // Authenticated users can create posts + create: (context) => !!context.user, + // Authenticated users can update (but object-level auth will restrict to own posts) + update: (context) => !!context.user, + // Authenticated users can delete (but object-level auth will restrict to own posts) + delete: (context) => !!context.user, + }, + // Object-level: users can only edit their own posts unless they're admin + objectLevel: async (object, context) => { + // Allow reading all posts + if (context.operation === 'get' || context.operation === 'list') return true + + // Admins can do anything + if (context.user?.role === 'admin') return true + + // For update/delete, must be the owner + if (context.operation === 'update' || context.operation === 'delete') { + return context.user && String(object.userId) === String(context.user.id) + } + + return false + }, +} + +// Comments resource +export const commentsAuth: ResourceAuthConfig = { + permissions: { + read: () => true, + create: (context) => !!context.user, + update: (context) => !!context.user, + delete: ['admin', 'editor'], + }, +} diff --git a/apps/benchmark/app/modules/base/index.ts b/apps/benchmark/app/modules/base/index.ts new file mode 100644 index 0000000..9369a0d --- /dev/null +++ b/apps/benchmark/app/modules/base/index.ts @@ -0,0 +1,43 @@ +import { defineNuxtModule, createResolver } from '@nuxt/kit' +import { createModuleImport } from '@websideproject/nuxt-auto-api' + +/** + * Base module for playground - registers core resources (users, posts, comments) + * This demonstrates how a main app can register its resources via hooks + */ +export default defineNuxtModule({ + meta: { + name: 'playground-base', + configKey: 'playgroundBase', + }, + + setup(_options, nuxt) { + const resolver = createResolver(import.meta.url) + + // Register resources at BUILD TIME via hook + nuxt.hook('autoApi:registerSchema', (registry) => { + // Register users + registry.register('users', { + schema: createModuleImport(resolver.resolve('../../server/database/schema'), 'users'), + authorization: createModuleImport(resolver.resolve('./auth'), 'usersAuth'), + validation: createModuleImport(resolver.resolve('../../server/validation/users'), 'usersValidation'), + hiddenFields: ['password', 'apiKey'], // Hide sensitive fields from all API responses + }) + + // Register posts + registry.register('posts', { + schema: createModuleImport(resolver.resolve('../../server/database/schema'), 'posts'), + authorization: createModuleImport(resolver.resolve('./auth'), 'postsAuth'), + validation: createModuleImport(resolver.resolve('../../server/validation/posts'), 'postsValidation'), + }) + + // Register comments + registry.register('comments', { + schema: createModuleImport(resolver.resolve('../../server/database/schema'), 'comments'), + authorization: createModuleImport(resolver.resolve('./auth'), 'commentsAuth'), + }) + + console.log('[playground-base] Registered 3 core resources at build time') + }) + }, +}) diff --git a/apps/benchmark/app/nuxt.config.ts b/apps/benchmark/app/nuxt.config.ts new file mode 100644 index 0000000..50ab3d4 --- /dev/null +++ b/apps/benchmark/app/nuxt.config.ts @@ -0,0 +1,27 @@ +export default defineNuxtConfig({ + modules: [ + '@websideproject/nuxt-auto-api', + './modules/base', + ], + + autoApi: { + prefix: '/api', + database: { + client: 'better-sqlite3' + }, + pagination: { + default: 'offset', + defaultLimit: 20, + maxLimit: 100 + } + }, + + compatibilityDate: '2025-01-15', + + // Ensure we can import from server/database + nitro: { + imports: { + dirs: ['./server/database'] + } + } +}) diff --git a/apps/benchmark/app/package.json b/apps/benchmark/app/package.json new file mode 100644 index 0000000..b05bdfe --- /dev/null +++ b/apps/benchmark/app/package.json @@ -0,0 +1,24 @@ +{ + "name": "benchmark-app", + "private": true, + "type": "module", + "scripts": { + "build": "nuxt build", + "dev": "nuxt dev", + "generate": "nuxt generate", + "preview": "nuxt preview", + "postinstall": "nuxt prepare", + "db:push": "drizzle-kit push" + }, + "dependencies": { + "better-sqlite3": "^11.0.0", + "drizzle-orm": "^0.45.1", + "drizzle-zod": "^0.8.3", + "nuxt": "^4.3.0", + "@websideproject/nuxt-auto-api": "workspace:*", + "zod": "^4.3.6" + }, + "devDependencies": { + "drizzle-kit": "^0.31.8" + } +} \ No newline at end of file diff --git a/apps/benchmark/app/server/api/manual/ping.get.ts b/apps/benchmark/app/server/api/manual/ping.get.ts new file mode 100644 index 0000000..5893fcb --- /dev/null +++ b/apps/benchmark/app/server/api/manual/ping.get.ts @@ -0,0 +1,3 @@ +export default defineEventHandler(() => { + return { ping: 'pong' } +}) diff --git a/apps/benchmark/app/server/api/manual/posts-complex.get.ts b/apps/benchmark/app/server/api/manual/posts-complex.get.ts new file mode 100644 index 0000000..5577409 --- /dev/null +++ b/apps/benchmark/app/server/api/manual/posts-complex.get.ts @@ -0,0 +1,14 @@ +import { useDB } from '../../database/db' + +export default defineEventHandler(async (event) => { + const db = useDB() + // limit to 20 to match default pagination + const result = await db.query.posts.findMany({ + limit: 20, + with: { + author: true, + comments: true, + } + }) + return result +}) diff --git a/apps/benchmark/app/server/api/manual/users.get.ts b/apps/benchmark/app/server/api/manual/users.get.ts new file mode 100644 index 0000000..2030ec3 --- /dev/null +++ b/apps/benchmark/app/server/api/manual/users.get.ts @@ -0,0 +1,8 @@ +import { useDB } from '../../database/db' +import { users } from '../../database/schema' + +export default defineEventHandler(async (event) => { + const db = useDB() + const result = await db.select().from(users).all() + return result +}) diff --git a/apps/benchmark/app/server/database/db.bun.ts b/apps/benchmark/app/server/database/db.bun.ts new file mode 100644 index 0000000..5d8ed9a --- /dev/null +++ b/apps/benchmark/app/server/database/db.bun.ts @@ -0,0 +1,17 @@ +import { Database } from 'bun:sqlite'; +import { drizzle } from 'drizzle-orm/bun-sqlite'; +import * as baseSchema from './schema'; + +let _db: ReturnType | null = null; + +export function useDB() { + if (!_db) { + const sqlite = new Database('.data/db.sqlite'); + _db = drizzle(sqlite, { + schema: { + ...baseSchema.schema, + } + }); + } + return _db; +} diff --git a/apps/benchmark/app/server/database/db.ts b/apps/benchmark/app/server/database/db.ts new file mode 100644 index 0000000..dd0d68e --- /dev/null +++ b/apps/benchmark/app/server/database/db.ts @@ -0,0 +1,17 @@ +import Database from 'better-sqlite3' +import { drizzle } from 'drizzle-orm/better-sqlite3' +import * as baseSchema from './schema' + +let _db: ReturnType | null = null + +export function useDB() { + if (!_db) { + const sqlite = new Database('.data/db.sqlite') + _db = drizzle(sqlite, { + schema: { + ...baseSchema.schema, + } + }) + } + return _db +} diff --git a/apps/benchmark/app/server/database/init-bun.ts b/apps/benchmark/app/server/database/init-bun.ts new file mode 100644 index 0000000..1ad9254 --- /dev/null +++ b/apps/benchmark/app/server/database/init-bun.ts @@ -0,0 +1,54 @@ +import { Database } from 'bun:sqlite'; + +const db = new Database('.data/db.sqlite'); + +console.log('Initializing database tables for Bun...'); + +// Enable foreign keys +db.run('PRAGMA foreign_keys = ON;'); + +// Create users table +db.run(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL UNIQUE, + name TEXT, + role TEXT DEFAULT 'user' NOT NULL, + password TEXT, + api_key TEXT, + created_at INTEGER, + updated_at INTEGER + ); +`); + +// Create posts table +db.run(` + CREATE TABLE IF NOT EXISTS posts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + content TEXT, + published INTEGER DEFAULT 0 NOT NULL, + user_id INTEGER NOT NULL, + organization_id TEXT, + created_at INTEGER, + updated_at INTEGER, + deleted_at INTEGER, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); +`); + +// Create comments table +db.run(` + CREATE TABLE IF NOT EXISTS comments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + content TEXT NOT NULL, + post_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + created_at INTEGER, + FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); +`); + +console.log('Database tables initialized successfully.'); +db.close(); diff --git a/apps/benchmark/app/server/database/schema.ts b/apps/benchmark/app/server/database/schema.ts new file mode 100644 index 0000000..f061404 --- /dev/null +++ b/apps/benchmark/app/server/database/schema.ts @@ -0,0 +1,84 @@ +import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core' +import { relations } from 'drizzle-orm' + +/** + * Users table + */ +export const users = sqliteTable('users', { + id: integer('id').primaryKey({ autoIncrement: true }), + email: text('email').notNull().unique(), + name: text('name'), + role: text('role', { enum: ['user', 'admin', 'editor'] }).default('user').notNull(), + password: text('password'), // Hidden field for demo + apiKey: text('api_key'), // Hidden field for demo + createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()), + updatedAt: integer('updated_at', { mode: 'timestamp' }).$defaultFn(() => new Date()), +}) + +/** + * Posts table + */ +export const posts = sqliteTable('posts', { + id: integer('id').primaryKey({ autoIncrement: true }), + title: text('title').notNull(), + content: text('content'), + published: integer('published', { mode: 'boolean' }).default(false).notNull(), + userId: integer('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + organizationId: text('organization_id'), // Multi-tenancy support + createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()), + updatedAt: integer('updated_at', { mode: 'timestamp' }).$defaultFn(() => new Date()), + deletedAt: integer('deleted_at', { mode: 'timestamp' }), // Soft delete support +}) + +/** + * Comments table + */ +export const comments = sqliteTable('comments', { + id: integer('id').primaryKey({ autoIncrement: true }), + content: text('content').notNull(), + postId: integer('post_id') + .notNull() + .references(() => posts.id, { onDelete: 'cascade' }), + userId: integer('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()), +}) + +/** + * Relations + */ +export const usersRelations = relations(users, ({ many }) => ({ + posts: many(posts), + comments: many(comments), +})) + +export const postsRelations = relations(posts, ({ one, many }) => ({ + author: one(users, { + fields: [posts.userId], + references: [users.id], + }), + comments: many(comments), +})) + +export const commentsRelations = relations(comments, ({ one }) => ({ + post: one(posts, { + fields: [comments.postId], + references: [posts.id], + }), + author: one(users, { + fields: [comments.userId], + references: [users.id], + }), +})) + +export const schema = { + users, + posts, + comments, + usersRelations, + postsRelations, + commentsRelations, +} diff --git a/apps/benchmark/app/server/database/seed.ts b/apps/benchmark/app/server/database/seed.ts new file mode 100644 index 0000000..e93f7ed --- /dev/null +++ b/apps/benchmark/app/server/database/seed.ts @@ -0,0 +1,143 @@ +import { useDB } from './db' +import { users, posts, comments } from './schema' + +export async function seed() { + const db = useDB() + + console.log('Seeding database...') + + // Clear existing data + await db.delete(comments) + await db.delete(posts) + await db.delete(users) + + // Create users with specific IDs matching demo auth + const insertedUsers = await db + .insert(users) + .values([ + { + id: 1, + email: 'admin@example.com', + name: 'Admin User', + role: 'admin', + password: '$2a$10$hashed_password_admin_123', + apiKey: 'sk_live_admin_abc123xyz789', + }, + { + id: 2, + email: 'editor@example.com', + name: 'Editor User', + role: 'editor', + password: '$2a$10$hashed_password_editor_456', + apiKey: 'sk_live_editor_def456uvw012', + }, + { + id: 3, + email: 'user@example.com', + name: 'Regular User', + role: 'user', + password: '$2a$10$hashed_password_user_789', + apiKey: 'sk_live_user_ghi789rst345', + }, + ]) + .returning() + + console.log(`Created ${insertedUsers.length} users`) + + // Create posts with different ownership for demo + const insertedPosts = await db + .insert(posts) + .values([ + { + title: 'My First Post', + content: 'This is a post by the regular user (ID 3). They should be able to edit this.', + published: true, + userId: 3, // Regular user + }, + { + title: 'My Draft Post', + content: 'This is a draft post by the regular user.', + published: false, + userId: 3, // Regular user + }, + { + title: 'Editor\'s Featured Article', + content: 'This is a post created by the editor user.', + published: true, + userId: 2, // Editor + }, + { + title: 'Editor\'s Work in Progress', + content: 'Another post by the editor.', + published: false, + userId: 2, // Editor + }, + { + title: 'Admin Announcement', + content: 'Important announcement from the admin.', + published: true, + userId: 1, // Admin + }, + { + title: 'System Update', + content: 'System maintenance scheduled.', + published: true, + userId: 1, // Admin + }, + // Additional posts for aggregation demos + { title: 'Post 7', content: 'Content for aggregation test', published: true, userId: 1 }, + { title: 'Post 8', content: 'Content for aggregation test', published: false, userId: 1 }, + { title: 'Post 9', content: 'Content for aggregation test', published: true, userId: 2 }, + { title: 'Post 10', content: 'Content for aggregation test', published: true, userId: 2 }, + { title: 'Post 11', content: 'Content for aggregation test', published: false, userId: 2 }, + { title: 'Post 12', content: 'Content for aggregation test', published: true, userId: 3 }, + { title: 'Post 13', content: 'Content for aggregation test', published: true, userId: 3 }, + { title: 'Post 14', content: 'Content for aggregation test', published: false, userId: 3 }, + { title: 'Post 15', content: 'Content for aggregation test', published: true, userId: 1 }, + { title: 'Post 16', content: 'Content for aggregation test', published: true, userId: 1 }, + { title: 'Post 17', content: 'Content for aggregation test', published: false, userId: 2 }, + { title: 'Post 18', content: 'Content for aggregation test', published: true, userId: 2 }, + { title: 'Post 19', content: 'Content for aggregation test', published: true, userId: 3 }, + { title: 'Post 20', content: 'Content for aggregation test', published: false, userId: 3 }, + ]) + .returning() + + console.log(`Created ${insertedPosts.length} posts`) + + // Create comments + const insertedComments = await db + .insert(comments) + .values([ + { + content: 'Great post!', + postId: insertedPosts[0].id, + userId: 2, // Editor commenting + }, + { + content: 'Thanks for sharing', + postId: insertedPosts[0].id, + userId: 1, // Admin commenting + }, + { + content: 'Very informative', + postId: insertedPosts[2].id, + userId: 3, // Regular user commenting + }, + ]) + .returning() + + console.log(`Created ${insertedComments.length} comments`) + console.log('Seeding completed!') +} + +// Run seed if executed directly +const isMainModule = import.meta.main || import.meta.url === `file://${process.argv[1]}` + +if (isMainModule) { + seed() + .then(() => process.exit(0)) + .catch((error) => { + console.error('Seed failed:', error) + process.exit(1) + }) +} diff --git a/apps/benchmark/app/server/plugins/db.ts b/apps/benchmark/app/server/plugins/db.ts new file mode 100644 index 0000000..391aa25 --- /dev/null +++ b/apps/benchmark/app/server/plugins/db.ts @@ -0,0 +1,15 @@ +import { defineNitroPlugin } from 'nitropack/runtime' +import { useDB } from '../database/db' + +/** + * Initialize database for auto-api + * The DB instance is stored in globalThis for handlers to access + */ +export default defineNitroPlugin((nitroApp) => { + const db = useDB() + + // Store DB in globalThis for auto-api handlers to access + ;(globalThis as any).__autoApiDb = db + + console.log('[benchmark] Database initialized for auto-api') +}) diff --git a/apps/benchmark/app/server/validation/posts.ts b/apps/benchmark/app/server/validation/posts.ts new file mode 100644 index 0000000..91a402a --- /dev/null +++ b/apps/benchmark/app/server/validation/posts.ts @@ -0,0 +1,31 @@ +import { createInsertSchema } from 'drizzle-zod' +import { z } from 'zod' +import { posts } from '../database/schema.ts' + +/** + * Validation schemas for posts resource + */ + +const baseInsertSchema = createInsertSchema(posts) + +export const postsValidation = { + create: baseInsertSchema.extend({ + title: z.string().min(3).max(200), + content: z.string().min(10).optional(), + published: z.boolean().default(false), + userId: z.number().int().positive(), + }), + update: baseInsertSchema.partial().extend({ + title: z.string().min(3).max(200).optional(), + content: z.string().min(10).optional(), + published: z.boolean().optional(), + }), + query: z.object({ + filter: z.record(z.string(), z.any()).optional(), + sort: z.union([z.string(), z.array(z.string())]).optional(), + fields: z.union([z.string(), z.array(z.string())]).optional(), + include: z.union([z.string(), z.array(z.string())]).optional(), + page: z.coerce.number().int().positive().optional(), + limit: z.coerce.number().int().positive().max(100).optional(), + }).optional(), +} diff --git a/apps/benchmark/app/server/validation/users.ts b/apps/benchmark/app/server/validation/users.ts new file mode 100644 index 0000000..d0797b8 --- /dev/null +++ b/apps/benchmark/app/server/validation/users.ts @@ -0,0 +1,33 @@ +import { createInsertSchema } from 'drizzle-zod' +import { z } from 'zod' +import { users } from '../database/schema.ts' + +/** + * Validation schemas for users resource + * + * This shows how to extend the auto-generated schema from drizzle-zod + * with custom validation rules + */ + +// Generate base schema from Drizzle table +const baseInsertSchema = createInsertSchema(users) + +export const usersValidation = { + create: baseInsertSchema.extend({ + email: z.string().email().toLowerCase().min(1), + name: z.string().min(2).max(100), + role: z.enum(['user', 'admin', 'editor']).default('user'), + }), + update: baseInsertSchema.partial().extend({ + email: z.string().email().toLowerCase().optional(), + name: z.string().min(2).max(100).optional(), + }), + query: z.object({ + filter: z.record(z.string(), z.any()).optional(), + sort: z.union([z.string(), z.array(z.string())]).optional(), + fields: z.union([z.string(), z.array(z.string())]).optional(), + include: z.union([z.string(), z.array(z.string())]).optional(), + page: z.coerce.number().int().positive().optional(), + limit: z.coerce.number().int().positive().max(100).optional(), + }).optional(), +} diff --git a/apps/benchmark/docker-compose.yml b/apps/benchmark/docker-compose.yml new file mode 100644 index 0000000..6ad92fa --- /dev/null +++ b/apps/benchmark/docker-compose.yml @@ -0,0 +1,39 @@ +services: + app: + build: + context: ../ + dockerfile: benchmark/Dockerfile + ports: + - "3000:3000" + environment: + - NITRO_PRESET=node-server + volumes: + - ./.data:/workspace/benchmark/app/.data + + app-bun: + build: + context: ../ + dockerfile: benchmark/Dockerfile.bun + ports: + - "3001:3000" + volumes: + - ./.data-bun:/workspace/benchmark/app/.data + + app-fastify: + build: + context: ../ + dockerfile: benchmark/Dockerfile.fastify + ports: + - "3002:3000" + volumes: + - ./.data-fastify:/workspace/benchmark/fastify-app/.data + + k6: + image: grafana/k6:latest + depends_on: + - app + - app-bun + - app-fastify + volumes: + - ./k6/script.js:/script.js + command: run /script.js diff --git a/apps/benchmark/fastify-app/drizzle.config.ts b/apps/benchmark/fastify-app/drizzle.config.ts new file mode 100644 index 0000000..1df4c2c --- /dev/null +++ b/apps/benchmark/fastify-app/drizzle.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'drizzle-kit' + +export default defineConfig({ + schema: './src/database/schema.ts', + out: './drizzle', + dialect: 'sqlite', + dbCredentials: { + url: '.data/db.sqlite', + }, +}) diff --git a/apps/benchmark/fastify-app/package.json b/apps/benchmark/fastify-app/package.json new file mode 100644 index 0000000..ede15e2 --- /dev/null +++ b/apps/benchmark/fastify-app/package.json @@ -0,0 +1,23 @@ +{ + "name": "benchmark-fastify", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "db:push": "drizzle-kit push" + }, + "dependencies": { + "better-sqlite3": "^11.0.0", + "drizzle-orm": "^0.30.9", + "fastify": "^4.26.2" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.9", + "@types/node": "^20.12.7", + "drizzle-kit": "^0.21.0", + "tsx": "^4.7.2", + "typescript": "^5.4.5" + } +} diff --git a/apps/benchmark/fastify-app/src/database/db.ts b/apps/benchmark/fastify-app/src/database/db.ts new file mode 100644 index 0000000..cfc026d --- /dev/null +++ b/apps/benchmark/fastify-app/src/database/db.ts @@ -0,0 +1,19 @@ +import Database from 'better-sqlite3' +import { drizzle, BetterSQLite3Database } from 'drizzle-orm/better-sqlite3' +import * as baseSchema from './schema.js' + +// Extract the schema object type +type Schema = typeof baseSchema.schema +let _db: BetterSQLite3Database | null = null + +export function useDB() { + if (!_db) { + const sqlite = new Database('.data/db.sqlite') + _db = drizzle(sqlite, { + schema: { + ...baseSchema.schema, + } + }) + } + return _db +} diff --git a/apps/benchmark/fastify-app/src/database/schema.ts b/apps/benchmark/fastify-app/src/database/schema.ts new file mode 100644 index 0000000..f061404 --- /dev/null +++ b/apps/benchmark/fastify-app/src/database/schema.ts @@ -0,0 +1,84 @@ +import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core' +import { relations } from 'drizzle-orm' + +/** + * Users table + */ +export const users = sqliteTable('users', { + id: integer('id').primaryKey({ autoIncrement: true }), + email: text('email').notNull().unique(), + name: text('name'), + role: text('role', { enum: ['user', 'admin', 'editor'] }).default('user').notNull(), + password: text('password'), // Hidden field for demo + apiKey: text('api_key'), // Hidden field for demo + createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()), + updatedAt: integer('updated_at', { mode: 'timestamp' }).$defaultFn(() => new Date()), +}) + +/** + * Posts table + */ +export const posts = sqliteTable('posts', { + id: integer('id').primaryKey({ autoIncrement: true }), + title: text('title').notNull(), + content: text('content'), + published: integer('published', { mode: 'boolean' }).default(false).notNull(), + userId: integer('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + organizationId: text('organization_id'), // Multi-tenancy support + createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()), + updatedAt: integer('updated_at', { mode: 'timestamp' }).$defaultFn(() => new Date()), + deletedAt: integer('deleted_at', { mode: 'timestamp' }), // Soft delete support +}) + +/** + * Comments table + */ +export const comments = sqliteTable('comments', { + id: integer('id').primaryKey({ autoIncrement: true }), + content: text('content').notNull(), + postId: integer('post_id') + .notNull() + .references(() => posts.id, { onDelete: 'cascade' }), + userId: integer('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()), +}) + +/** + * Relations + */ +export const usersRelations = relations(users, ({ many }) => ({ + posts: many(posts), + comments: many(comments), +})) + +export const postsRelations = relations(posts, ({ one, many }) => ({ + author: one(users, { + fields: [posts.userId], + references: [users.id], + }), + comments: many(comments), +})) + +export const commentsRelations = relations(comments, ({ one }) => ({ + post: one(posts, { + fields: [comments.postId], + references: [posts.id], + }), + author: one(users, { + fields: [comments.userId], + references: [users.id], + }), +})) + +export const schema = { + users, + posts, + comments, + usersRelations, + postsRelations, + commentsRelations, +} diff --git a/apps/benchmark/fastify-app/src/database/seed.ts b/apps/benchmark/fastify-app/src/database/seed.ts new file mode 100644 index 0000000..bba1b96 --- /dev/null +++ b/apps/benchmark/fastify-app/src/database/seed.ts @@ -0,0 +1,143 @@ +import { useDB } from './db.js' +import { users, posts, comments } from './schema.js' + +export async function seed() { + const db = useDB() + + console.log('Seeding database...') + + // Clear existing data + await db.delete(comments) + await db.delete(posts) + await db.delete(users) + + // Create users with specific IDs matching demo auth + const insertedUsers = await db + .insert(users) + .values([ + { + id: 1, + email: 'admin@example.com', + name: 'Admin User', + role: 'admin', + password: '$2a$10$hashed_password_admin_123', + apiKey: 'sk_live_admin_abc123xyz789', + }, + { + id: 2, + email: 'editor@example.com', + name: 'Editor User', + role: 'editor', + password: '$2a$10$hashed_password_editor_456', + apiKey: 'sk_live_editor_def456uvw012', + }, + { + id: 3, + email: 'user@example.com', + name: 'Regular User', + role: 'user', + password: '$2a$10$hashed_password_user_789', + apiKey: 'sk_live_user_ghi789rst345', + }, + ]) + .returning() + + console.log(`Created ${insertedUsers.length} users`) + + // Create posts with different ownership for demo + const insertedPosts = await db + .insert(posts) + .values([ + { + title: 'My First Post', + content: 'This is a post by the regular user (ID 3). They should be able to edit this.', + published: true, + userId: 3, // Regular user + }, + { + title: 'My Draft Post', + content: 'This is a draft post by the regular user.', + published: false, + userId: 3, // Regular user + }, + { + title: 'Editor\'s Featured Article', + content: 'This is a post created by the editor user.', + published: true, + userId: 2, // Editor + }, + { + title: 'Editor\'s Work in Progress', + content: 'Another post by the editor.', + published: false, + userId: 2, // Editor + }, + { + title: 'Admin Announcement', + content: 'Important announcement from the admin.', + published: true, + userId: 1, // Admin + }, + { + title: 'System Update', + content: 'System maintenance scheduled.', + published: true, + userId: 1, // Admin + }, + // Additional posts for aggregation demos + { title: 'Post 7', content: 'Content for aggregation test', published: true, userId: 1 }, + { title: 'Post 8', content: 'Content for aggregation test', published: false, userId: 1 }, + { title: 'Post 9', content: 'Content for aggregation test', published: true, userId: 2 }, + { title: 'Post 10', content: 'Content for aggregation test', published: true, userId: 2 }, + { title: 'Post 11', content: 'Content for aggregation test', published: false, userId: 2 }, + { title: 'Post 12', content: 'Content for aggregation test', published: true, userId: 3 }, + { title: 'Post 13', content: 'Content for aggregation test', published: true, userId: 3 }, + { title: 'Post 14', content: 'Content for aggregation test', published: false, userId: 3 }, + { title: 'Post 15', content: 'Content for aggregation test', published: true, userId: 1 }, + { title: 'Post 16', content: 'Content for aggregation test', published: true, userId: 1 }, + { title: 'Post 17', content: 'Content for aggregation test', published: false, userId: 2 }, + { title: 'Post 18', content: 'Content for aggregation test', published: true, userId: 2 }, + { title: 'Post 19', content: 'Content for aggregation test', published: true, userId: 3 }, + { title: 'Post 20', content: 'Content for aggregation test', published: false, userId: 3 }, + ]) + .returning() + + console.log(`Created ${insertedPosts.length} posts`) + + // Create comments + const insertedComments = await db + .insert(comments) + .values([ + { + content: 'Great post!', + postId: insertedPosts[0].id, + userId: 2, // Editor commenting + }, + { + content: 'Thanks for sharing', + postId: insertedPosts[0].id, + userId: 1, // Admin commenting + }, + { + content: 'Very informative', + postId: insertedPosts[2].id, + userId: 3, // Regular user commenting + }, + ]) + .returning() + + console.log(`Created ${insertedComments.length} comments`) + console.log('Seeding completed!') +} + +// Run seed if executed directly +const isMainModule = (import.meta as any).main || import.meta.url === `file://${process.argv[1]}` + +if (isMainModule) { + seed() + .then(() => process.exit(0)) + .catch((error) => { + console.error('Seed failed:', error) + process.exit(1) + }) +} diff --git a/apps/benchmark/fastify-app/src/index.ts b/apps/benchmark/fastify-app/src/index.ts new file mode 100644 index 0000000..d9969c4 --- /dev/null +++ b/apps/benchmark/fastify-app/src/index.ts @@ -0,0 +1,22 @@ +import fastify from 'fastify' +import pingRoutes from './routes/ping.js' +import usersRoutes from './routes/users.js' +import postsRoutes from './routes/posts.js' + +const server = fastify({ logger: false }) + +server.register(pingRoutes, { prefix: '/api/manual/ping' }) +server.register(usersRoutes, { prefix: '/api/manual/users' }) +server.register(postsRoutes, { prefix: '/api/manual/posts-complex' }) + +const start = async () => { + try { + await server.listen({ port: 3000, host: '0.0.0.0' }) + console.log('Fastify server listening on http://0.0.0.0:3000') + } catch (err) { + server.log.error(err) + process.exit(1) + } +} + +start() diff --git a/apps/benchmark/fastify-app/src/routes/ping.ts b/apps/benchmark/fastify-app/src/routes/ping.ts new file mode 100644 index 0000000..5d61d89 --- /dev/null +++ b/apps/benchmark/fastify-app/src/routes/ping.ts @@ -0,0 +1,7 @@ +import { FastifyInstance } from 'fastify' + +export default async function (fastify: FastifyInstance) { + fastify.get('/', async (request, reply) => { + return { ping: 'pong' } + }) +} diff --git a/apps/benchmark/fastify-app/src/routes/posts.ts b/apps/benchmark/fastify-app/src/routes/posts.ts new file mode 100644 index 0000000..a1be4ec --- /dev/null +++ b/apps/benchmark/fastify-app/src/routes/posts.ts @@ -0,0 +1,16 @@ +import { FastifyInstance } from 'fastify' +import { useDB } from '../database/db.js' + +export default async function (fastify: FastifyInstance) { + fastify.get('/', async (request, reply) => { + const db = useDB() + const result = await db.query.posts.findMany({ + limit: 20, + with: { + author: true, + comments: true, + } + }) + return result + }) +} diff --git a/apps/benchmark/fastify-app/src/routes/users.ts b/apps/benchmark/fastify-app/src/routes/users.ts new file mode 100644 index 0000000..b553284 --- /dev/null +++ b/apps/benchmark/fastify-app/src/routes/users.ts @@ -0,0 +1,11 @@ +import { FastifyInstance } from 'fastify' +import { useDB } from '../database/db.js' +import { users } from '../database/schema.js' + +export default async function (fastify: FastifyInstance) { + fastify.get('/', async (request, reply) => { + const db = useDB() + const result = await db.select().from(users).all() + return result + }) +} diff --git a/apps/benchmark/fastify-app/tsconfig.json b/apps/benchmark/fastify-app/tsconfig.json new file mode 100644 index 0000000..4517883 --- /dev/null +++ b/apps/benchmark/fastify-app/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} diff --git a/apps/benchmark/k6/script.js b/apps/benchmark/k6/script.js new file mode 100644 index 0000000..412158c --- /dev/null +++ b/apps/benchmark/k6/script.js @@ -0,0 +1,422 @@ +import http from 'k6/http'; +import { check } from 'k6'; + +export const options = { + scenarios: { + // --- NODE JS TESTS --- + // 1. Manual Ping + warmup_manual_ping: { + executor: 'constant-vus', + vus: 5, + duration: '5s', + exec: 'manualPing', + startTime: '0s', + tags: { test_type: 'warmup_manual_ping', is_warmup: 'true' }, + }, + manual_ping: { + executor: 'constant-vus', + vus: 20, + duration: '20s', + exec: 'manualPing', + startTime: '5s', + tags: { test_type: 'manual_ping' }, + }, + + // 2. Manual Users Simple + warmup_manual_users_simple: { + executor: 'constant-vus', + vus: 5, + duration: '5s', + exec: 'manualUsers', + startTime: '25s', + tags: { test_type: 'warmup_manual_users_simple', is_warmup: 'true' }, + }, + manual_users_simple: { + executor: 'constant-vus', + vus: 20, + duration: '20s', + exec: 'manualUsers', + startTime: '30s', + tags: { test_type: 'manual_users_simple' }, + }, + + // 3. Auto Users Simple + warmup_auto_users_simple: { + executor: 'constant-vus', + vus: 5, + duration: '5s', + exec: 'autoUsers', + startTime: '50s', + tags: { test_type: 'warmup_auto_users_simple', is_warmup: 'true' }, + }, + auto_users_simple: { + executor: 'constant-vus', + vus: 20, + duration: '20s', + exec: 'autoUsers', + startTime: '55s', + tags: { test_type: 'auto_users_simple' }, + }, + + // 4. Manual Posts Complex + warmup_manual_posts_complex: { + executor: 'constant-vus', + vus: 5, + duration: '5s', + exec: 'manualPostsComplex', + startTime: '75s', + tags: { test_type: 'warmup_manual_posts_complex', is_warmup: 'true' }, + }, + manual_posts_complex: { + executor: 'constant-vus', + vus: 20, + duration: '20s', + exec: 'manualPostsComplex', + startTime: '80s', + tags: { test_type: 'manual_posts_complex' }, + }, + + // 5. Auto Posts Complex + warmup_auto_posts_complex: { + executor: 'constant-vus', + vus: 5, + duration: '5s', + exec: 'autoPostsComplex', + startTime: '100s', + tags: { test_type: 'warmup_auto_posts_complex', is_warmup: 'true' }, + }, + auto_posts_complex: { + executor: 'constant-vus', + vus: 20, + duration: '20s', + exec: 'autoPostsComplex', + startTime: '105s', + tags: { test_type: 'auto_posts_complex' }, + }, + + + // --- BUN TESTS --- + // 6. Manual Ping Bun + warmup_manual_ping_bun: { + executor: 'constant-vus', + vus: 5, + duration: '5s', + exec: 'manualPingBun', + startTime: '125s', + tags: { test_type: 'warmup_manual_ping_bun', is_warmup: 'true' }, + }, + manual_ping_bun: { + executor: 'constant-vus', + vus: 20, + duration: '20s', + exec: 'manualPingBun', + startTime: '130s', + tags: { test_type: 'manual_ping_bun' }, + }, + + // 7. Manual Users Simple Bun + warmup_manual_users_simple_bun: { + executor: 'constant-vus', + vus: 5, + duration: '5s', + exec: 'manualUsersBun', + startTime: '150s', + tags: { test_type: 'warmup_manual_users_simple_bun', is_warmup: 'true' }, + }, + manual_users_simple_bun: { + executor: 'constant-vus', + vus: 20, + duration: '20s', + exec: 'manualUsersBun', + startTime: '155s', + tags: { test_type: 'manual_users_simple_bun' }, + }, + + // 8. Auto Users Simple Bun + warmup_auto_users_simple_bun: { + executor: 'constant-vus', + vus: 5, + duration: '5s', + exec: 'autoUsersBun', + startTime: '175s', + tags: { test_type: 'warmup_auto_users_simple_bun', is_warmup: 'true' }, + }, + auto_users_simple_bun: { + executor: 'constant-vus', + vus: 20, + duration: '20s', + exec: 'autoUsersBun', + startTime: '180s', + tags: { test_type: 'auto_users_simple_bun' }, + }, + + // 9. Manual Posts Complex Bun + warmup_manual_posts_complex_bun: { + executor: 'constant-vus', + vus: 5, + duration: '5s', + exec: 'manualPostsComplexBun', + startTime: '200s', + tags: { test_type: 'warmup_manual_posts_complex_bun', is_warmup: 'true' }, + }, + manual_posts_complex_bun: { + executor: 'constant-vus', + vus: 20, + duration: '20s', + exec: 'manualPostsComplexBun', + startTime: '205s', + tags: { test_type: 'manual_posts_complex_bun' }, + }, + + // 10. Auto Posts Complex Bun + warmup_auto_posts_complex_bun: { + executor: 'constant-vus', + vus: 5, + duration: '5s', + exec: 'autoPostsComplexBun', + startTime: '225s', + tags: { test_type: 'warmup_auto_posts_complex_bun', is_warmup: 'true' }, + }, + auto_posts_complex_bun: { + executor: 'constant-vus', + vus: 20, + duration: '20s', + exec: 'autoPostsComplexBun', + startTime: '230s', + tags: { test_type: 'auto_posts_complex_bun' }, + }, + + // --- FASTIFY TESTS --- + // 11. Manual Ping Fastify + warmup_manual_ping_fastify: { + executor: 'constant-vus', + vus: 5, + duration: '5s', + exec: 'manualPingFastify', + startTime: '250s', + tags: { test_type: 'warmup_manual_ping_fastify', is_warmup: 'true' }, + }, + manual_ping_fastify: { + executor: 'constant-vus', + vus: 20, + duration: '20s', + exec: 'manualPingFastify', + startTime: '255s', + tags: { test_type: 'manual_ping_fastify' }, + }, + + // 12. Manual Users Simple Fastify + warmup_manual_users_simple_fastify: { + executor: 'constant-vus', + vus: 5, + duration: '5s', + exec: 'manualUsersFastify', + startTime: '275s', + tags: { test_type: 'warmup_manual_users_simple_fastify', is_warmup: 'true' }, + }, + manual_users_simple_fastify: { + executor: 'constant-vus', + vus: 20, + duration: '20s', + exec: 'manualUsersFastify', + startTime: '280s', + tags: { test_type: 'manual_users_simple_fastify' }, + }, + + // 13. Manual Posts Complex Fastify + warmup_manual_posts_complex_fastify: { + executor: 'constant-vus', + vus: 5, + duration: '5s', + exec: 'manualPostsComplexFastify', + startTime: '300s', + tags: { test_type: 'warmup_manual_posts_complex_fastify', is_warmup: 'true' }, + }, + manual_posts_complex_fastify: { + executor: 'constant-vus', + vus: 20, + duration: '20s', + exec: 'manualPostsComplexFastify', + startTime: '305s', + tags: { test_type: 'manual_posts_complex_fastify' }, + }, + }, + thresholds: { + 'http_req_duration{test_type:manual_ping}': ['p(95)<100'], + 'http_req_duration{test_type:manual_users_simple}': ['p(95)<500'], + 'http_req_duration{test_type:auto_users_simple}': ['p(95)<500'], + 'http_req_duration{test_type:manual_posts_complex}': ['p(95)<500'], + 'http_req_duration{test_type:auto_posts_complex}': ['p(95)<500'], + 'http_reqs{test_type:manual_ping}': ['count>=0'], + 'http_reqs{test_type:manual_users_simple}': ['count>=0'], + 'http_reqs{test_type:auto_users_simple}': ['count>=0'], + 'http_reqs{test_type:manual_posts_complex}': ['count>=0'], + 'http_reqs{test_type:auto_posts_complex}': ['count>=0'], + + // Bun Thresholds + 'http_req_duration{test_type:manual_ping_bun}': ['p(95)<100'], + 'http_req_duration{test_type:manual_users_simple_bun}': ['p(95)<500'], + 'http_req_duration{test_type:auto_users_simple_bun}': ['p(95)<500'], + 'http_req_duration{test_type:manual_posts_complex_bun}': ['p(95)<500'], + 'http_req_duration{test_type:auto_posts_complex_bun}': ['p(95)<500'], + 'http_reqs{test_type:manual_ping_bun}': ['count>=0'], + 'http_reqs{test_type:manual_users_simple_bun}': ['count>=0'], + 'http_reqs{test_type:auto_users_simple_bun}': ['count>=0'], + 'http_reqs{test_type:manual_posts_complex_bun}': ['count>=0'], + 'http_reqs{test_type:auto_posts_complex_bun}': ['count>=0'], + + // Fastify Thresholds + 'http_req_duration{test_type:manual_ping_fastify}': ['p(95)<100'], + 'http_req_duration{test_type:manual_users_simple_fastify}': ['p(95)<500'], + 'http_req_duration{test_type:manual_posts_complex_fastify}': ['p(95)<500'], + 'http_reqs{test_type:manual_ping_fastify}': ['count>=0'], + 'http_reqs{test_type:manual_users_simple_fastify}': ['count>=0'], + 'http_reqs{test_type:manual_posts_complex_fastify}': ['count>=0'], + }, +}; + +const NODE_URL = 'http://app:3000'; +const BUN_URL = 'http://app-bun:3000'; +const FASTIFY_URL = 'http://app-fastify:3000'; + +function checkResponse(res, name) { + const success = check(res, { 'status was 200': (r) => r.status == 200 }); + if (!success) { + console.error(`[${name}] Failed: ${res.status} ${res.body}`); + } +} + +// Node Functions +export function manualPing() { + const res = http.get(`${NODE_URL}/api/manual/ping`); + checkResponse(res, 'manualPing'); +} + +export function manualUsers() { + const res = http.get(`${NODE_URL}/api/manual/users`); + checkResponse(res, 'manualUsers'); +} + +export function autoUsers() { + const res = http.get(`${NODE_URL}/api/users`); + checkResponse(res, 'autoUsers'); +} + +export function manualPostsComplex() { + const res = http.get(`${NODE_URL}/api/manual/posts-complex`); + checkResponse(res, 'manualPostsComplex'); +} + +export function autoPostsComplex() { + const res = http.get(`${NODE_URL}/api/posts?include=author&include=comments&limit=20`); + checkResponse(res, 'autoPostsComplex'); +} + +// Bun Functions +export function manualPingBun() { + const res = http.get(`${BUN_URL}/api/manual/ping`); + checkResponse(res, 'manualPingBun'); +} + +export function manualUsersBun() { + const res = http.get(`${BUN_URL}/api/manual/users`); + checkResponse(res, 'manualUsersBun'); +} + +export function autoUsersBun() { + const res = http.get(`${BUN_URL}/api/users`); + checkResponse(res, 'autoUsersBun'); +} + +export function manualPostsComplexBun() { + const res = http.get(`${BUN_URL}/api/manual/posts-complex`); + checkResponse(res, 'manualPostsComplexBun'); +} + +export function autoPostsComplexBun() { + const res = http.get(`${BUN_URL}/api/posts?include=author&include=comments&limit=20`); + checkResponse(res, 'autoPostsComplexBun'); +} + +// Fastify Functions +export function manualPingFastify() { + const res = http.get(`${FASTIFY_URL}/api/manual/ping`); + checkResponse(res, 'manualPingFastify'); +} + +export function manualUsersFastify() { + const res = http.get(`${FASTIFY_URL}/api/manual/users`); + checkResponse(res, 'manualUsersFastify'); +} + +export function manualPostsComplexFastify() { + const res = http.get(`${FASTIFY_URL}/api/manual/posts-complex`); + checkResponse(res, 'manualPostsComplexFastify'); +} + +export function handleSummary(data) { + const tests = [ + 'manual_ping', + 'manual_users_simple', + 'auto_users_simple', + 'manual_posts_complex', + 'auto_posts_complex', + 'manual_ping_bun', + 'manual_users_simple_bun', + 'auto_users_simple_bun', + 'manual_posts_complex_bun', + 'auto_posts_complex_bun', + 'manual_ping_fastify', + 'manual_users_simple_fastify', + 'manual_posts_complex_fastify' + ]; + + const baselines = {}; + + let output = '\n\n'; + const separator = '--------------------------------------------------------------------------------------------------------------\n'; + output += separator; + output += '| Test Case | RPS (req/s) | Avg (ms) | p95 (ms) | Max (ms) | Failure % | % Slower |\n'; + output += '|---------------------------|-------------|-------------|-------------|-------------|-----------|-----------|\n'; + + tests.forEach(test => { + const durationKey = `http_req_duration{test_type:${test}}`; + const reqsKey = `http_reqs{test_type:${test}}`; + + const durationMetric = data.metrics[durationKey]; + const reqsMetric = data.metrics[reqsKey]; + + if (!durationMetric || !reqsMetric) { + output += `| ${test.padEnd(25)} | N/A | N/A | N/A | N/A | N/A | N/A |\n`; + return; + } + + const count = reqsMetric.values.count; + const rps = (count / 20).toFixed(2); + const avgVal = durationMetric.values.avg; + const avg = avgVal.toFixed(2); + const p95 = durationMetric.values['p(95)'].toFixed(2); + const max = durationMetric.values.max.toFixed(2); + + let slower = '-'; + + if (test.startsWith('manual_')) { + baselines[test] = avgVal; + } else if (test.startsWith('auto_')) { + const manualKey = test.replace('auto_', 'manual_'); + if (baselines[manualKey]) { + const manualAvg = baselines[manualKey]; + const diff = ((avgVal - manualAvg) / manualAvg) * 100; + slower = (diff > 0 ? '+' : '') + diff.toFixed(1) + '%'; + } + } + + output += `| ${test.padEnd(25)} | ${rps.padEnd(11)} | ${avg.padEnd(11)} | ${p95.padEnd(11)} | ${max.padEnd(11)} | - | ${slower.padEnd(9)} |\n`; + }); + + output += separator; + + return { + 'stdout': output, + }; +} diff --git a/apps/benchmark/package.json b/apps/benchmark/package.json new file mode 100644 index 0000000..16f9180 --- /dev/null +++ b/apps/benchmark/package.json @@ -0,0 +1,13 @@ +{ + "name": "nuxt-auto-benchmark", + "private": true, + "type": "module", + "scripts": { + "dev": "cd app && nuxt dev", + "build": "cd app && nuxt build", + "dev:fastify": "cd fastify-app && tsx src/index.ts" + }, + "devDependencies": { + "@websideproject/nuxt-auto-api": "workspace:*" + } +} diff --git a/apps/docs/content/1.getting-started/.navigation.yml b/apps/docs/content/1.getting-started/.navigation.yml index 6dca48e..bcd2ba1 100644 --- a/apps/docs/content/1.getting-started/.navigation.yml +++ b/apps/docs/content/1.getting-started/.navigation.yml @@ -1,2 +1 @@ title: Getting Started -icon: false diff --git a/apps/docs/content/1.getting-started/1.introduction.md b/apps/docs/content/1.getting-started/1.introduction.md new file mode 100644 index 0000000..f091ac8 --- /dev/null +++ b/apps/docs/content/1.getting-started/1.introduction.md @@ -0,0 +1,153 @@ +# Introduction + +Nuxt Auto is a collection of modules that dramatically accelerate full-stack development by automatically generating production-ready APIs and admin interfaces from your database schemas. + +## What is Nuxt Auto? + +Nuxt Auto consists of two complementary Nuxt modules: + +### Nuxt Auto API + +**Auto-generate type-safe REST APIs from Drizzle ORM schemas** + +Define your database schema with Drizzle ORM and get complete CRUD endpoints automatically. No need to write repetitive route handlers, validation logic, or authorization checks manually. + +**Key capabilities:** +- Automatic CRUD endpoint generation +- Multi-tier authorization (operation, SQL, object, field level) +- Built-in validation with Zod +- Multi-database support (SQLite, Postgres, MySQL, D1, Turso, PlanetScale) +- Plugin system for extensibility +- Custom endpoints and handler overrides +- Cursor & offset pagination +- Soft delete support +- Multi-tenancy +- M2M relationships +- Lifecycle hooks + +### Nuxt Auto Admin + +**Beautiful admin panel generated from Auto API resources** + +Once your APIs are defined, Auto Admin automatically creates a complete admin interface with full CRUD operations, permissions, and customization options. + +**Key capabilities:** +- Auto-generated CRUD pages (list, detail, create, edit) +- Permission-based access control +- Rich form widgets (text, select, date, relations, rich text, markdown, code, file upload, etc.) +- M2M relationship management +- Custom pages for specialized admin functionality +- Fully customizable branding and theming +- Dark mode support +- Responsive design + +## Why Nuxt Auto? + +### Eliminate Boilerplate + +Stop writing the same CRUD endpoints and admin interfaces over and over. Define your schema once, get everything else automatically. + +**Without Nuxt Auto:** +```typescript +// Manual API route +export default defineEventHandler(async (event) => { + const id = getRouterParam(event, 'id') + const body = await readBody(event) + + // Manual validation + if (!body.email || !isValidEmail(body.email)) { + throw createError({ statusCode: 400, message: 'Invalid email' }) + } + + // Manual authorization + const user = await getUser(event) + if (!user || user.role !== 'admin') { + throw createError({ statusCode: 403, message: 'Forbidden' }) + } + + // Manual database query + const result = await db.update(users) + .set(body) + .where(eq(users.id, id)) + .returning() + + return result[0] +}) +``` + +**With Nuxt Auto:** +```typescript +// Your schema IS your API +export const users = sqliteTable('users', { + id: integer('id').primaryKey({ autoIncrement: true }), + email: text('email').notNull().unique(), + name: text('name'), + role: text('role', { enum: ['user', 'admin'] }).default('user'), +}) +``` + +### Type-Safe End-to-End + +Full TypeScript inference from database schema to frontend components. Change your schema, and TypeScript will guide you through updating your entire stack. + +### Production-Ready Features + +Built-in authorization, validation, pagination, multi-tenancy, soft deletes, and more. All the features you need for production applications without writing them yourself. + +### Flexible & Extensible + +Not a black box. Override any handler, add custom endpoints, create plugins, define custom validation, and build custom admin pages when needed. + +## Use Cases + +Nuxt Auto is perfect for: + +- **SaaS applications** - Multi-tenant data isolation, admin panels, user management +- **Internal tools** - Rapid development of CRUD interfaces for internal teams +- **Content management** - Blog posts, pages, media management +- **Data dashboards** - Admin interfaces for viewing and editing data +- **MVP development** - Ship faster with auto-generated APIs and admin UIs +- **Monolithic applications** - Full-stack Nuxt apps with database-driven features + +## Architecture + +``` +┌─────────────────────────────────────────┐ +│ Nuxt Application │ +├─────────────────────────────────────────┤ +│ │ +│ Frontend (Auto-generated Composables) │ +│ • useAutoApiList() │ +│ • useAutoApiMutation() │ +│ • Admin UI Components │ +│ │ +├─────────────────────────────────────────┤ +│ │ +│ Auto API (REST Endpoints) │ +│ • GET /api/users │ +│ • POST /api/users │ +│ • PATCH /api/users/:id │ +│ • DELETE /api/users/:id │ +│ │ +├─────────────────────────────────────────┤ +│ │ +│ Drizzle ORM │ +│ • Type-safe queries │ +│ • Schema definitions │ +│ • Migrations │ +│ │ +├─────────────────────────────────────────┤ +│ │ +│ Database │ +│ • SQLite / Postgres / MySQL │ +│ • Cloudflare D1 / Turso / PlanetScale │ +│ │ +└─────────────────────────────────────────┘ +``` + +## Next Steps + +- **[Installation](/getting-started/installation)** - Set up Nuxt Auto in your project +- **[Quick Start](/getting-started/quick-start)** - Build your first API and admin panel +- **[Auto API Docs](/auto-api/getting-started)** - Deep dive into API features +- **[Auto Admin Docs](/auto-admin/getting-started)** - Customize your admin panel diff --git a/apps/docs/content/1.getting-started/2.installation.md b/apps/docs/content/1.getting-started/2.installation.md new file mode 100644 index 0000000..92dbd2a --- /dev/null +++ b/apps/docs/content/1.getting-started/2.installation.md @@ -0,0 +1,266 @@ +# Installation + +This guide will help you install and set up Nuxt Auto in your Nuxt 3 project. + +## Prerequisites + +Before installing Nuxt Auto, ensure you have: + +- **Node.js** 18.x or later +- **Nuxt 3** (latest stable version recommended) +- **Package manager** - npm, pnpm, yarn, or bun +- **Drizzle ORM** - For database schema definitions + +## Install Dependencies + +### 1. Install Core Packages + +First, install the Nuxt Auto modules: + +```bash +# Using npm +npm install @websideproject/nuxt-auto-api @websideproject/nuxt-auto-admin + +# Using pnpm +pnpm add @websideproject/nuxt-auto-api @websideproject/nuxt-auto-admin + +# Using yarn +yarn add @websideproject/nuxt-auto-api @websideproject/nuxt-auto-admin + +# Using bun +bun add @websideproject/nuxt-auto-api @websideproject/nuxt-auto-admin +``` + +If you only need the API module, you can install just `@websideproject/nuxt-auto-api`. + +### 2. Install Nuxt UI (Required for Admin) + +The admin module requires Nuxt UI for its components: + +```bash +# Using npm +npm install @nuxt/ui + +# Using pnpm +pnpm add @nuxt/ui + +# Using yarn +yarn add @nuxt/ui + +# Using bun +bun add @nuxt/ui +``` + +### 3. Install Drizzle ORM + +Choose your database and install the corresponding Drizzle packages: + +#### SQLite (better-sqlite3) + +```bash +npm install drizzle-orm better-sqlite3 +npm install -D drizzle-kit @types/better-sqlite3 +``` + +#### PostgreSQL + +```bash +npm install drizzle-orm postgres +npm install -D drizzle-kit +``` + +#### MySQL + +```bash +npm install drizzle-orm mysql2 +npm install -D drizzle-kit +``` + +#### Cloudflare D1 + +```bash +npm install drizzle-orm +npm install -D drizzle-kit wrangler +``` + +#### Turso + +```bash +npm install drizzle-orm @libsql/client +npm install -D drizzle-kit +``` + +### 4. Install Optional Dependencies + +For additional features: + +**TanStack Query** (for frontend data fetching): +```bash +npm install @tanstack/vue-query +``` + +**Zod** (for validation): +```bash +npm install zod drizzle-zod +``` + +**Better Auth** (for authentication): +```bash +npm install better-auth +``` + +## Configure Modules + +Add the modules to your `nuxt.config.ts`: + +```typescript +export default defineNuxtConfig({ + modules: [ + '@nuxt/ui', // Required for admin + '@websideproject/nuxt-auto-api', // API module + '@websideproject/nuxt-auto-admin', // Admin module + ], + + autoApi: { + prefix: '/api', + database: { + client: 'better-sqlite3', // or 'pg', 'mysql2', 'd1', 'turso' + }, + }, + + autoAdmin: { + prefix: '/admin', + branding: { + title: 'Admin Panel', + }, + }, +}) +``` + +**Important:** `nuxt-auto-api` must be loaded before `nuxt-auto-admin`. + +## TypeScript Configuration + +Ensure your `tsconfig.json` extends Nuxt's configuration: + +```json +{ + "extends": "./.nuxt/tsconfig.json" +} +``` + +This gives you full type inference for all auto-generated composables and types. + +## Database Setup + +Create your Drizzle schema and database connection: + +### 1. Create Schema + +```typescript +// server/database/schema.ts +import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core' + +export const users = sqliteTable('users', { + id: integer('id').primaryKey({ autoIncrement: true }), + email: text('email').notNull().unique(), + name: text('name'), + role: text('role', { enum: ['user', 'admin'] }).default('user'), + createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()), +}) +``` + +### 2. Initialize Database + +```typescript +// server/plugins/database.ts +import { drizzle } from 'drizzle-orm/better-sqlite3' +import Database from 'better-sqlite3' +import { initializeDatabase } from '@websideproject/nuxt-auto-api/database' +import * as schema from '../database/schema' + +export default defineNitroPlugin(() => { + const sqlite = new Database('sqlite.db') + const db = drizzle(sqlite, { schema }) + + initializeDatabase(db, 'better-sqlite3') +}) +``` + +### 3. Configure Drizzle Kit + +```typescript +// drizzle.config.ts +import type { Config } from 'drizzle-kit' + +export default { + schema: './server/database/schema.ts', + out: './drizzle', + driver: 'better-sqlite3', + dbCredentials: { + url: 'sqlite.db', + }, +} satisfies Config +``` + +### 4. Generate and Run Migrations + +```bash +# Generate migration +npx drizzle-kit generate:sqlite + +# Push schema to database (development) +npx drizzle-kit push:sqlite +``` + +## Verify Installation + +Start your development server: + +```bash +npm run dev +``` + +You should see: +- API endpoints available at `http://localhost:3000/api` +- Admin panel at `http://localhost:3000/admin` + +## Next Steps + +- **[Quick Start](/getting-started/quick-start)** - Build your first API and admin panel +- **[Auto API - Getting Started](/auto-api/getting-started)** - Configure API features +- **[Auto Admin - Getting Started](/auto-admin/getting-started)** - Customize admin panel + +## Troubleshooting + +### Module Not Found Errors + +If you get module resolution errors, try: + +```bash +# Clear Nuxt cache +rm -rf .nuxt + +# Reinstall dependencies +rm -rf node_modules +npm install + +# Rebuild +npm run dev +``` + +### Database Connection Issues + +Ensure your database path is correct and the database file exists. For SQLite, the file will be created automatically on first run. + +### Type Errors + +Make sure you're extending Nuxt's TypeScript config: + +```json +{ + "extends": "./.nuxt/tsconfig.json" +} +``` + +Restart your TypeScript server in your editor after changes. diff --git a/apps/docs/content/1.getting-started/2.introduction.md b/apps/docs/content/1.getting-started/2.introduction.md deleted file mode 100644 index 4fabae3..0000000 --- a/apps/docs/content/1.getting-started/2.introduction.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -title: Introduction -description: Welcome to Docus theme documentation. -navigation: - icon: i-lucide-house -seo: - title: Introduction - description: Discover how to create, manage, and publish documentation - effortlessly with Docus. ---- - -Welcome to **Docus**, a fully integrated documentation solution built with [Nuxt UI](https://ui.nuxt.com). - -## What is Docus? - -Docus is a theme based on the [UI documentation template](https://docs-template.nuxt.dev/). While the visual style comes ready out of the box, your focus should be on writing content using the Markdown and [MDC syntax](https://content.nuxt.com/docs/files/markdown#mdc-syntax) provided by [Nuxt Content](https://content.nuxt.com). - -We use this theme across all our Nuxt module documentations, including: - -::card-group - :::card - --- - icon: i-simple-icons-nuxtdotjs - target: _blank - title: Nuxt Image - to: https://image.nuxt.com - --- - The documentation of `@nuxt/image` - ::: - - :::card - --- - icon: i-simple-icons-nuxtdotjs - target: _blank - title: Nuxt Supabase - to: https://supabase.nuxtjs.org - --- - The documentation of `@nuxt/supabase` - ::: -:: - -## Key Features - -This theme includes a range of features designed to improve documentation management: - -- **Powered by** [**Nuxt 4**](https://nuxt.com): Utilizes the latest Nuxt framework for optimal performance. -- **Built with** [**Nuxt UI**](https://ui.nuxt.com): Integrates a comprehensive suite of UI components. -- [**MDC Syntax**](https://content.nuxt.com/usage/markdown) **via** [**Nuxt Content**](https://content.nuxt.com): Supports Markdown with component integration for dynamic content. -- [**Nuxt Studio**](https://content.nuxt.com/docs/studio) **Compatible**: Write and edit your content visually. No Markdown knowledge is required! -- **Auto-generated Sidebar Navigation**: Automatically generates navigation from content structure. -- **Full-Text Search**: Includes built-in search functionality for content discovery. -- **Optimized Typography**: Features refined typography for enhanced readability. -- **Dark Mode**: Offers dark mode support for user preference. -- **Extensive Functionality**: Explore the theme to fully appreciate its capabilities. diff --git a/apps/docs/content/1.getting-started/3.installation.md b/apps/docs/content/1.getting-started/3.installation.md deleted file mode 100644 index 49fcd4b..0000000 --- a/apps/docs/content/1.getting-started/3.installation.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -title: Installation -description: Get started with Docus. -navigation: - icon: i-lucide-download -seo: - description: Get started with Docus documentation theme. ---- - -## Local development - -::steps -### Create your docs directory - -Use the `docus` CLI to create a new Docus project in the `docs/` directory: - -```bash [Terminal] -npx docus init docs -``` - -We recommend using the `npm` package manager. - -### Start your docs server in development - -Move to the `docs/` directory and start your docs server in development mode: - -```bash [Terminal] -npm run dev -``` - -A local preview of your documentation will be available at - -### Write your documentation - -Head over the [Edition](https://docus.dev/concepts/edition) section to learn how to write your documentation. -:: - -## Online Edition with Nuxt Studio - -::prose-steps -### Create a new project on Nuxt Studio - -Choose `Start from a template` and select **Docus.** Clone it on your GitHub personal account or any organisation of your choice. - -### Deploy in one click - -Once your project has been created and you're in the project dashboard, navigate to the `Deploy` section, choose the `GitHub Pages` tab then click on the **Deploy** button. - - :::prose-note - --- - to: https://content.nuxt.com/docs/studio/setup#enable-the-full-editing-experience - --- - This is a one click static deployment available with [GitHub Pages](https://docs.github.com/en/pages/getting-started-with-github-pages/creating-a-github-pages-site) but you can also handle deployment yourself and use the `Selfhosted` tab. - ::: - -### Write your documentation in the editor - -Once the deployment is achieved, you'll be able to display the preview of your documentation. You can browse your content pages to edit them or create new ones. - -:video{controls loop poster="https://res.cloudinary.com/nuxt/video/upload/v1747230893/studio/wzt9zfmdvk7hgmdx3cnt.jpg" src="https://res.cloudinary.com/nuxt/video/upload/v1747230893/studio/wzt9zfmdvk7hgmdx3cnt.mp4"} -:: diff --git a/apps/docs/content/1.getting-started/3.quick-start.md b/apps/docs/content/1.getting-started/3.quick-start.md new file mode 100644 index 0000000..48585d1 --- /dev/null +++ b/apps/docs/content/1.getting-started/3.quick-start.md @@ -0,0 +1,361 @@ +# Quick Start + +This guide will walk you through creating your first API and admin panel with Nuxt Auto in under 10 minutes. + +## Project Setup + +### 1. Create a New Nuxt Project + +```bash +npx nuxi@latest init my-app +cd my-app +``` + +### 2. Install Nuxt Auto + +```bash +npm install @websideproject/nuxt-auto-api @websideproject/nuxt-auto-admin @nuxt/ui +npm install drizzle-orm better-sqlite3 +npm install -D drizzle-kit @types/better-sqlite3 +``` + +### 3. Configure Nuxt + +Update `nuxt.config.ts`: + +```typescript +export default defineNuxtConfig({ + modules: [ + '@nuxt/ui', + '@websideproject/nuxt-auto-api', + '@websideproject/nuxt-auto-admin', + ], + + autoApi: { + prefix: '/api', + database: { + client: 'better-sqlite3', + }, + }, + + autoAdmin: { + prefix: '/admin', + branding: { + title: 'Blog Admin', + }, + }, + + compatibilityDate: '2024-01-01', +}) +``` + +## Create Your Schema + +### 1. Define Database Schema + +Create `server/database/schema.ts`: + +```typescript +import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core' + +export const posts = sqliteTable('posts', { + id: integer('id').primaryKey({ autoIncrement: true }), + title: text('title').notNull(), + slug: text('slug').notNull().unique(), + content: text('content'), + published: integer('published', { mode: 'boolean' }).default(false), + createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()), + updatedAt: integer('updated_at', { mode: 'timestamp' }).$defaultFn(() => new Date()), +}) + +export const authors = sqliteTable('authors', { + id: integer('id').primaryKey({ autoIncrement: true }), + name: text('name').notNull(), + email: text('email').notNull().unique(), + bio: text('bio'), +}) +``` + +### 2. Initialize Database + +Create `server/plugins/database.ts`: + +```typescript +import { drizzle } from 'drizzle-orm/better-sqlite3' +import Database from 'better-sqlite3' +import { initializeDatabase } from '@websideproject/nuxt-auto-api/database' +import * as schema from '../database/schema' + +export default defineNitroPlugin(() => { + const sqlite = new Database('sqlite.db') + const db = drizzle(sqlite, { schema }) + + initializeDatabase(db, 'better-sqlite3') +}) +``` + +### 3. Configure Drizzle Kit + +Create `drizzle.config.ts`: + +```typescript +import type { Config } from 'drizzle-kit' + +export default { + schema: './server/database/schema.ts', + out: './drizzle', + driver: 'better-sqlite3', + dbCredentials: { + url: 'sqlite.db', + }, +} satisfies Config +``` + +### 4. Generate Database + +```bash +npx drizzle-kit push:sqlite +``` + +## Register Resources + +### 1. Create a Module + +Create `modules/base/index.ts`: + +```typescript +import { defineNuxtModule, createResolver } from '@nuxt/kit' +import { createModuleImport } from '@websideproject/nuxt-auto-api' + +export default defineNuxtModule({ + meta: { + name: 'base', + }, + setup(_options, nuxt) { + const resolver = createResolver(import.meta.url) + + nuxt.hook('autoApi:registerSchema', (registry) => { + registry.register('posts', { + schema: createModuleImport( + resolver.resolve('../../server/database/schema'), + 'posts' + ), + }) + + registry.register('authors', { + schema: createModuleImport( + resolver.resolve('../../server/database/schema'), + 'authors' + ), + }) + }) + }, +}) +``` + +### 2. Load the Module + +Update `nuxt.config.ts` to include your module: + +```typescript +export default defineNuxtConfig({ + modules: [ + '@nuxt/ui', + '@websideproject/nuxt-auto-api', + '@websideproject/nuxt-auto-admin', + './modules/base', // Add this + ], + + // ... rest of config +}) +``` + +## Test Your API + +Start the development server: + +```bash +npm run dev +``` + +### API Endpoints + +Your API is now available at: + +``` +GET /api/posts # List all posts +GET /api/posts/:id # Get single post +POST /api/posts # Create post +PATCH /api/posts/:id # Update post +DELETE /api/posts/:id # Delete post + +GET /api/authors # List all authors +GET /api/authors/:id # Get single author +POST /api/authors # Create author +PATCH /api/authors/:id # Update author +DELETE /api/authors/:id # Delete author +``` + +### Test with cURL + +```bash +# Create an author +curl -X POST http://localhost:3000/api/authors \ + -H "Content-Type: application/json" \ + -d '{"name":"John Doe","email":"john@example.com"}' + +# Create a post +curl -X POST http://localhost:3000/api/posts \ + -H "Content-Type: application/json" \ + -d '{"title":"Hello World","slug":"hello-world","content":"My first post"}' + +# List posts +curl http://localhost:3000/api/posts +``` + +## Access Admin Panel + +Navigate to `http://localhost:3000/admin` + +You'll see: +- **Posts** resource with list, create, edit, and delete pages +- **Authors** resource with full CRUD operations +- Auto-generated forms based on your schema + +## Use in Frontend + +### 1. List Posts + +```vue + + + +``` + +### 2. Create Post + +```vue + + + +``` + +## Customize Admin Panel + +### Configure Resources + +Update `nuxt.config.ts`: + +```typescript +export default defineNuxtConfig({ + // ... modules + + autoAdmin: { + prefix: '/admin', + branding: { + title: 'Blog Admin', + logo: '/logo.svg', + }, + resources: { + posts: { + displayName: 'Blog Posts', + icon: 'i-heroicons-document-text', + group: 'Content', + listFields: ['id', 'title', 'published', 'createdAt'], + formFields: { + edit: [ + { name: 'title', widget: 'TextInput', required: true }, + { name: 'slug', widget: 'SlugInput', options: { generateFrom: 'title' } }, + { name: 'content', widget: 'MarkdownEditor' }, + { name: 'published', widget: 'Checkbox' }, + ], + }, + }, + authors: { + displayName: 'Authors', + icon: 'i-heroicons-user-group', + group: 'Content', + listFields: ['id', 'name', 'email'], + }, + }, + }, +}) +``` + +## Next Steps + +Now that you have a working setup: + +### Learn More About Auto API + +- **[Validation](/auto-api/validation)** - Add custom validation rules +- **[Authorization](/auto-api/authorization)** - Secure your endpoints +- **[Custom Endpoints](/auto-api/custom-endpoints)** - Add custom logic +- **[Pagination](/auto-api/pagination)** - Configure cursor or offset pagination +- **[Multi-Tenancy](/auto-api/multitenancy)** - Build SaaS applications + +### Learn More About Auto Admin + +- **[Configuration & Theming](/auto-admin/configuration-theming)** - Customize appearance +- **[Form Fields & Widgets](/auto-admin/form-fields-widgets)** - Rich form controls +- **[Permissions](/auto-admin/permissions)** - Access control +- **[Custom Pages](/auto-admin/custom-pages)** - Add custom functionality + +## Example Project + +Clone the example project for a complete working setup: + +```bash +git clone https://github.com/websideproject/nuxt-auto +cd nuxt-auto/apps/playground +bun install +bun dev +``` + +Visit `http://localhost:3000` to see the example in action. diff --git a/apps/docs/content/1.getting-started/4.project-structure.md b/apps/docs/content/1.getting-started/4.project-structure.md deleted file mode 100644 index 169d3c8..0000000 --- a/apps/docs/content/1.getting-started/4.project-structure.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -navigation: - icon: i-lucide-folder-tree -title: Project Structure ---- - -Docus provides a ready-to-use [documentation website starter](https://github.com/nuxt-content/docus/tree/.starter). - -This is the minimal directory structure to get an up and running Docus website. - -```bash -content/ - index.md -public/ - favicon.ico -package.json -``` - -### `content/` directory - -This is where you [write pages](https://docus.dev/concepts/edition) in Markdown. - -### `public/` directory - -Files contained within the `public/` directory are served at the root and are not modified by the build process of your documentation. This is where you can locate your medias. - -### `package.json` - -This file contains all the dependencies and scripts for your application. The `package.json` of a Docus application si really minimal and looks like: - -```json [package.json] -{ - "name": "docus-starter", - "scripts": { - "dev": "docus dev", - "build": "docus build" - }, - "devDependencies": { - "docus": "latest" - } -} -``` - -### `app.config.ts` - -*This file is not mandatory to start a Docus application.* - -This is where you can [configure Docus](https://docus.dev/concepts/configuration) to fit your branding, handle SEO and adapt links and socials. - -::prose-tip{to="https://docus.dev/concepts/nuxt"} -Docus uses a layer system, you can go further and use any feature or file of a classical Nuxt project from `nuxt.config.ts` to `app/components` or `server/` directory. -:: diff --git a/apps/docs/content/1.getting-started/5.studio.md b/apps/docs/content/1.getting-started/5.studio.md deleted file mode 100644 index 23068aa..0000000 --- a/apps/docs/content/1.getting-started/5.studio.md +++ /dev/null @@ -1,123 +0,0 @@ ---- -title: Web Editor -description: Build your documentation using Nuxt Studio web editor -navigation: - icon: i-lucide-mouse-pointer-2 ---- - -## **Introduction** - -The [Nuxt Studio](https://nuxt.studio) **web editor** is a browser-based visual interface for creating, editing, and reviewing your documentation. It provides a preview experience while keeping your work in sync with your Git repository. - -:video{controls loop src="https://res.cloudinary.com/nuxt/video/upload/v1747230893/studio/wzt9zfmdvk7hgmdx3cnt.mp4"} - -::prose-tip{to="https://content.nuxt.com/studio"} -Learn more about Nuxt Studio in the Nuxt Content documentation. -:: - -## **Web Editor vs. CLI** - -The **web editor** of [Nuxt Studio](https://nuxt.studio) allows you to manage your documentation entirely from your browser. There is no need for local development tools or terminal commands. It’s ideal for maintaining your docs in one centralised place, with an easy tool without any Markdown skills required. - -The **CLI (Command Line Interface)**, on the other hand, is a local tool designed for developers who prefer working in their own IDE. - -::prose-note -Both tools are fully integrated with Git, so you can switch between them as needed. Team members can choose whichever method suits their workflow best. -:: - -## **Two distinct editors** - -Nuxt Studio offers a versatile workspace for both developers and content writers, giving them the freedom to choose between two distinct editors for content creation and management: the **Markdown editor** and the **Visual editor**. - -You can select your favorite editor from the settings page of your project. - -::prose-note -Each editor serves its own purpose, some users are used to Markdown edition, while others prefer a non-technical, visual approach. At the end, **Markdown syntax is the final output** for both editors. -:: - -## **Markdown editor** - -The Markdown editor in Nuxt Studio provides full control over your content, allowing you to write directly you documentation in **Markdown** and integrate Vue components with the [MDC syntax](https://content.nuxt.com/docs/files/markdown#mdc-syntax). - -When your file is saved with the Markdown editor, the content is stored exactly as you've written it, preserving all specific syntax and formatting. This editor is ideal for users comfortable with Markdown who want precise control over the layout and structure of their content. - -## **Visual editor** - -The Nuxt Studio editor is heavily inspired by Notion, well known for its intuitive design and flexibility. Much like a standard text editor, the Studio editor is designed to be familiar and easy to use. - -However, it stands out with its additional features that improve the writing experience: - -### **Toolbar** - -Highlight your text to reveal the toolbar, giving you access to all the standard text editing features provided by the [Markdown syntax](/essentials/markdown-syntax): - -- Title formatting -- Bold -- Italic -- Strike-through -- Code -- Link -- Class -- Bullet list -- Numerated list - -### **The** `/` **shortcut** - -Simply type `/` anywhere in the editor to access all Studio features. - -#### **Formatting features** - -- Title formatting -- Line break -- Horizontal rule -- Code-block -- Paragraph -- Bold & italic - -#### **Components** - -One of Studio's standout features is its ability to integrate and customize any complex component directly within the editor. - -In other terms, all [Nuxt UI components](/essentials/components) are usable and can be integrated directly from the editor. An editor can also tweak the component properties, slots and styles. - -::prose-note -You can also create custom components and let the user integrate them from the visual editor. -:: - -Just type `/` to access the list of all the components available. - -#### **Images** - -Using the `/`shortcut, you can quickly insert an image by selecting the `Image` option. A modal will open to let you choose the media you want to insert. - -From the media modal, you can set the **alt attribute** for SEO and accessibility purpose. - -#### **Videos** - -Using the `/` shortcut, you can quickly insert a video by selecting the `Video` choice and filling up the Video URL. - -As soon as a video is added, a tab will automatically open with all the props field **available by default**, for you to fill out the URL and customize your media. - -## **Live Preview** - -Once your documentation is deployed, it provides a live preview feature that lets you instantly see updates to your project. - -We're using your production website to override contents and display the new visual. This is why we need the URL to be set in the **deploy** section. - -When you are editing your website with Studio, the live preview can be displayed on the right part of your screen. You get an instant feedback when typing. It syncs the preview based on your draft updates. - -## **Making Changes** - -To edit your documentation: - -1. **Browse files** using the file explorer. -2. **Open a file** by clicking on it. -3. **Edit content** in either visual or Markdown mode. All edits are automatically saved as drafts. -4. **Preview your changes** to see how they’ll appear when published. - -## **Publishing Changes** - -When you’re ready to publish: - -- Click the **Publish** button in the top-right corner of the editor. -- Your changes will be pushed directly to your deployment branch and go live immediately. diff --git a/apps/docs/content/1.getting-started/6.migration.md b/apps/docs/content/1.getting-started/6.migration.md deleted file mode 100644 index fca9709..0000000 --- a/apps/docs/content/1.getting-started/6.migration.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -title: Migration -description: " How to migrate your documentation from an existing Markdown - solution to Docus" -navigation: - icon: i-lucide-replace ---- - -## **Migrating to Docus** - -Already using a Markdown-based solution for your documentation? Whether it’s **Docus v1**, the **Nuxt UI docs template**, or another static site setup, migrating to Docus is simple and straightforward. - -Docus offers a clean and maintainable solution with a single dependency: the Docus library itself. There’s no need to manage multiple dependencies. With everything built-in and maintained together, keeping your documentation up to date is easier than ever. - -To migrate, just move your existing Markdown files into the `content/` directory of the Docus starter. - -From there, you have two scenarios: - -- **If your current docs already use Nuxt Content and the MDC syntax**, make sure the components used in your content exist in Nuxt UI. If any components are missing, you can easily create your own custom ones. -- **If you’re using standard Markdown**, you can copy your files as is. Then, enhance your documentation progressively using the [built-in components](https://docus.dev/essentials/components) provided by Nuxt UI. - -Once your content has been moved to the `content/` folder, you can go through the [configuration section](https://docus.dev/concepts/configuration) to easily customize your app. - -Docus is designed to focus on writing content, so if you're already using Markdown, you can easily switch to it. diff --git a/apps/docs/content/2.auto-api/.navigation.yml b/apps/docs/content/2.auto-api/.navigation.yml new file mode 100644 index 0000000..5e8e61d --- /dev/null +++ b/apps/docs/content/2.auto-api/.navigation.yml @@ -0,0 +1 @@ +title: Auto API diff --git a/apps/docs/content/2.auto-api/1.getting-started.md b/apps/docs/content/2.auto-api/1.getting-started.md new file mode 100644 index 0000000..f7c0a86 --- /dev/null +++ b/apps/docs/content/2.auto-api/1.getting-started.md @@ -0,0 +1,162 @@ +# Getting Started + +Nuxt Auto API automatically generates type-safe REST APIs from your Drizzle ORM schemas with built-in authorization, validation, and multi-tenancy support. + +## Features + +- **Automatic CRUD endpoints** from Drizzle schemas +- **Plugin system** for extending the request pipeline +- **Multi-database support** (SQLite, Postgres, MySQL, D1, Turso, PlanetScale) +- **Multi-tier authorization** (operation, SQL-level list filter, object-level, field-level) +- **Validation** with Zod and drizzle-zod +- **Custom endpoints** with `createEndpoint()` and standalone helpers +- **Cursor & offset pagination** +- **Soft delete** support (auto-detected) +- **Multi-tenancy** with flexible configuration +- **M2M relationships** with auto-detection +- **Type-safe frontend** with TanStack Query integration + +## Installation + +```bash +npm install @websideproject/nuxt-auto-api +``` + +## Basic Setup + +### 1. Add the module to `nuxt.config.ts` + +```typescript +export default defineNuxtConfig({ + modules: ['@websideproject/nuxt-auto-api'], + + autoApi: { + prefix: '/api', + database: { + client: 'better-sqlite3', + }, + }, +}) +``` + +### 2. Create your Drizzle schema + +```typescript +// server/database/schema.ts +import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core' + +export const users = sqliteTable('users', { + id: integer('id').primaryKey({ autoIncrement: true }), + email: text('email').notNull().unique(), + name: text('name'), + role: text('role', { enum: ['user', 'admin'] }).default('user'), + createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()), +}) +``` + +### 3. Initialize the database + +```typescript +// server/plugins/database.ts +import { drizzle } from 'drizzle-orm/better-sqlite3' +import Database from 'better-sqlite3' +import { initializeDatabase } from '@websideproject/nuxt-auto-api/database' +import * as schema from '../database/schema' + +export default defineNitroPlugin(() => { + const sqlite = new Database('sqlite.db') + const db = drizzle(sqlite, { schema }) + + initializeDatabase(db, 'better-sqlite3') +}) +``` + +For other databases, see [Database Adapters](/auto-api/database-adapters). + +### 4. Register resources via module + +```typescript +// modules/base/index.ts +import { defineNuxtModule, createResolver } from '@nuxt/kit' +import { createModuleImport } from '@websideproject/nuxt-auto-api' + +export default defineNuxtModule({ + meta: { + name: 'base', + }, + setup(_options, nuxt) { + const resolver = createResolver(import.meta.url) + + nuxt.hook('autoApi:registerSchema', (registry) => { + registry.register('users', { + schema: createModuleImport( + resolver.resolve('../../server/database/schema'), + 'users' + ), + }) + }) + }, +}) +``` + +### 5. Start using your API + +The following endpoints are automatically generated: + +``` +GET /api/users # List users +GET /api/users/:id # Get user by ID +POST /api/users # Create user +PATCH /api/users/:id # Update user +DELETE /api/users/:id # Delete user +``` + +## Frontend Usage + +Use the auto-generated composables with TanStack Query: + +```vue + +``` + +For detailed frontend documentation, see the [Composables Guide](/auto-api/frontend-composables). + +## Next Steps + +- [Validation](/auto-api/validation) - Add custom validation rules +- [Custom Endpoints](/auto-api/custom-endpoints) - `createEndpoint()` and standalone helpers +- [Pagination](/auto-api/pagination) - Cursor and offset pagination +- [Soft Deletes](/auto-api/soft-deletes) - Soft delete support +- [Multi-Tenancy](/auto-api/multi-tenancy) - Multi-tenant applications +- [Better-Auth Integration](/auto-api/better-auth) - Authentication setup +- [Plugin System](/auto-api/plugin-system) - Extend the request pipeline +- [Database Adapters](/auto-api/database-adapters) - Multi-engine support +- [M2M Relationships](./13.m2m-relationships.md) - Many-to-many relations diff --git a/apps/docs/content/2.auto-api/10.aggregations.md b/apps/docs/content/2.auto-api/10.aggregations.md new file mode 100644 index 0000000..c06f8f3 --- /dev/null +++ b/apps/docs/content/2.auto-api/10.aggregations.md @@ -0,0 +1,523 @@ +# Aggregations + +Nuxt Auto API provides powerful aggregation capabilities for analyzing your data, including simple aggregates on list endpoints and complex grouped aggregations. + +## Simple Aggregations + +Add aggregate functions to list requests using the `aggregate` query parameter: + +```typescript +// GET /api/orders?aggregate=count + +{ + "data": [/* order records */], + "meta": { + "total": 150, + "limit": 20, + "page": 1, + "aggregates": { + "count": 150 + } + } +} +``` + +### Multiple Aggregates + +Combine multiple aggregate functions: + +```typescript +// GET /api/orders?aggregate=count,sum(amount),avg(amount) + +{ + "data": [/* order records */], + "meta": { + "aggregates": { + "count": 150, + "sum_amount": 45000.00, + "avg_amount": 300.00 + } + } +} +``` + +### Available Functions + +| Function | Description | Example | +|----------|-------------|---------| +| `count` | Count records | `aggregate=count` | +| `sum(field)` | Sum of field values | `aggregate=sum(amount)` | +| `avg(field)` | Average of field values | `aggregate=avg(price)` | +| `min(field)` | Minimum value | `aggregate=min(price)` | +| `max(field)` | Maximum value | `aggregate=max(price)` | + +## Aggregates with Filters + +Combine aggregations with filters: + +```typescript +// GET /api/orders?aggregate=sum(amount),count&filter[status]=completed + +{ + "data": [/* completed orders */], + "meta": { + "aggregates": { + "count": 100, + "sum_amount": 30000.00 + } + } +} +``` + +## Complex Aggregations (Grouped) + +Use the dedicated `/aggregate` endpoint for complex grouped aggregations: + +```typescript +// GET /api/orders/aggregate?aggregate=count,sum(amount)&groupBy=status + +{ + "data": [ + { + "group": { "status": "pending" }, + "count": 50, + "sum_amount": 15000.00 + }, + { + "group": { "status": "completed" }, + "count": 100, + "sum_amount": 30000.00 + } + ], + "meta": { + "total": 2 + } +} +``` + +### Multiple Group By Fields + +```typescript +// GET /api/orders/aggregate?aggregate=count,sum(amount)&groupBy=status,paymentMethod + +{ + "data": [ + { + "group": { + "status": "completed", + "paymentMethod": "credit_card" + }, + "count": 60, + "sum_amount": 18000.00 + }, + { + "group": { + "status": "completed", + "paymentMethod": "paypal" + }, + "count": 40, + "sum_amount": 12000.00 + } + ] +} +``` + +## Having Clause + +Filter groups using the `having` parameter: + +```typescript +// GET /api/orders/aggregate?aggregate=count,sum(amount)&groupBy=userId&having={"count":{"$gt":5}} + +{ + "data": [ + { + "group": { "userId": 1 }, + "count": 10, + "sum_amount": 3000.00 + }, + { + "group": { "userId": 2 }, + "count": 7, + "sum_amount": 2100.00 + } + // Only groups with count > 5 + ] +} +``` + +### Having Operators + +| Operator | Description | Example | +|----------|-------------|---------| +| `$gt` | Greater than | `{"count":{"$gt":10}}` | +| `$gte` | Greater than or equal | `{"sum_amount":{"$gte":1000}}` | +| `$lt` | Less than | `{"avg_price":{"$lt":50}}` | +| `$lte` | Less than or equal | `{"count":{"$lte":5}}` | +| `$eq` | Equal | `{"count":{"$eq":0}}` | +| `$ne` | Not equal | `{"count":{"$ne":0}}` | + +## Filters with Aggregations + +Apply filters before aggregation using the `filter` parameter: + +```typescript +// GET /api/orders/aggregate?aggregate=count,sum(amount)&groupBy=status&filter[createdAt][$gte]=2024-01-01 + +{ + "data": [ + { + "group": { "status": "completed" }, + "count": 50, + "sum_amount": 15000.00 + } + // Only orders created after 2024-01-01 + ] +} +``` + +## Frontend Usage + +### With Composables + +```typescript +// Simple aggregations on list +const { data } = await useAutoApiFetch('orders', { + query: { + aggregate: 'count,sum(amount),avg(amount)', + filter: { + status: 'completed' + } + } +}) + +console.log(data.value.meta.aggregates) +// { count: 100, sum_amount: 30000, avg_amount: 300 } +``` + +```typescript +// Complex grouped aggregations +const { data } = await $fetch('/api/orders/aggregate', { + query: { + aggregate: 'count,sum(amount)', + groupBy: 'status', + having: { + count: { $gt: 10 } + } + } +}) + +console.log(data.data) +// [{ group: { status: 'completed' }, count: 100, sum_amount: 30000 }] +``` + +## Real-World Examples + +### Sales Dashboard + +```typescript +// Total sales by month +const salesByMonth = await $fetch('/api/orders/aggregate', { + query: { + aggregate: 'sum(amount),count', + groupBy: 'monthYear', // Assumes computed field + filter: { + createdAt: { + $gte: '2024-01-01' + } + } + } +}) +``` + +### User Activity Report + +```typescript +// Active users by number of orders +const activeUsers = await $fetch('/api/orders/aggregate', { + query: { + aggregate: 'count', + groupBy: 'userId', + having: { + count: { $gte: 5 } + } + } +}) +``` + +### Product Performance + +```typescript +// Best-selling products +const topProducts = await $fetch('/api/orderItems/aggregate', { + query: { + aggregate: 'sum(quantity),sum(total)', + groupBy: 'productId', + filter: { + createdAt: { + $gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString() + } + } + } +}) +``` + +### Revenue by Payment Method + +```typescript +const revenueByPayment = await $fetch('/api/orders/aggregate', { + query: { + aggregate: 'sum(amount),count,avg(amount)', + groupBy: 'paymentMethod', + filter: { + status: 'completed' + } + } +}) +``` + +## Configuration + +```typescript +export default defineNuxtConfig({ + autoApi: { + aggregations: { + // Enable/disable aggregations (default: true) + enabled: true, + + // Allow groupBy (default: true) + allowGroupBy: true, + + // Maximum number of groupBy fields (default: 5) + maxGroupByFields: 5 + } + } +}) +``` + +## Authorization + +Aggregations require `read` permission on the resource: + +```typescript +// User must have permission to read orders +GET /api/orders/aggregate?aggregate=count,sum(amount)&groupBy=status +``` + +Collection-level authorization applies (not object-level): +- User needs permission to query the resource +- Individual records are not checked (aggregates are anonymous) + +## Performance Considerations + +### Database Indexes + +Create indexes on fields used in: +- `groupBy` clauses +- `filter` clauses +- Aggregate functions (for some databases) + +```sql +-- Index for grouping +CREATE INDEX idx_orders_status ON orders(status); + +-- Index for filtering +CREATE INDEX idx_orders_created_at ON orders(created_at); + +-- Composite index for group + filter +CREATE INDEX idx_orders_status_created ON orders(status, created_at); +``` + +### Query Optimization + +Aggregations can be slow on large tables: + +```typescript +// Good: Filter before aggregating +GET /api/orders/aggregate? + aggregate=count,sum(amount)& + groupBy=status& + filter[createdAt][$gte]=2024-01-01 + +// Less efficient: Aggregate entire table +GET /api/orders/aggregate?aggregate=count,sum(amount)&groupBy=status +``` + +### Limit Group Count + +Avoid grouping by high-cardinality fields: + +```typescript +// Bad: Too many groups (one per user) +GET /api/orders/aggregate?aggregate=count&groupBy=userId + +// Better: Limit with having clause +GET /api/orders/aggregate? + aggregate=count& + groupBy=userId& + having={"count":{"$gte":10}} +``` + +## Caching + +Cache aggregation results for expensive queries: + +```typescript +// Frontend caching with TanStack Query +const { data } = useQuery({ + queryKey: ['orders', 'aggregate', 'by-status'], + queryFn: () => $fetch('/api/orders/aggregate', { + query: { + aggregate: 'count,sum(amount)', + groupBy: 'status' + } + }), + staleTime: 5 * 60 * 1000, // 5 minutes +}) +``` + +Server-side caching: + +```typescript +// server/api/reports/sales-summary.ts +export default defineEventHandler(async (event) => { + // Cache expensive aggregation + return await useStorage('cache').getItem('sales-summary', async () => { + return await $fetch('/api/orders/aggregate', { + query: { + aggregate: 'sum(amount),count', + groupBy: 'status' + } + }) + }) +}) +``` + +## Common Patterns + +### Dashboard Stats + +```typescript +// Single request for multiple stats +const stats = await Promise.all([ + // Total revenue + $fetch('/api/orders', { + query: { aggregate: 'sum(amount)' } + }), + + // Orders by status + $fetch('/api/orders/aggregate', { + query: { + aggregate: 'count', + groupBy: 'status' + } + }), + + // Top customers + $fetch('/api/orders/aggregate', { + query: { + aggregate: 'sum(amount),count', + groupBy: 'customerId', + having: { count: { $gte: 5 } } + } + }) +]) +``` + +### Time-Series Data + +```typescript +// Group by date (requires date extraction in SQL) +const dailySales = await $fetch('/api/orders/aggregate', { + query: { + aggregate: 'sum(amount),count', + groupBy: 'date', // DATE(created_at) + filter: { + createdAt: { + $gte: '2024-01-01', + $lt: '2024-02-01' + } + } + } +}) +``` + +### Conversion Metrics + +```typescript +// Calculate conversion rates +const funnelStats = await $fetch('/api/leads/aggregate', { + query: { + aggregate: 'count', + groupBy: 'stage' + } +}) + +const conversionRate = { + leadToQualified: funnelStats.data.find(s => s.group.stage === 'qualified').count / + funnelStats.data.find(s => s.group.stage === 'lead').count, + qualifiedToCustomer: funnelStats.data.find(s => s.group.stage === 'customer').count / + funnelStats.data.find(s => s.group.stage === 'qualified').count +} +``` + +## Limitations + +1. **No nested aggregations**: Can't aggregate on computed aggregates +2. **Limited having operators**: Only basic comparison operators +3. **No percentile functions**: Use custom SQL for advanced statistics +4. **Group limit**: Maximum 5 group by fields (configurable) + +For complex analytics beyond these limitations, consider: +- Custom API endpoints with raw SQL +- Analytics tools (Metabase, Looker) +- Data warehouse solutions + +## Error Handling + +```typescript +// Invalid aggregate function +GET /api/orders?aggregate=invalid(amount) +// Response: Warning logged, aggregate ignored + +// Unknown field +GET /api/orders?aggregate=sum(nonexistent) +// Response: Warning logged, aggregate ignored + +// Too many groupBy fields +GET /api/orders/aggregate?groupBy=a,b,c,d,e,f +// Response: 400 - "Group by limited to 5 fields" + +// Aggregations disabled +GET /api/orders?aggregate=count +// Response: 403 - "Aggregations are disabled" +``` + +## TypeScript Support + +```typescript +interface OrderAggregates { + count: number + sum_amount: number + avg_amount: number +} + +const { data } = await useAutoApiFetch('orders', { + query: { + aggregate: 'count,sum(amount),avg(amount)' + } +}) + +const aggregates = data.value.meta.aggregates as OrderAggregates +``` + +## Best Practices + +1. **Index fields**: Always index fields used in groupBy and filters +2. **Filter first**: Apply filters to reduce dataset before aggregating +3. **Use having wisely**: Filter groups to reduce result set size +4. **Cache results**: Cache expensive aggregations with appropriate TTL +5. **Monitor performance**: Track slow aggregation queries +6. **Limit groups**: Avoid high-cardinality groupBy fields +7. **Use simple aggregates**: Prefer simple aggregates on list endpoint for basic stats +8. **Dedicate endpoint**: Use `/aggregate` endpoint for complex grouped queries diff --git a/apps/docs/content/2.auto-api/11.lifecycle-hooks.md b/apps/docs/content/2.auto-api/11.lifecycle-hooks.md new file mode 100644 index 0000000..338c8ba --- /dev/null +++ b/apps/docs/content/2.auto-api/11.lifecycle-hooks.md @@ -0,0 +1,523 @@ +# Lifecycle Hooks + +Lifecycle hooks allow you to execute custom logic before and after CRUD operations. Perfect for audit logging, notifications, data transformation, and business logic. + +## Available Hooks + +| Hook | When it runs | Can modify data | Use cases | +|------|--------------|-----------------|-----------| +| `beforeCreate` | Before inserting record | ✅ Yes | Set defaults, validate, transform | +| `afterCreate` | After inserting record | ❌ No | Send notifications, log audit | +| `beforeUpdate` | Before updating record | ✅ Yes | Validate changes, transform | +| `afterUpdate` | After updating record | ❌ No | Notify users, sync services | +| `beforeDelete` | Before deleting record | ❌ No | Check dependencies, validate | +| `afterDelete` | After deleting record | ❌ No | Clean up relations, log audit | +| `beforeList` | Before listing records | ❌ No | Log access (rarely needed) | +| `afterList` | After listing records | ❌ No | Log access, track analytics | +| `beforeGet` | Before fetching single record | ❌ No | Log access (rarely needed) | +| `afterGet` | After fetching single record | ❌ No | Track views, log access | + +## Hook Configuration Methods + +Nuxt Auto API supports three ways to configure hooks, with clear priority ordering. + +### Method 1: Per-Resource (via Module Registration) + +**Highest priority**. Best for module-based architecture. + +```typescript +// playground/modules/base/index.ts +import { createModuleImport } from '@websideproject/nuxt-auto-api' + +export default defineNuxtModule({ + async setup(options, nuxt) { + const resolver = createResolver(import.meta.url) + + nuxt.hook('autoApi:registerSchema', (registry) => { + registry.register('users', { + schema: createModuleImport(resolver.resolve('./schema'), 'users'), + hooks: createModuleImport(resolver.resolve('./hooks'), 'userHooks') + }) + }) + } +}) +``` + +```typescript +// playground/modules/base/hooks.ts +export const userHooks = { + beforeCreate: async (data, context) => { + data.createdBy = context.user.id + data.status = data.status || 'active' + return data + }, + + afterCreate: async (user, context) => { + console.log(`User ${user.id} created by ${context.user.id}`) + // Send welcome email, create audit log, etc. + } +} +``` + +### Method 2: Plugin-Based (Runtime Registration) + +**Medium priority**. Best for cross-cutting concerns. + +```typescript +// server/plugins/user-hooks.ts +export default defineNitroPlugin(() => { + const hooks = globalThis.__autoApiHooks || (globalThis.__autoApiHooks = {}) + + hooks.users = { + ...(hooks.users || {}), + + afterCreate: async (user, context) => { + // Send welcome email + await sendEmail({ + to: user.email, + template: 'welcome', + data: { name: user.name } + }) + }, + + afterUpdate: async (user, context) => { + // Invalidate cache + await clearCache(`user:${user.id}`) + } + } +}) +``` + +### Method 3: Config-Based (nuxt.config.ts) + +**Lowest priority**. Best for simple projects. + +```typescript +// nuxt.config.ts +export default defineNuxtConfig({ + autoApi: { + hooks: { + users: { + beforeCreate: async (data, context) => { + data.createdBy = context.user.id + return data + }, + + afterCreate: async (user, context) => { + console.log('User created:', user.id) + } + }, + + posts: { + beforeCreate: async (data, context) => { + data.authorId = context.user.id + data.publishedAt = data.publishedAt || new Date() + return data + } + } + } + } +}) +``` + +## Hook Priority & Merging + +When multiple hooks exist for the same event: + +1. **Registry hooks** (createModuleImport) - execute last (highest priority) +2. **Plugin hooks** (globalThis) - execute second +3. **Config hooks** (nuxt.config.ts) - execute first (lowest priority) + +```typescript +// If all three exist: +// 1. Config hook runs +// 2. Plugin hook runs +// 3. Registry hook runs +// 4. Final result from registry hook is used +``` + +## Before Hooks (Data Modification) + +Before hooks can modify data before it's saved: + +```typescript +export const productHooks = { + beforeCreate: async (data, context) => { + // Set defaults + data.status = data.status || 'draft' + data.createdBy = context.user.id + + // Generate slug from title + data.slug = slugify(data.title) + + // Validate business rules + if (data.price < 0) { + throw new Error('Price cannot be negative') + } + + // Transform data + data.name = data.name.trim().toLowerCase() + + // Return modified data + return data + }, + + beforeUpdate: async (id, data, context) => { + // Track who modified + data.modifiedBy = context.user.id + data.modifiedAt = new Date() + + // Prevent changing certain fields + delete data.createdBy + delete data.createdAt + + return data + } +} +``` + +## After Hooks (Side Effects) + +After hooks are for side effects (no data modification): + +```typescript +export const orderHooks = { + afterCreate: async (order, context) => { + // Send confirmation email + await sendEmail({ + to: order.customerEmail, + template: 'order-confirmation', + data: { orderNumber: order.id, total: order.total } + }) + + // Create audit log + await db.insert(auditLogs).values({ + action: 'order.created', + userId: context.user.id, + resourceId: order.id, + timestamp: new Date() + }) + + // Notify external service + await fetch('https://analytics.example.com/track', { + method: 'POST', + body: JSON.stringify({ + event: 'order_created', + orderId: order.id, + total: order.total + }) + }) + }, + + afterUpdate: async (order, context) => { + // If status changed to 'shipped' + if (order.status === 'shipped') { + await sendEmail({ + to: order.customerEmail, + template: 'order-shipped', + data: { trackingNumber: order.trackingNumber } + }) + } + }, + + afterDelete: async (id, context) => { + // Clean up related records + await db.delete(orderItems).where(eq(orderItems.orderId, id)) + + // Log deletion + console.log(`Order ${id} deleted by user ${context.user.id}`) + } +} +``` + +## Hook Context + +All hooks receive a `HandlerContext` object: + +```typescript +interface HandlerContext { + db: any // Database instance + schema: any // Drizzle schema + user: AuthUser | null // Current user + permissions: string[] // User permissions + params: Record // Route params + query: Record // Query parameters + validated: { // Validated data + body?: any + query?: any + } + event: H3Event // H3 event object + resource: string // Resource name (e.g., 'users') + operation: string // Operation type + tenant?: { // Multi-tenancy info + id: string | number + field: string + canAccessAllTenants: boolean + } + resourceConfig?: ResourceRegistration // Resource configuration +} +``` + +Using context: + +```typescript +export const postHooks = { + beforeCreate: async (data, context) => { + // Auto-set author from authenticated user + data.authorId = context.user.id + + // Auto-set tenant + if (context.tenant) { + data.organizationId = context.tenant.id + } + + // Access query parameters + if (context.query.publishNow === 'true') { + data.publishedAt = new Date() + } + + return data + }, + + afterCreate: async (post, context) => { + // Log to database + await context.db.insert(auditLogs).values({ + action: 'post.created', + userId: context.user.id, + postId: post.id + }) + } +} +``` + +## Error Handling + +### Before Hooks + +Before hooks should throw errors to block the operation: + +```typescript +beforeCreate: async (data, context) => { + // Validation error - blocks creation + if (!data.email || !data.email.includes('@')) { + throw new Error('Invalid email address') + } + + // Business rule error - blocks creation + const existingUser = await context.db.query.users.findFirst({ + where: eq(users.email, data.email) + }) + + if (existingUser) { + throw new Error('Email already exists') + } + + return data +} +``` + +### After Hooks + +After hooks should not throw errors (or set `errorHandling: 'throw'`): + +```typescript +// Default behavior: log errors, don't rollback +afterCreate: async (user, context) => { + try { + await sendWelcomeEmail(user.email) + } catch (error) { + // Error is logged but operation continues + console.error('Failed to send welcome email:', error) + } +} +``` + +Configure error handling: + +```typescript +export default defineNuxtConfig({ + autoApi: { + hookConfig: { + // 'log' (default): log errors but don't throw + // 'throw': throw errors and rollback + errorHandling: 'log', + + // Hook timeout in milliseconds + timeout: 5000, + + // Execute multiple hooks in parallel + parallel: false + } + } +}) +``` + +## Hook Execution in Bulk Operations + +Hooks execute for each item in bulk operations: + +```typescript +// Bulk create 100 users +POST /api/users/bulk { items: [/* 100 users */] } + +// beforeCreate runs 100 times (once per user) +// afterCreate runs 100 times (once per user) +``` + +## Common Use Cases + +### Audit Logging + +```typescript +const auditHooks = { + afterCreate: async (record, context) => { + await db.insert(auditLogs).values({ + action: `${context.resource}.created`, + userId: context.user.id, + resourceId: record.id, + changes: JSON.stringify(record) + }) + }, + + afterUpdate: async (record, context) => { + await db.insert(auditLogs).values({ + action: `${context.resource}.updated`, + userId: context.user.id, + resourceId: record.id, + changes: JSON.stringify(record) + }) + } +} +``` + +### Notifications + +```typescript +const notificationHooks = { + afterCreate: async (comment, context) => { + // Notify post author about new comment + const post = await db.query.posts.findFirst({ + where: eq(posts.id, comment.postId) + }) + + if (post && post.authorId !== context.user.id) { + await sendNotification(post.authorId, { + type: 'new_comment', + message: `${context.user.name} commented on your post` + }) + } + } +} +``` + +### Data Validation + +```typescript +const validationHooks = { + beforeCreate: async (data, context) => { + // Complex business rule validation + if (data.price > 1000 && !context.permissions.includes('admin')) { + throw new Error('Only admins can create items over $1000') + } + + // Cross-field validation + if (data.discountPercent > 0 && !data.discountCode) { + throw new Error('Discount code required when discount is applied') + } + + return data + } +} +``` + +### Cache Invalidation + +```typescript +const cacheHooks = { + afterUpdate: async (record, context) => { + await clearCache(`${context.resource}:${record.id}`) + await clearCache(`${context.resource}:list`) + }, + + afterDelete: async (id, context) => { + await clearCache(`${context.resource}:${id}`) + await clearCache(`${context.resource}:list`) + } +} +``` + +### External Service Integration + +```typescript +const integrationHooks = { + afterCreate: async (customer, context) => { + // Sync to CRM + await fetch('https://crm.example.com/api/customers', { + method: 'POST', + body: JSON.stringify(customer) + }) + + // Add to mailing list + await mailchimp.lists.addMember({ + email: customer.email, + firstName: customer.firstName, + lastName: customer.lastName + }) + } +} +``` + +## Best Practices + +1. **Keep hooks focused**: One responsibility per hook +2. **Handle errors**: Use try-catch in after hooks +3. **Don't query in before hooks**: Data should already be validated +4. **Use after hooks for async operations**: Email, webhooks, etc. +5. **Return modified data from before hooks**: Always return the data object +6. **Don't modify data in after hooks**: Too late, data is already saved +7. **Log important operations**: Especially in production +8. **Test hooks thoroughly**: They can break your API if buggy +9. **Use transactions carefully**: After hooks run outside transaction scope +10. **Monitor hook performance**: Set appropriate timeouts + +## Migration Path + +If you have custom handlers, migrate to hooks: + +```typescript +// Before: Custom handler +export default defineEventHandler(async (event) => { + const body = await readBody(event) + + // Custom logic + body.createdBy = event.context.user.id + body.status = 'active' + + const user = await db.insert(users).values(body).returning() + + // Send email + await sendWelcomeEmail(user.email) + + return { data: user } +}) + +// After: Use hooks +export const userHooks = { + beforeCreate: async (data, context) => { + data.createdBy = context.user.id + data.status = 'active' + return data + }, + + afterCreate: async (user, context) => { + await sendWelcomeEmail(user.email) + } +} +``` + +Benefits: +- Automatic validation +- Authorization checks +- Multi-tenancy support +- Consistent error handling +- Works with bulk operations diff --git a/apps/docs/content/2.auto-api/12.m2m-relationships.md b/apps/docs/content/2.auto-api/12.m2m-relationships.md new file mode 100644 index 0000000..4ab16a9 --- /dev/null +++ b/apps/docs/content/2.auto-api/12.m2m-relationships.md @@ -0,0 +1,834 @@ +# Many-to-Many (M2M) Relationships + +This guide covers how to work with many-to-many relationships in `@websideproject/nuxt-auto-api`. + +## Overview + +Many-to-many relationships are managed through junction tables. The module provides intelligent auto-detection with optional configuration overrides: + +1. **Auto-Detection** (RECOMMENDED) - Automatically detects junction tables from Drizzle schema +2. **Configuration Overrides** - Customize labels, help text, and display fields + +The auto-detection system reads Drizzle's `.references()` metadata to accurately identify M2M relationships without relying on naming conventions. + +## Quick Start + +M2M relationships work automatically with minimal configuration: + +```typescript +export default defineNuxtConfig({ + autoApi: { + m2m: { + // Auto-detection enabled by default + autoDetect: true, + + // Optional: Customize labels and display fields + relations: { + articles: { + categories: { + label: 'Categories', // Override default label + help: 'Select article categories', // Add help text + displayField: 'name', // Field shown in dropdown + } + } + } + } + } +}) +``` + +Then use the generated endpoints: + +```bash +# List categories for article 10 +GET /api/articles/10/relations/categories + +# Update categories (replace all) +POST /api/articles/10/relations/categories +{ "ids": [7, 8, 9] } +``` + +--- + +## Table of Contents + +1. [How Auto-Detection Works](#how-auto-detection-works) +2. [Schema Setup](#schema-setup) +3. [Configuration Options](#configuration-options) +4. [API Endpoints](#api-endpoints) +5. [Frontend Composables](#composables-frontend) +6. [Permissions](#permissions) +7. [Metadata Columns](#metadata-columns) +8. [Best Practices](#best-practices) +9. [Troubleshooting](#troubleshooting) + +--- + +## How Auto-Detection Works + +The module automatically detects M2M relationships by analyzing your Drizzle schema using a two-phase approach: + +### Phase 1: Drizzle FK References (Primary) + +The system reads `.references()` calls in your junction tables to extract the exact target resources: + +```typescript +export const articleCategories = sqliteTable('article_categories', { + articleId: integer('article_id') + .references(() => articles.id, { onDelete: 'cascade' }), // ← Reads this + categoryId: integer('category_id') + .references(() => categories.id, { onDelete: 'cascade' }), // ← And this +}, (table) => ({ + pk: primaryKey({ columns: [table.articleId, table.categoryId] }) +})) +``` + +The detection extracts: +- `articles` from the first reference +- `categories` from the second reference +- Automatically creates `articles ↔ categories` M2M relationship + +### Phase 2: Heuristic Fallback + +If FK references aren't available, the system falls back to column name pattern matching: +- Columns ending with `Id` (camelCase): `articleId`, `categoryId` +- Columns ending with `_id` (snake_case): `article_id`, `category_id` +- Columns starting with `id`: `idArticle`, `idCategory` + +### Detection Criteria + +A table is recognized as a junction table if: +1. ✅ Has exactly 2 foreign key columns +2. ✅ Has NO standalone `id` column (uses composite primary key) +3. ✅ Table name matches resource pair pattern (e.g., `articleCategories`) + +### Drizzle Relations (Required) + +For accurate detection, define Drizzle relations in your schema: + +```typescript +import { relations } from 'drizzle-orm' + +export const articlesRelations = relations(articles, ({ many }) => ({ + articleCategories: many(articleCategories), + articleTags: many(articleTags), +})) + +export const categoriesRelations = relations(categories, ({ many }) => ({ + articleCategories: many(articleCategories), +})) + +export const articleCategoriesRelations = relations(articleCategories, ({ one }) => ({ + article: one(articles, { + fields: [articleCategories.articleId], + references: [articles.id], + }), + category: one(categories, { + fields: [articleCategories.categoryId], + references: [categories.id], + }), +})) +``` + +These relations enable: +- Accurate FK target extraction +- Drizzle's relational query API +- Type-safe queries with `include=` parameter + +--- + +## Schema Setup + +### Complete Example + +```typescript +// schema.ts +import { sqliteTable, integer, primaryKey, text } from 'drizzle-orm/sqlite-core' +import { relations } from 'drizzle-orm' + +// Resource tables +export const articles = sqliteTable('articles', { + id: integer('id').primaryKey({ autoIncrement: true }), + title: text('title').notNull(), +}) + +export const categories = sqliteTable('categories', { + id: integer('id').primaryKey({ autoIncrement: true }), + name: text('name').notNull(), +}) + +// Junction table +export const articleCategories = sqliteTable('article_categories', { + articleId: integer('article_id') + .notNull() + .references(() => articles.id, { onDelete: 'cascade' }), + categoryId: integer('category_id') + .notNull() + .references(() => categories.id, { onDelete: 'cascade' }), +}, (table) => ({ + pk: primaryKey({ columns: [table.articleId, table.categoryId] }) +})) + +// Relations (required for auto-detection) +export const articlesRelations = relations(articles, ({ many }) => ({ + articleCategories: many(articleCategories), +})) + +export const categoriesRelations = relations(categories, ({ many }) => ({ + articleCategories: many(articleCategories), +})) + +export const articleCategoriesRelations = relations(articleCategories, ({ one }) => ({ + article: one(articles, { + fields: [articleCategories.articleId], + references: [articles.id], + }), + category: one(categories, { + fields: [articleCategories.categoryId], + references: [categories.id], + }), +})) +``` + +### Junction Table Requirements + +✅ **Required for auto-detection:** +- Exactly 2 foreign key columns with `.references()` +- Composite primary key (no standalone `id`) +- Cascade deletes for cleanup + +✅ **Supported naming conventions:** +- camelCase: `articleCategories`, `articleId` +- snake_case: `article_categories`, `article_id` +- Mixed: Any combination + +--- + +## Naming Convention Support + +The M2M auto-detection system supports **both camelCase and snake_case** naming conventions, as well as mixed conventions and plural forms. + +### Supported Table Name Patterns + +All of these table names will be auto-detected correctly: + +```typescript +// ✅ camelCase +export const articleCategories = sqliteTable('articleCategories', { ... }) + +// ✅ snake_case +export const articleCategories = sqliteTable('article_categories', { ... }) + +// ✅ Plural forms +export const articleCategories = sqliteTable('articlesCategories', { ... }) +export const articleCategories = sqliteTable('articles_categories', { ... }) + +// ✅ Mixed conventions +export const articleCategories = sqliteTable('article_categories', { + articleId: integer('articleId'), // camelCase column + categoryId: integer('category_id'), // snake_case column +}) +``` + +### Supported Column Name Patterns + +Foreign key columns are extracted using these patterns: + +```typescript +// ✅ Suffix patterns (most common) +articleId → article // camelCase suffix +article_id → article // snake_case suffix + +// ✅ Prefix patterns +idArticle → article // prefix pattern + +// ✅ Variations +articlesId → articles // plural handling +articles_id → articles // snake_case plural +``` + +### Resource Name Matching + +After extracting the resource name from a column (e.g., `article` from `article_id`), the system tries to match it against registered resources: + +```typescript +// For extracted name "article", tries: +[ + 'article', // exact match + 'articles', // add 's' + 'articl', // remove last char + // ... more variations +] + +// Matches 'articles' in registry ✅ +``` + +### Example: snake_case Schema + +This schema works automatically without any configuration: + +```typescript +export const articleCategories = sqliteTable('article_categories', { + articleId: integer('article_id') + .references(() => articles.id, { onDelete: 'cascade' }), + categoryId: integer('category_id') + .references(() => categories.id, { onDelete: 'cascade' }), +}, (table) => ({ + pk: primaryKey({ columns: [table.articleId, table.categoryId] }) +})) + +// Drizzle relations (still required) +export const articleCategoriesRelations = relations(articleCategories, ({ one }) => ({ + article: one(articles, { + fields: [articleCategories.articleId], + references: [articles.id], + }), + category: one(categories, { + fields: [articleCategories.categoryId], + references: [categories.id], + }), +})) +``` + +**Result:** Auto-detects as junction table linking `articles ↔ categories` ✅ + +### Verification + +Test auto-detection with these debug endpoints: + +```bash +# List all detected junction tables +GET /api/_m2m/junctions +# Response: { "junctions": ["articleCategories", "articleTags"], "count": 2 } + +# Check if specific table is detected as junction +GET /api/_m2m/is-junction/articleCategories +# Response: { "table": "articleCategories", "isJunction": true } + +# Detect M2M relationships for a resource +GET /api/_m2m/detect/articles +# Response: { "relationships": [...] } +``` + +### Manual Override (If Needed) + +If auto-detection doesn't work for a specific table, you can still manually configure it: + +```typescript +// nuxt.config.ts +autoAdmin: { + resources: { + customJunction: { + type: 'junction' // Force hide from sidebar + }, + orderItems: { + type: 'resource', // Show in sidebar despite junction pattern + displayName: 'Order Items', + } + } +} +``` + +--- + +## Configuration Options + +### Minimal Setup (Recommended) + +Let auto-detection handle structure, only customize UI labels: + +```typescript +export default defineNuxtConfig({ + autoApi: { + m2m: { + autoDetect: true, // Default, can be omitted + + // Optional: Override UI labels only + relations: { + articles: { + categories: { + label: 'Categories', + help: 'Select categories for this article', + displayField: 'name', + } + } + } + } + } +}) +``` + +### Explicit Structure (Advanced) + +Override auto-detection for specific relations: + +```typescript +export default defineNuxtConfig({ + autoApi: { + m2m: { + autoDetect: true, + + relations: { + articles: { + categories: { + // Explicit structure (overrides auto-detection) + junctionTable: 'articleCategories', + leftKey: 'articleId', + rightKey: 'categoryId', + + // UI customization + label: 'Categories', + help: 'Select categories', + displayField: 'name', + metadataColumns: ['sortOrder'], + } + } + } + } + } +}) +``` + +### Fully Explicit (No Auto-Detection) + +Disable auto-detection completely: + +```typescript +export default defineNuxtConfig({ + autoApi: { + m2m: { + autoDetect: false, // Disable auto-detection + + // Must configure every M2M relation explicitly + relations: { + articles: { + categories: { + junctionTable: 'articleCategories', + leftKey: 'articleId', + rightKey: 'categoryId', + label: 'Categories', + displayField: 'name', + }, + tags: { + junctionTable: 'articleTags', + leftKey: 'articleId', + rightKey: 'tagId', + label: 'Tags', + displayField: 'name', + } + } + } + } + } +}) +``` + +### Configuration Reference + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `autoDetect` | `boolean` | `true` | Enable auto-detection | +| `junctionTable` | `string` | Auto | Junction table name (schema export) | +| `leftKey` | `string` | Auto | FK column to source resource | +| `rightKey` | `string` | Auto | FK column to related resource | +| `label` | `string` | Auto | Display label for admin UI | +| `help` | `string` | - | Help text for admin UI | +| `displayField` | `string` | `'name'` | Field shown in dropdown | +| `metadataColumns` | `string[]` | `[]` | Additional junction columns | + +### When to Use Each Approach + +| Approach | Use When | Benefits | +|----------|----------|----------| +| **Auto-detection only** | Standard schema with Drizzle relations | Minimal config, automatic updates | +| **Auto + UI overrides** | Need custom labels/help text | Auto structure + custom UX | +| **Explicit structure** | Non-standard naming or complex junctions | Full control, predictable behavior | + +--- + +## API Endpoints + +### List Relations + +Get all related records: + +``` +GET /api/{resource}/{id}/relations/{relation} +``` + +**Query Parameters:** +- `includeRecords` - Include full records (default: false) +- `includeMetadata` - Include metadata columns (default: false) +- `limit` - Limit results +- `offset` - Offset for pagination + +**Example:** +```bash +GET /api/articles/10/relations/categories?includeRecords=true +``` + +**Response:** +```json +{ + "ids": [7, 8, 9], + "records": [ + { "id": 7, "name": "Technology" }, + { "id": 8, "name": "Business" }, + { "id": 9, "name": "Science" } + ], + "total": 3 +} +``` + +### Sync Relations (Replace All) + +Replace all relations with new set: + +``` +POST /api/{resource}/{id}/relations/{relation} +``` + +**Body:** +```json +{ + "ids": [7, 8, 9], + "metadata": [...] // Optional +} +``` + +**Response:** +```json +{ + "success": true, + "added": 2, + "removed": 1, + "total": 3 +} +``` + +### Add Relations + +Add without removing existing: + +``` +POST /api/{resource}/{id}/relations/{relation}/add +``` + +**Body:** +```json +{ + "ids": [10, 11] +} +``` + +### Remove Relations + +Remove specific relations: + +``` +DELETE /api/{resource}/{id}/relations/{relation}/remove +``` + +**Body:** +```json +{ + "ids": [7, 8] +} +``` + +### Batch Sync + +Sync multiple relations atomically: + +``` +POST /api/{resource}/{id}/relations/batch +``` + +**Body:** +```json +{ + "relations": { + "categories": { "ids": [7, 8, 9] }, + "tags": { "ids": [1, 2, 3] } + } +} +``` + +--- + +## Composables (Frontend) + +### useM2MRelation + +Query related records: + +```vue + +``` + +### useM2MSync + +Replace all relations: + +```vue + +``` + +### useM2MAdd + +Add relations: + +```vue + +``` + +### useM2MRemove + +Remove relations: + +```vue + +``` + +--- + +## Permissions + +Control M2M operations via resource permissions: + +```typescript +// modules/blog/auth.ts +export const articlesAuth = { + async canUpdate(user, context) { + // Controls regular updates AND M2M operations + return user?.role === 'admin' + }, + + // Optional: Specific M2M permission + async canUpdateM2M(user, context) { + // Falls back to canUpdate if not provided + return user?.role === 'editor' || user?.role === 'admin' + } +} +``` + +--- + +## Metadata Columns + +Junction tables can include extra data: + +```typescript +// Schema with metadata +export const articleCategories = sqliteTable('article_categories', { + articleId: integer('article_id').notNull().references(() => articles.id), + categoryId: integer('category_id').notNull().references(() => categories.id), + sortOrder: integer('sort_order').default(0), // ✅ Metadata + isPrimary: integer('is_primary', { mode: 'boolean' }), // ✅ Metadata +}, (table) => ({ + pk: primaryKey({ columns: [table.articleId, table.categoryId] }) +})) + +// Config +m2m: { + relations: { + articles: { + categories: { + junctionTable: 'articleCategories', + leftKey: 'articleId', + rightKey: 'categoryId', + metadataColumns: ['sortOrder', 'isPrimary'], // Declare metadata + } + } + } +} + +// Usage +POST /api/articles/10/relations/categories +{ + "ids": [7, 8, 9], + "metadata": [ + { "sortOrder": 1, "isPrimary": true }, + { "sortOrder": 2, "isPrimary": false }, + { "sortOrder": 3, "isPrimary": false } + ] +} +``` + +--- + +## Best Practices + +### 1. Define Drizzle Relations + +Always define relations for both resource tables and junction tables: + +```typescript +// ✅ GOOD - Complete relations +export const articlesRelations = relations(articles, ({ many }) => ({ + articleCategories: many(articleCategories), +})) + +export const articleCategoriesRelations = relations(articleCategories, ({ one }) => ({ + article: one(articles, { + fields: [articleCategories.articleId], + references: [articles.id], + }), + category: one(categories, { + fields: [articleCategories.categoryId], + references: [categories.id], + }), +})) +``` + +### 2. Use Cascade Deletes + +```typescript +articleId: integer('article_id') + .references(() => articles.id, { onDelete: 'cascade' }) // ✅ +``` + +### 3. Use Composite Primary Keys + +```typescript +// ✅ GOOD +primaryKey({ columns: [table.articleId, table.categoryId] }) + +// ❌ BAD +id: integer('id').primaryKey() // Allows duplicates +``` + +### 4. Customize UI Labels + +Override auto-generated labels for better UX: + +```typescript +relations: { + articles: { + categories: { + label: 'Categories', + help: 'Select categories for this article', + displayField: 'name', // or 'title', 'email', etc. + } + } +} +``` + +### 5. Disable Auto-Detection for Complex Schemas + +If your schema has non-standard junction tables, disable auto-detection: + +```typescript +m2m: { + autoDetect: false, // Disable auto-detection + relations: { + // Explicitly configure every M2M relation + articles: { + categories: { + junctionTable: 'article_category_links', // Non-standard name + leftKey: 'article_id', + rightKey: 'category_id', + } + } + } +} +``` + +--- + +## Troubleshooting + +### Relations Not Found + +**Problem:** M2M endpoints return 404 + +**Solution:** +1. Check config in `nuxt.config.ts` +2. Verify `junctionTable` matches schema export name +3. Confirm column names match `leftKey`/`rightKey` + +### Junction Table Not in Schema + +**Problem:** "Junction table not found in schema" + +**Solution:** Register the junction table: + +```typescript +// modules/blog/index.ts +nuxt.hook('autoApi:registerSchema', (registry) => { + registry.register('articleCategories', { + schema: createModuleImport('./schema', 'articleCategories') + }) +}) +``` + +### Auto-Detection Not Finding Relations + +**Problem:** M2M relations not detected automatically + +**Solution:** +1. Verify `.references()` is defined on FK columns +2. Check Drizzle relations are exported +3. Ensure composite primary key (no standalone `id`) +4. Verify junction table follows naming pattern + +**Debug endpoint:** +```bash +GET /api/_m2m/debug-detection +``` + +If auto-detection doesn't work for your schema, use explicit configuration: + +```typescript +m2m: { + autoDetect: false, + relations: { + articles: { + categories: { + junctionTable: 'articleCategories', + leftKey: 'articleId', + rightKey: 'categoryId', + } + } + } +} +``` + +--- + +## See Also + +- [Admin UI M2M Guide](../../nuxt-auto-admin/docs/m2m-relationships.md) +- [Permissions Documentation](/auto-api/permissions) +- [Hooks Guide](/auto-api/hooks) diff --git a/apps/docs/content/2.auto-api/13.plugin-system.md b/apps/docs/content/2.auto-api/13.plugin-system.md new file mode 100644 index 0000000..b1080a7 --- /dev/null +++ b/apps/docs/content/2.auto-api/13.plugin-system.md @@ -0,0 +1,432 @@ +# Plugin System + +The plugin system provides a structured architecture for extending @websideproject/nuxt-auto-api. Plugins can hook into both the build-time module setup and the server-side runtime pipeline. + +## Overview + +A plugin has two optional phases: + +- **Build-time** (`buildSetup`) - Runs during Nuxt module setup. Register server handlers, add imports, modify options. +- **Runtime** (`runtimeSetup`) - Runs when the Nitro server starts. Register middleware, hooks, and context extenders. + +## Plugin Registration + +Plugins are registered via a **server file** that exports an array. This approach gives full closure, import, and TypeScript support with zero serialization issues. + +### Step 1: Point to the file in `nuxt.config.ts` + +```typescript +export default defineNuxtConfig({ + autoApi: { + plugins: '~/server/autoapi-plugins', + }, +}) +``` + +### Step 2: Create the server file + +```typescript +// server/autoapi-plugins.ts +import { createRateLimitPlugin, createRequestMetadataPlugin } from '@websideproject/nuxt-auto-api/plugins' + +export default [ + createRateLimitPlugin({ + windowMs: 60000, + max: 200, + skip: (ctx) => ctx.user?.role === 'admin', + }), + createRequestMetadataPlugin({ + autoPopulateOn: ['create', 'update'], + resources: ['users'], + }), +] +``` + +That's it. The module handles initialization, context wiring, and lifecycle automatically. + +### Why a file instead of inline config? + +You might wonder why plugins aren't defined inline in `nuxt.config.ts` like this: + +```typescript +// THIS DOES NOT WORK +import { createRateLimitPlugin } from '@websideproject/nuxt-auto-api/plugins' + +export default defineNuxtConfig({ + autoApi: { + plugins: [ + createRateLimitPlugin({ windowMs: 60000, max: 200 }) + ] + } +}) +``` + +The reason is the **serialization boundary** between build time and runtime. + +`nuxt.config.ts` runs at build time during Nuxt module setup. The module needs to pass plugin runtime logic to the Nitro server, which runs separately. To bridge this gap, it generates a virtual module (`.nuxt/@websideproject/nuxt-auto-api-plugins.mjs`). The only way to get inline functions into that file is `Function.prototype.toString()` — which captures the function **text** but loses all **closure variables**. + +For example, `createRateLimitPlugin()` creates an in-memory `Map` and a `setInterval` timer inside the factory. The returned `runtimeSetup` function closes over these variables. When serialized via `.toString()`, the function text references `store` and `cleanupTimer`, but those variables don't exist in the generated file — causing `store is not defined` at runtime. + +The same problem applies to any callback that uses imports from the calling scope: + +```typescript +// THIS ALSO DOES NOT WORK — geoip is a closure variable +import { geoip } from 'maxmind' + +export default defineNuxtConfig({ + autoApi: { + plugins: [ + createRequestMetadataPlugin({ + extract: async (event) => geoip.lookup(getRequestIP(event)) + // ^^^^^^ lost during serialization + }) + ] + } +}) +``` + +A dedicated server file solves this completely. Nitro bundles it as a real module — imports, closures, timers, Maps, and everything else work naturally because the code runs as-is, never serialized. + +## Plugin Sources + +Plugins can come from three sources, all merged at build time: + +### 1. App-level (your server file) + +```typescript +// nuxt.config.ts +autoApi: { + plugins: '~/server/autoapi-plugins' +} + +// server/autoapi-plugins.ts +import { createRateLimitPlugin } from '@websideproject/nuxt-auto-api/plugins' +import { createAuditLogPlugin } from '@websideproject/nuxt-auto-api-audit-log' + +export default [ + createRateLimitPlugin({ max: 200 }), + createAuditLogPlugin({ destination: 'db' }), +] +``` + +### 2. Community Nuxt modules (via hook) + +Community modules can register plugins without the user touching their config: + +```typescript +// @websideproject/nuxt-auto-api-audit-log/src/module.ts +export default defineNuxtModule({ + setup(options, nuxt) { + const resolver = createResolver(import.meta.url) + nuxt.hook('autoApi:registerPlugins', (ctx) => { + ctx.addFile(resolver.resolve('./runtime/audit-plugin')) + }) + } +}) +``` + +The file must default-export a single `AutoApiPlugin` or an array of them: + +```typescript +// @websideproject/nuxt-auto-api-audit-log/src/runtime/audit-plugin.ts +import { defineAutoApiPlugin } from '@websideproject/nuxt-auto-api/plugins' + +export default defineAutoApiPlugin({ + name: 'audit-log', + runtimeSetup(ctx) { + ctx.addMiddleware({ + name: 'audit-log', + stage: 'post-execute', + handler: async (context) => { /* ... */ }, + }) + }, +}) +``` + +### 3. Built-in plugins (shipped with @websideproject/nuxt-auto-api) + +These are imported from `@websideproject/nuxt-auto-api/plugins` and used in your server file like any other plugin. + +## Creating a Plugin + +### Using a factory function (recommended for configurable plugins) + +```typescript +import { defineAutoApiPlugin } from '@websideproject/nuxt-auto-api/plugins' +import type { AutoApiPlugin } from '@websideproject/nuxt-auto-api/plugins' + +export interface MyPluginOptions { + threshold?: number +} + +export function createMyPlugin(options: MyPluginOptions = {}): AutoApiPlugin { + const { threshold = 100 } = options + + return defineAutoApiPlugin({ + name: 'my-plugin', + version: '1.0.0', + + runtimeSetup(ctx) { + ctx.addMiddleware({ + name: 'my-middleware', + stage: 'pre-auth', + handler: async (context) => { + // `threshold` is available here — closure works! + console.log(`Threshold: ${threshold}`) + }, + }) + }, + }) +} +``` + +### Using defineAutoApiPlugin directly (for simple plugins) + +```typescript +import { defineAutoApiPlugin } from '@websideproject/nuxt-auto-api/plugins' + +export default defineAutoApiPlugin({ + name: 'simple-plugin', + + runtimeSetup(ctx) { + ctx.extendContext(async (context) => { + context.customData = { fromPlugin: true } + }) + + ctx.addHook('users', { + afterCreate: async (result) => { + console.log('User created:', result.id) + }, + }) + + ctx.addGlobalHook({ + afterUpdate: async (result, context) => { + console.log(`${context.resource} updated`) + }, + }) + }, +}) +``` + +## Middleware Stages + +Plugins can register middleware at four stages of the request pipeline: + +``` +pre-auth → authorize → post-auth → validate → pre-execute → handler → post-execute +``` + +| Stage | When it runs | Use case | +|-------|-------------|----------| +| `pre-auth` | Before authorization | Rate limiting, request logging, IP filtering | +| `post-auth` | After authorization, before validation | Tenant injection, audit logging | +| `pre-execute` | After validation, before the handler | Cache checks, request enrichment | +| `post-execute` | After the handler returns | Response logging, cache population | + +### Middleware Options + +```typescript +ctx.addMiddleware({ + name: 'audit-log', + stage: 'post-execute', + order: 10, // Lower runs first (default: 0) + resources: ['users', 'posts'], // Only these resources (default: all) + operations: ['create', 'update'], // Only these operations (default: all) + handler: async (context) => { + await logAuditEntry(context) + }, +}) +``` + +## Context Extenders + +Context extenders run on every request after the initial context is built, before any middleware. They enrich the `HandlerContext` with additional data. + +```typescript +ctx.extendContext(async (context) => { + if (context.user) { + context.tenant = { + id: context.user.tenantId, + field: 'tenantId', + canAccessAllTenants: context.user.role === 'super-admin', + } + } +}) +``` + +## Build-Time Context + +The `buildSetup` function receives a `PluginBuildContext` with access to Nuxt Kit utilities: + +```typescript +interface PluginBuildContext { + addServerHandler: typeof addServerHandler + addServerImportsDir: typeof addServerImportsDir + addImportsDir: typeof addImportsDir + addServerPlugin: (path: string) => void + addPlugin: typeof addPlugin + addTemplate: typeof addTemplate + options: AutoApiOptions // Mutable - plugins can modify options + nuxt: Nuxt + resolver: ReturnType + logger: { info, warn, error, debug } +} +``` + +## Runtime Context + +The `runtimeSetup` function receives a `PluginRuntimeContext`: + +```typescript +interface PluginRuntimeContext { + addMiddleware: (middleware: AutoApiMiddleware) => void + extendContext: (fn: ContextExtender) => void + addHook: (resource: string, hooks: ResourceHooks) => void + addGlobalHook: (hooks: ResourceHooks) => void + runtimeConfig: any + logger: { info, warn, error, debug } +} +``` + +## Built-in Plugins + +### Rate Limiting + +```typescript +import { createRateLimitPlugin } from '@websideproject/nuxt-auto-api/plugins' + +createRateLimitPlugin({ + windowMs: 60000, // 1 minute + max: 100, // 100 requests per window + byIp: true, // Rate limit by IP (default) + byUser: false, // Also rate limit by user ID + skip: (ctx) => ctx.user?.role === 'admin', + message: 'Too many requests', +}) +``` + +### Request Metadata + +```typescript +import { createRequestMetadataPlugin } from '@websideproject/nuxt-auto-api/plugins' + +createRequestMetadataPlugin({ + autoPopulate: (metadata, data, context) => { + if (context.operation === 'create') { + data.signupCountry = metadata.country + data.signupIp = metadata.ip + } + return data + }, + autoPopulateOn: ['create', 'update'], + resources: ['users'], +}) +``` + +See [Request Metadata Plugin](/auto-api/request-metadata) for full documentation. + +### Better Auth Integration + +```typescript +import { createBetterAuthPlugin } from '@websideproject/nuxt-auto-api/plugins' + +createBetterAuthPlugin({ + getSession: async (event) => { + const session = await auth.api.getSession({ headers: event.headers }) + return session + }, + mapUser: (session) => ({ + id: session.user.id, + email: session.user.email, + permissions: session.user.role === 'admin' ? ['admin'] : ['user'], + }), +}) +``` + +## Plugin Execution Order + +1. **Build-time**: Inline plugins execute in array order during module setup. File-based plugins do not have build-time phases (their code runs at runtime). +2. **Runtime**: Plugins execute in order: user file plugins first, then community module plugins. Middleware is sorted by `order` field across all plugins. + +## Writing a Community Plugin Package + +### As a factory function (users add to their plugins file) + +```typescript +// @websideproject/nuxt-auto-api-audit-log/src/plugin.ts +import { defineAutoApiPlugin } from '@websideproject/nuxt-auto-api/plugins' +import type { AutoApiPlugin } from '@websideproject/nuxt-auto-api/plugins' + +export interface AuditLogOptions { + destination: 'db' | 'console' | 'external' +} + +export function createAuditLogPlugin(options: AuditLogOptions): AutoApiPlugin { + return defineAutoApiPlugin({ + name: 'audit-log', + runtimeSetup(ctx) { + ctx.addMiddleware({ + name: 'audit-log', + stage: 'post-execute', + handler: async (context) => { + // Log the operation ... + }, + }) + }, + }) +} +``` + +Users add it to their server file: + +```typescript +// server/autoapi-plugins.ts +import { createAuditLogPlugin } from '@websideproject/nuxt-auto-api-audit-log' + +export default [ + createAuditLogPlugin({ destination: 'db' }), +] +``` + +### As a Nuxt module (zero-config for users) + +```typescript +// @websideproject/nuxt-auto-api-audit-log/src/module.ts +import { defineNuxtModule, createResolver } from '@nuxt/kit' + +export default defineNuxtModule({ + meta: { name: '@websideproject/nuxt-auto-api-audit-log' }, + setup(options, nuxt) { + const resolver = createResolver(import.meta.url) + nuxt.hook('autoApi:registerPlugins', (ctx) => { + ctx.addFile(resolver.resolve('./runtime/audit-plugin')) + }) + }, +}) +``` + +Users just add the module to `nuxt.config.ts`: + +```typescript +modules: ['@websideproject/nuxt-auto-api', '@websideproject/nuxt-auto-api-audit-log'] +``` + +## Migration from Extensions + +If you were using the legacy `extensions` field, migrate to the file-based approach: + +```typescript +// Before (deprecated) +autoApi: { + extensions: [ + createRateLimitExtension({ max: 100 }) + ] +} + +// After +autoApi: { + plugins: '~/server/autoapi-plugins' +} +``` + +The `extensions` field still works for backward compatibility but is deprecated. diff --git a/apps/docs/content/2.auto-api/14.database-adapters.md b/apps/docs/content/2.auto-api/14.database-adapters.md new file mode 100644 index 0000000..3b94e28 --- /dev/null +++ b/apps/docs/content/2.auto-api/14.database-adapters.md @@ -0,0 +1,147 @@ +# Database Adapters + +@websideproject/nuxt-auto-api supports multiple database engines through a unified adapter layer. Each adapter normalizes engine-specific behavior like transactions, batch operations, and mutation count parsing. + +## Supported Engines + +| Engine | Package | Transactions | Returning | Native Batch | +|--------|---------|-------------|-----------|-------------| +| `better-sqlite3` | `better-sqlite3` | `db.transaction()` | Yes | No | +| `postgres` | `pg` / `postgres` | `db.transaction()` | Yes | No | +| `mysql` | `mysql2` | `db.transaction()` | No | No | +| `d1` | Cloudflare D1 | `db.batch()` | Yes | Yes | +| `turso` | `@libsql/client` | `db.batch()` | Yes | Yes | +| `planetscale` | `@planetscale/database` | `db.transaction()` | No | No | + +## Setup + +### Using `initializeDatabase` + +The recommended way to initialize the database is with `initializeDatabase()` in a server plugin: + +```typescript +// server/plugins/database.ts +import { drizzle } from 'drizzle-orm/better-sqlite3' +import Database from 'better-sqlite3' +import { initializeDatabase } from '@websideproject/nuxt-auto-api/database' +import * as schema from '../database/schema' + +export default defineNitroPlugin(() => { + const sqlite = new Database('sqlite.db') + const db = drizzle(sqlite, { schema }) + + initializeDatabase(db, 'better-sqlite3') +}) +``` + +### Postgres + +```typescript +import { drizzle } from 'drizzle-orm/node-postgres' +import { Pool } from 'pg' +import { initializeDatabase } from '@websideproject/nuxt-auto-api/database' + +export default defineNitroPlugin(() => { + const pool = new Pool({ connectionString: process.env.DATABASE_URL }) + const db = drizzle(pool, { schema }) + + initializeDatabase(db, 'postgres') +}) +``` + +### Cloudflare D1 + +```typescript +import { drizzle } from 'drizzle-orm/d1' +import { initializeDatabase } from '@websideproject/nuxt-auto-api/database' + +export default defineNitroPlugin(() => { + const db = drizzle(env.DB, { schema }) + + initializeDatabase(db, 'd1') +}) +``` + +### Turso / LibSQL + +```typescript +import { drizzle } from 'drizzle-orm/libsql' +import { createClient } from '@libsql/client' +import { initializeDatabase } from '@websideproject/nuxt-auto-api/database' + +export default defineNitroPlugin(() => { + const client = createClient({ + url: process.env.TURSO_DATABASE_URL!, + authToken: process.env.TURSO_AUTH_TOKEN, + }) + const db = drizzle(client, { schema }) + + initializeDatabase(db, 'turso') +}) +``` + +## Configuration + +Set the engine in `nuxt.config.ts`: + +```typescript +export default defineNuxtConfig({ + autoApi: { + database: { + client: 'better-sqlite3', // or 'postgres', 'mysql', 'd1', 'turso', 'planetscale' + }, + }, +}) +``` + +## Using the Adapter Directly + +The adapter is available in `HandlerContext` and from the `getDatabaseAdapter()` helper: + +```typescript +import { getDatabaseAdapter } from '@websideproject/nuxt-auto-api/database' + +// In a server handler +const adapter = getDatabaseAdapter() + +// Run atomic operations (transaction or batch depending on engine) +const result = await adapter.atomic(async ({ tx }) => { + await tx.insert(users).values({ name: 'Alice' }) + await tx.insert(posts).values({ title: 'Hello', userId: 1 }) + return 'done' +}) + +// Check engine capabilities +if (adapter.supportsReturning) { + const [created] = await db.insert(users).values(data).returning() +} + +// Parse mutation counts +const result = await db.delete(posts).where(eq(posts.draft, true)) +const deleted = adapter.getMutationCount(result) +``` + +## Adapter API + +```typescript +interface DatabaseAdapter { + engine: DatabaseEngine // Engine identifier + db: any // Drizzle database instance + atomic(fn): Promise // Run operations atomically + getMutationCount(result): number // Parse affected rows + supportsReturning: boolean // Does the engine support RETURNING? + supportsNativeBatch: boolean // Does the engine use batch() instead of transaction()? +} +``` + +## Backward Compatibility + +If you are using the legacy `globalThis.__autoApiDb` pattern, it still works. The system will automatically wrap it in a default SQLite adapter with a deprecation warning. To silence the warning, switch to `initializeDatabase()`. + +```typescript +// Legacy (still works, deprecated) +globalThis.__autoApiDb = db + +// Recommended +initializeDatabase(db, 'better-sqlite3') +``` diff --git a/apps/docs/content/2.auto-api/15.custom-endpoints.md b/apps/docs/content/2.auto-api/15.custom-endpoints.md new file mode 100644 index 0000000..4a833ed --- /dev/null +++ b/apps/docs/content/2.auto-api/15.custom-endpoints.md @@ -0,0 +1,276 @@ +# Custom Endpoints + +@websideproject/nuxt-auto-api provides two approaches for building custom server endpoints that integrate with the auto-api pipeline: `createEndpoint()` for full pipeline integration, and standalone helpers for lightweight use in regular Nitro handlers. + +## createEndpoint + +`createEndpoint()` is the recommended way to build custom endpoints. It handles the full middleware pipeline, Zod validation, response formatting, and serialization. + +### Resource-Bound Endpoint + +When you specify a `resource`, the endpoint uses the full auto-api pipeline (authorization, validation, middleware): + +```typescript +// server/api/users/[id]/stats.get.ts +import { createEndpoint } from '@websideproject/nuxt-auto-api/utils' +import { eq, count } from 'drizzle-orm' +import { users, posts } from '../../database/schema' + +export default createEndpoint({ + resource: 'users', + operation: 'get', + + async handler(ctx) { + const userId = parseInt(ctx.params.id) + const [user, postCount] = await Promise.all([ + ctx.db.query.users.findFirst({ where: eq(users.id, userId) }), + ctx.db.select({ count: count() }).from(posts).where(eq(posts.userId, userId)), + ]) + + return { ...user, postCount: postCount[0].count } + }, +}) +``` + +### Standalone Endpoint + +Without a `resource`, the endpoint creates a lightweight context with database access and plugin middleware, but skips resource-specific authorization and validation: + +```typescript +// server/api/health.get.ts +import { createEndpoint } from '@websideproject/nuxt-auto-api/utils' + +export default createEndpoint({ + async handler(ctx) { + return { status: 'ok', timestamp: new Date().toISOString() } + }, + responseFormat: 'raw', // Don't wrap in { data } +}) +``` + +### Body and Query Validation + +Use Zod schemas for type-safe input validation: + +```typescript +// server/api/users/invite.post.ts +import { z } from 'zod' +import { createEndpoint } from '@websideproject/nuxt-auto-api/utils' + +export default createEndpoint({ + resource: 'users', + operation: 'create', + + body: z.object({ + email: z.string().email(), + role: z.enum(['user', 'editor', 'admin']).default('user'), + }), + + query: z.object({ + sendEmail: z.coerce.boolean().optional().default(true), + }), + + async handler(ctx) { + // ctx.body and ctx.queryParams are typed + const { email, role } = ctx.body + const { sendEmail } = ctx.queryParams + + const user = await ctx.db.insert(users).values({ email, role }).returning() + + if (sendEmail) { + await sendInviteEmail(email) + } + + return user[0] + }, +}) +``` + +### Response Format + +Control how the response is wrapped: + +```typescript +// 'auto' (default) - wraps in { data: result } +createEndpoint({ + responseFormat: 'auto', + handler: async () => ({ name: 'Alice' }), +}) +// Returns: { data: { name: 'Alice' } } + +// 'raw' - passes through as-is +createEndpoint({ + responseFormat: 'raw', + handler: async () => ({ name: 'Alice' }), +}) +// Returns: { name: 'Alice' } +``` + +### Transform + +Transform the result before sending: + +```typescript +createEndpoint({ + handler: async (ctx) => { + return await ctx.db.query.users.findMany() + }, + transform: (users, ctx) => ({ + users, + total: users.length, + requestedBy: ctx.user?.email, + }), + responseFormat: 'auto', +}) +// Returns: { data: { users: [...], total: 5, requestedBy: 'admin@example.com' } } +``` + +### Skip Authorization / Validation + +```typescript +createEndpoint({ + resource: 'users', + skipAuthorization: true, // Public endpoint + skipValidation: true, // Custom validation in handler + handler: async (ctx) => { /* ... */ }, +}) +``` + +## Standalone Helpers + +For regular Nitro event handlers where you don't need the full pipeline, use the standalone helpers: + +### getAutoApiContext + +Get a lightweight HandlerContext with database and plugin context extenders: + +```typescript +// server/api/dashboard.get.ts +export default defineEventHandler(async (event) => { + const ctx = await getAutoApiContext(event) + + const userCount = await ctx.db.select({ count: count() }).from(users) + const postCount = await ctx.db.select({ count: count() }).from(posts) + + return respondWith({ + users: userCount[0].count, + posts: postCount[0].count, + }) +}) +``` + +### validateBody / validateQuery + +Validate request input with Zod and get typed results: + +```typescript +// server/api/contact.post.ts +import { z } from 'zod' + +const ContactSchema = z.object({ + name: z.string().min(1), + email: z.string().email(), + message: z.string().min(10), +}) + +export default defineEventHandler(async (event) => { + const body = await validateBody(event, ContactSchema) + // body is typed as { name: string, email: string, message: string } + + await sendContactEmail(body) + return respondWith({ sent: true }) +}) +``` + +### respondWith / respondWithList / respondWithError + +Standardized response formatting with serialization: + +```typescript +export default defineEventHandler(async (event) => { + const ctx = await getAutoApiContext(event) + const users = await ctx.db.select().from(usersTable) + + // Single item + return respondWith(users[0]) + // { data: { id: 1, name: 'Alice', createdAt: '2025-01-01T00:00:00.000Z' } } + + // List with metadata + return respondWithList(users, { total: 100, page: 1, limit: 20 }) + // { data: [...], meta: { total: 100, page: 1, limit: 20 } } + + // Error + respondWithError(404, 'User not found') + respondWithError(422, 'Validation failed', { field: 'email', message: 'Already taken' }) +}) +``` + +### getDb + +Quick access to the database and adapter: + +```typescript +export default defineEventHandler(async (event) => { + const { db, adapter } = getDb() + + // Use adapter for atomic operations + await adapter.atomic(async ({ tx }) => { + await tx.insert(orders).values(orderData) + await tx.insert(orderItems).values(itemsData) + }) + + return respondWith({ success: true }) +}) +``` + +## All Available Helpers + +| Helper | Description | +|--------|------------| +| `createEndpoint(options)` | Full pipeline endpoint with Zod validation | +| `getAutoApiContext(event)` | Lightweight HandlerContext for standalone handlers | +| `validateBody(event, zodSchema)` | Validate request body, throw 400 on failure | +| `validateQuery(event, zodSchema)` | Validate query params, throw 400 on failure | +| `respondWith(data)` | Wrap in `{ data }` with serialization | +| `respondWithList(data, meta?)` | Wrap in `{ data, meta }` with serialization | +| `respondWithError(status, message, details?)` | Throw standardized error | +| `getDb()` | Get `{ db, adapter }` | +| `getResourceSchema(name)` | Get table schema from registry | +| `getRegistry()` | Get full resource registry | +| `serialize(data)` | Serialize dates to ISO strings | +| `filterHidden(data, fields)` | Remove hidden fields from response | + +## Migration from defineAutoApiHandler + +`defineAutoApiHandler` is deprecated in favor of `createEndpoint`. Here's how to migrate: + +```typescript +// Before (deprecated) +export default defineAutoApiHandler({ + async execute(context) { + return { data: { message: 'hello' } } + }, + transform(result, context) { + return { ...result, extra: true } + }, + skipAuthorization: true, +}) + +// After +export default createEndpoint({ + skipAuthorization: true, + async handler(ctx) { + return { message: 'hello' } + }, + transform(result, ctx) { + return { ...result, extra: true } + }, +}) +``` + +Key differences: +- `execute` is now `handler` +- `handler` receives `EndpointContext` with typed `body` and `queryParams` +- Zod schemas for `body` and `query` validation +- `responseFormat` option ('auto' wraps in `{ data }`, 'raw' passes through) +- Plugin middleware runs at all four stages diff --git a/apps/docs/content/2.auto-api/16.multi-tenancy.md b/apps/docs/content/2.auto-api/16.multi-tenancy.md new file mode 100644 index 0000000..a224a12 --- /dev/null +++ b/apps/docs/content/2.auto-api/16.multi-tenancy.md @@ -0,0 +1,1093 @@ +# Multi-Tenancy + +This guide covers building multi-tenant SaaS applications with organization-level permissions, where users can have different roles across organizations. + +## Overview + +Multi-tenancy enables: +- **Data isolation** - Automatic filtering by organization +- **Organization-level permissions** - User is admin in Org A, member in Org B +- **API token scoping** - Tokens bound to organizations +- **Flexible tenant resolution** - From user, header, subdomain, etc. +- **Admin bypass** - Superadmins can access all organizations + +--- + +## Table of Contents + +1. [Quick Start](#quick-start) +2. [Schema Design](#schema-design) +3. [Configuration](#configuration) +4. [Organization-Member Permissions](#organization-member-permissions) +5. [Better-Auth Integration](#better-auth-integration) +6. [API Token Plugin](#api-token-plugin-with-organizations) +7. [Tenant Resolution Strategies](#tenant-resolution-strategies) +8. [Advanced Patterns](#advanced-patterns) +9. [Testing](#testing) + +--- + +## Quick Start + +### 1. Add Organization Field to Schema + +```typescript +// server/database/schema.ts +import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core' +import { relations } from 'drizzle-orm' + +export const organizations = sqliteTable('organizations', { + id: text('id').primaryKey(), + name: text('name').notNull(), + slug: text('slug').notNull().unique(), + createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()), +}) + +export const posts = sqliteTable('posts', { + id: integer('id').primaryKey({ autoIncrement: true }), + title: text('title').notNull(), + content: text('content'), + userId: integer('user_id').notNull(), + organizationId: text('organization_id') // ← Multi-tenancy field + .notNull() + .references(() => organizations.id, { onDelete: 'cascade' }), + createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()), +}) + +export const postsRelations = relations(posts, ({ one }) => ({ + organization: one(organizations, { + fields: [posts.organizationId], + references: [organizations.id], + }), + user: one(users, { + fields: [posts.userId], + references: [users.id], + }), +})) +``` + +### 2. Enable Multi-Tenancy + +```typescript +// nuxt.config.ts +export default defineNuxtConfig({ + autoApi: { + multiTenancy: { + enabled: true, + tenantIdField: 'organizationId', + getTenantId: (event) => event.context.user?.organizationId, + } + } +}) +``` + +### 3. Set User Context + +```typescript +// server/plugins/auth.ts +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('request', async (event) => { + const session = await getSession(event) + + if (session) { + event.context.user = { + id: session.user.id, + email: session.user.email, + organizationId: session.activeOrganizationId, // ← Current org + } + } + }) +}) +``` + +### 4. Done! + +All operations are now automatically scoped to the user's organization: + +```bash +# User in Org A +GET /api/posts +# Returns only posts where organizationId = 'org_a' + +POST /api/posts +{ "title": "New Post", "content": "..." } +# Automatically sets organizationId = 'org_a' +``` + +--- + +## Schema Design + +### Organization-Member Pattern + +For users with different roles per organization, use a join table: + +```typescript +// Organizations +export const organizations = sqliteTable('organizations', { + id: text('id').primaryKey(), + name: text('name').notNull(), + slug: text('slug').notNull().unique(), + plan: text('plan', { enum: ['free', 'pro', 'enterprise'] }).default('free'), + createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()), +}) + +// Users (global, no org field) +export const users = sqliteTable('users', { + id: integer('id').primaryKey({ autoIncrement: true }), + email: text('email').notNull().unique(), + name: text('name'), + createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()), +}) + +// Organization Members (role per org) +export const organizationMembers = sqliteTable('organization_members', { + id: integer('id').primaryKey({ autoIncrement: true }), + userId: integer('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + organizationId: text('organization_id') + .notNull() + .references(() => organizations.id, { onDelete: 'cascade' }), + role: text('role', { enum: ['owner', 'admin', 'member', 'viewer'] }) + .notNull() + .default('member'), + invitedAt: integer('invited_at', { mode: 'timestamp' }), + joinedAt: integer('joined_at', { mode: 'timestamp' }).$defaultFn(() => new Date()), +}, (table) => ({ + // User can be member of org only once + uniqueUserOrg: unique().on(table.userId, table.organizationId), +})) + +// Relations +export const organizationMembersRelations = relations(organizationMembers, ({ one }) => ({ + user: one(users, { + fields: [organizationMembers.userId], + references: [users.id], + }), + organization: one(organizations, { + fields: [organizationMembers.organizationId], + references: [organizations.id], + }), +})) + +export const usersRelations = relations(users, ({ many }) => ({ + memberships: many(organizationMembers), +})) + +export const organizationsRelations = relations(organizations, ({ many }) => ({ + members: many(organizationMembers), + posts: many(posts), +})) + +// Scoped Resources +export const posts = sqliteTable('posts', { + id: integer('id').primaryKey({ autoIncrement: true }), + title: text('title').notNull(), + content: text('content'), + authorId: integer('author_id').notNull(), // Not FK to prevent cascade + organizationId: text('organization_id') + .notNull() + .references(() => organizations.id, { onDelete: 'cascade' }), + createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()), +}) +``` + +### Why This Pattern? + +✅ **User belongs to multiple organizations** - via `organizationMembers` +✅ **Different role per organization** - `role` field on membership +✅ **Resources scoped to organization** - `organizationId` on `posts` +✅ **Clean user switching** - Change active organization without re-auth + +--- + +## Configuration + +### Basic Configuration + +```typescript +export default defineNuxtConfig({ + autoApi: { + multiTenancy: { + enabled: true, + tenantIdField: 'organizationId', // Field name in tables + scopedResources: '*', // All resources (default) + excludedResources: [], // Global resources + requireTenant: true, // Require tenant for all ops + getTenantId: (event) => { + return event.context.user?.organizationId + }, + allowCrossTenantAccess: (user) => { + return user?.role === 'superadmin' + }, + } + } +}) +``` + +### Configuration Reference + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `enabled` | `boolean` | `false` | Enable multi-tenancy | +| `tenantIdField` | `string` | `'organizationId'` | Column name for tenant ID | +| `scopedResources` | `'*'` \| `string[]` | `'*'` | Which resources to scope | +| `excludedResources` | `string[]` | `[]` | Global resources (not scoped) | +| `requireTenant` | `boolean` | `true` | Require tenant for all operations | +| `getTenantId` | `function` | - | Extract tenant ID from request | +| `allowCrossTenantAccess` | `function` | - | Check if user can access all orgs | + +### Scoped vs Global Resources + +```typescript +multiTenancy: { + enabled: true, + + // All resources except these are scoped + excludedResources: [ + 'users', // Global user table + 'countries', // Shared reference data + 'timezones', // Shared reference data + ], + + // Only these resources are scoped + scopedResources: [ + 'posts', + 'comments', + 'projects', + ], +} +``` + +--- + +## Organization-Member Permissions + +### Permission Helpers + +Create helpers to check organization membership and role: + +```typescript +// server/utils/orgPermissions.ts +import type { H3Event } from 'h3' +import { db } from '../database/db' +import { organizationMembers } from '../database/schema' +import { and, eq } from 'drizzle-orm' + +export type OrgRole = 'owner' | 'admin' | 'member' | 'viewer' + +export async function getOrgMembership( + userId: number, + organizationId: string +): Promise { + const membership = await db.query.organizationMembers.findFirst({ + where: and( + eq(organizationMembers.userId, userId), + eq(organizationMembers.organizationId, organizationId) + ), + }) + + return membership?.role ?? null +} + +export async function hasOrgRole( + userId: number, + organizationId: string, + requiredRoles: OrgRole | OrgRole[] +): Promise { + const role = await getOrgMembership(userId, organizationId) + if (!role) return false + + const roles = Array.isArray(requiredRoles) ? requiredRoles : [requiredRoles] + return roles.includes(role) +} + +export async function isOrgOwner(userId: number, organizationId: string): Promise { + return hasOrgRole(userId, organizationId, 'owner') +} + +export async function isOrgAdmin(userId: number, organizationId: string): Promise { + return hasOrgRole(userId, organizationId, ['owner', 'admin']) +} + +// Role hierarchy check +const roleHierarchy: Record = { + owner: 4, + admin: 3, + member: 2, + viewer: 1, +} + +export async function hasMinimumRole( + userId: number, + organizationId: string, + minimumRole: OrgRole +): Promise { + const userRole = await getOrgMembership(userId, organizationId) + if (!userRole) return false + + return roleHierarchy[userRole] >= roleHierarchy[minimumRole] +} +``` + +### Resource Permissions with Organization Roles + +```typescript +// modules/blog/auth.ts +import { hasMinimumRole, isOrgAdmin } from '~/server/utils/orgPermissions' + +export const postsAuth = { + async canList(user, context) { + if (!user) return false + + // Must be member of the organization + return hasMinimumRole(user.id, context.tenant.id, 'viewer') + }, + + async canCreate(user, context) { + // Must be member or admin + return hasMinimumRole(user.id, context.tenant.id, 'member') + }, + + async canUpdate(user, context) { + // Check per-object (own posts or admin) + return true // Object-level check handles this + }, + + async canDelete(user, context) { + // Only admins can delete + return isOrgAdmin(user.id, context.tenant.id) + }, + + // Object-level: Can update own posts, or admin can update any + async objectLevel(post, context) { + if (!context.user) return false + + // Post author can edit + if (post.authorId === context.user.id) return true + + // Org admin can edit any post in their org + return isOrgAdmin(context.user.id, post.organizationId) + }, + + // SQL-level list filtering + listFilter: (table, context) => { + const { user } = context + if (!user) return eq(table.id, -1) // Return nothing + + // Viewers see only published, members+ see all + return hasMinimumRole(user.id, context.tenant.id, 'member') + ? undefined // No filter (tenant isolation handles org boundary) + : eq(table.published, true) + }, +} +``` + +### Example: Different Permissions Per Org + +```typescript +// User Alice +const alice = { + id: 1, + email: 'alice@example.com', + organizationId: 'org_a', // Currently active org +} + +// Alice's memberships +await db.insert(organizationMembers).values([ + { userId: 1, organizationId: 'org_a', role: 'admin' }, // Admin in Org A + { userId: 1, organizationId: 'org_b', role: 'viewer' }, // Viewer in Org B +]) + +// In Org A context (alice.organizationId = 'org_a') +await canCreate(alice, { tenant: { id: 'org_a' } }) // ✅ true (admin) +await canDelete(alice, { tenant: { id: 'org_a' } }) // ✅ true (admin) + +// If Alice switches to Org B (alice.organizationId = 'org_b') +await canCreate(alice, { tenant: { id: 'org_b' } }) // ❌ false (viewer) +await canDelete(alice, { tenant: { id: 'org_b' } }) // ❌ false (viewer) +``` + +--- + +## Better-Auth Integration + +### Installation + +```bash +npm install better-auth +``` + +### Setup with Organization Plugin + +```typescript +// server/lib/auth.ts +import { betterAuth } from 'better-auth' +import { organization } from 'better-auth/plugins' +import { drizzleAdapter } from 'better-auth/adapters/drizzle' +import { db } from '../database/db' +import * as schema from '../database/schema' + +export const auth = betterAuth({ + database: drizzleAdapter(db, { + provider: 'sqlite', + schema, + }), + + plugins: [ + organization({ + // Organization plugin adds: + // - organizations table + // - members table with roles + // - session.activeOrganizationId + // - organization switching API + roles: ['owner', 'admin', 'member', 'viewer'], + }), + ], + + session: { + expiresIn: 60 * 60 * 24 * 7, // 7 days + updateAge: 60 * 60 * 24, // Update every 24 hours + }, +}) +``` + +### Auth Plugin for Nuxt-Auto-API + +```typescript +// server/plugins/auth.ts +import { auth } from '../lib/auth' + +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('request', async (event) => { + // Get session from Better-Auth + const session = await auth.api.getSession({ + headers: event.node.req.headers, + }) + + if (!session) return + + // Get active organization membership + const activeMembership = session.user.organizationId + ? await db.query.organizationMembers.findFirst({ + where: and( + eq(organizationMembers.userId, session.user.id), + eq(organizationMembers.organizationId, session.user.organizationId) + ), + }) + : null + + // Set user context with organization + event.context.user = { + id: session.user.id, + email: session.user.email, + name: session.user.name, + organizationId: session.activeOrganizationId, // Current org + role: activeMembership?.role ?? 'viewer', // Role in current org + } + + // Set permissions array (for simple permission checks) + event.context.permissions = activeMembership + ? [activeMembership.role] + : [] + }) +}) +``` + +### Organization Switching + +```typescript +// Frontend: Switch active organization +async function switchOrganization(orgId: string) { + await $fetch('/api/auth/organization/set-active', { + method: 'POST', + body: { organizationId: orgId }, + }) + + // Refresh session + await refreshNuxtData() + + // All subsequent API calls now use new org context +} +``` + +### Better-Auth Organization Schema + +```typescript +// Better-Auth creates these tables automatically: +// - organizations: { id, name, slug, createdAt, ... } +// - members: { id, userId, organizationId, role, ... } +// - sessions: { ..., activeOrganizationId } + +// You don't need to define them manually! +``` + +--- + +## API Token Plugin with Organizations + +### Configuration + +```typescript +// server/plugins/apiKeys.ts +import { createApiTokenPlugin } from '@websideproject/nuxt-auto-api/plugins' + +export default createApiTokenPlugin({ + resources: { + apiKeys: { + userRelation: { + field: 'userId', + resource: 'users', + }, + orgField: 'organizationId', // ← Org scoping for tokens + scopeField: 'scopes', + expiresField: 'expiresAt', + lastUsedField: 'lastUsedAt', + authEnabled: true, + }, + }, + + auth: { + enabled: true, + header: 'Authorization', + prefix: 'Bearer', + tokenPrefix: 'sk_', + }, + + mapUser: async (dbRow, db) => { + // Fetch user's role in the token's organization + const membership = await db.query.organizationMembers.findFirst({ + where: and( + eq(organizationMembers.userId, dbRow.userId), + eq(organizationMembers.organizationId, dbRow.organizationId) + ), + }) + + return { + id: dbRow.userId, + email: dbRow.user?.email, + organizationId: dbRow.organizationId, // Token's org + role: membership?.role ?? 'member', // Role in that org + } + }, +}) +``` + +### API Keys Schema + +```typescript +// modules/api-tokens/schema.ts +export const apiKeys = sqliteTable('api_keys', { + id: integer('id').primaryKey({ autoIncrement: true }), + name: text('name').notNull(), + key: text('key').notNull().unique(), // SHA-256 hash + userId: integer('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + organizationId: text('organization_id') // ← Token scoped to org + .notNull() + .references(() => organizations.id, { onDelete: 'cascade' }), + scopes: text('scopes', { mode: 'json' }).$type(), + expiresAt: integer('expires_at', { mode: 'timestamp' }), + lastUsedAt: integer('last_used_at', { mode: 'timestamp' }), + createdAt: integer('created_at', { mode: 'timestamp' }) + .notNull() + .$defaultFn(() => new Date()), +}) +``` + +### Creating Organization-Scoped Tokens + +```typescript +// Backend: Create token endpoint +export default defineEventHandler(async (event) => { + const user = event.context.user + if (!user?.organizationId) { + throw createError({ statusCode: 401, message: 'Unauthorized' }) + } + + // Generate token + const rawToken = `sk_${randomBytes(32).toString('hex')}` + const hashedKey = createHash('sha256').update(rawToken).digest('hex') + + // Store with org scope + const apiKey = await db.insert(apiKeys).values({ + name: 'My API Token', + key: hashedKey, + userId: user.id, + organizationId: user.organizationId, // ← Bound to user's current org + scopes: ['posts:read', 'posts:write'], + expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 1 year + }).returning() + + // Return raw token ONCE (never stored) + return { + id: apiKey.id, + token: rawToken, // Show to user, they must save it + name: apiKey.name, + scopes: apiKey.scopes, + expiresAt: apiKey.expiresAt, + } +}) +``` + +### Using Organization-Scoped Tokens + +```bash +# Token automatically sets organization context +curl -H "Authorization: Bearer sk_abc123..." \ + https://api.example.com/api/posts + +# Returns only posts from token's organization +# Token cannot access other organizations +``` + +### Token Behavior with Multi-Tenancy + +When API token plugin detects `orgField`: + +1. **Extracts organization** from token record +2. **Sets tenant context** automatically: + ```typescript + context.tenant = { + id: token.organizationId, + field: 'organizationId', + canAccessAllTenants: false, // Tokens are single-org only + } + ``` +3. **All operations scoped** to token's organization +4. **Cannot bypass** tenant isolation (even if user is admin in another org) + +### Token Introspection + +```bash +GET /api/_token/introspect +Authorization: Bearer sk_abc123... + +{ + "data": { + "resource": "apiKeys", + "id": 42, + "name": "My API Token", + "scopes": ["posts:read", "posts:write"], + "organizationId": "org_a", + "expiresAt": "2026-01-01T00:00:00Z", + "lastUsedAt": "2026-02-08T10:30:00Z" + } +} +``` + +--- + +## Tenant Resolution Strategies + +### 1. From User Context (Recommended) + +```typescript +multiTenancy: { + getTenantId: (event) => { + return event.context.user?.organizationId + } +} +``` + +**Pros:** Works with Better-Auth, simple +**Use case:** Standard SaaS with user login + +### 2. From Subdomain + +```typescript +multiTenancy: { + getTenantId: (event) => { + const host = event.node.req.headers.host + if (!host) return null + + // Extract subdomain: acme.example.com → acme + const subdomain = host.split('.')[0] + if (subdomain === 'www' || subdomain === 'api') return null + + return subdomain + } +} +``` + +**Pros:** Tenant-specific URLs +**Use case:** `acme.app.com`, `globex.app.com` + +### 3. From Header + +```typescript +multiTenancy: { + getTenantId: (event) => { + return getHeader(event, 'x-tenant-id') + } +} +``` + +**Pros:** Flexible, works with any client +**Use case:** Mobile apps, API integrations + +### 4. From JWT Claim + +```typescript +multiTenancy: { + getTenantId: (event) => { + const token = getHeader(event, 'authorization')?.replace('Bearer ', '') + if (!token) return null + + const decoded = decodeJWT(token) + return decoded.organizationId + } +} +``` + +**Pros:** Embedded in auth token +**Use case:** External auth providers (Auth0, Clerk) + +### 5. Hybrid Approach + +```typescript +multiTenancy: { + getTenantId: async (event) => { + // 1. Try user context (Better-Auth) + if (event.context.user?.organizationId) { + return event.context.user.organizationId + } + + // 2. Try header (API clients) + const headerTenant = getHeader(event, 'x-tenant-id') + if (headerTenant) return headerTenant + + // 3. Try subdomain (multi-site) + const host = event.node.req.headers.host + if (host) { + const subdomain = host.split('.')[0] + if (subdomain && subdomain !== 'www') return subdomain + } + + return null + } +} +``` + +--- + +## Advanced Patterns + +### Superadmin Bypass + +```typescript +multiTenancy: { + allowCrossTenantAccess: (user) => { + // Superadmin can access all organizations + return user?.role === 'superadmin' || user?.permissions?.includes('cross_tenant_access') + } +} + +// Superadmin queries +GET /api/posts?organizationId=org_a // Returns org_a posts +GET /api/posts?organizationId=org_b // Returns org_b posts +GET /api/posts // Returns ALL posts (cross-tenant) +``` + +### Organization Isolation Levels + +```typescript +// STRICT: Require tenant for all operations +multiTenancy: { + enabled: true, + requireTenant: true, // 400 error if no tenant +} + +// PERMISSIVE: Allow operations without tenant (returns global data) +multiTenancy: { + enabled: true, + requireTenant: false, // Allow null tenant +} +``` + +### Resource-Level Overrides + +```typescript +// Global handler override +export default defineAutoApiHandler({ + resource: 'users', + + // Disable multi-tenancy for this resource + multiTenancy: { + enabled: false, + }, + + operations: { + list: true, + // ... + }, +}) +``` + +### Audit Logging with Organization Context + +```typescript +// server/plugins/audit.ts +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('autoApi:afterCreate', async (result, context) => { + await db.insert(auditLogs).values({ + action: 'create', + resource: context.resource, + resourceId: result.id, + userId: context.user?.id, + organizationId: context.tenant?.id, // ← Org context + timestamp: new Date(), + }) + }) +}) +``` + +--- + +## Testing + +### Test Setup + +```typescript +// test/multi-tenancy.test.ts +import { describe, it, expect, beforeEach } from 'vitest' +import { db } from '../server/database/db' +import { organizations, organizationMembers, users, posts } from '../server/database/schema' + +describe('Multi-Tenancy', () => { + let org1: any, org2: any + let user1: any, user2: any + let adminUser: any + + beforeEach(async () => { + // Create organizations + [org1, org2] = await db.insert(organizations).values([ + { id: 'org_a', name: 'Acme Corp', slug: 'acme' }, + { id: 'org_b', name: 'Globex Inc', slug: 'globex' }, + ]).returning() + + // Create users + [user1, user2, adminUser] = await db.insert(users).values([ + { email: 'alice@acme.com', name: 'Alice' }, + { email: 'bob@globex.com', name: 'Bob' }, + { email: 'admin@example.com', name: 'Admin' }, + ]).returning() + + // Create memberships + await db.insert(organizationMembers).values([ + { userId: user1.id, organizationId: org1.id, role: 'admin' }, + { userId: user2.id, organizationId: org2.id, role: 'member' }, + { userId: adminUser.id, organizationId: org1.id, role: 'owner' }, + { userId: adminUser.id, organizationId: org2.id, role: 'owner' }, + ]) + }) + + it('lists posts scoped to user organization', async () => { + // Create posts in different orgs + await db.insert(posts).values([ + { title: 'Post 1', organizationId: org1.id, authorId: user1.id }, + { title: 'Post 2', organizationId: org2.id, authorId: user2.id }, + ]) + + // User 1 (Org A) can only see Org A posts + const response1 = await $fetch('/api/posts', { + headers: { cookie: `session=${user1Session}` }, + }) + expect(response1.data).toHaveLength(1) + expect(response1.data[0].title).toBe('Post 1') + + // User 2 (Org B) can only see Org B posts + const response2 = await $fetch('/api/posts', { + headers: { cookie: `session=${user2Session}` }, + }) + expect(response2.data).toHaveLength(1) + expect(response2.data[0].title).toBe('Post 2') + }) + + it('auto-assigns organization on create', async () => { + const newPost = await $fetch('/api/posts', { + method: 'POST', + body: { title: 'New Post', content: 'Test' }, + headers: { cookie: `session=${user1Session}` }, + }) + + expect(newPost.organizationId).toBe(org1.id) + }) + + it('blocks cross-organization access', async () => { + const org2Post = await db.insert(posts).values({ + title: 'Org B Post', + organizationId: org2.id, + authorId: user2.id, + }).returning() + + // User 1 (Org A) cannot access Org B post + await expect( + $fetch(`/api/posts/${org2Post[0].id}`, { + headers: { cookie: `session=${user1Session}` }, + }) + ).rejects.toThrow(/404|403/) + }) + + it('allows superadmin cross-tenant access', async () => { + const adminSession = await createSession(adminUser, org1.id) + + // Admin can access all organizations' posts + const response = await $fetch('/api/posts', { + headers: { cookie: `session=${adminSession}` }, + }) + + expect(response.data.length).toBeGreaterThanOrEqual(2) + }) +}) +``` + +### Testing Organization Switching + +```typescript +it('switches active organization', async () => { + // User is member of both orgs + await db.insert(organizationMembers).values([ + { userId: user1.id, organizationId: org1.id, role: 'admin' }, + { userId: user1.id, organizationId: org2.id, role: 'viewer' }, + ]) + + // Create posts in each org + await db.insert(posts).values([ + { title: 'Org A Post', organizationId: org1.id, authorId: user1.id }, + { title: 'Org B Post', organizationId: org2.id, authorId: user1.id }, + ]) + + // User in Org A sees Org A posts + let session = await createSession(user1, org1.id) + let response = await $fetch('/api/posts', { + headers: { cookie: `session=${session}` }, + }) + expect(response.data[0].title).toBe('Org A Post') + + // Switch to Org B + await $fetch('/api/auth/organization/set-active', { + method: 'POST', + body: { organizationId: org2.id }, + headers: { cookie: `session=${session}` }, + }) + + // Now sees Org B posts + session = await refreshSession(session) + response = await $fetch('/api/posts', { + headers: { cookie: `session=${session}` }, + }) + expect(response.data[0].title).toBe('Org B Post') +}) +``` + +--- + +## Best Practices + +### 1. Always Use Organization-Member Pattern + +```typescript +// ✅ GOOD: Roles on membership table +organizationMembers: { + userId, organizationId, role: 'admin' +} + +// ❌ BAD: Role directly on user +users: { + id, email, role: 'admin' // Role for ALL organizations +} +``` + +### 2. Validate Organization Membership + +```typescript +async objectLevel(post, context) { + // Verify user is member of the resource's organization + const isMember = await hasMinimumRole( + context.user.id, + post.organizationId, + 'viewer' + ) + + if (!isMember) return false + + // Then check specific permissions + return post.authorId === context.user.id || isOrgAdmin(...) +} +``` + +### 3. Use Tenant Context, Not Query Params + +```typescript +// ✅ GOOD: Tenant from context +GET /api/posts +// Returns posts for context.tenant.id + +// ❌ BAD: Tenant from query (can be spoofed) +GET /api/posts?organizationId=org_b +// User could access other orgs! +``` + +### 4. Cascade Delete Organization Data + +```typescript +organizationId: text('organization_id') + .references(() => organizations.id, { onDelete: 'cascade' }) +``` + +### 5. Test Cross-Tenant Scenarios + +```typescript +// Test matrix: +// - User in Org A cannot see Org B data +// - User in Org A cannot modify Org B data +// - Admin in Org A cannot access Org B +// - Superadmin can access all orgs +// - Organization switching works correctly +``` + +--- + +## Troubleshooting + +### Multi-Tenancy Not Working + +**Problem:** All organizations' data visible + +**Solution:** +1. Verify `multiTenancy.enabled: true` +2. Check `getTenantId` returns correct org ID +3. Ensure `organizationId` field exists in tables +4. Check `event.context.user?.organizationId` is set + +### Cross-Tenant Data Leakage + +**Problem:** User sees data from other organizations + +**Solution:** +1. Disable `allowCrossTenantAccess` for regular users +2. Verify tenant context is set correctly +3. Check excluded resources list +4. Add integration tests for cross-tenant scenarios + +### API Tokens Not Scoped + +**Problem:** API token accesses all organizations + +**Solution:** +1. Ensure `orgField: 'organizationId'` in token plugin config +2. Verify tokens have `organizationId` column +3. Check `mapUser` includes `organizationId` +4. Test token introspection endpoint + +--- + +## See Also + +- [Authorization Guide](/auto-api/authentication-authorization) - Permission system +- [Better-Auth Integration](/auto-api/better-auth) - Session management +- [API Token Plugin](/auto-api/plugin-system) - Token authentication +- [Permissions](/auto-api/authentication-authorization) - Resource permissions diff --git a/apps/docs/content/2.auto-api/2.validation.md b/apps/docs/content/2.auto-api/2.validation.md new file mode 100644 index 0000000..d632c5d --- /dev/null +++ b/apps/docs/content/2.auto-api/2.validation.md @@ -0,0 +1,240 @@ +# Validation + +`@websideproject/nuxt-auto-api` uses Zod for validation with automatic schema generation from Drizzle tables via `drizzle-zod`. + +## Automatic Validation + +By default, validation schemas are auto-generated from your Drizzle tables: + +```typescript +import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core' + +export const users = sqliteTable('users', { + id: integer('id').primaryKey({ autoIncrement: true }), + email: text('email').notNull(), + name: text('name'), +}) +``` + +This automatically validates: +- Required fields (`email` is required, `name` is optional) +- Field types (integer, text, boolean, etc.) +- Enum values (if defined) + +## Custom Validation + +For custom rules, create validation schemas with `drizzle-zod`: + +```typescript +// server/validation/users.ts +import { createInsertSchema } from 'drizzle-zod' +import { z } from 'zod' +import { users } from '../database/schema' + +const baseInsertSchema = createInsertSchema(users) + +export const usersValidation = { + create: baseInsertSchema.extend({ + email: z.string().email().toLowerCase().min(1), + name: z.string().min(2).max(100), + role: z.enum(['user', 'admin', 'editor']).default('user'), + }), + update: baseInsertSchema.partial().extend({ + email: z.string().email().toLowerCase().optional(), + name: z.string().min(2).max(100).optional(), + }), + query: z.object({ + filter: z.record(z.any()).optional(), + sort: z.union([z.string(), z.array(z.string())]).optional(), + fields: z.union([z.string(), z.array(z.string())]).optional(), + include: z.union([z.string(), z.array(z.string())]).optional(), + page: z.number().int().positive().optional(), + limit: z.number().int().positive().max(100).optional(), + }).optional(), +} +``` + +## Register Validation + +Register validation schemas in your module: + +```typescript +// modules/base/index.ts +nuxt.hook('autoApi:registerSchema', (registry) => { + registry.register('users', { + schema: createModuleImport(resolver.resolve('../../server/database/schema'), 'users'), + validation: createModuleImport( + resolver.resolve('../../server/validation/users'), + 'usersValidation' + ), + }) +}) +``` + +## Validation Errors + +Invalid requests return 400 with Zod error details: + +**Request:** +```bash +POST /api/users +{ + "email": "invalid-email", + "name": "A" +} +``` + +**Response:** +```json +{ + "statusCode": 400, + "message": "Validation error", + "data": { + "errors": [ + { + "code": "invalid_string", + "path": ["email"], + "message": "Invalid email" + }, + { + "code": "too_small", + "path": ["name"], + "message": "String must contain at least 2 character(s)", + "minimum": 2 + } + ] + } +} +``` + +## Query Parameter Validation + +Query parameters are validated by default: + +```typescript +// Invalid: limit too high +GET /api/users?limit=1000 +// Response: 400 - limit must be <= 100 + +// Invalid: page must be positive +GET /api/users?page=-1 +// Response: 400 - page must be positive + +// Valid +GET /api/users?limit=50&page=2&sort=-createdAt +``` + +## Custom Query Validation + +Override query validation in your schema: + +```typescript +export const usersValidation = { + // ... create, update schemas + query: z.object({ + filter: z.object({ + role: z.enum(['user', 'admin', 'editor']).optional(), + verified: z.boolean().optional(), + }).optional(), + sort: z.enum(['name', '-name', 'createdAt', '-createdAt']).optional(), + limit: z.number().int().positive().max(50).optional(), // Lower limit + }), +} +``` + +## Accessing Validated Data + +In custom handlers, use `context.validated`: + +```typescript +import { defineAutoApiHandler } from '@websideproject/nuxt-auto-api/utils' + +export default defineAutoApiHandler({ + async execute(context) { + // Access validated body + const validatedBody = context.validated.body + console.log(validatedBody.email) // Guaranteed to be valid email + + // Access validated query + const validatedQuery = context.validated.query + console.log(validatedQuery.limit) // Guaranteed to be valid number + + // ... custom logic + }, +}) +``` + +## Skip Validation + +In rare cases, skip validation for custom handlers: + +```typescript +export default defineAutoApiHandler({ + skipValidation: true, + async execute(context) { + // No validation performed + const body = await readBody(context.event) + // ... custom logic without validation + }, +}) +``` + +## Best Practices + +1. **Use drizzle-zod** for base schemas - keeps validation in sync with database +2. **Extend, don't replace** - build on auto-generated schemas +3. **Validate at boundaries** - user input, not internal operations +4. **Use TypeScript** - get type inference from Zod schemas +5. **Test validation** - write tests for edge cases + +## Advanced Patterns + +### Conditional Validation + +```typescript +const createUserSchema = baseInsertSchema.extend({ + email: z.string().email(), + role: z.enum(['user', 'admin']), +}).refine( + (data) => { + // Admin users must have @company.com email + if (data.role === 'admin') { + return data.email.endsWith('@company.com') + } + return true + }, + { + message: 'Admin users must use company email', + path: ['email'], + } +) +``` + +### Async Validation + +```typescript +const createUserSchema = baseInsertSchema.extend({ + email: z.string().email(), +}).refine( + async (data) => { + // Check if email already exists + const existing = await db.query.users.findFirst({ + where: eq(users.email, data.email), + }) + return !existing + }, + { + message: 'Email already exists', + path: ['email'], + } +) +``` + +### Transform Data + +```typescript +const createUserSchema = baseInsertSchema.extend({ + email: z.string().email().transform(val => val.toLowerCase()), + name: z.string().transform(val => val.trim()), +}) +``` diff --git a/apps/docs/content/2.auto-api/20.rate-limiting.md b/apps/docs/content/2.auto-api/20.rate-limiting.md new file mode 100644 index 0000000..6ec4150 --- /dev/null +++ b/apps/docs/content/2.auto-api/20.rate-limiting.md @@ -0,0 +1,580 @@ +# Rate Limiting + +Rate limiting is provided as a built-in plugin to protect your API from abuse and ensure fair usage. It registers as `pre-auth` middleware that runs automatically on every API request. + +## Installation + +The rate limiting plugin is included with @websideproject/nuxt-auto-api. Enable it via the `plugins` array: + +```typescript +// nuxt.config.ts +import { createRateLimitPlugin } from '@websideproject/nuxt-auto-api/plugins' + +export default defineNuxtConfig({ + autoApi: { + plugins: [ + createRateLimitPlugin({ + windowMs: 60000, // 1 minute + max: 100, // 100 requests per minute + byIp: true, + byUser: false + }) + ] + } +}) +``` + +No additional Nitro plugin or manual middleware setup is needed. The plugin automatically registers `pre-auth` middleware that checks rate limits before authorization runs. + +> **Migration note:** If you were using the legacy `createRateLimitExtension` with `extensions: [...]`, switch to `createRateLimitPlugin` with `plugins: [...]`. The configuration options are the same. + +## Configuration Options + +```typescript +interface RateLimitConfig { + // Window size in milliseconds (default: 60000 = 1 minute) + windowMs?: number + + // Maximum requests per window (default: 100) + max?: number + + // Rate limit by IP address (default: true) + byIp?: boolean + + // Rate limit by user ID (default: false) + byUser?: boolean + + // Custom key generator + keyGenerator?: (event: any) => string + + // Skip rate limiting for certain requests + skip?: (event: any) => boolean + + // Custom error message + message?: string + + // Custom storage backend + store?: RateLimitStore +} +``` + +## Examples + +### Different Limits by Endpoint + +```typescript +// server/plugins/rate-limit.ts +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('request', async (event) => { + if (event.path.startsWith('/api/auth/')) { + // Stricter limit for auth endpoints + await checkRateLimitWithConfig(event, { + windowMs: 60000, + max: 5 // Only 5 attempts per minute + }) + } else if (event.path.startsWith('/api/')) { + // Normal limit for other endpoints + await checkRateLimit(event) + } + }) +}) +``` + +### Rate Limit by User + +```typescript +createRateLimitExtension({ + windowMs: 60000, + max: 100, + byIp: false, + byUser: true // Rate limit per authenticated user +}) +``` + +### Combined IP and User + +```typescript +createRateLimitExtension({ + windowMs: 60000, + max: 100, + byIp: true, + byUser: true // Both IP and user must be within limits +}) +``` + +### Custom Key Generator + +```typescript +createRateLimitExtension({ + windowMs: 60000, + max: 100, + keyGenerator: (event) => { + // Rate limit by API key + const apiKey = event.node.req.headers['x-api-key'] + return `apiKey:${apiKey}` + } +}) +``` + +### Skip Certain Requests + +```typescript +createRateLimitExtension({ + windowMs: 60000, + max: 100, + skip: (event) => { + // Don't rate limit admins + return event.context.user?.role === 'admin' + } +}) +``` + +### Custom Error Message + +```typescript +createRateLimitExtension({ + windowMs: 60000, + max: 100, + message: 'You have exceeded the request limit. Please try again in a minute.' +}) +``` + +## Response Headers + +Rate limit information is included in response headers: + +```http +HTTP/1.1 200 OK +X-RateLimit-Limit: 100 +X-RateLimit-Remaining: 95 +X-RateLimit-Reset: 1704067200000 +``` + +When limit is exceeded: + +```http +HTTP/1.1 429 Too Many Requests +X-RateLimit-Limit: 100 +X-RateLimit-Remaining: 0 +X-RateLimit-Reset: 1704067200000 + +{ + "statusCode": 429, + "statusMessage": "Too Many Requests", + "message": "Too many requests, please try again later", + "data": { + "limit": 100, + "windowMs": 60000, + "retryAfter": 60 + } +} +``` + +## Storage Backends + +### In-Memory (Default) + +Suitable for single-server deployments: + +```typescript +createRateLimitExtension({ + // Uses in-memory store by default +}) +``` + +**Pros:** +- Fast +- No external dependencies +- Automatic cleanup + +**Cons:** +- Not shared across multiple servers +- Lost on server restart +- Limited to server memory + +### Redis (Recommended for Production) + +For multi-server deployments: + +```typescript +// server/utils/redis-rate-limit-store.ts +import { Redis } from 'ioredis' + +const redis = new Redis(process.env.REDIS_URL) + +export class RedisRateLimitStore { + async increment(key: string): Promise { + const count = await redis.incr(key) + + // Set TTL on first increment + if (count === 1) { + await redis.expire(key, 60) // 60 seconds + } + + return count + } + + async reset(key: string): Promise { + await redis.del(key) + } + + async get(key: string): Promise { + const count = await redis.get(key) + return count ? parseInt(count, 10) : 0 + } +} +``` + +```typescript +// nuxt.config.ts +import { createRateLimitExtension } from '@websideproject/nuxt-auto-api/extensions/rate-limiting' +import { RedisRateLimitStore } from './server/utils/redis-rate-limit-store' + +export default defineNuxtConfig({ + autoApi: { + extensions: [ + createRateLimitExtension({ + store: new RedisRateLimitStore() + }) + ] + } +}) +``` + +**Pros:** +- Shared across multiple servers +- Persists across restarts +- Scalable + +**Cons:** +- Requires Redis server +- Slightly slower than in-memory + +## Advanced Patterns + +### Different Limits for Different Users + +```typescript +// server/plugins/rate-limit.ts +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('request', async (event) => { + if (!event.path.startsWith('/api/')) return + + const user = event.context.user + + let limit = 100 // Default + + if (user?.plan === 'premium') { + limit = 1000 + } else if (user?.plan === 'enterprise') { + limit = 10000 + } + + await checkRateLimitWithConfig(event, { + windowMs: 60000, + max: limit, + keyGenerator: () => `user:${user?.id || 'anonymous'}` + }) + }) +}) +``` + +### Burst vs Sustained Rate + +```typescript +// Allow bursts but limit sustained rate +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('request', async (event) => { + if (!event.path.startsWith('/api/')) return + + // Short-term burst limit: 20 requests per 10 seconds + await checkRateLimitWithConfig(event, { + windowMs: 10000, + max: 20, + keyGenerator: (e) => `burst:${getClientIP(e)}` + }) + + // Long-term sustained limit: 100 requests per minute + await checkRateLimitWithConfig(event, { + windowMs: 60000, + max: 100, + keyGenerator: (e) => `sustained:${getClientIP(e)}` + }) + }) +}) +``` + +### Progressive Rate Limiting + +```typescript +// Increase limits based on usage +const getUserRateLimit = async (userId: string) => { + const usage = await db.query.usage.findFirst({ + where: eq(usage.userId, userId) + }) + + if (!usage) return 100 + + // Increase limit for active users + if (usage.requestCount > 10000) { + return 500 + } else if (usage.requestCount > 1000) { + return 200 + } + + return 100 +} +``` + +### Per-Endpoint Limits + +```typescript +const ENDPOINT_LIMITS = { + '/api/search': { windowMs: 60000, max: 10 }, + '/api/export': { windowMs: 3600000, max: 5 }, + '/api/upload': { windowMs: 60000, max: 20 }, + default: { windowMs: 60000, max: 100 } +} + +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('request', async (event) => { + const config = ENDPOINT_LIMITS[event.path] || ENDPOINT_LIMITS.default + + await checkRateLimitWithConfig(event, config) + }) +}) +``` + +## Monitoring + +### Track Rate Limit Hits + +```typescript +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('request', async (event) => { + try { + await checkRateLimit(event) + } catch (error) { + if (error.statusCode === 429) { + // Log rate limit hit + console.warn('Rate limit exceeded:', { + ip: getClientIP(event), + path: event.path, + userId: event.context.user?.id + }) + + // Track in analytics + await trackEvent('rate_limit_exceeded', { + ip: getClientIP(event), + path: event.path + }) + } + throw error + } + }) +}) +``` + +### Prometheus Metrics + +```typescript +import { Counter } from 'prom-client' + +const rateLimitCounter = new Counter({ + name: 'rate_limit_exceeded_total', + help: 'Total number of rate limit exceeded errors', + labelNames: ['path', 'user_type'] +}) + +// In your rate limit check +if (error.statusCode === 429) { + rateLimitCounter.inc({ + path: event.path, + user_type: event.context.user ? 'authenticated' : 'anonymous' + }) +} +``` + +## Testing + +### Disable in Development + +```typescript +createRateLimitExtension({ + skip: (event) => { + return process.env.NODE_ENV === 'development' + } +}) +``` + +### Test Rate Limiting + +```typescript +// test/rate-limiting.test.ts +import { describe, it, expect } from 'vitest' + +describe('Rate Limiting', () => { + it('should block requests after limit', async () => { + const requests = [] + + // Make 101 requests (limit is 100) + for (let i = 0; i < 101; i++) { + requests.push( + $fetch('/api/users').catch(e => e) + ) + } + + const results = await Promise.all(requests) + + // Last request should be rate limited + expect(results[100].statusCode).toBe(429) + }) +}) +``` + +## Client-Side Handling + +### Respect Rate Limits + +```typescript +// composables/useAutoApiWithRateLimit.ts +export const useAutoApiWithRateLimit = (resource: string) => { + const { fetch, ...rest } = useAutoApiFetch(resource) + + const fetchWithRetry = async (options: any) => { + try { + return await fetch(options) + } catch (error) { + if (error.statusCode === 429) { + const retryAfter = error.data?.retryAfter || 60 + + // Show user-friendly message + console.warn(`Rate limited. Retrying in ${retryAfter} seconds...`) + + // Wait and retry + await new Promise(resolve => setTimeout(resolve, retryAfter * 1000)) + return await fetch(options) + } + throw error + } + } + + return { ...rest, fetch: fetchWithRetry } +} +``` + +### Display Rate Limit Info + +```vue + + + +``` + +## Best Practices + +1. **Start conservative**: Begin with lower limits and increase based on usage +2. **Monitor closely**: Track 429 errors to find the right limits +3. **Use Redis in production**: In-memory store doesn't scale +4. **Differentiate users**: Premium users get higher limits +5. **Protect expensive endpoints**: Lower limits for resource-intensive operations +6. **Clear error messages**: Help users understand what happened +7. **Provide headers**: Include X-RateLimit-* headers +8. **Test thoroughly**: Ensure rate limiting doesn't break normal usage +9. **Document limits**: Tell users what the limits are +10. **Consider quotas**: Combine with daily/monthly quotas for paid tiers + +## Troubleshooting + +### Rate Limit Not Working + +Check that: +1. Extension is registered in nuxt.config.ts +2. Nitro plugin is applying checkRateLimit +3. Redis is connected (if using Redis store) +4. Skip function isn't excluding all requests + +### Too Many False Positives + +Adjust limits: +```typescript +createRateLimitExtension({ + windowMs: 60000, + max: 200 // Increase limit +}) +``` + +Or use per-user limits instead of per-IP: +```typescript +createRateLimitExtension({ + byIp: false, + byUser: true +}) +``` + +### Memory Issues + +Switch to Redis: +- In-memory store grows with traffic +- Redis provides bounded memory usage + +## Alternatives + +- **Cloudflare**: Rate limiting at the edge +- **nginx**: Rate limiting at reverse proxy +- **API Gateway**: AWS API Gateway, Kong, etc. +- **Custom middleware**: Full control but more work + +## Migration Path + +From custom rate limiting: + +```typescript +// Before: Custom implementation +const requests = new Map() + +export default defineEventHandler(async (event) => { + const ip = getClientIP(event) + const count = requests.get(ip) || 0 + + if (count > 100) { + throw createError({ statusCode: 429, message: 'Too many requests' }) + } + + requests.set(ip, count + 1) + // ... rest of handler +}) + +// After: Use extension +createRateLimitExtension({ + max: 100, + byIp: true +}) +``` + +Benefits: +- Automatic window management +- Response headers +- Multiple storage backends +- Better error messages +- Configuration options diff --git a/apps/docs/content/2.auto-api/21.request-metadata.md b/apps/docs/content/2.auto-api/21.request-metadata.md new file mode 100644 index 0000000..1b72f3a --- /dev/null +++ b/apps/docs/content/2.auto-api/21.request-metadata.md @@ -0,0 +1,707 @@ +# Request Metadata Plugin + +The Request Metadata Plugin automatically captures request metadata (IP address, geolocation, user-agent, etc.) and makes it available throughout your API handlers. It supports both context-only access (for hooks and authorization) and optional database persistence. + +## Installation + +The plugin is included with `@websideproject/nuxt-auto-api` and can be imported from the plugins index: + +```typescript +import { createRequestMetadataPlugin } from '@websideproject/nuxt-auto-api/plugins' +``` + +## Basic Usage + +### Context-Only (No Database Storage) + +Use this when you only need metadata for hooks, logging, or authorization decisions: + +```typescript +// server/plugins/autoapi.ts +import { createRequestMetadataPlugin } from '@websideproject/nuxt-auto-api/plugins' + +export default defineNitroPlugin(() => { + // Plugin configuration... + plugins: [ + createRequestMetadataPlugin({ + autoPopulate: false, // Don't write to DB + }), + ], +}) +``` + +Access metadata in hooks: + +```typescript +autoApi: { + hooks: { + users: { + afterCreate: async (result, context) => { + // Metadata available in context + const country = context.requestMeta?.country || 'US' + const ip = context.requestMeta?.ip + + // Send localized welcome email + await sendWelcomeEmail(result.email, country) + + // Log for analytics + console.log(`New user from ${country}: ${ip}`) + } + } + } +} +``` + +### Auto-Population (Database Storage) + +Store metadata in database columns automatically on create/update operations: + +```typescript +// Schema with metadata columns +export const users = sqliteTable('users', { + id: integer('id').primaryKey(), + email: text('email').notNull(), + signupIp: text('signup_ip'), + signupCountry: text('signup_country'), +}) + +// Plugin configuration +createRequestMetadataPlugin({ + autoPopulate: { + ip: 'signupIp', + country: 'signupCountry' + }, + autoPopulateOn: ['create'], // Only on signup +}) +``` + +When a user signs up via `POST /api/users`, the record will include: +```json +{ + "email": "user@example.com", + "signupIp": "1.2.3.4", + "signupCountry": "US" +} +``` + +## Configuration Options + +### `extract` (optional) + +Custom function to extract metadata from the request. Defaults to Cloudflare headers with fallback to standard headers. + +```typescript +createRequestMetadataPlugin({ + extract: async (event) => { + const ip = getRequestIP(event) + const geo = await geoipService.lookup(ip) + + return { + ip, + country: geo?.country, + city: geo?.city, + customField: 'value' + } + } +}) +``` + +**Default extraction logic:** +- IP: `CF-Connecting-IP` → `X-Forwarded-For` → `X-Real-IP` → socket IP +- Country: `CF-IPCountry` +- City: `CF-IPCity` +- Region: `CF-IPRegion` +- Timezone: `CF-Timezone` +- Latitude: `CF-IPLatitude` +- Longitude: `CF-IPLongitude` +- User-Agent: `User-Agent` header + +### `autoPopulate` (optional) + +Configures how metadata is stored in the database. Supports multiple strategies: + +#### Option 1: Column Mapping (Simple) + +Map metadata fields to database columns: + +```typescript +autoPopulate: { + ip: 'signupIp', + country: 'signupCountry', + city: 'signupCity' +} +``` + +**Rules:** +- Only populates if the column exists in the schema +- Won't overwrite user-provided values +- Skips if metadata value is undefined + +#### Option 2: JSON Field Storage (Nested) + +Store all metadata in a single JSON column: + +```typescript +// Schema +export const users = sqliteTable('users', { + id: integer('id').primaryKey(), + email: text('email').notNull(), + metadata: text('metadata', { mode: 'json' }), +}) + +// Plugin +autoPopulate: { + json: 'metadata', // Column name + path: 'signup', // Optional: nest under this key + merge: true, // Merge with existing JSON data (default: true) +} +``` + +**Result:** +```json +{ + "metadata": { + "signup": { + "ip": "1.2.3.4", + "country": "US", + "city": "San Francisco", + "userAgent": "Mozilla/5.0..." + } + } +} +``` + +Without `path` (top-level merge): +```typescript +autoPopulate: { + json: 'metadata', + merge: true, +} +``` + +**Result:** +```json +{ + "metadata": { + "ip": "1.2.3.4", + "country": "US", + "city": "San Francisco" + } +} +``` + +#### Option 3: Custom Mapper (Full Control) + +Use a custom function for complex storage logic: + +```typescript +autoPopulate: async (metadata, data, context) => { + // Store in JSON column + data.signupMeta = { + ip: metadata.ip, + location: `${metadata.city}, ${metadata.country}`, + timestamp: new Date().toISOString(), + } + + // Compute derived fields + data.isDomestic = metadata.country === 'US' + data.signupSource = metadata.country === 'US' ? 'domestic' : 'international' + + // Conditional logic + if (metadata.vpnDetected) { + data.riskScore = 0.8 + await notifySecurityTeam(data.email, metadata.ip) + } + + return data +} +``` + +#### Option 4: Disabled (Default) + +```typescript +autoPopulate: false // Context-only, no DB storage +``` + +### `autoPopulateOn` (optional) + +Which operations to auto-populate on. Default: `['create']` + +```typescript +// Only on create (signup) +autoPopulateOn: ['create'] + +// Track last access on every update +autoPopulateOn: ['update'] + +// Both create and update +autoPopulateOn: ['create', 'update'] +``` + +### `resources` (optional) + +Limit auto-population to specific resources: + +```typescript +resources: ['users', 'orders'] // Only auto-populate for these tables +``` + +If not specified, applies to all resources. + +## Use Cases + +### 1. Auditing & Compliance + +Track where users signed up from: + +```typescript +// Schema +export const users = sqliteTable('users', { + id: integer('id').primaryKey(), + email: text('email').notNull(), + signupIp: text('signup_ip'), + signupCountry: text('signup_country'), + signupTimestamp: integer('signup_timestamp'), +}) + +// Plugin +createRequestMetadataPlugin({ + autoPopulate: { + ip: 'signupIp', + country: 'signupCountry' + }, + autoPopulateOn: ['create'], +}) +``` + +### 2. Fraud Detection + +Analyze signup patterns: + +```typescript +// In a hook +afterCreate: async (result, context) => { + const { ip, country, vpnDetected } = context.requestMeta || {} + + // Check for suspicious activity + const recentSignups = await db.select() + .from(users) + .where(eq(users.signupIp, ip)) + .limit(10) + + if (recentSignups.length > 5) { + await flagForReview(result.id, 'Multiple signups from same IP') + } +} +``` + +### 3. Country-Based Features + +Block or enable features based on country: + +```typescript +// Authorization function +autoApi: { + authorization: { + payments: { + permissions: { + create: (context) => { + const country = context.requestMeta?.country + if (country !== 'US') { + throw createError({ + statusCode: 403, + message: 'Payments only available in US' + }) + } + return true + } + } + } + } +} +``` + +### 4. Localized Content + +Send region-appropriate emails: + +```typescript +afterCreate: async (result, context) => { + const country = context.requestMeta?.country || 'US' + const locale = country === 'US' ? 'en-US' : 'en-GB' + + await sendWelcomeEmail(result.email, locale) +} +``` + +### 5. Last Seen Tracking + +Update user's last IP on every action: + +```typescript +// Schema +export const users = sqliteTable('users', { + id: integer('id').primaryKey(), + lastIp: text('last_ip'), + lastSeen: integer('last_seen'), +}) + +// Plugin +createRequestMetadataPlugin({ + autoPopulate: { ip: 'lastIp' }, + autoPopulateOn: ['update'], + resources: ['users'], +}) + +// Hook to also update timestamp +hooks: { + users: { + beforeUpdate: async (id, data, context) => { + data.lastSeen = Date.now() + return data + } + } +} +``` + +### 6. Hybrid Storage (Best Practice) + +Store frequently queried fields as columns + full metadata as JSON: + +```typescript +// Schema +export const users = sqliteTable('users', { + id: integer('id').primaryKey(), + email: text('email').notNull(), + signupCountry: text('signup_country'), // Indexed for fast queries + signupMeta: text('signup_meta', { mode: 'json' }), // Full metadata +}) + +// Plugin +createRequestMetadataPlugin({ + autoPopulate: (metadata, data) => { + // Store country as column for indexed queries + data.signupCountry = metadata.country + + // Store full metadata as JSON + data.signupMeta = metadata + + return data + }, +}) +``` + +**Benefits:** +- Fast queries: `SELECT * FROM users WHERE signupCountry = 'US'` +- Full context preserved for detailed analysis +- No need to add columns for every metadata field + +## Custom Extractors + +### MaxMind GeoIP + +```typescript +import { lookup } from 'geoip-lite' + +createRequestMetadataPlugin({ + extract: async (event) => { + const ip = getRequestIP(event) + const geo = lookup(ip) + + return { + ip, + country: geo?.country, + city: geo?.city, + region: geo?.region, + timezone: geo?.timezone, + latitude: String(geo?.ll[0]), + longitude: String(geo?.ll[1]), + userAgent: getHeader(event, 'user-agent'), + } + } +}) +``` + +### Third-Party Geolocation API + +```typescript +createRequestMetadataPlugin({ + extract: async (event) => { + const ip = getRequestIP(event) + + // Call external service + const response = await $fetch(`https://api.ipdata.co/${ip}`, { + params: { 'api-key': process.env.IPDATA_API_KEY } + }) + + return { + ip, + country: response.country_code, + city: response.city, + timezone: response.time_zone.name, + isp: response.asn.name, + vpnDetected: response.threat.is_vpn, + } + } +}) +``` + +### Custom Headers + +```typescript +createRequestMetadataPlugin({ + extract: (event) => { + const headers = getRequestHeaders(event) + + return { + ip: headers['x-real-ip'], + userAgent: headers['user-agent'], + referrer: headers['referer'], + acceptLanguage: headers['accept-language'], + // Custom headers from your proxy + customerId: headers['x-customer-id'], + requestId: headers['x-request-id'], + } + } +}) +``` + +## Context Structure + +The plugin adds `requestMeta` to `HandlerContext`: + +```typescript +interface HandlerContext { + // ... other fields ... + + requestMeta?: { + // Default fields (Cloudflare) + ip?: string + country?: string + city?: string + region?: string + timezone?: string + latitude?: string + longitude?: string + userAgent?: string + + // Custom fields from your extractor + [key: string]: any + } +} +``` + +## Best Practices + +### 1. Privacy Considerations + +**Don't store IP addresses unless necessary:** +```typescript +// ✅ Good: Context-only for temporary use +createRequestMetadataPlugin({ + autoPopulate: false, // Don't persist +}) + +// Use in hooks for analytics/logging +afterCreate: async (result, context) => { + await analytics.track({ + userId: result.id, + ip: context.requestMeta?.ip, // Sent to analytics service, not stored + }) +} +``` + +### 2. Indexed Columns + +**Store frequently queried fields as columns:** +```typescript +// ✅ Good: Country as indexed column +export const users = sqliteTable('users', { + signupCountry: text('signup_country').index(), // Fast queries + signupMeta: text('signup_meta', { mode: 'json' }), // Full data +}) + +// Can efficiently query +const usUsers = await db.select() + .from(users) + .where(eq(users.signupCountry, 'US')) +``` + +### 3. Don't Overwrite User Input + +The plugin automatically skips fields the user has set: + +```typescript +// User explicitly provides a value +POST /api/users +{ + "email": "user@example.com", + "signupIp": "custom-value" // Won't be overwritten +} +``` + +### 4. Handle Missing Metadata + +Always check for undefined metadata: + +```typescript +afterCreate: async (result, context) => { + const country = context.requestMeta?.country || 'UNKNOWN' + const ip = context.requestMeta?.ip || 'unknown' + + // Safe to use + console.log(`User from ${country} (${ip})`) +} +``` + +### 5. Resource Filtering + +Only auto-populate for relevant resources: + +```typescript +createRequestMetadataPlugin({ + autoPopulate: { ip: 'signupIp' }, + resources: ['users'], // Not needed for products, categories, etc. +}) +``` + +## TypeScript Support + +The plugin is fully typed. Context metadata is available with autocomplete: + +```typescript +import type { HandlerContext } from '@websideproject/nuxt-auto-api' + +// In hooks +afterCreate: async (result: any, context: HandlerContext) => { + // context.requestMeta is typed + const ip: string | undefined = context.requestMeta?.ip + const country: string | undefined = context.requestMeta?.country +} +``` + +Custom extractors are also typed: + +```typescript +import type { H3Event } from 'h3' + +createRequestMetadataPlugin({ + extract: async (event: H3Event): Promise> => { + return { + customField: 'value' + } + } +}) +``` + +## Comparison: Storage Strategies + +| Strategy | Best For | Example | +|----------|----------|---------| +| **Context-only** | Hooks, logging, auth decisions | `autoPopulate: false` | +| **Column mapping** | Simple schemas, frequent queries | `{ ip: 'signupIp' }` | +| **JSON field** | Flexible schemas, rare queries | `{ json: 'metadata' }` | +| **Custom mapper** | Complex logic, computed fields | Custom function | +| **Hybrid** | Production apps (indexed + flexible) | Column + JSON | + +## Performance Considerations + +### Extraction Cost + +- **Cloudflare headers** (default): ~0ms (just reading headers) +- **GeoIP-lite lookup**: ~1-5ms (in-memory database) +- **External API calls**: ~50-500ms (network latency) + +For external APIs, consider: +- Caching results by IP +- Using background jobs for non-critical data +- Fallback to fast headers if API fails + +### Storage Cost + +- **Context-only**: Zero DB overhead +- **Column mapping**: Minimal (native column writes) +- **JSON field**: Slightly slower on write, but flexible + +## Example: Complete Setup + +```typescript +// server/db/schema.ts +export const users = sqliteTable('users', { + id: integer('id').primaryKey(), + email: text('email').notNull().unique(), + name: text('name'), + + // Metadata columns + signupCountry: text('signup_country').index(), // Fast queries + signupMeta: text('signup_meta', { mode: 'json' }), // Full metadata + + // Audit fields + lastIp: text('last_ip'), + lastSeen: integer('last_seen'), +}) + +// server/plugins/autoapi.ts +import { createRequestMetadataPlugin } from '@websideproject/nuxt-auto-api/plugins' + +export default defineNitroPlugin(() => { + // Auto API configuration + plugins: [ + createRequestMetadataPlugin({ + // Use Cloudflare headers (default) + extract: undefined, + + // Hybrid storage + autoPopulate: (metadata, data, context) => { + if (context.operation === 'create') { + // Store country for fast queries + data.signupCountry = metadata.country + + // Store full metadata + data.signupMeta = { + ip: metadata.ip, + country: metadata.country, + city: metadata.city, + userAgent: metadata.userAgent, + timestamp: new Date().toISOString(), + } + } + + if (context.operation === 'update') { + // Track last seen + data.lastIp = metadata.ip + data.lastSeen = Date.now() + } + + return data + }, + + autoPopulateOn: ['create', 'update'], + resources: ['users'], // Only track for users + }), + ], + + hooks: { + users: { + afterCreate: async (result, context) => { + // Send localized welcome email + const country = context.requestMeta?.country || 'US' + await sendWelcomeEmail(result.email, country) + + // Log to analytics (not stored in DB) + await analytics.track({ + event: 'user_signup', + userId: result.id, + properties: context.requestMeta, + }) + } + } + } +}) +``` + +## Related + +- [Plugin System](/auto-api/plugin-system) +- [Hook System](/auto-api/hooks) +- [Authorization](/auto-api/authentication-authorization) diff --git a/apps/docs/content/2.auto-api/22.plugin-catalog.md b/apps/docs/content/2.auto-api/22.plugin-catalog.md new file mode 100644 index 0000000..c940c32 --- /dev/null +++ b/apps/docs/content/2.auto-api/22.plugin-catalog.md @@ -0,0 +1,546 @@ +# Plugin Catalog + +This document lists all shipped plugins for @websideproject/nuxt-auto-api. + +See [Plugin System](/auto-api/plugin-system) for how plugins work and how to create your own. + +## Pipeline Capabilities Reference + +Before reading the plugin list, understand what the plugin system can do: + +| Capability | How | Notes | +|---|---|---| +| **Modify data before write** | `beforeCreate`/`beforeUpdate` return modified data | Works | +| **Block an operation** | Throw from any middleware or before-hook | Works | +| **Side effects after mutation** | `afterCreate`/`afterUpdate`/`afterDelete` hooks | Works | +| **Enrich request context** | `extendContext()` — adds data to `HandlerContext` | Runs once per request | +| **Access database** | `context.db` (full Drizzle instance) in any hook/middleware | No transaction control across hooks | +| **Add new endpoints** | `buildSetup` → `addServerHandler()` | Build-time only | +| **Modify response data** | Return values from `afterGet`/`afterList`/`afterCreate`/`afterUpdate` hooks | Hooks returning `undefined` are side-effect only (backward compatible) | +| **Modify list query/filters** | Push SQL conditions into `context.additionalFilters` in `beforeList` or middleware | List handler merges with `and()` | +| **Short-circuit with cached data** | Set `context.shortCircuit = { data }` in `pre-execute` middleware | Skips handler, returns cached data directly | +| **Access "before" state on delete** | Must manually fetch via `context.db` in `beforeDelete` | `beforeDelete(id, context)` — no data param | + +--- + +## Shipped Plugins + +### Rate Limiting (`createRateLimitPlugin`) + +Protects API endpoints from abuse with in-memory sliding window rate limiting. + +- **Hooks used:** `pre-auth` middleware +- **Approach:** In-memory Map keyed by IP/user with periodic cleanup via `setInterval` +- **Use case:** Prevent brute-force attacks, protect against DDoS, enforce API quotas + +```typescript +createRateLimitPlugin({ windowMs: 60000, max: 100, byIp: true }) +``` + +### Request Metadata (`createRequestMetadataPlugin`) + +Extracts request metadata (IP, geo, user-agent) from HTTP headers and optionally auto-populates database columns on create/update. + +- **Hooks used:** Context extender + `beforeCreate`/`beforeUpdate` global hooks +- **Approach:** Reads Cloudflare / X-Forwarded-For headers, populates `context.requestMeta`. Optional before-hooks write metadata to DB columns via data return value. +- **Use case:** Signup geo-tracking, last-seen IP, fraud detection, analytics + +```typescript +createRequestMetadataPlugin({ autoPopulate: { ip: 'signupIp', country: 'signupCountry' }, resources: ['users'] }) +``` + +### Better Auth (`createBetterAuthPlugin`) + +Integrates [Better Auth](https://better-auth.com) sessions into the HandlerContext. + +- **Hooks used:** Context extender +- **Approach:** Reads session from `event.context` or a custom `getSession` function, maps to `context.user` and `context.permissions` +- **Use case:** Authentication for all auto-api endpoints without manual middleware + +```typescript +createBetterAuthPlugin({ getSession: async (event) => auth.api.getSession({ headers: event.headers }) }) +``` + +--- + +### Audit Log (`createAuditLogPlugin`) + +Records every create, update, and delete operation in a dedicated audit log table. + +- **Hooks used:** `beforeUpdate`/`beforeDelete` (snapshot current state), `afterCreate`/`afterUpdate`/`afterDelete` (write log entry), `buildSetup` (`GET /api/audit-logs`) +- **Approach:** After each mutating operation, write a row to an audit table with: resource, operation, record ID, user, timestamp, before/after data, IP. +- **Use case:** Compliance (SOC2, GDPR), admin dashboards, debugging + +```typescript +createAuditLogPlugin({ + table: 'auditLogs', + resources: ['users', 'orders'], // or '*' for all + excludeFields: ['password'], + async: true, +}) +``` + +--- + +### Webhook (`createWebhookPlugin`) + +Sends HTTP webhook notifications when resources change. + +- **Hooks used:** `afterCreate`, `afterUpdate`, `afterDelete` global hooks +- **Approach:** After mutations, POST a JSON payload to configured webhook URLs. Fire-and-forget with HMAC signing and retry logic. +- **Use case:** Third-party integrations (Slack, Zapier, n8n), event-driven architectures + +```typescript +createWebhookPlugin({ + endpoints: [ + { url: 'https://hooks.slack.com/...', events: ['users.create'] }, + { url: 'https://n8n.example.com/webhook/...', events: ['*'] }, + ], + signing: { secret: process.env.WEBHOOK_SECRET!, algorithm: 'sha256' }, + retry: { attempts: 3, backoffMs: 1000 }, +}) +``` + +--- + +### Activity Feed (`createActivityFeedPlugin`) + +Maintains a user-facing activity feed of changes across resources. + +- **Hooks used:** `afterCreate`, `afterUpdate`, `afterDelete` global hooks + `buildSetup` (`GET /api/activities`) +- **Approach:** After mutations, write a human-readable activity entry to a table via `context.db`. +- **Use case:** Dashboard "recent activity", notification center, collaboration features + +```typescript +createActivityFeedPlugin({ + table: 'activities', + resources: ['articles', 'users', 'comments'], + template: '{user.email} {operation}d a {resource}', +}) +``` + +--- + +### Slug Generation (`createSlugPlugin`) + +Automatically generates URL slugs from a source field on create/update. + +- **Hooks used:** `beforeCreate` (generate slug), `beforeUpdate` (regenerate if source changed) +- **Approach:** Transliterate to ASCII, kebab-case, check uniqueness via `context.db`, append `-2`, `-3` if needed. +- **Use case:** Blog articles, categories, any resource with URL-friendly identifiers + +```typescript +createSlugPlugin({ + resources: { + articles: { source: 'title', target: 'slug' }, + categories: { source: 'name', target: 'slug' }, + }, + separator: '-', + maxLength: 80, +}) +``` + +--- + +### Data Validation (`createSchemaValidationPlugin`) + +Adds runtime schema validation to create/update operations. Works with any library implementing `.safeParse()` (Zod, Valibot, ArkType). + +- **Hooks used:** `beforeCreate`/`beforeUpdate` hooks (throw on validation failure) +- **Approach:** Run validation schema against incoming data. Throw 422 with structured errors on failure. +- **Use case:** Complex cross-field validation, conditional rules, schema enforcement + +```typescript +import { z } from 'zod' +createSchemaValidationPlugin({ + resources: { + users: { + create: z.object({ email: z.string().email(), age: z.number().min(18) }), + update: z.object({ email: z.string().email().optional() }), + }, + }, +}) +``` + +--- + +### Data Export (`createExportPlugin`) + +Adds CSV/JSON export endpoints to resources. + +- **Hooks used:** `buildSetup` (`GET /api/:resource/export`) +- **Approach:** Register export endpoints via `buildSetup.addServerHandler()`. Supports field selection and format choice. +- **Use case:** Admin panel "Export" buttons, reporting, data migration + +```typescript +createExportPlugin({ + formats: ['csv', 'json'], + maxRows: 10000, + resources: ['users', 'orders', 'articles'], +}) +``` + +--- + +### File Upload (`createFileUploadPlugin`) + +Handles file uploads tied to resource records. + +- **Hooks used:** `buildSetup` (`POST/DELETE /api/:resource/:id/upload`) +- **Approach:** Register upload/delete endpoints. Store files via local filesystem or NuxtHub Blob. Save URL in a DB column. +- **Use case:** Profile avatars, article cover images, document attachments + +```typescript +createFileUploadPlugin({ + storage: 'local', + resources: { + users: { field: 'avatarUrl', maxSize: '2mb', accept: ['image/*'] }, + articles: { field: 'coverImage', maxSize: '5mb', accept: ['image/*'] }, + }, +}) +``` + +--- + +### Revision History (`createRevisionPlugin`) + +Stores a full history of every version of a record, enabling rollback. + +- **Hooks used:** `beforeUpdate` (snapshot current state), `afterUpdate` (write revision), `buildSetup` (list + restore endpoints) +- **Approach:** Before each update, snapshot the current record. Provide `GET /api/:resource/:id/revisions` and `POST /api/:resource/:id/revisions/:version/restore`. +- **Use case:** CMS content versioning, document editing, compliance, undo + +```typescript +createRevisionPlugin({ + resources: ['articles', 'pages'], + maxRevisionsPerRecord: 50, + table: 'revisions', +}) +``` + +--- + +### Cache (`createCachePlugin`) + +In-memory caching for list/get operations with automatic invalidation on mutations. + +- **Hooks used:** `pre-execute` middleware (serve from cache via `context.shortCircuit`), `post-execute` middleware + `afterList`/`afterGet` (store in cache), `afterCreate`/`afterUpdate`/`afterDelete` (invalidate) +- **Approach:** In-memory Map with TTL, LRU eviction, and per-resource invalidation. +- **Use case:** Reduce database load for read-heavy workloads + +```typescript +createCachePlugin({ + ttlMs: 30000, + maxEntries: 500, + resources: ['articles', 'categories'], + invalidateOn: ['create', 'update', 'delete'], +}) +``` + +--- + +### Search (`createSearchPlugin`) + +Adds full-text-like search via SQL LIKE/ILIKE to list queries. + +- **Hooks used:** Resource-specific `beforeList` hooks (push conditions to `context.additionalFilters`) +- **Approach:** Read `?q=searchterm` from query, build `OR(ILIKE(col1, '%q%'), ILIKE(col2, '%q%'), ...)` and push into `context.additionalFilters`. +- **Use case:** Search bars, filtering by text across multiple fields + +```typescript +createSearchPlugin({ + resources: { + articles: { fields: ['title', 'body'], minLength: 3 }, + users: { fields: ['name', 'email'] }, + }, + queryParam: 'q', +}) +``` + +--- + +### Field Encryption (`createEncryptionPlugin`) + +Transparently encrypts fields before write and decrypts after read using AES-256-GCM. + +- **Hooks used:** `beforeCreate`/`beforeUpdate` (encrypt), `afterGet`/`afterList` (decrypt via return value) +- **Approach:** AES-256-GCM with random IV. Format: `iv:tag:ciphertext` (base64). +- **Use case:** PII protection, HIPAA compliance, sensitive data at rest + +```typescript +createEncryptionPlugin({ + secret: process.env.ENCRYPTION_KEY!, + resources: { + users: ['ssn', 'taxId'], + payments: ['cardNumber'], + }, +}) +``` + +--- + +### API Token (`createApiTokenPlugin`) + +Full API token management with Bearer authentication, scope enforcement, caching, and token rotation. This is the most feature-rich shipped plugin — it covers the full lifecycle from token creation to request authentication. + +- **Hooks used:** Context extender (Bearer auth), `pre-auth` middleware (scope enforcement), `beforeCreate`/`afterCreate` (hash + one-time reveal), `afterGet`/`afterList` (mask hash), `beforeUpdate`/`afterUpdate` (block secret writes, rotation), `afterDelete` (cache invalidation), `buildSetup` (introspection endpoint) +- **Use case:** API key management, service-to-service auth, CI/CD tokens, team/org-scoped tokens + +#### How It Works + +The plugin operates at two levels: + +1. **Token CRUD lifecycle** — hooks on your token resource(s) handle hashing on create, masking on read, rotation on update, and cache invalidation on delete. +2. **Request authentication** — a context extender reads `Authorization: Bearer `, hashes the raw token, looks it up in the DB (or cache), loads the owning user, and sets `context.user` + `context.permissions`. A separate pre-auth middleware then enforces token scopes. + +``` +Request with Bearer token + │ + ├─ Context Extender (runs for every request) + │ ├─ Already authenticated (session/cookie)? → skip + │ ├─ Hash the raw token with SHA-256 + │ ├─ Check in-memory cache → hit? validate expiry, set context + │ ├─ Cache miss → query all configured token tables + │ ├─ Load user row via foreign key + │ └─ Set context.user, context.permissions, context.tenant (org tokens) + │ + ├─ Pre-auth Middleware (scope enforcement) + │ ├─ No scopes or "*" → pass through (unrestricted) + │ ├─ Map operation: list/get → "read" + │ └─ Check "resource:operation" against token scopes → 403 if denied + │ + └─ Normal authorize() runs (resource auth config — functions, object-level, field-level) +``` + +Both the scope check AND the resource auth config must pass. A token with `articles:read` scope still goes through any function-based permission check like `read: (ctx) => ctx.user?.role === 'editor'`. + +#### Minimal Setup + +```typescript +createApiTokenPlugin({ + resources: { + apiKeys: { + userRelation: { field: 'userId', resource: 'users' }, + }, + }, +}) +``` + +This gives you: token hashing, one-time reveal, Bearer auth, in-memory cache, and hash masking on read. Scopes, expiry, and lastUsedAt are opt-in via additional columns. + +#### Full Setup + +```typescript +createApiTokenPlugin({ + resources: { + apiKeys: { + secretField: 'key', // column storing the hash (default: 'key') + userRelation: { field: 'userId', resource: 'users' }, + scopeField: 'scopes', // JSON array column + expiresField: 'expiresAt', // timestamp column + lastUsedField: 'lastUsedAt', // auto-updated on auth + }, + teamApiKeys: { // second token table (org-scoped) + userRelation: { field: 'userId', resource: 'users' }, + orgField: 'organizationId', // sets context.tenant on auth + scopeField: 'scopes', + }, + }, + auth: { + tokenPrefix: 'sk_', // prefix prepended to generated tokens + // header: 'Authorization', // default + // prefix: 'Bearer', // default + }, + // hashAlgorithm: 'sha256', // default + // tokenLength: 32, // bytes of randomness (default) + cache: { + enabled: true, // default + ttlMs: 300_000, // 5 min (default) + maxEntries: 1_000, // default + }, + lastUsedDebounceMs: 60_000, // 1 min (default) + mapUser: (row) => ({ id: row.id, email: row.email, roles: [row.role] }), + getPermissions: (row) => row.role === 'admin' ? ['admin'] : [], +}) +``` + +#### Configuration Reference + +| Option | Type | Default | Description | +|---|---|---|---| +| `resources` | `Record` | *required* | Token table(s) and their column config | +| `auth.enabled` | `boolean` | `true` | Enable Bearer token authentication | +| `auth.header` | `string` | `'Authorization'` | HTTP header to read | +| `auth.prefix` | `string` | `'Bearer'` | Header value prefix | +| `auth.tokenPrefix` | `string` | `''` | Prefix prepended to generated tokens (e.g. `'sk_'`) | +| `hashAlgorithm` | `'sha256' \| 'sha512'` | `'sha256'` | Hash algorithm for token storage | +| `tokenLength` | `number` | `32` | Random bytes in generated tokens (64 hex chars) | +| `cache.enabled` | `boolean` | `true` | Enable in-memory token cache | +| `cache.ttlMs` | `number` | `300000` | Cache TTL in milliseconds | +| `cache.maxEntries` | `number` | `1000` | Max cached entries (oldest evicted first) | +| `lastUsedDebounceMs` | `number` | `60000` | Debounce window for lastUsedAt DB writes | +| `mapUser` | `(row) => AuthUser` | auto-map | Custom user mapping from DB row | +| `getPermissions` | `(row) => string[]` | `user.permissions` | Extract permission strings from user row | + +**Per-resource config (`ApiTokenResourceConfig`):** + +| Option | Type | Default | Description | +|---|---|---|---| +| `secretField` | `string` | `'key'` | Column that stores the hashed token | +| `userRelation.field` | `string` | `'userId'` | FK column on the token table | +| `userRelation.resource` | `string` | `'users'` | User table name | +| `orgField` | `string` | — | Organization FK column (enables team tokens) | +| `scopeField` | `string` | — | JSON array column for scopes | +| `expiresField` | `string` | — | Timestamp column for token expiry | +| `lastUsedField` | `string` | — | Timestamp column, auto-updated on each auth | +| `authEnabled` | `boolean` | `true` | Whether tokens from this table can authenticate | + +#### Scopes + +Scopes are a **coarse pre-filter** that runs before the resource auth config. They answer "is this token allowed to touch this resource/operation at all?" — not "does the user have permission?" (that's the resource auth config's job). + +**Format:** `resource:operation` strings stored as a JSON array in the scope column. + +| Scope | Meaning | +|---|---| +| `"articles:read"` | Read (list/get) articles only | +| `"articles:create"` | Create articles only | +| `"articles:*"` | All operations on articles | +| `"*:read"` | Read any resource | +| `"*"` | Unrestricted (all resources, all operations) | +| `null` / empty | Unrestricted (backward compatible — simple tokens without scopes) | + +Operations are mapped: `list` and `get` both check against `read`. Other operations (`create`, `update`, `delete`) match directly. + +**Example flow — token with `["articles:read", "articles:create"]`:** + +1. `GET /api/articles` → scope check: `articles:read` matches → pass → resource auth runs +2. `POST /api/articles` → scope check: `articles:create` matches → pass → resource auth runs +3. `DELETE /api/articles/1` → scope check: `articles:delete` not in scopes → **403** +4. `GET /api/users` → scope check: `users:read` not in scopes → **403** + +#### Token Lifecycle + +**Create** — `POST /api/apiKeys { "name": "CI Key", "scopes": ["articles:read"] }` + +The plugin generates a random token (e.g. `sk_a1b2c3d4...`), hashes it with SHA-256, stores the hash. The response includes the raw token **once** — it is never returned again. + +```json +{ "data": { "id": 1, "name": "CI Key", "key": "sk_a1b2c3d4e5f6...", "scopes": ["articles:read"] } } +``` + +The `userId` is auto-set from `context.user.id` if not provided. For team tokens, `orgField` is auto-set from `context.tenant.id`. + +**Read** — `GET /api/apiKeys` or `GET /api/apiKeys/1` + +The hash is replaced with a masked preview: `sk_...f6e5`. The full hash is never exposed. + +**Update** — `PATCH /api/apiKeys/1 { "name": "Renamed Key" }` + +Direct writes to the secret field are blocked. The name, scopes, and other fields can be updated normally. + +**Rotate** — `PATCH /api/apiKeys/1 { "_rotate": true }` + +Generates a new token, hashes it, invalidates the cache for the old token. The response includes the new raw token (one-time). The old token stops working immediately. + +**Delete** — `DELETE /api/apiKeys/1` + +Deletes the token record and invalidates the cache entry. + +#### Introspection Endpoint + +`GET /api/_token/introspect` — requires a valid Bearer token in the request. + +Returns metadata about the token without exposing the hash: + +```json +{ + "data": { + "resource": "apiKeys", + "id": 1, + "name": "CI Key", + "scopes": ["articles:read"], + "expiresAt": "2025-12-31T00:00:00.000Z", + "lastUsedAt": "2025-06-15T10:30:00.000Z", + "organizationId": null + } +} +``` + +Returns 401 if the request is not authenticated via an API token. + +#### Cache Strategy + +The plugin maintains an in-memory cache with two maps: + +- `Map` — O(1) lookup by token hash +- `Map<"resource:id", hash>` — O(1) invalidation by record + +Cache entries expire after `ttlMs` (default 5 min). Periodic cleanup runs on a `setInterval` (non-blocking, `.unref()`). When max entries is reached, the oldest entry is evicted (Map insertion order). Delete and rotate operations invalidate the cache immediately via the reverse map. + +#### Security + +| Concern | Mitigation | +|---|---| +| Brute force | 256-bit entropy (32 random bytes) makes guessing infeasible. Pair with rate limit plugin for additional protection. | +| Timing attacks | SHA-256 hash lookup in SQL. Constant-time comparison is not needed because the hash itself is the lookup key — a miss reveals nothing about valid hashes. | +| Token leakage | Configurable prefix (e.g. `sk_`) enables secret scanning tools (GitHub, GitGuardian) to detect leaked tokens in commits. | +| Over-privilege | Scopes are a coarse gate. Resource auth config (functions, object-level, field-level) runs on top. Both must pass. | +| Stale tokens | Expiry check on every auth. Cache TTL (5 min default) limits the window for deleted/expired tokens. | +| Hash exposure | Hash replaced with masked preview (`sk_...f6e5`) in GET/LIST. Raw token only returned on create and rotate. | + +#### Usage with Other Auth + +The token plugin is designed to coexist with any session-based auth. The context extender **skips** when `context.user` is already set, so session auth always takes priority. + +```typescript +// server/autoapi-plugins.ts +export default [ + createBetterAuthPlugin({ ... }), // Session auth for browsers + createApiTokenPlugin({ ... }), // Token auth for API clients — skips if session already set user +] +``` + +With a plain Nitro auth plugin (no Better Auth): + +```typescript +// server/plugins/auth.ts +export default defineNitroPlugin((nitro) => { + nitro.hooks.hook('request', async (event) => { + const session = getCookie(event, 'session') + if (session) event.context.user = await getUserFromSession(session) + // Token plugin handles API requests that don't have a session + }) +}) +``` + +--- + +### Soft Delete + +**Built into core** — not a plugin. + +The delete handler automatically checks for a `deletedAt`/`deleted_at` column and does `UPDATE SET deletedAt = NOW()` instead of `DELETE`. List/get handlers auto-filter soft-deleted records. + +--- + +## Summary + +| Plugin | Status | Hooks Used | +|---|---|---| +| Rate Limiting | Shipped | `pre-auth` middleware | +| Request Metadata | Shipped | Context extender + before hooks | +| Better Auth | Shipped | Context extender | +| Audit Log | Shipped | before + after hooks + `buildSetup` | +| Webhook | Shipped | after hooks | +| Activity Feed | Shipped | after hooks + `buildSetup` | +| Slug Generation | Shipped | before hooks + `context.db` | +| Data Validation | Shipped | before hooks (throw on failure) | +| Data Export | Shipped | `buildSetup` (new endpoints) | +| File Upload | Shipped | `buildSetup` + endpoints | +| Revision History | Shipped | before/after hooks + `buildSetup` | +| Cache | Shipped | `pre-execute` middleware + `context.shortCircuit` | +| Search | Shipped | `beforeList` + `context.additionalFilters` | +| Field Encryption | Shipped | before hooks + after hooks (return value) | +| API Token | Shipped | Context extender + `pre-auth` middleware + before/after hooks + `buildSetup` | +| Soft Delete | Core feature | N/A | diff --git a/apps/docs/content/2.auto-api/3.handler-overrides.md b/apps/docs/content/2.auto-api/3.handler-overrides.md new file mode 100644 index 0000000..50c9cd4 --- /dev/null +++ b/apps/docs/content/2.auto-api/3.handler-overrides.md @@ -0,0 +1,317 @@ +# Handler Overrides + +> **Note:** `defineAutoApiHandler` is deprecated. Use [`createEndpoint()`](/auto-api/custom-endpoints) instead, which provides Zod body/query validation, typed context, response formatting, and full plugin middleware support. + +Sometimes you need custom endpoint logic while preserving the auth/authz/validation pipeline. Use `createEndpoint` (recommended) or the legacy `defineAutoApiHandler` for this. + +## Basic Usage + +Create a custom endpoint that reuses the auto-api pipeline: + +```typescript +// server/api/users/[id]/stats.get.ts +import { defineAutoApiHandler } from '@websideproject/nuxt-auto-api/utils' +import { eq, count } from 'drizzle-orm' +import { users, posts, comments } from '../../database/schema' + +export default defineAutoApiHandler({ + async execute(context) { + const userId = parseInt(context.params.id) + + // Custom query logic + const [user, postStats, commentStats] = await Promise.all([ + context.db.query.users.findFirst({ + where: eq(users.id, userId), + }), + context.db + .select({ count: count() }) + .from(posts) + .where(eq(posts.userId, userId)), + context.db + .select({ count: count() }) + .from(comments) + .where(eq(comments.userId, userId)), + ]) + + if (!user) { + throw createError({ statusCode: 404, message: 'User not found' }) + } + + // Object-level auth check (reuses config from registry) + if (context.objectLevelCheck) { + const authorized = await context.objectLevelCheck(user, context) + if (!authorized) { + throw createError({ statusCode: 403, message: 'Forbidden' }) + } + } + + return { + data: { + ...user, + stats: { + postCount: postStats[0].count, + commentCount: commentStats[0].count, + }, + }, + } + }, +}) +``` + +**Result:** +``` +GET /api/users/123/stats +``` +- ✅ Authentication runs (if configured) +- ✅ Operation-level authorization runs +- ✅ Validation runs (query params) +- ✅ Custom logic executes +- ✅ Object-level authorization (manual call) + +## Context Object + +The `context` object provides access to: + +```typescript +interface HandlerContext { + db: any // Database instance + schema: any // Drizzle schema + user: AuthUser | null // Authenticated user + permissions: string[] // User permissions + params: Record // Route params + query: Record // Query params + validated: { // Validated data + body?: any + query?: any + } + event: H3Event // H3 event + resource: string // Resource name + operation: string // Operation type + objectLevelCheck?: Function // Object-level auth function + listFilter?: Function // SQL-level list filter (see better-auth docs) + tenant?: { // Multi-tenancy info + id: string | number + field: string + canAccessAllTenants: boolean + } +} +``` + +## Skip Authorization + +For public endpoints, skip authorization: + +```typescript +export default defineAutoApiHandler({ + skipAuthorization: true, + async execute(context) { + // No auth required + return { data: { message: 'Public data' } } + }, +}) +``` + +## Skip Validation + +For custom validation logic: + +```typescript +export default defineAutoApiHandler({ + skipValidation: true, + async execute(context) { + const body = await readBody(context.event) + + // Custom validation + if (!body.customField) { + throw createError({ statusCode: 400, message: 'customField required' }) + } + + // ... custom logic + }, +}) +``` + +## Transform Response + +Transform the result before returning: + +```typescript +export default defineAutoApiHandler({ + async execute(context) { + const users = await context.db.query.users.findMany() + return users + }, + transform(result, context) { + // Add metadata + return { + data: result, + meta: { + count: result.length, + timestamp: new Date().toISOString(), + }, + } + }, +}) +``` + +## Common Patterns + +### Bulk Operations + +```typescript +// server/api/users/bulk.post.ts +export default defineAutoApiHandler({ + async execute(context) { + const users = context.validated.body.users // array of users + + // Batch insert + const created = await context.db.insert(users).values(users).returning() + + return { data: created } + }, +}) +``` + +### Aggregations + +```typescript +// server/api/posts/stats.get.ts +export default defineAutoApiHandler({ + async execute(context) { + const stats = await context.db + .select({ + total: count(), + published: count(posts.published), + }) + .from(posts) + + return { data: stats[0] } + }, +}) +``` + +### Custom Queries + +```typescript +// server/api/users/search.get.ts +export default defineAutoApiHandler({ + async execute(context) { + const { q } = context.validated.query + + const results = await context.db.query.users.findMany({ + where: or( + like(users.name, `%${q}%`), + like(users.email, `%${q}%`) + ), + limit: 20, + }) + + return { data: results } + }, +}) +``` + +### File Uploads + +```typescript +// server/api/users/[id]/avatar.post.ts +export default defineAutoApiHandler({ + skipValidation: true, // File uploads need custom handling + async execute(context) { + const userId = context.params.id + const files = await readMultipartFormData(context.event) + + if (!files || files.length === 0) { + throw createError({ statusCode: 400, message: 'No file uploaded' }) + } + + const file = files[0] + const avatarUrl = await uploadToS3(file) + + // Update user + await context.db + .update(users) + .set({ avatarUrl }) + .where(eq(users.id, userId)) + + return { data: { avatarUrl } } + }, +}) +``` + +### Relations & Includes + +```typescript +// server/api/users/[id]/full.get.ts +export default defineAutoApiHandler({ + async execute(context) { + const userId = context.params.id + + const user = await context.db.query.users.findFirst({ + where: eq(users.id, userId), + with: { + posts: { + with: { + comments: true, + }, + }, + profile: true, + }, + }) + + return { data: user } + }, +}) +``` + +## When to Use Handler Overrides + +✅ **Use handler overrides for:** +- Custom business logic +- Complex queries or aggregations +- Bulk operations +- File uploads +- Third-party API calls +- Custom response formats + +❌ **Don't use handler overrides for:** +- Simple CRUD - use auto-generated endpoints +- Adding validation - use validation schemas +- Authorization rules - use auth config +- Field filtering - use query params (`?fields=name,email`) + +## Best Practices + +1. **Preserve the pipeline** - Keep auth/authz/validation unless absolutely necessary +2. **Reuse object-level checks** - Call `context.objectLevelCheck` when needed +3. **Type safety** - Use TypeScript for context and return types +4. **Error handling** - Use `createError` for consistent error responses +5. **Document endpoints** - Add JSDoc comments explaining custom behavior + +## Advanced: Middleware Composition + +You can compose multiple handlers: + +```typescript +// Reusable middleware +async function requireOwnership(context: HandlerContext, resourceId: string) { + const resource = await context.db.query.users.findFirst({ + where: eq(users.id, resourceId), + }) + + if (resource.userId !== context.user.id && !context.permissions.includes('admin')) { + throw createError({ statusCode: 403, message: 'Not the owner' }) + } + + return resource +} + +// Use in handler +export default defineAutoApiHandler({ + async execute(context) { + const post = await requireOwnership(context, context.params.id) + + // ... custom logic with guaranteed ownership + }, +}) +``` diff --git a/apps/docs/content/2.auto-api/30.cloudflare-d1.md b/apps/docs/content/2.auto-api/30.cloudflare-d1.md new file mode 100644 index 0000000..b6e229f --- /dev/null +++ b/apps/docs/content/2.auto-api/30.cloudflare-d1.md @@ -0,0 +1,464 @@ +# Cloudflare D1 + +Nuxt Auto API fully supports Cloudflare D1, allowing you to deploy your API to the edge with a serverless SQLite database. + +## Table of Contents + +- [Overview](#overview) +- [Setup Guide](#setup-guide) +- [Local Development](#local-development) +- [Database Migrations](#database-migrations) +- [Deployment](#deployment) +- [Performance Optimization](#performance-optimization) +- [Troubleshooting](#troubleshooting) + +## Overview + +Cloudflare D1 is a serverless SQLite database that runs on Cloudflare's global network. Nuxt Auto API uses the same Drizzle ORM schema for both local SQLite (better-sqlite3) and production D1. + +### Key Benefits + +- **Edge Performance**: Database runs close to your users worldwide +- **Serverless**: No infrastructure management +- **Cost-Effective**: Pay only for what you use +- **SQLite Compatible**: Same schema and queries as local development + +### Differences from SQLite + +| Feature | SQLite (better-sqlite3) | Cloudflare D1 | +|---------|-------------------------|---------------| +| Execution | Synchronous | Asynchronous | +| Location | Local file | Edge network | +| Scaling | Single instance | Globally distributed | +| Cost | Free (development) | Pay-per-use | + +## Setup Guide + +### Step 1: Install Dependencies + +```bash +npm install drizzle-orm wrangler --save-dev +``` + +### Step 2: Create D1 Database + +```bash +# Create D1 database +npx wrangler d1 create @websideproject/nuxt-auto-api-db + +# Output: +# Database created! +# [[d1_databases]] +# binding = "DB" +# database_name = "@websideproject/nuxt-auto-api-db" +# database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +``` + +Copy the output to your `wrangler.toml`. + +### Step 3: Configure wrangler.toml + +Create `wrangler.toml` in your project root: + +```toml +name = "@websideproject/nuxt-auto-api" +main = ".output/server/index.mjs" +compatibility_date = "2024-01-01" + +[[d1_databases]] +binding = "DB" +database_name = "@websideproject/nuxt-auto-api-db" +database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + +# For local development +[env.local] +[[env.local.d1_databases]] +binding = "DB" +database_name = "@websideproject/nuxt-auto-api-db" +database_id = "local" +``` + +### Step 4: Update Drizzle Configuration + +```typescript +// drizzle.config.ts +import { defineConfig } from 'drizzle-kit' + +export default defineConfig({ + schema: './server/database/schema.ts', + out: './server/database/migrations', + dialect: 'sqlite', // D1 uses SQLite dialect + driver: 'd1-http', // For remote operations + dbCredentials: { + accountId: process.env.CLOUDFLARE_ACCOUNT_ID!, + databaseId: process.env.CLOUDFLARE_D1_ID!, + token: process.env.CLOUDFLARE_API_TOKEN! + } +}) +``` + +### Step 5: Initialize D1 in Nitro Plugin + +Create `server/plugins/database.d1.ts`: + +```typescript +import { drizzle } from 'drizzle-orm/d1' +import * as schema from '../database/schema' + +export default defineNitroPlugin((nitroApp) => { + // Get D1 binding from Cloudflare + const binding = nitroApp.cloudflare?.env?.DB + + if (!binding) { + throw new Error('D1 database binding not found') + } + + const db = drizzle(binding, { schema }) + + // Store in globalThis for auto-api + globalThis.__autoApiDb = db + + console.log('[@websideproject/nuxt-auto-api] D1 database initialized') +}) +``` + +### Step 6: Update nuxt.config.ts + +```typescript +export default defineNuxtConfig({ + modules: ['@websideproject/nuxt-auto-api'], + + autoApi: { + prefix: '/api', + database: { + client: 'd1' // Specify D1 client + } + }, + + nitro: { + preset: 'cloudflare-pages' // or 'cloudflare-workers' + } +}) +``` + +## Local Development + +### Option 1: Wrangler Dev (Recommended) + +Use Wrangler for local D1 development: + +```bash +# Start Wrangler dev server +npx wrangler dev + +# In separate terminal, run Nuxt +npm run dev +``` + +### Option 2: Hybrid Setup + +Use SQLite locally, D1 in production: + +```typescript +// server/plugins/database.ts +import { drizzle as drizzleSqlite } from 'drizzle-orm/better-sqlite3' +import { drizzle as drizzleD1 } from 'drizzle-orm/d1' +import Database from 'better-sqlite3' +import * as schema from '../database/schema' + +export default defineNitroPlugin((nitroApp) => { + let db + + if (process.dev) { + // Local development: Use SQLite + const sqlite = new Database('.data/db.sqlite') + db = drizzleSqlite(sqlite, { schema }) + console.log('[@websideproject/nuxt-auto-api] SQLite database initialized') + } else if (nitroApp.cloudflare?.env?.DB) { + // Production: Use D1 + db = drizzleD1(nitroApp.cloudflare.env.DB, { schema }) + console.log('[@websideproject/nuxt-auto-api] D1 database initialized') + } else { + throw new Error('Database not configured') + } + + globalThis.__autoApiDb = db +}) +``` + +## Database Migrations + +### Generate Migrations + +```bash +# Generate migration files +npx drizzle-kit generate +``` + +This creates SQL files in `server/database/migrations/`. + +### Apply Migrations to D1 + +```bash +# Apply to local D1 +npx wrangler d1 migrations apply @websideproject/nuxt-auto-api-db --local + +# Apply to remote D1 (production) +npx wrangler d1 migrations apply @websideproject/nuxt-auto-api-db --remote +``` + +### Manual Migration + +You can also apply migrations manually: + +```bash +# Execute SQL file +npx wrangler d1 execute @websideproject/nuxt-auto-api-db --file=./migrations/0001_initial.sql --remote +``` + +## Deployment + +### Deploy to Cloudflare Pages + +```bash +# Build for Cloudflare Pages +npm run build + +# Deploy with Wrangler +npx wrangler pages deploy .output/public +``` + +### Deploy with GitHub Actions + +```yaml +# .github/workflows/deploy.yml +name: Deploy to Cloudflare Pages + +on: + push: + branches: [main] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - uses: actions/setup-node@v3 + with: + node-version: 20 + + - run: npm ci + - run: npm run build + + - name: Deploy to Cloudflare Pages + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + command: pages deploy .output/public --project-name=@websideproject/nuxt-auto-api +``` + +### Environment Variables + +Set in Cloudflare Dashboard or via CLI: + +```bash +# Set production environment variables +npx wrangler pages deployment create \ + --project-name=@websideproject/nuxt-auto-api \ + --branch=main \ + --d1-binding=DB:@websideproject/nuxt-auto-api-db +``` + +## Performance Optimization + +### D1 Limits + +- **Max query size**: 1MB +- **Max result size**: 10MB +- **Read operations**: ~50,000 RPM per database +- **Write operations**: ~5,000 RPM per database +- **Query timeout**: 30 seconds + +### Optimization Tips + +#### 1. Add Indexes + +```typescript +// server/database/schema.ts +import { sqliteTable, integer, text, index } from 'drizzle-orm/sqlite-core' + +export const users = sqliteTable('users', { + id: integer('id').primaryKey(), + email: text('email').notNull().unique(), + status: text('status') +}, (table) => ({ + emailIdx: index('email_idx').on(table.email), + statusIdx: index('status_idx').on(table.status) +})) +``` + +#### 2. Use Pagination + +Always paginate large result sets: + +```typescript +// ✅ Good - Paginated +const posts = await db.query.posts.findMany({ + limit: 20, + offset: 0 +}) + +// ❌ Bad - No limit +const posts = await db.query.posts.findMany() +``` + +#### 3. Reduce Result Size + +Select only needed fields: + +```typescript +// ✅ Good - Specific fields +const users = await db.select({ + id: schema.users.id, + name: schema.users.name +}).from(schema.users) + +// ❌ Bad - All fields +const users = await db.select().from(schema.users) +``` + +#### 4. Batch Operations + +```typescript +// Use D1's batch API for multiple operations +const batch = [ + db.insert(schema.users).values({ email: 'user1@test.com' }), + db.insert(schema.users).values({ email: 'user2@test.com' }), + db.insert(schema.users).values({ email: 'user3@test.com' }) +] + +await db.batch(batch) +``` + +#### 5. Cache Frequently Accessed Data + +Use Cloudflare KV or Cache API for hot data: + +```typescript +// Cache expensive query results +const cacheKey = 'popular-posts' +const cached = await caches.default.match(cacheKey) + +if (cached) { + return cached.json() +} + +const posts = await db.query.posts.findMany({ + where: eq(schema.posts.published, true), + limit: 10 +}) + +await caches.default.put( + cacheKey, + new Response(JSON.stringify(posts), { + headers: { 'Cache-Control': 'max-age=300' } + }) +) +``` + +## Troubleshooting + +### "Database binding not found" + +**Problem**: D1 binding is not available in the Nitro context. + +**Solution**: +1. Check `wrangler.toml` has correct binding configuration +2. Verify Cloudflare Pages has D1 binding configured +3. Run `npx wrangler pages deployment list` to check bindings + +### "Migrations not applied" + +**Problem**: Tables don't exist in D1. + +**Solution**: +```bash +# Check migration status +npx wrangler d1 migrations list @websideproject/nuxt-auto-api-db + +# Apply migrations +npx wrangler d1 migrations apply @websideproject/nuxt-auto-api-db --remote +``` + +### "Query timeout" + +**Problem**: Query exceeds 30-second timeout. + +**Solution**: +1. Add indexes on filtered/sorted columns +2. Reduce result set size with pagination +3. Optimize query with EXPLAIN QUERY PLAN +4. Consider breaking into smaller queries + +### "Too many connections" + +**Problem**: Hitting rate limits. + +**Solution**: +1. D1 handles connection pooling automatically +2. Implement request queuing on your side +3. Use caching to reduce database load +4. Consider upgrading to higher D1 tier + +### Enable Query Logging + +```typescript +// server/plugins/database.d1.ts +export default defineNitroPlugin((nitroApp) => { + const db = drizzle(nitroApp.cloudflare.env.DB, { + schema, + logger: true // Enable query logging + }) + + globalThis.__autoApiDb = db +}) +``` + +## D1-Specific Features + +### Query Tracing + +D1 returns execution metadata: + +```typescript +const result = await db.prepare('SELECT * FROM users').all() + +console.log('Query duration:', result.meta.duration) +console.log('Rows read:', result.meta.rows_read) +console.log('Rows written:', result.meta.rows_written) +``` + +### Prepared Statements + +D1 automatically uses prepared statements: + +```typescript +// Efficiently reuse prepared statement +const stmt = db.prepare('SELECT * FROM users WHERE id = ?') + +const user1 = await stmt.bind(1).first() +const user2 = await stmt.bind(2).first() +``` + +## Migration from SQLite to D1 + +See [Migration Guide](/auto-api/migration-sqlite-d1) for detailed instructions. + +## Resources + +- [Cloudflare D1 Docs](https://developers.cloudflare.com/d1/) +- [Drizzle ORM Docs](https://orm.drizzle.team/) +- [Wrangler CLI Docs](https://developers.cloudflare.com/workers/wrangler/) +- [Nuxt Cloudflare Preset](https://nitro.unjs.io/deploy/providers/cloudflare) diff --git a/apps/docs/content/2.auto-api/31.migration-sqlite-d1.md b/apps/docs/content/2.auto-api/31.migration-sqlite-d1.md new file mode 100644 index 0000000..80f856b --- /dev/null +++ b/apps/docs/content/2.auto-api/31.migration-sqlite-d1.md @@ -0,0 +1,449 @@ +# SQLite to D1 Migration + +This guide walks you through migrating your Nuxt Auto API application from local SQLite to Cloudflare D1 for edge deployment. + +## Table of Contents + +- [Prerequisites](#prerequisites) +- [Step-by-Step Migration](#step-by-step-migration) +- [Data Migration](#data-migration) +- [Testing the Migration](#testing-the-migration) +- [Rollback Plan](#rollback-plan) + +## Prerequisites + +Before starting the migration: + +- ✅ Working Nuxt Auto API application with SQLite +- ✅ Cloudflare account with Pages or Workers +- ✅ Git repository for version control +- ✅ Backup of your SQLite database + +## Step-by-Step Migration + +### Step 1: Backup Your Data + +Export your SQLite database: + +```bash +# Export database to SQL file +sqlite3 .data/db.sqlite .dump > backup.sql + +# Or use Drizzle to export +npx drizzle-kit push:sqlite --config=drizzle.config.ts +``` + +### Step 2: Install Dependencies + +```bash +npm install wrangler --save-dev +``` + +### Step 3: Create D1 Database + +```bash +# Create D1 database +npx wrangler d1 create @websideproject/nuxt-auto-api-db + +# Save the output - you'll need database_id +``` + +Example output: +``` +[[d1_databases]] +binding = "DB" +database_name = "@websideproject/nuxt-auto-api-db" +database_id = "abc12345-6789-def0-1234-56789abcdef0" +``` + +### Step 4: Configure wrangler.toml + +Create `wrangler.toml` in project root: + +```toml +name = "@websideproject/nuxt-auto-api" +main = ".output/server/index.mjs" +compatibility_date = "2024-01-01" + +# Production D1 +[[d1_databases]] +binding = "DB" +database_name = "@websideproject/nuxt-auto-api-db" +database_id = "abc12345-6789-def0-1234-56789abcdef0" + +# Local development (optional) +[env.local] +[[env.local.d1_databases]] +binding = "DB" +database_name = "@websideproject/nuxt-auto-api-db" +database_id = "local" +``` + +### Step 5: Update Drizzle Config + +```typescript +// drizzle.config.ts +import { defineConfig } from 'drizzle-kit' + +export default defineConfig({ + schema: './server/database/schema.ts', + out: './server/database/migrations', + dialect: 'sqlite', + + // For D1 remote operations + ...(process.env.CLOUDFLARE_ACCOUNT_ID && { + driver: 'd1-http', + dbCredentials: { + accountId: process.env.CLOUDFLARE_ACCOUNT_ID, + databaseId: process.env.CLOUDFLARE_D1_ID, + token: process.env.CLOUDFLARE_API_TOKEN + } + }) +}) +``` + +### Step 6: Generate Migrations + +If you don't have migrations yet: + +```bash +# Generate migrations from schema +npx drizzle-kit generate +``` + +This creates SQL files in `server/database/migrations/`. + +### Step 7: Apply Migrations to D1 + +```bash +# Apply migrations to D1 +npx wrangler d1 migrations apply @websideproject/nuxt-auto-api-db --remote +``` + +### Step 8: Update Database Plugin + +#### Option A: D1 Only (Production) + +```typescript +// server/plugins/database.ts +import { drizzle } from 'drizzle-orm/d1' +import * as schema from '../database/schema' + +export default defineNitroPlugin((nitroApp) => { + if (!nitroApp.cloudflare?.env?.DB) { + throw new Error('D1 database binding not found') + } + + const db = drizzle(nitroApp.cloudflare.env.DB, { schema }) + globalThis.__autoApiDb = db + + console.log('[@websideproject/nuxt-auto-api] D1 database initialized') +}) +``` + +#### Option B: Hybrid (SQLite Local, D1 Production) + +```typescript +// server/plugins/database.ts +import { drizzle as drizzleSqlite } from 'drizzle-orm/better-sqlite3' +import { drizzle as drizzleD1 } from 'drizzle-orm/d1' +import Database from 'better-sqlite3' +import * as schema from '../database/schema' + +export default defineNitroPlugin((nitroApp) => { + let db + + if (process.dev) { + // Local development: SQLite + const sqlite = new Database('.data/db.sqlite') + db = drizzleSqlite(sqlite, { schema }) + console.log('[dev] SQLite database initialized') + } else { + // Production: D1 + if (!nitroApp.cloudflare?.env?.DB) { + throw new Error('D1 database binding not found') + } + db = drizzleD1(nitroApp.cloudflare.env.DB, { schema }) + console.log('[prod] D1 database initialized') + } + + globalThis.__autoApiDb = db +}) +``` + +### Step 9: Update nuxt.config.ts + +```typescript +// nuxt.config.ts +export default defineNuxtConfig({ + modules: ['@websideproject/nuxt-auto-api'], + + autoApi: { + prefix: '/api', + database: { + client: process.dev ? 'better-sqlite3' : 'd1' + } + }, + + nitro: { + preset: 'cloudflare-pages' + } +}) +``` + +### Step 10: Set Environment Variables + +Create `.env`: + +```bash +CLOUDFLARE_ACCOUNT_ID=your-account-id +CLOUDFLARE_D1_ID=abc12345-6789-def0-1234-56789abcdef0 +CLOUDFLARE_API_TOKEN=your-api-token +``` + +Add to `.gitignore`: +``` +.env +.data/ +``` + +## Data Migration + +### Method 1: SQL Dump (Recommended) + +```bash +# 1. Export SQLite data +sqlite3 .data/db.sqlite .dump > data-export.sql + +# 2. Clean up SQLite-specific syntax +# Remove lines starting with: +# - PRAGMA +# - BEGIN TRANSACTION +# - COMMIT +sed -i '/^PRAGMA/d' data-export.sql +sed -i '/^BEGIN TRANSACTION/d' data-export.sql +sed -i '/^COMMIT/d' data-export.sql + +# 3. Import to D1 +npx wrangler d1 execute @websideproject/nuxt-auto-api-db --remote --file=data-export.sql +``` + +### Method 2: Programmatic Migration + +```typescript +// scripts/migrate-to-d1.ts +import Database from 'better-sqlite3' +import { drizzle } from 'drizzle-orm/d1' +import * as schema from '../server/database/schema' + +async function migrate() { + // Connect to SQLite + const sqlite = new Database('.data/db.sqlite') + const sqliteDb = drizzle(sqlite, { schema }) + + // Connect to D1 (requires Wrangler) + // This would run in a Cloudflare Worker context + const d1Db = drizzle(env.DB, { schema }) + + // Fetch all data from SQLite + const users = await sqliteDb.select().from(schema.users) + const posts = await sqliteDb.select().from(schema.posts) + + // Insert into D1 + if (users.length > 0) { + await d1Db.insert(schema.users).values(users) + } + + if (posts.length > 0) { + await d1Db.insert(schema.posts).values(posts) + } + + console.log('Migration complete!') +} + +migrate() +``` + +### Method 3: CSV Export/Import + +```bash +# Export to CSV +sqlite3 -header -csv .data/db.sqlite "SELECT * FROM users;" > users.csv +sqlite3 -header -csv .data/db.sqlite "SELECT * FROM posts;" > posts.csv + +# Convert CSV to SQL INSERT statements +# (Use a script or tool like csv2sql) + +# Import to D1 +npx wrangler d1 execute @websideproject/nuxt-auto-api-db --remote --file=insert-data.sql +``` + +## Testing the Migration + +### 1. Test Locally with D1 + +```bash +# Start Wrangler dev server +npx wrangler dev + +# In another terminal, start Nuxt +npm run dev + +# Test API endpoints +curl http://localhost:3000/api/users +curl http://localhost:3000/api/posts +``` + +### 2. Deploy to Staging + +```bash +# Build for Cloudflare +npm run build + +# Deploy to staging +npx wrangler pages deploy .output/public --project-name=@websideproject/nuxt-auto-api-staging +``` + +### 3. Verify Data + +```bash +# Query D1 directly +npx wrangler d1 execute @websideproject/nuxt-auto-api-db --remote --command="SELECT COUNT(*) FROM users" +npx wrangler d1 execute @websideproject/nuxt-auto-api-db --remote --command="SELECT COUNT(*) FROM posts" + +# Compare with SQLite counts +sqlite3 .data/db.sqlite "SELECT COUNT(*) FROM users" +sqlite3 .data/db.sqlite "SELECT COUNT(*) FROM posts" +``` + +### 4. Run Integration Tests + +```bash +# Run tests against D1 +npm run test:d1 +``` + +## Deployment Checklist + +- [ ] Migrations applied to D1 +- [ ] Data migrated successfully +- [ ] Environment variables set in Cloudflare +- [ ] D1 bindings configured in Cloudflare Pages +- [ ] Local development tested with D1 +- [ ] Staging deployment tested +- [ ] Integration tests pass +- [ ] Performance benchmarks acceptable +- [ ] Monitoring/alerting configured + +## Rollback Plan + +If you need to rollback: + +### 1. Revert Database Plugin + +```typescript +// server/plugins/database.ts +import { drizzle } from 'drizzle-orm/better-sqlite3' +import Database from 'better-sqlite3' +import * as schema from '../database/schema' + +export default defineNitroPlugin(() => { + const sqlite = new Database('.data/db.sqlite') + const db = drizzle(sqlite, { schema }) + globalThis.__autoApiDb = db +}) +``` + +### 2. Revert nuxt.config.ts + +```typescript +export default defineNuxtConfig({ + autoApi: { + database: { + client: 'better-sqlite3' + } + } +}) +``` + +### 3. Restore SQLite Data + +```bash +# Restore from backup +cp backup.sql .data/db.sqlite +``` + +### 4. Redeploy + +```bash +npm run build +# Deploy to your original hosting +``` + +## Common Issues + +### Issue: "Binding not found" + +**Cause**: D1 binding not configured in Cloudflare. + +**Fix**: +```bash +# Check bindings +npx wrangler pages deployment list + +# Add binding via dashboard or CLI +``` + +### Issue: "Migration failed" + +**Cause**: SQL syntax incompatible with D1. + +**Fix**: +- Remove SQLite-specific pragmas +- Check for unsupported data types +- Use standard SQL syntax + +### Issue: "Data import timeout" + +**Cause**: Importing too much data at once. + +**Fix**: +```bash +# Split into smaller batches +split -l 1000 data-export.sql data-batch- + +# Import each batch +for file in data-batch-*; do + npx wrangler d1 execute @websideproject/nuxt-auto-api-db --remote --file=$file +done +``` + +## Post-Migration + +After successful migration: + +1. **Monitor Performance** + - Check D1 analytics in Cloudflare dashboard + - Monitor query latency + - Watch for rate limit errors + +2. **Optimize Queries** + - Add indexes for frequently queried fields + - Implement caching for hot data + - Use pagination everywhere + +3. **Update Documentation** + - Document D1-specific configuration + - Update team onboarding guides + - Add troubleshooting tips + +4. **Clean Up** + - Archive SQLite database + - Remove SQLite-specific code + - Update CI/CD pipelines + +## Resources + +- [Cloudflare D1 Docs](https://developers.cloudflare.com/d1/) +- [Drizzle Migrations](https://orm.drizzle.team/docs/migrations) +- [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/) diff --git a/apps/docs/content/2.auto-api/32.frontend-composables.md b/apps/docs/content/2.auto-api/32.frontend-composables.md new file mode 100644 index 0000000..0d7928b --- /dev/null +++ b/apps/docs/content/2.auto-api/32.frontend-composables.md @@ -0,0 +1,754 @@ +# Frontend Composables + +Nuxt Auto API provides TanStack Query-powered composables for seamless data fetching, caching, and mutations on the frontend. + +## Table of Contents + +- [Installation](#installation) +- [Query Composables](#query-composables) + - [useAutoApiList](#useautoapilist) + - [useAutoApiGet](#useautoapiget) + - [useAutoApiInfinite](#useautoapiinfinite) +- [Mutation Composables](#mutation-composables) + - [useAutoApiMutation](#useautoapimutation) (Recommended) + - [useAutoApiCreate](#useautoapicreate) + - [useAutoApiUpdate](#useautoapiupdate) + - [useAutoApiDelete](#useautoapidelete) +- [Advanced Patterns](#advanced-patterns) + - [Optimistic Updates](#optimistic-updates) + - [Infinite Scroll](#infinite-scroll) + - [Server-Side Rendering](#server-side-rendering) +- [Error Handling](#error-handling) +- [TypeScript Support](#typescript-support) + +## Installation + +The composables are automatically available when you install `@websideproject/nuxt-auto-api`. They use TanStack Query (Vue Query) under the hood. + +```bash +npm install @websideproject/nuxt-auto-api +``` + +## Query Composables + +### useAutoApiList + +Fetch a list of resources with automatic caching and revalidation. + +#### Basic Usage + +```vue + + + +``` + +#### With Filters + +```vue + +``` + +#### With Relations + +```vue + + + +``` + +#### API Reference + +```typescript +function useAutoApiList( + resource: MaybeRef, + params?: MaybeRef, + options?: UseQueryOptions +) + +interface ListQueryParams { + filter?: Record // Filtering + sort?: string | string[] // Sorting (-field for desc) + page?: number // Page number + limit?: number // Items per page + include?: string | string[] // Relations to include + fields?: string | string[] // Select specific fields +} +``` + +#### Return Value + +```typescript +{ + data: Ref> // { data: T[], meta: { ... } } + isLoading: Ref + error: Ref + refetch: () => void + // ... other TanStack Query properties +} +``` + +### useAutoApiGet + +Fetch a single resource by ID. + +#### Basic Usage + +```vue + + + +``` + +#### With Relations + +```vue + + + +``` + +#### API Reference + +```typescript +function useAutoApiGet( + resource: MaybeRef, + id: MaybeRef, + params?: MaybeRef<{ + include?: string | string[] + fields?: string | string[] + }>, + options?: UseQueryOptions +) +``` + +### useAutoApiInfinite + +Infinite scroll pagination with cursor-based loading. + +#### Basic Usage + +```vue + + + +``` + +#### With Intersection Observer + +```vue + + + +``` + +## Mutation Composables + +### useAutoApiMutation + +Unified mutation API that automatically dispatches to the correct operation (create, update, or delete). This is the recommended approach for most use cases as it provides a cleaner, more consistent API. + +#### Basic Usage + +```vue + +``` + +#### With Toast Notifications + +The mutation composables integrate with Nuxt UI's toast system: + +```vue + +``` + +#### API Reference + +```typescript +function useAutoApiMutation( + resource: MaybeRef, + action: 'create' | 'update' | 'delete', + options?: { + toast?: { + success?: { title: string; description?: string } + error?: { title: string; description?: string } + } + onSuccess?: (data: any, variables: any, context: any) => void + onError?: (error: Error, variables: any, context: any) => void + // ... other TanStack Query mutation options + } +) +``` + +#### Return Value + +```typescript +{ + mutate: (variables: TVariables) => void + mutateAsync: (variables: TVariables) => Promise + isPending: Ref + isSuccess: Ref + isError: Ref + error: Ref + data: Ref + reset: () => void + // ... other TanStack Query mutation properties +} +``` + +### useAutoApiCreate + +Create a new resource with automatic cache invalidation. + +> **Note:** Consider using the unified `useAutoApiMutation('resource', 'create')` API for a more consistent interface. This section documents the direct API. + +#### Basic Usage + +```vue + + +