From 898fbd9635516eac1415452b6b7ae29412704b42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Rish=C3=B8j?= Date: Thu, 20 Nov 2025 17:14:21 +0100 Subject: [PATCH 1/6] ignore IDE folder --- benchmarks/.gitignore | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 benchmarks/.gitignore diff --git a/benchmarks/.gitignore b/benchmarks/.gitignore new file mode 100644 index 0000000..1183a3a --- /dev/null +++ b/benchmarks/.gitignore @@ -0,0 +1,12 @@ +# Benchmark artifacts +*.db +*.db-shm +*.db-wal +results/ +node_modules/ + +# Environment +.env + +# Prisma +migrations/ From a0a98a522664553310d2e9c5b847f77f88cede60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Rish=C3=B8j?= Date: Thu, 20 Nov 2025 17:19:12 +0100 Subject: [PATCH 2/6] Add comprehensive benchmark suite for adapter performance testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add benchmark infrastructure comparing Bun SQLite vs LibSQL adapters - Implement Prisma's official benchmarking methodology (500 iterations, outlier removal) - Include 27 query patterns: simple selects, joins, filters, aggregations, writes - Add statistical analysis utilities with median/percentile calculations - Create visualization tools with comparison tables and bar charts - Migrate to Prisma 7 with prisma.config.ts configuration - Add benchmark scripts to package.json (bench, bench:quick, bench:compare) - Update to ESM format with "type": "module" - Fix type/value export separation in src/index.ts for ESM compatibility - Add .gitignore entries for IDE and generated files ðŸĪ– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- benchmarks/.env.example | 7 + benchmarks/README.md | 312 +++++++++++++++++++++++++++ benchmarks/compare-adapters.ts | 80 +++++++ benchmarks/package.json | 28 +++ benchmarks/prisma.config.ts | 11 + benchmarks/queries.ts | 384 +++++++++++++++++++++++++++++++++ benchmarks/runner.ts | 292 +++++++++++++++++++++++++ benchmarks/schema.prisma | 107 +++++++++ benchmarks/seed.ts | 200 +++++++++++++++++ benchmarks/stats.ts | 189 ++++++++++++++++ benchmarks/visualize.ts | 282 ++++++++++++++++++++++++ 11 files changed, 1892 insertions(+) create mode 100644 benchmarks/.env.example create mode 100644 benchmarks/README.md create mode 100644 benchmarks/compare-adapters.ts create mode 100644 benchmarks/package.json create mode 100644 benchmarks/prisma.config.ts create mode 100644 benchmarks/queries.ts create mode 100644 benchmarks/runner.ts create mode 100644 benchmarks/schema.prisma create mode 100644 benchmarks/seed.ts create mode 100644 benchmarks/stats.ts create mode 100644 benchmarks/visualize.ts diff --git a/benchmarks/.env.example b/benchmarks/.env.example new file mode 100644 index 0000000..b2bc4da --- /dev/null +++ b/benchmarks/.env.example @@ -0,0 +1,7 @@ +# Prisma Benchmarks Configuration + +# Note: Prisma 7 uses prisma.config.ts for database URL configuration +# This .env file is kept for any other environment-specific settings + +# Example: Custom configuration (if needed) +# NODE_ENV=production diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 0000000..2df5006 --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,312 @@ +# Prisma Adapter Benchmarks + +This benchmark suite compares the performance of the Bun SQLite adapter (`@synapsenwerkstatt/prisma-bun-sqlite-adapter`) against Prisma's official LibSQL adapter (`@prisma/adapter-libsql`). + +**Built for Prisma 7** with `prisma.config.ts` configuration. + +## Methodology + +The benchmarking approach follows [Prisma's official methodology](https://www.prisma.io/blog/performance-benchmarks-comparing-query-latency-across-typescript-orms-and-databases): + +- **Query execution timing**: Uses `performance.now()` before and after each query +- **Multiple iterations**: Each query runs 500 times (configurable) to reduce variance +- **Outlier removal**: Removes measurements above the 99th percentile +- **Deterministic data**: Uses faker.js with a fixed seed for reproducible datasets +- **Comprehensive metrics**: Tracks median, mean, min, max, percentiles (P50-P99), and standard deviation + +## Installation + +Install the required dependencies: + +```bash +# Install benchmark dependencies +bun add -d @faker-js/faker + +# Install LibSQL adapter for comparison (optional) +bun add -d @prisma/adapter-libsql @libsql/client +``` + +## Database Schema + +The benchmark uses a realistic e-commerce schema with 8 models: +- **User** - Basic user information +- **Profile** - User profile (1:1 with User) +- **Post** - Blog posts with many-to-many tags +- **Category** - Post categories +- **Tag** - Post tags (many-to-many) +- **Order** - User orders +- **OrderItem** - Order line items +- **Product** - Product catalog + +## Setup + +### 1. Configuration + +Prisma 7 uses `prisma.config.ts` for configuration. The benchmark database URL is configured there: + +```typescript +// benchmarks/prisma.config.ts +import { defineConfig } from 'prisma/config' + +export default defineConfig({ + schema: 'schema.prisma', + migrations: { + path: 'migrations', + }, + datasource: { + url: 'file:./benchmark.db', + }, +}) +``` + +### 2. Generate Prisma Client + +```bash +cd benchmarks +bunx prisma generate +``` + +### 3. Create and Seed Database + +```bash +# Create database and run migrations +bunx prisma migrate dev --name init + +# Seed with test data (1000 users, 3000 posts, 5000 orders, etc.) +bun seed.ts +``` + +## Running Benchmarks + +### Benchmark Single Adapter (Bun SQLite) + +```bash +# Run full benchmark suite (500 iterations per query) +bun runner.ts --seed + +# Quick benchmark (6 selected queries) +bun runner.ts --quick + +# Custom iteration count +bun runner.ts --iterations=1000 --warmup=20 +``` + +### Compare Both Adapters + +```bash +# Compare Bun SQLite vs LibSQL +bun compare-adapters.ts --seed + +# Quick comparison +bun compare-adapters.ts --quick + +# Custom iterations +bun compare-adapters.ts --iterations=1000 --warmup=20 +``` + +### Command Line Options + +- `--seed` - Drop and reseed database before benchmarking +- `--quick` - Run only 6 selected queries instead of full suite +- `--iterations=N` - Number of iterations per query (default: 500) +- `--warmup=N` - Number of warmup iterations (default: 10) + +## Query Suite + +The benchmark includes 27 queries covering various patterns: + +### Simple Queries +- `findMany-users-simple` - Select all users +- `findMany-users-limit` - Select with limit +- `findUnique-user` - Find by ID + +### Filtered Queries +- `findMany-posts-published` - Boolean filter +- `findMany-orders-status` - String filter +- `findMany-products-price-range` - Numeric range filter + +### Queries with Relations +- `findMany-users-with-profile` - Single relation +- `findMany-posts-with-author` - Inverse relation +- `findMany-posts-with-relations` - Multiple relations +- `findMany-users-with-all-relations` - Complex nested relations +- `findUnique-user-nested` - Deeply nested includes + +### Aggregations +- `aggregate-users-count` - Simple count +- `aggregate-orders-sum` - Sum aggregation +- `aggregate-products-stats` - Multiple aggregations +- `groupBy-posts-by-category` - Group by with count +- `groupBy-orders-by-status` - Group by with sum + +### Complex Queries +- `findMany-posts-complex-filter` - Multiple conditions with OR +- `findMany-orders-with-items-and-products` - Multiple nested relations + +### Write Operations +- `create-user` - Simple create +- `update-user` - Simple update +- `create-post-with-relations` - Create with relations + +### Raw Queries +- `raw-query-simple` - Basic SQL +- `raw-query-join` - SQL with joins + +### Transactions +- `transaction-simple` - Transaction with multiple queries + +## Results + +Results are saved to `benchmarks/results/` with timestamps: + +- **JSON format**: `results-YYYY-MM-DDTHH-MM-SS.json` - Raw data for analysis +- **Markdown report**: Auto-generated with comparison tables and summary + +### Visualize Results + +```bash +# Generate visualizations from saved results +bun visualize.ts results/results-2024-01-15T10-30-00.json +``` + +This generates: +- Comparison tables showing performance differences +- ASCII bar charts for selected queries +- Markdown report with detailed analysis + +## Understanding Results + +### Metrics Explained + +- **Median**: Middle value, less affected by outliers (primary metric) +- **Mean**: Average of all measurements +- **Min/Max**: Fastest and slowest measurements +- **P50-P99**: Percentiles showing distribution +- **Std Dev**: Variation in measurements + +### Interpreting Comparisons + +The comparison output shows: +- **Absolute timings**: Raw latency for each adapter +- **Percentage difference**: How much faster/slower one adapter is +- **Visual indicators**: ðŸŸĒ (faster), ðŸ”ī (slower), ⚩ (same) + +Example output: +``` +Bun SQLite - findMany-users-limit + Median: 2.45ms + +LibSQL - findMany-users-limit + Median: 3.12ms + +Comparison: +27.35% slower +``` + +## Dataset Configuration + +Edit the `CONFIG` object in `seed.ts` to adjust data volume: + +```typescript +const CONFIG = { + users: 1000, // Number of users + postsPerUser: 3, // Posts per user + categoriesCount: 20, // Total categories + tagsCount: 50, // Total tags + tagsPerPost: 3, // Tags per post + ordersPerUser: 5, // Orders per user + productsCount: 200, // Total products + itemsPerOrder: 4, // Items per order +} +``` + +Default dataset: +- 1,000 users +- 1,000 profiles +- 3,000 posts +- 5,000 orders +- 20,000 order items +- 200 products +- 50 tags + +## Troubleshooting + +### "Module not found: @prisma/client" + +Run Prisma generate first: +```bash +cd benchmarks +bunx prisma generate +``` + +### "Table does not exist" + +Run migrations: +```bash +bunx prisma migrate dev --name init +``` + +### "Cannot find module 'prisma/config'" + +Make sure you're using Prisma 7: +```bash +bunx prisma --version # Should show 7.x.x +bun install # Update dependencies +``` + +### "Cannot find module @prisma/adapter-libsql" + +The LibSQL adapter is optional. To use it: +```bash +bun add -d @prisma/adapter-libsql @libsql/client +``` + +Or run only the Bun adapter benchmark: +```bash +bun runner.ts +``` + +### Out of Memory + +Reduce iterations or dataset size: +```bash +bun runner.ts --iterations=100 +``` + +Or edit `seed.ts` to reduce data volume. + +## Requirements + +- **Bun**: >= 1.0.0 +- **Node.js**: >= 20.19.0 (required by Prisma 7) +- **TypeScript**: >= 5.4.0 +- **Prisma**: >= 7.0.0 +- **@prisma/client**: >= 7.0.0 +- **@prisma/driver-adapter-utils**: >= 7.0.0 + +### Compatibility + +| Prisma Version | Adapter Version | Status | +|----------------|-----------------|--------| +| 7.0.x+ | 2.0.x | ✅ Stable | +| 6.13.x+ | 1.x.x | ✅ Legacy | + +## Contributing + +To add new queries: + +1. Add to `queries.ts`: +```typescript +{ + name: 'my-new-query', + description: 'Description of what it tests', + query: async (prisma) => { + return prisma.model.findMany({ ... }) + }, +} +``` + +2. Run benchmarks to test + +## License + +MIT diff --git a/benchmarks/compare-adapters.ts b/benchmarks/compare-adapters.ts new file mode 100644 index 0000000..56c29d4 --- /dev/null +++ b/benchmarks/compare-adapters.ts @@ -0,0 +1,80 @@ +/** + * Comparison runner for Bun SQLite vs LibSQL adapters + * + * This script benchmarks both adapters side-by-side to compare performance + */ + +import { PrismaClient } from './generated/client' +import { PrismaBunSQLite } from '../dist/index' +import { runBenchmarks } from './runner' +import { queries, getQuickBenchmarkQueries } from './queries' + +interface AdapterConfig { + name: string + createClient: () => Promise + cleanup?: () => Promise +} + +async function main() { + const args = Bun.argv.slice(2) + const quick = args.includes('--quick') + const iterations = parseInt(args.find(a => a.startsWith('--iterations='))?.split('=')[1] ?? '500') + const warmup = parseInt(args.find(a => a.startsWith('--warmup='))?.split('=')[1] ?? '10') + const seed = args.includes('--seed') + + console.log('🔎 Prisma Adapter Comparison: Bun SQLite vs LibSQL') + console.log('=' .repeat(60)) + + // Configuration + const dbPath = 'benchmark.db' + + // Bun SQLite adapter + const bunAdapter: AdapterConfig = { + name: 'Bun SQLite (@synapsenwerkstatt/prisma-bun-sqlite-adapter)', + createClient: async () => { + const adapter = new PrismaBunSQLite({ + url: `file:${dbPath}`, + }) + return new PrismaClient({ adapter }) + }, + } + + // LibSQL adapter (requires @prisma/adapter-libsql and @libsql/client) + const libsqlAdapter: AdapterConfig = { + name: 'LibSQL (@prisma/adapter-libsql)', + createClient: async () => { + try { + const { PrismaLibSql } = await import('@prisma/adapter-libsql') + + // In Prisma 7, PrismaLibSql is a factory that takes config + const adapter = new PrismaLibSql({ + url: `file:${dbPath}`, + }) + return new PrismaClient({ adapter }) + } catch (error) { + console.error('\n❌ Failed to load LibSQL adapter. Make sure you have installed:') + console.error(' bun add @prisma/adapter-libsql @libsql/client') + console.error(' Error:', error) + throw error + } + }, + } + + // Run benchmarks + await runBenchmarks({ + iterations, + warmupIterations: warmup, + adapters: [bunAdapter, libsqlAdapter], + queries: quick ? getQuickBenchmarkQueries() : queries, + freshDatabase: seed, + }) +} + +if (import.meta.main) { + try { + await main() + } catch (error) { + console.error('❌ Comparison failed:', error) + process.exit(1) + } +} diff --git a/benchmarks/package.json b/benchmarks/package.json new file mode 100644 index 0000000..bed429a --- /dev/null +++ b/benchmarks/package.json @@ -0,0 +1,28 @@ +{ + "name": "prisma-bun-sqlite-adapter-benchmarks", + "version": "1.0.0", + "type": "module", + "private": true, + "description": "Performance benchmarks for Prisma Bun SQLite adapter", + "scripts": { + "setup": "bunx prisma generate && bunx prisma migrate dev --name init", + "seed": "bun seed.ts", + "bench": "bun runner.ts", + "bench:quick": "bun runner.ts --quick", + "bench:seed": "bun runner.ts --seed", + "compare": "bun compare-adapters.ts", + "compare:quick": "bun compare-adapters.ts --quick", + "compare:seed": "bun compare-adapters.ts --seed", + "visualize": "bun visualize.ts" + }, + "dependencies": { + "@prisma/client": "^7.0.0", + "@synapsenwerkstatt/prisma-bun-sqlite-adapter": "file:.." + }, + "devDependencies": { + "@faker-js/faker": "^9.2.0", + "prisma": "^7.0.0", + "@prisma/adapter-libsql": "^7.0.0", + "@libsql/client": "^0.14.0" + } +} diff --git a/benchmarks/prisma.config.ts b/benchmarks/prisma.config.ts new file mode 100644 index 0000000..8129121 --- /dev/null +++ b/benchmarks/prisma.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'prisma/config' + +export default defineConfig({ + schema: 'schema.prisma', + migrations: { + path: 'migrations', + }, + datasource: { + url: 'file:./benchmark.db', + }, +}) diff --git a/benchmarks/queries.ts b/benchmarks/queries.ts new file mode 100644 index 0000000..6c8db65 --- /dev/null +++ b/benchmarks/queries.ts @@ -0,0 +1,384 @@ +import { PrismaClient } from './generated/client' + +/** + * Benchmark query suite + * Covers various query patterns: simple selects, joins, filters, aggregations, writes + */ + +export interface QueryBenchmark { + name: string + description: string + query: (prisma: PrismaClient) => Promise +} + +export const queries: QueryBenchmark[] = [ + // Simple queries + { + name: 'findMany-users-simple', + description: 'Select all users without relations', + query: async (prisma) => { + return prisma.user.findMany() + }, + }, + { + name: 'findMany-users-limit', + description: 'Select 100 users with limit', + query: async (prisma) => { + return prisma.user.findMany({ + take: 100, + }) + }, + }, + { + name: 'findUnique-user', + description: 'Find single user by ID', + query: async (prisma) => { + return prisma.user.findUnique({ + where: { id: 1 }, + }) + }, + }, + + // Queries with simple filters + { + name: 'findMany-posts-published', + description: 'Find published posts with boolean filter', + query: async (prisma) => { + return prisma.post.findMany({ + where: { published: true }, + }) + }, + }, + { + name: 'findMany-orders-status', + description: 'Find orders by status', + query: async (prisma) => { + return prisma.order.findMany({ + where: { status: 'delivered' }, + }) + }, + }, + { + name: 'findMany-products-price-range', + description: 'Find products in price range', + query: async (prisma) => { + return prisma.product.findMany({ + where: { + price: { + gte: 100, + lte: 500, + }, + }, + }) + }, + }, + + // Queries with single includes + { + name: 'findMany-users-with-profile', + description: 'Select users with profile relation', + query: async (prisma) => { + return prisma.user.findMany({ + include: { + profile: true, + }, + take: 100, + }) + }, + }, + { + name: 'findMany-posts-with-author', + description: 'Select posts with author relation', + query: async (prisma) => { + return prisma.post.findMany({ + include: { + author: true, + }, + take: 100, + }) + }, + }, + { + name: 'findMany-orders-with-user', + description: 'Select orders with user relation', + query: async (prisma) => { + return prisma.order.findMany({ + include: { + user: true, + }, + take: 100, + }) + }, + }, + + // Queries with multiple includes (complex joins) + { + name: 'findMany-posts-with-relations', + description: 'Select posts with author, category, and tags', + query: async (prisma) => { + return prisma.post.findMany({ + include: { + author: true, + category: true, + tags: true, + }, + take: 50, + }) + }, + }, + { + name: 'findMany-users-with-all-relations', + description: 'Select users with profile, posts, and orders', + query: async (prisma) => { + return prisma.user.findMany({ + include: { + profile: true, + posts: true, + orders: true, + }, + take: 20, + }) + }, + }, + { + name: 'findMany-orders-with-items-and-products', + description: 'Select orders with items and product details', + query: async (prisma) => { + return prisma.order.findMany({ + include: { + items: { + include: { + product: true, + }, + }, + }, + take: 50, + }) + }, + }, + + // Nested queries + { + name: 'findUnique-user-nested', + description: 'Find user with deeply nested relations', + query: async (prisma) => { + return prisma.user.findUnique({ + where: { id: 1 }, + include: { + profile: true, + posts: { + include: { + category: true, + tags: true, + }, + }, + orders: { + include: { + items: { + include: { + product: true, + }, + }, + }, + }, + }, + }) + }, + }, + + // Aggregation queries + { + name: 'aggregate-users-count', + description: 'Count total users', + query: async (prisma) => { + return prisma.user.count() + }, + }, + { + name: 'aggregate-orders-sum', + description: 'Sum order totals', + query: async (prisma) => { + return prisma.order.aggregate({ + _sum: { + total: true, + }, + }) + }, + }, + { + name: 'aggregate-products-stats', + description: 'Calculate product price statistics', + query: async (prisma) => { + return prisma.product.aggregate({ + _avg: { + price: true, + }, + _min: { + price: true, + }, + _max: { + price: true, + }, + }) + }, + }, + + // GroupBy queries + { + name: 'groupBy-posts-by-category', + description: 'Group posts by category with count', + query: async (prisma) => { + return prisma.post.groupBy({ + by: ['categoryId'], + _count: { + id: true, + }, + }) + }, + }, + { + name: 'groupBy-orders-by-status', + description: 'Group orders by status with sum', + query: async (prisma) => { + return prisma.order.groupBy({ + by: ['status'], + _sum: { + total: true, + }, + _count: { + id: true, + }, + }) + }, + }, + + // Complex filters + { + name: 'findMany-posts-complex-filter', + description: 'Find posts with multiple conditions', + query: async (prisma) => { + return prisma.post.findMany({ + where: { + published: true, + author: { + email: { + contains: '@', + }, + }, + OR: [ + { + title: { + contains: 'test', + }, + }, + { + content: { + contains: 'example', + }, + }, + ], + }, + take: 50, + }) + }, + }, + + // Write operations + { + name: 'create-user', + description: 'Create a new user', + query: (() => { + let counter = 0 + return async (prisma) => { + counter++ + return prisma.user.create({ + data: { + email: `test-${Date.now()}-${counter}-${Math.random().toString(36).substring(7)}@example.com`, + name: `Test User ${counter}`, + }, + }) + } + })(), + }, + { + name: 'update-user', + description: 'Update user name', + query: async (prisma) => { + return prisma.user.update({ + where: { id: 1 }, + data: { + name: 'Updated Name', + }, + }) + }, + }, + { + name: 'create-post-with-relations', + description: 'Create post with relations', + query: async (prisma) => { + return prisma.post.create({ + data: { + title: 'Benchmark Post', + content: 'Content for benchmark', + published: true, + author: { + connect: { id: 1 }, + }, + category: { + connect: { id: 1 }, + }, + tags: { + connect: [{ id: 1 }, { id: 2 }], + }, + }, + }) + }, + }, + + // Raw queries + { + name: 'raw-query-simple', + description: 'Execute raw SQL query', + query: async (prisma) => { + return prisma.$queryRaw`SELECT * FROM User LIMIT 100` + }, + }, + { + name: 'raw-query-join', + description: 'Execute raw SQL with join', + query: async (prisma) => { + return prisma.$queryRaw` + SELECT u.*, p.* + FROM User u + LEFT JOIN Profile p ON u.id = p.userId + LIMIT 100 + ` + }, + }, + + // Transaction + { + name: 'transaction-simple', + description: 'Execute simple transaction', + query: async (prisma) => { + return prisma.$transaction([ + prisma.user.findUnique({ where: { id: 1 } }), + prisma.post.findMany({ where: { authorId: 1 }, take: 10 }), + ]) + }, + }, +] + +/** + * Get a subset of queries for quick benchmarking + */ +export function getQuickBenchmarkQueries(): QueryBenchmark[] { + return [ + queries.find(q => q.name === 'findMany-users-limit')!, + queries.find(q => q.name === 'findMany-posts-published')!, + queries.find(q => q.name === 'findMany-users-with-profile')!, + queries.find(q => q.name === 'findMany-posts-with-relations')!, + queries.find(q => q.name === 'aggregate-users-count')!, + queries.find(q => q.name === 'create-user')!, + ] +} diff --git a/benchmarks/runner.ts b/benchmarks/runner.ts new file mode 100644 index 0000000..ba17346 --- /dev/null +++ b/benchmarks/runner.ts @@ -0,0 +1,292 @@ +import { PrismaClient } from './generated/client' +import { PrismaBunSQLite } from '../dist/index' +import { queries, getQuickBenchmarkQueries, type QueryBenchmark } from './queries' +import { analyzeMeasurements, printResults, compareResults, type BenchmarkResult } from './stats' +import { seedDatabase } from './seed' + +/** + * Benchmark runner configuration + */ +interface BenchmarkConfig { + iterations: number + warmupIterations: number + adapters: AdapterConfig[] + queries: QueryBenchmark[] + freshDatabase: boolean +} + +interface AdapterConfig { + name: string + createClient: () => Promise + cleanup?: () => Promise +} + +/** + * Run a single query benchmark + */ +async function benchmarkQuery( + prisma: PrismaClient, + query: QueryBenchmark, + iterations: number, + warmupIterations: number +): Promise { + const measurements: number[] = [] + + // Warmup iterations + console.log(` Warmup (${warmupIterations} iterations)...`) + for (let i = 0; i < warmupIterations; i++) { + await query.query(prisma) + } + + // Actual measurements + console.log(` Measuring (${iterations} iterations)...`) + for (let i = 0; i < iterations; i++) { + const start = performance.now() + await query.query(prisma) + const end = performance.now() + measurements.push(end - start) + + if ((i + 1) % 100 === 0) { + console.log(` Progress: ${i + 1}/${iterations}`) + } + } + + return measurements +} + +/** + * Run benchmarks for a single adapter + */ +async function benchmarkAdapter( + config: AdapterConfig, + queries: QueryBenchmark[], + iterations: number, + warmupIterations: number +): Promise> { + console.log(`\n${'='.repeat(60)}`) + console.log(`🏃 Running benchmarks for: ${config.name}`) + console.log(`${'='.repeat(60)}`) + + const prisma = await config.createClient() + const results = new Map() + + try { + for (const query of queries) { + console.log(`\n📝 Query: ${query.name}`) + console.log(` ${query.description}`) + + const measurements = await benchmarkQuery( + prisma, + query, + iterations, + warmupIterations + ) + + const result = analyzeMeasurements( + `${config.name} - ${query.name}`, + measurements + ) + + results.set(query.name, result) + printResults(result) + } + } finally { + await prisma.$disconnect() + if (config.cleanup) { + await config.cleanup() + } + } + + return results +} + +/** + * Compare results from multiple adapters + */ +function compareAdapterResults( + results: Map> +): void { + const adapterNames = Array.from(results.keys()) + if (adapterNames.length < 2) { + return + } + + console.log(`\n\n${'='.repeat(60)}`) + console.log(`📊 COMPARISON RESULTS`) + console.log(`${'='.repeat(60)}`) + + const baseline = adapterNames[0] + const baselineResults = results.get(baseline)! + + for (let i = 1; i < adapterNames.length; i++) { + const comparisonName = adapterNames[i] + const comparisonResults = results.get(comparisonName)! + + console.log(`\n\n🔎 Comparing ${baseline} vs ${comparisonName}`) + console.log(`${'─'.repeat(60)}`) + + for (const [queryName, baselineResult] of baselineResults) { + const comparisonResult = comparisonResults.get(queryName) + if (comparisonResult) { + compareResults(baselineResult, comparisonResult) + } + } + } +} + +/** + * Generate summary report + */ +function generateSummary( + results: Map> +): void { + console.log(`\n\n${'='.repeat(60)}`) + console.log(`📋 SUMMARY REPORT`) + console.log(`${'='.repeat(60)}`) + + const adapterNames = Array.from(results.keys()) + + for (const adapterName of adapterNames) { + const adapterResults = results.get(adapterName)! + const allMedians = Array.from(adapterResults.values()).map(r => r.median) + const avgMedian = allMedians.reduce((sum, m) => sum + m, 0) / allMedians.length + + console.log(`\n${adapterName}:`) + console.log(` Queries tested: ${adapterResults.size}`) + console.log(` Average median latency: ${avgMedian.toFixed(2)}ms`) + + // Find fastest and slowest queries + const sorted = Array.from(adapterResults.values()).sort((a, b) => a.median - b.median) + console.log(` Fastest query: ${sorted[0].name} (${sorted[0].median.toFixed(2)}ms)`) + console.log(` Slowest query: ${sorted[sorted.length - 1].name} (${sorted[sorted.length - 1].median.toFixed(2)}ms)`) + } + + // Overall winner + if (adapterNames.length > 1) { + const averages = adapterNames.map(name => { + const adapterResults = results.get(name)! + const allMedians = Array.from(adapterResults.values()).map(r => r.median) + return { + name, + avgMedian: allMedians.reduce((sum, m) => sum + m, 0) / allMedians.length, + } + }) + + averages.sort((a, b) => a.avgMedian - b.avgMedian) + console.log(`\n🏆 Overall fastest adapter: ${averages[0].name}`) + console.log(` Average median latency: ${averages[0].avgMedian.toFixed(2)}ms`) + } +} + +/** + * Export results to JSON + */ +function exportResults( + results: Map>, + filename: string +): void { + const data = Object.fromEntries( + Array.from(results.entries()).map(([adapterName, queryResults]) => [ + adapterName, + Object.fromEntries(queryResults), + ]) + ) + + Bun.write(filename, JSON.stringify(data, null, 2)) + console.log(`\nðŸ’ū Results exported to: ${filename}`) +} + +/** + * Main benchmark execution + */ +export async function runBenchmarks(config: BenchmarkConfig): Promise { + console.log('🚀 Starting benchmarks...') + console.log(`Configuration:`) + console.log(` Iterations: ${config.iterations}`) + console.log(` Warmup iterations: ${config.warmupIterations}`) + console.log(` Adapters: ${config.adapters.map(a => a.name).join(', ')}`) + console.log(` Queries: ${config.queries.length}`) + + // Seed database if needed + if (config.freshDatabase) { + console.log('\nðŸŒą Seeding database...') + const prisma = await config.adapters[0].createClient() + try { + await seedDatabase(prisma) + } finally { + await prisma.$disconnect() + } + } + + // Run benchmarks for each adapter + const allResults = new Map>() + + for (const adapter of config.adapters) { + const results = await benchmarkAdapter( + adapter, + config.queries, + config.iterations, + config.warmupIterations + ) + allResults.set(adapter.name, results) + } + + // Generate comparison and summary + compareAdapterResults(allResults) + generateSummary(allResults) + + // Export results + const timestamp = new Date().toISOString().replace(/[:.]/g, '-') + exportResults(allResults, `results/${timestamp}.json`) + + console.log('\n✅ Benchmarks complete!') +} + +// CLI runner +if (import.meta.main) { + const args = Bun.argv.slice(2) + const quick = args.includes('--quick') + const iterations = parseInt(args.find(a => a.startsWith('--iterations='))?.split('=')[1] ?? '500') + const warmup = parseInt(args.find(a => a.startsWith('--warmup='))?.split('=')[1] ?? '10') + + console.log('📊 Prisma Adapter Benchmark Suite') + console.log('=' .repeat(60)) + + // Bun SQLite adapter configuration + const bunAdapter: AdapterConfig = { + name: 'Bun SQLite Adapter', + createClient: async () => { + const adapter = new PrismaBunSQLite({ + url: 'file:benchmark.db', + }) + return new PrismaClient({ adapter }) + }, + } + + // TODO: Add libsql adapter configuration when ready + // const libsqlAdapter: AdapterConfig = { + // name: 'LibSQL Adapter', + // createClient: async () => { + // const { PrismaLibSQL } = await import('@prisma/adapter-libsql') + // const { createClient } = await import('@libsql/client') + // const libsql = createClient({ url: 'file:benchmarks/benchmark.db' }) + // const adapter = new PrismaLibSQL(libsql) + // return new PrismaClient({ adapter }) + // }, + // } + + const config: BenchmarkConfig = { + iterations, + warmupIterations: warmup, + adapters: [bunAdapter], + queries: quick ? getQuickBenchmarkQueries() : queries, + freshDatabase: args.includes('--seed'), + } + + try { + await runBenchmarks(config) + } catch (error) { + console.error('❌ Benchmark failed:', error) + process.exit(1) + } +} diff --git a/benchmarks/schema.prisma b/benchmarks/schema.prisma new file mode 100644 index 0000000..9796d28 --- /dev/null +++ b/benchmarks/schema.prisma @@ -0,0 +1,107 @@ +// Benchmark schema with realistic relations +// Based on Prisma's blog benchmark methodology +// Prisma 7 - URL configured in prisma.config.ts + +generator client { + provider = "prisma-client" + output = "generated" +} + +datasource db { + provider = "sqlite" +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + name String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + posts Post[] + profile Profile? + orders Order[] +} + +model Profile { + id Int @id @default(autoincrement()) + bio String? + avatar String? + userId Int @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model Post { + id Int @id @default(autoincrement()) + title String + content String? + published Boolean @default(false) + authorId Int + author User @relation(fields: [authorId], references: [id], onDelete: Cascade) + categoryId Int + category Category @relation(fields: [categoryId], references: [id]) + tags Tag[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([authorId]) + @@index([categoryId]) + @@index([published]) +} + +model Category { + id Int @id @default(autoincrement()) + name String @unique + posts Post[] + createdAt DateTime @default(now()) +} + +model Tag { + id Int @id @default(autoincrement()) + name String @unique + posts Post[] + createdAt DateTime @default(now()) +} + +model Order { + id Int @id @default(autoincrement()) + orderNumber String @unique + total Float + status String @default("pending") + userId Int + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + items OrderItem[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) + @@index([status]) +} + +model OrderItem { + id Int @id @default(autoincrement()) + orderId Int + order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) + productId Int + product Product @relation(fields: [productId], references: [id]) + quantity Int + price Float + createdAt DateTime @default(now()) + + @@index([orderId]) + @@index([productId]) +} + +model Product { + id Int @id @default(autoincrement()) + name String + description String? + price Float + stock Int @default(0) + items OrderItem[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([price]) +} diff --git a/benchmarks/seed.ts b/benchmarks/seed.ts new file mode 100644 index 0000000..008c5d3 --- /dev/null +++ b/benchmarks/seed.ts @@ -0,0 +1,200 @@ +import { PrismaClient } from './generated/client' +import { PrismaBunSQLite } from '../dist/index' +import { faker } from '@faker-js/faker' + +// Deterministic seed for reproducibility +const SEED_VALUE = 12345 +faker.seed(SEED_VALUE) + +// Configuration +const CONFIG = { + users: 1000, + postsPerUser: 3, + categoriesCount: 20, + tagsCount: 50, + tagsPerPost: 3, + ordersPerUser: 5, + productsCount: 200, + itemsPerOrder: 4, +} + +export async function seedDatabase(prisma: PrismaClient) { + console.log('ðŸŒą Starting database seeding...') + console.log(`Configuration:`, CONFIG) + + // Clear existing data + console.log('\nðŸ“Ķ Clearing existing data...') + await prisma.orderItem.deleteMany() + await prisma.order.deleteMany() + await prisma.product.deleteMany() + await prisma.post.deleteMany() + await prisma.tag.deleteMany() + await prisma.category.deleteMany() + await prisma.profile.deleteMany() + await prisma.user.deleteMany() + + // Create categories + console.log('\n📂 Creating categories...') + const categories = await Promise.all( + Array.from({ length: CONFIG.categoriesCount }, (_, i) => { + return prisma.category.create({ + data: { + name: `${faker.commerce.department()}-${i}`, + }, + }) + }) + ) + console.log(`✅ Created ${categories.length} categories`) + + // Create tags + console.log('\n🏷ïļ Creating tags...') + const tags = await Promise.all( + Array.from({ length: CONFIG.tagsCount }, (_, i) => { + return prisma.tag.create({ + data: { + name: `${faker.word.noun()}-${i}`, + }, + }) + }) + ) + console.log(`✅ Created ${tags.length} tags`) + + // Create products + console.log('\n🛍ïļ Creating products...') + const products = await Promise.all( + Array.from({ length: CONFIG.productsCount }, () => { + return prisma.product.create({ + data: { + name: faker.commerce.productName(), + description: faker.commerce.productDescription(), + price: parseFloat(faker.commerce.price({ min: 10, max: 1000 })), + stock: faker.number.int({ min: 0, max: 1000 }), + }, + }) + }) + ) + console.log(`✅ Created ${products.length} products`) + + // Create users with profiles, posts, and orders + console.log('\nðŸ‘Ĩ Creating users with related data...') + const batchSize = 50 + let userCount = 0 + + for (let i = 0; i < CONFIG.users; i += batchSize) { + const batch = Math.min(batchSize, CONFIG.users - i) + + await Promise.all( + Array.from({ length: batch }, async () => { + const user = await prisma.user.create({ + data: { + email: faker.internet.email(), + name: faker.person.fullName(), + }, + }) + + // Create profile + await prisma.profile.create({ + data: { + userId: user.id, + bio: faker.person.bio(), + avatar: faker.image.avatar(), + }, + }) + + // Create posts + await Promise.all( + Array.from({ length: CONFIG.postsPerUser }, async () => { + const selectedTags = faker.helpers.arrayElements( + tags, + CONFIG.tagsPerPost + ) + + await prisma.post.create({ + data: { + title: faker.lorem.sentence(), + content: faker.lorem.paragraphs(3), + published: faker.datatype.boolean(), + authorId: user.id, + categoryId: faker.helpers.arrayElement(categories).id, + tags: { + connect: selectedTags.map(tag => ({ id: tag.id })), + }, + }, + }) + }) + ) + + // Create orders + await Promise.all( + Array.from({ length: CONFIG.ordersPerUser }, async () => { + const selectedProducts = faker.helpers.arrayElements( + products, + CONFIG.itemsPerOrder + ) + + const items = selectedProducts.map(product => ({ + productId: product.id, + quantity: faker.number.int({ min: 1, max: 5 }), + price: product.price, + })) + + const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0) + + await prisma.order.create({ + data: { + orderNumber: faker.string.alphanumeric(10).toUpperCase(), + total, + status: faker.helpers.arrayElement(['pending', 'processing', 'shipped', 'delivered']), + userId: user.id, + items: { + create: items, + }, + }, + }) + }) + ) + + userCount++ + }) + ) + + console.log(` Progress: ${userCount}/${CONFIG.users} users`) + } + + console.log(`✅ Created ${userCount} users with profiles, posts, and orders`) + + // Summary + const counts = { + users: await prisma.user.count(), + profiles: await prisma.profile.count(), + posts: await prisma.post.count(), + categories: await prisma.category.count(), + tags: await prisma.tag.count(), + orders: await prisma.order.count(), + orderItems: await prisma.orderItem.count(), + products: await prisma.product.count(), + } + + console.log('\n📊 Database seeding complete!') + console.log('Final counts:') + console.log(JSON.stringify(counts, null, 2)) + + return counts +} + +// Run directly if this file is executed +if (import.meta.main) { + const adapter = new PrismaBunSQLite({ + url: 'file:./benchmark.db', + }) + const prisma = new PrismaClient({ adapter }) + + try { + await seedDatabase(prisma) + } catch (error) { + console.error('❌ Seeding failed:', error) + process.exit(1) + } finally { + await prisma.$disconnect() + } +} diff --git a/benchmarks/stats.ts b/benchmarks/stats.ts new file mode 100644 index 0000000..601ef85 --- /dev/null +++ b/benchmarks/stats.ts @@ -0,0 +1,189 @@ +/** + * Statistical utilities for benchmark analysis + * Following Prisma's methodology for outlier removal and metric calculation + */ + +export interface BenchmarkResult { + name: string + iterations: number + measurements: number[] + median: number + mean: number + min: number + max: number + p50: number + p75: number + p90: number + p95: number + p99: number + stdDev: number +} + +/** + * Remove outliers above the 99th percentile + */ +function removeOutliers(measurements: number[]): number[] { + const sorted = [...measurements].sort((a, b) => a - b) + const p99Index = Math.floor(sorted.length * 0.99) + return sorted.slice(0, p99Index) +} + +/** + * Calculate percentile value + */ +function percentile(sorted: number[], p: number): number { + const index = Math.ceil(sorted.length * p) - 1 + return sorted[Math.max(0, index)] +} + +/** + * Calculate median + */ +function median(sorted: number[]): number { + const mid = Math.floor(sorted.length / 2) + if (sorted.length % 2 === 0) { + return (sorted[mid - 1] + sorted[mid]) / 2 + } + return sorted[mid] +} + +/** + * Calculate mean + */ +function mean(values: number[]): number { + return values.reduce((sum, val) => sum + val, 0) / values.length +} + +/** + * Calculate standard deviation + */ +function stdDev(values: number[], meanValue: number): number { + const variance = values.reduce((sum, val) => { + const diff = val - meanValue + return sum + diff * diff + }, 0) / values.length + return Math.sqrt(variance) +} + +/** + * Analyze benchmark measurements and calculate statistics + */ +export function analyzeMeasurements( + name: string, + measurements: number[] +): BenchmarkResult { + // Remove outliers above 99th percentile + const filtered = removeOutliers(measurements) + const sorted = [...filtered].sort((a, b) => a - b) + + const meanValue = mean(filtered) + + return { + name, + iterations: measurements.length, + measurements: filtered, + median: median(sorted), + mean: meanValue, + min: sorted[0], + max: sorted[sorted.length - 1], + p50: percentile(sorted, 0.5), + p75: percentile(sorted, 0.75), + p90: percentile(sorted, 0.9), + p95: percentile(sorted, 0.95), + p99: percentile(sorted, 0.99), + stdDev: stdDev(filtered, meanValue), + } +} + +/** + * Format duration in milliseconds with appropriate precision + */ +export function formatDuration(ms: number): string { + if (ms < 1) { + return `${(ms * 1000).toFixed(2)}Ξs` + } else if (ms < 1000) { + return `${ms.toFixed(2)}ms` + } else { + return `${(ms / 1000).toFixed(2)}s` + } +} + +/** + * Calculate percentage difference between two values + */ +export function percentDiff(baseline: number, comparison: number): string { + const diff = ((comparison - baseline) / baseline) * 100 + const sign = diff > 0 ? '+' : '' + return `${sign}${diff.toFixed(2)}%` +} + +/** + * Generate histogram data for visualization + */ +export interface HistogramBin { + min: number + max: number + count: number + percentage: number +} + +export function generateHistogram( + measurements: number[], + bins: number = 20 +): HistogramBin[] { + const sorted = [...measurements].sort((a, b) => a - b) + const min = sorted[0] + const max = sorted[sorted.length - 1] + const binSize = (max - min) / bins + + const histogram: HistogramBin[] = [] + + for (let i = 0; i < bins; i++) { + const binMin = min + i * binSize + const binMax = binMin + binSize + const count = sorted.filter(v => v >= binMin && v < binMax).length + + histogram.push({ + min: binMin, + max: binMax, + count, + percentage: (count / measurements.length) * 100, + }) + } + + return histogram +} + +/** + * Print benchmark results to console in a formatted table + */ +export function printResults(result: BenchmarkResult): void { + console.log(`\n📊 ${result.name}`) + console.log(`${'─'.repeat(60)}`) + console.log(`Iterations: ${result.iterations}`) + console.log(`Median: ${formatDuration(result.median)}`) + console.log(`Mean: ${formatDuration(result.mean)}`) + console.log(`Std Dev: ${formatDuration(result.stdDev)}`) + console.log(`Min: ${formatDuration(result.min)}`) + console.log(`Max: ${formatDuration(result.max)}`) + console.log(`P50: ${formatDuration(result.p50)}`) + console.log(`P75: ${formatDuration(result.p75)}`) + console.log(`P90: ${formatDuration(result.p90)}`) + console.log(`P95: ${formatDuration(result.p95)}`) + console.log(`P99: ${formatDuration(result.p99)}`) +} + +/** + * Compare two benchmark results + */ +export function compareResults( + baseline: BenchmarkResult, + comparison: BenchmarkResult +): void { + console.log(`\n🔎 Comparison: ${baseline.name} vs ${comparison.name}`) + console.log(`${'─'.repeat(60)}`) + console.log(`Median: ${formatDuration(baseline.median)} → ${formatDuration(comparison.median)} (${percentDiff(baseline.median, comparison.median)})`) + console.log(`Mean: ${formatDuration(baseline.mean)} → ${formatDuration(comparison.mean)} (${percentDiff(baseline.mean, comparison.mean)})`) + console.log(`P95: ${formatDuration(baseline.p95)} → ${formatDuration(comparison.p95)} (${percentDiff(baseline.p95, comparison.p95)})`) + console.log(`P99: ${formatDuration(baseline.p99)} → ${formatDuration(comparison.p99)} (${percentDiff(baseline.p99, comparison.p99)})`) +} diff --git a/benchmarks/visualize.ts b/benchmarks/visualize.ts new file mode 100644 index 0000000..e851691 --- /dev/null +++ b/benchmarks/visualize.ts @@ -0,0 +1,282 @@ +/** + * Visualization utilities for benchmark results + * Generates charts and reports from benchmark data + */ + +import type { BenchmarkResult } from './stats' +import { formatDuration, generateHistogram } from './stats' + +interface ComparisonData { + adapters: string[] + results: Map> +} + +/** + * Generate ASCII bar chart for comparing adapter performance + */ +export function generateBarChart( + queryName: string, + results: Map, + width: number = 50 +): string { + const entries = Array.from(results.entries()) + const maxMedian = Math.max(...entries.map(([_, r]) => r.median)) + + // Shorten adapter names for display and sort: LibSQL first, then Bun SQLite + const entriesWithNames = entries.map(([name, result]) => { + let displayName = name + if (name.includes('synapsenwerkstatt')) displayName = 'Bun SQLite' + else if (name.includes('adapter-libsql')) displayName = 'LibSQL' + return { displayName, result, order: displayName === 'LibSQL' ? 0 : 1 } + }) + + // Sort: LibSQL first, Bun SQLite second + entriesWithNames.sort((a, b) => a.order - b.order) + + const maxNameLength = Math.max(...entriesWithNames.map(e => e.displayName.length)) + + let chart = `\n📊 ${queryName}\n` + chart += '─'.repeat(width + maxNameLength + 15) + '\n' + + entriesWithNames.forEach(({ displayName, result }) => { + const barLength = Math.round((result.median / maxMedian) * width) + const bar = '█'.repeat(barLength) + // Pad adapter name to align all bars + const paddedName = displayName.padEnd(maxNameLength) + chart += `${paddedName} ${bar} ${formatDuration(result.median)}\n` + }) + + return chart +} + +/** + * Generate comparison table for all queries + */ +export function generateComparisonTable(data: ComparisonData): string { + const { adapters, results } = data + + if (adapters.length < 2) { + return 'Need at least 2 adapters to compare\n' + } + + const baseline = adapters[0] + const comparison = adapters[1] + const baselineResults = results.get(baseline)! + const comparisonResults = results.get(comparison)! + + // Shorten adapter names for display - swap order so LibSQL is first column + const col1Name = 'LibSQL' + const col2Name = 'Bun SQLite' + + let table = '\n📊 PERFORMANCE COMPARISON TABLE\n' + table += '='.repeat(80) + '\n\n' + + // Headers - LibSQL first, then Bun SQLite + const col1 = 'Query'.padEnd(35) + const col2 = col1Name.padEnd(15) + const col3 = col2Name.padEnd(15) + const col4 = 'Difference'.padEnd(12) + + table += `${col1} ${col2} ${col3} ${col4}\n` + table += '-'.repeat(80) + '\n' + + // Rows - swap column order + for (const [queryName, baselineResult] of baselineResults) { + const comparisonResult = comparisonResults.get(queryName) + if (!comparisonResult) continue + + // Calculate how much faster Bun is compared to LibSQL + const speedup = ((comparisonResult.median - baselineResult.median) / comparisonResult.median) * 100 + const speedupStr = speedup.toFixed(1) + const emoji = speedup > 0 ? 'ðŸŸĒ' : speedup < 0 ? 'ðŸ”ī' : '⚩' + + // Show as positive percentage when Bun is faster + const displayPercent = speedup > 0 ? `+${speedupStr}%` : `${speedupStr}%` + + const c1 = queryName.padEnd(35) + const c2 = formatDuration(comparisonResult.median).padEnd(15) // LibSQL + const c3 = formatDuration(baselineResult.median).padEnd(15) // Bun SQLite + const c4 = `${emoji} ${displayPercent}`.padEnd(12) + + table += `${c1} ${c2} ${c3} ${c4}\n` + } + + table += '\n' + '='.repeat(80) + '\n' + table += 'ðŸŸĒ = Faster (Bun) ðŸ”ī = Slower (Bun) ⚩ = Same\n' + + return table +} + +/** + * Generate histogram visualization + */ +export function generateHistogramChart( + result: BenchmarkResult, + width: number = 50, + height: number = 10 +): string { + const histogram = generateHistogram(result.measurements, 20) + const maxCount = Math.max(...histogram.map(b => b.count)) + + let chart = `\n📊 Distribution: ${result.name}\n` + chart += '─'.repeat(width + 20) + '\n\n' + + // Generate bars from top to bottom + for (let i = height; i > 0; i--) { + const threshold = (i / height) * maxCount + let row = '' + + for (const bin of histogram) { + row += bin.count >= threshold ? '█' : ' ' + } + + chart += `${row}\n` + } + + // X-axis + chart += '─'.repeat(20) + '\n' + chart += `${formatDuration(result.min)} → ${formatDuration(result.max)}\n` + + return chart +} + +/** + * Generate markdown report + */ +export function generateMarkdownReport(data: ComparisonData): string { + const { adapters, results } = data + + let md = '# Prisma Adapter Benchmark Results\n\n' + md += `**Date:** ${new Date().toISOString()}\n\n` + md += `**Adapters Compared:** ${adapters.join(' vs ')}\n\n` + + md += '## Summary\n\n' + + // Calculate averages for each adapter + for (const adapter of adapters) { + const adapterResults = results.get(adapter)! + const allMedians = Array.from(adapterResults.values()).map(r => r.median) + const avgMedian = allMedians.reduce((sum, m) => sum + m, 0) / allMedians.length + + md += `### ${adapter}\n\n` + md += `- **Queries tested:** ${adapterResults.size}\n` + md += `- **Average median latency:** ${formatDuration(avgMedian)}\n` + + const sorted = Array.from(adapterResults.values()).sort((a, b) => a.median - b.median) + md += `- **Fastest query:** ${sorted[0].name.split(' - ')[1]} (${formatDuration(sorted[0].median)})\n` + md += `- **Slowest query:** ${sorted[sorted.length - 1].name.split(' - ')[1]} (${formatDuration(sorted[sorted.length - 1].median)})\n\n` + } + + // Detailed results table + if (adapters.length >= 2) { + md += '## Detailed Comparison\n\n' + md += '| Query | ' + adapters.map(a => `${a} (median)`).join(' | ') + ' | Difference |\n' + md += '|-------|' + adapters.map(() => '--------').join('|') + '|------------|\n' + + const firstAdapter = adapters[0] + const firstResults = results.get(firstAdapter)! + + for (const [queryName, firstResult] of firstResults) { + const queryDisplayName = queryName.split(' - ')[1] || queryName + let row = `| ${queryDisplayName} ` + + const values = adapters.map(adapter => { + const result = results.get(adapter)!.get(queryName) + return result ? result.median : null + }) + + for (const val of values) { + row += `| ${val ? formatDuration(val) : 'N/A'} ` + } + + // Calculate difference between first two adapters + if (values[0] !== null && values[1] !== null) { + const diff = ((values[1]! - values[0]!) / values[0]!) * 100 + const sign = diff > 0 ? '+' : '' + row += `| ${sign}${diff.toFixed(1)}% ` + } else { + row += '| N/A ' + } + + row += '|\n' + md += row + } + } + + md += '\n## Methodology\n\n' + md += 'These benchmarks follow the methodology outlined in [Prisma\'s performance benchmarks blog post](https://www.prisma.io/blog/performance-benchmarks-comparing-query-latency-across-typescript-orms-and-databases):\n\n' + md += '- Each query ran 500 times (configurable)\n' + md += '- Results above the 99th percentile were removed as outliers\n' + md += '- Measurements taken using `performance.now()`\n' + md += '- Test data generated with faker.js using deterministic seed\n' + md += '- All tests run on local SQLite database\n\n' + + return md +} + +/** + * Load and visualize results from JSON file + */ +export async function visualizeResults(filename: string): Promise { + console.log(`📂 Loading results from: ${filename}`) + + const file = Bun.file(filename) + const data = await file.json() + + const results = new Map>() + for (const [adapterName, queryResults] of Object.entries(data)) { + results.set(adapterName, new Map(Object.entries(queryResults as any))) + } + + const adapters = Array.from(results.keys()) + const comparisonData: ComparisonData = { adapters, results } + + // Generate comparison table + console.log(generateComparisonTable(comparisonData)) + + // Generate bar charts for selected queries + const selectedQueries = [ + 'findMany-users-limit', + 'findMany-posts-published', + 'findMany-users-with-profile', + 'findMany-posts-with-relations', + ] + + console.log('\n📊 BAR CHARTS FOR SELECTED QUERIES\n') + for (const queryName of selectedQueries) { + const queryResults = new Map() + for (const [adapterName, adapterResults] of results) { + const result = adapterResults.get(queryName) + if (result) { + queryResults.set(adapterName, result) + } + } + if (queryResults.size > 0) { + console.log(generateBarChart(queryName, queryResults)) + } + } + + // Generate markdown report + const markdown = generateMarkdownReport(comparisonData) + const mdFilename = filename.replace('.json', '.md') + await Bun.write(mdFilename, markdown) + console.log(`\n📝 Markdown report saved to: ${mdFilename}`) +} + +// CLI runner +if (import.meta.main) { + const args = Bun.argv.slice(2) + const filename = args[0] + + if (!filename) { + console.error('Usage: bun benchmarks/visualize.ts ') + process.exit(1) + } + + try { + await visualizeResults(filename) + } catch (error) { + console.error('❌ Visualization failed:', error) + process.exit(1) + } +} From e33fe766ef3f8eea827b07d4fcbed70f88e51c83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Rish=C3=B8j?= Date: Thu, 20 Nov 2025 17:19:33 +0100 Subject: [PATCH 3/6] fix: ignore prisma generated files --- benchmarks/.gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/benchmarks/.gitignore b/benchmarks/.gitignore index 1183a3a..279aaff 100644 --- a/benchmarks/.gitignore +++ b/benchmarks/.gitignore @@ -10,3 +10,4 @@ node_modules/ # Prisma migrations/ +generated/ From 23dd951979d97709275df16409f6b85f84cb5b35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Rish=C3=B8j?= Date: Thu, 20 Nov 2025 17:19:41 +0100 Subject: [PATCH 4/6] ignore IDE files --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 73dc864..cb4e675 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ node_modules dist bun.lock .claude -CLAUDE.md \ No newline at end of file +CLAUDE.md +.idea From a16410a76fa5d57cb1d1acea520a5b1ff64c1cfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Rish=C3=B8j?= Date: Thu, 20 Nov 2025 17:24:10 +0100 Subject: [PATCH 5/6] require Bun 1.2.17 (for `columnTypes` and `declaredTypes`) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3909254..a0bbe2f 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "@prisma/client": "^6.15.0" }, "engines": { - "bun": ">=1.0.0" + "bun": ">=1.2.17" }, "dependencies": { "async-mutex": "^0.5.0", From edabe0b15c08110f29d6a036cae5be8e3be0e340 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Rish=C3=B8j?= Date: Thu, 20 Nov 2025 17:24:30 +0100 Subject: [PATCH 6/6] ESM fixes --- package.json | 3 ++- src/index.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index a0bbe2f..9baa3e9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "@synapsenwerkstatt/prisma-bun-sqlite-adapter", "version": "1.2.2", + "type": "module", "description": "Prisma driver adapter for bun:sqlite", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -44,4 +45,4 @@ "async-mutex": "^0.5.0", "@prisma/driver-adapter-utils": "^6.15.0" } -} \ No newline at end of file +} diff --git a/src/index.ts b/src/index.ts index 983298e..0189d3a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,2 @@ -export { PrismaBunSQLiteAdapterFactory as PrismaBunSQLite, WALConfig } from './adapter' +export { PrismaBunSQLiteAdapterFactory as PrismaBunSQLite } from './adapter' +export type { WALConfig } from './adapter'