diff --git a/apps/web/app/api/auth/demo/route.ts b/apps/web/app/api/auth/demo/route.ts index 6c1d0123..0d3cb5c6 100644 --- a/apps/web/app/api/auth/demo/route.ts +++ b/apps/web/app/api/auth/demo/route.ts @@ -1,6 +1,7 @@ import { getAuth } from '@/lib/auth'; import { getDBService } from '@/lib/database'; import { proxyAuthRequest, shouldProxyAuthRequest } from '@/lib/auth/auth-proxy'; +import { ensureDemoConnection } from '@/lib/demo/ensure-demo-connection'; import { NextRequest, NextResponse } from 'next/server'; export const runtime = 'nodejs'; @@ -158,6 +159,16 @@ export async function POST(req: NextRequest) { }); } + // Ensure demo SQLite connection exists for the user's organization + const memberships = await db.organizations.listByUser(userId); + if (memberships.length > 0) { + try { + await ensureDemoConnection(db, userId, memberships[0]!.organizationId); + } catch (error) { + console.warn('[demo] failed to ensure demo connection:', error); + } + } + const response = await auth.api.signInEmail({ headers: req.headers, body: { diff --git a/apps/web/app/api/connection/route.ts b/apps/web/app/api/connection/route.ts index e9d48fc4..bc95c7c8 100644 --- a/apps/web/app/api/connection/route.ts +++ b/apps/web/app/api/connection/route.ts @@ -5,6 +5,7 @@ import { handleApiError } from '../utils/handle-error'; import { parseJsonBody } from '../utils/parse-json'; import { withManagedOrganizationHandler, withOrganizationHandler } from '../utils/with-organization-handler'; import { getApiLocale, translateApi } from '@/app/api/utils/i18n'; +import { ensureDemoConnection } from '@/lib/demo/ensure-demo-connection'; // GET /api/connections?id=xxx export const GET = withOrganizationHandler(async ({ req, db, organizationId }) => { @@ -27,7 +28,17 @@ export const GET = withOrganizationHandler(async ({ req, db, organizationId }) = return NextResponse.json(ResponseUtil.success(record)); } - const data = await db.connections.list(organizationId); + let data = await db.connections.list(organizationId); + + if (data.length === 0 && userId) { + try { + await ensureDemoConnection(db, userId, organizationId); + data = await db.connections.list(organizationId); + } catch (err) { + console.warn('[api/connection] failed to create demo connection:', err); + } + } + return NextResponse.json(ResponseUtil.success(data)); } catch (err: any) { return handleApiError(err); diff --git a/apps/web/lib/demo/ensure-demo-connection.ts b/apps/web/lib/demo/ensure-demo-connection.ts new file mode 100644 index 00000000..48bb725a --- /dev/null +++ b/apps/web/lib/demo/ensure-demo-connection.ts @@ -0,0 +1,57 @@ +import fs from 'node:fs'; +import type { DBService } from '@/lib/database'; +import { getDemoSqlitePath } from './paths'; + +const DEMO_CONNECTION_NAME = 'Demo Database'; + +/** + * Ensure a "Demo Database" SQLite connection exists for the given organization. + * Idempotent: skips if the connection already exists or if the demo.sqlite file is not available. + */ +export async function ensureDemoConnection( + db: DBService, + userId: string, + organizationId: string, +): Promise { + const demoPath = getDemoSqlitePath(); + if (!demoPath || !fs.existsSync(demoPath)) { + console.log('[demo] demo.sqlite not found, skipping demo connection creation'); + return; + } + + const existing = await db.connections.list(organizationId); + const hasDemoConnection = existing.some((c) => c.name === DEMO_CONNECTION_NAME); + if (hasDemoConnection) { + return; + } + + console.log(`[demo] creating "${DEMO_CONNECTION_NAME}" connection for org ${organizationId}`); + + await db.connections.create(userId, organizationId, { + connection: { + organizationId, + type: 'sqlite', + engine: 'sqlite', + name: DEMO_CONNECTION_NAME, + description: 'Built-in demo database with sample users, orders, and logs data', + host: null, + port: null, + database: 'main', + path: demoPath, + status: 'ready', + }, + identities: [ + { + name: 'Default', + username: 'sqlite', + isDefault: true, + database: 'main', + enabled: true, + role: null, + options: '{}', + } as any, + ], + }); + + console.log(`[demo] "${DEMO_CONNECTION_NAME}" connection created`); +} diff --git a/apps/web/lib/demo/generate-demo-sqlite.ts b/apps/web/lib/demo/generate-demo-sqlite.ts new file mode 100644 index 00000000..20258386 --- /dev/null +++ b/apps/web/lib/demo/generate-demo-sqlite.ts @@ -0,0 +1,173 @@ +import fs from 'node:fs'; +import Database from 'better-sqlite3'; +import { faker } from '@faker-js/faker'; + +const SEED = 42; +const USER_COUNT = 100; +const ORDER_COUNT = 1000; +const LOG_COUNT = 10000; + +const ORDER_STATUSES = ['pending', 'completed', 'cancelled', 'refunded'] as const; +const LOG_LEVELS = ['debug', 'info', 'warn', 'error'] as const; +const SERVICES = ['api-gateway', 'auth-service', 'payment-service', 'order-service', 'notification-service', 'search-service', 'analytics-service', 'file-service'] as const; + +const LOG_MESSAGES: Record = { + 'debug': [ + 'Cache miss for key', + 'Retry attempt', + 'Connection pool stats', + 'Query plan analysis complete', + 'GC pause detected', + ], + 'info': [ + 'Request processed successfully', + 'User session created', + 'Payment processed', + 'Email sent successfully', + 'Database migration applied', + 'Health check passed', + 'New user registered', + ], + 'warn': [ + 'Response time exceeded threshold', + 'Rate limit approaching', + 'Disk usage above 80%', + 'Deprecated API version used', + 'Connection pool nearly exhausted', + 'Certificate expires in 7 days', + ], + 'error': [ + 'Database connection timeout', + 'Authentication failed', + 'Payment gateway unavailable', + 'File upload failed: size limit exceeded', + 'Unhandled exception in request handler', + 'Failed to send notification', + ], +}; + +function createTables(db: Database.Database) { + db.exec(` + CREATE TABLE users ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + country TEXT NOT NULL, + created_at TEXT NOT NULL + ); + + CREATE TABLE orders ( + order_id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id), + amount REAL NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL + ); + + CREATE TABLE logs ( + id INTEGER PRIMARY KEY, + timestamp TEXT NOT NULL, + level TEXT NOT NULL, + service TEXT NOT NULL, + message TEXT NOT NULL, + duration_ms INTEGER + ); + + CREATE INDEX idx_orders_user_id ON orders(user_id); + CREATE INDEX idx_orders_status ON orders(status); + CREATE INDEX idx_logs_level ON logs(level); + CREATE INDEX idx_logs_timestamp ON logs(timestamp); + CREATE INDEX idx_logs_service ON logs(service); + `); +} + +function seedUsers(db: Database.Database) { + const insert = db.prepare('INSERT INTO users (id, name, country, created_at) VALUES (?, ?, ?, ?)'); + const tx = db.transaction(() => { + for (let i = 1; i <= USER_COUNT; i++) { + insert.run( + i, + faker.person.fullName(), + faker.location.country(), + faker.date.between({ from: '2024-01-01', to: '2026-03-01' }).toISOString(), + ); + } + }); + tx(); +} + +function seedOrders(db: Database.Database) { + const insert = db.prepare('INSERT INTO orders (order_id, user_id, amount, status, created_at) VALUES (?, ?, ?, ?, ?)'); + const tx = db.transaction(() => { + for (let i = 1; i <= ORDER_COUNT; i++) { + const userId = faker.number.int({ min: 1, max: USER_COUNT }); + const amount = Math.round(faker.number.float({ min: 5, max: 500, fractionDigits: 2 }) * 100) / 100; + const status = faker.helpers.arrayElement(ORDER_STATUSES); + insert.run( + i, + userId, + amount, + status, + faker.date.between({ from: '2025-01-01', to: '2026-03-30' }).toISOString(), + ); + } + }); + tx(); +} + +function seedLogs(db: Database.Database) { + const insert = db.prepare('INSERT INTO logs (id, timestamp, level, service, message, duration_ms) VALUES (?, ?, ?, ?, ?, ?)'); + // Weighted log levels: more info/debug, fewer warn/error + const levelWeights = { debug: 25, info: 45, warn: 20, error: 10 }; + const weightedLevels: string[] = []; + for (const [level, weight] of Object.entries(levelWeights)) { + for (let i = 0; i < weight; i++) weightedLevels.push(level); + } + + const tx = db.transaction(() => { + for (let i = 1; i <= LOG_COUNT; i++) { + const level = faker.helpers.arrayElement(weightedLevels); + const service = faker.helpers.arrayElement(SERVICES); + const message = faker.helpers.arrayElement(LOG_MESSAGES[level] ?? LOG_MESSAGES['info']!); + const durationMs = level === 'error' + ? faker.number.int({ min: 500, max: 30000 }) + : faker.number.int({ min: 1, max: 2000 }); + insert.run( + i, + faker.date.between({ from: '2026-03-01', to: '2026-03-31' }).toISOString(), + level, + service, + message, + durationMs, + ); + } + }); + tx(); +} + +/** + * Generate the demo.sqlite file at the given absolute path. + * Idempotent: skips if the file already exists. + * Returns true if the file was created, false if it already existed. + */ +export function generateDemoSqlite(targetPath: string): boolean { + if (fs.existsSync(targetPath)) { + console.log(`[demo] demo.sqlite already exists at ${targetPath}, skipping`); + return false; + } + + console.log(`[demo] generating demo.sqlite at ${targetPath}...`); + faker.seed(SEED); + + const db = new Database(targetPath); + try { + db.pragma('journal_mode = WAL'); + createTables(db); + seedUsers(db); + seedOrders(db); + seedLogs(db); + console.log(`[demo] demo.sqlite created (${USER_COUNT} users, ${ORDER_COUNT} orders, ${LOG_COUNT} logs)`); + return true; + } finally { + db.close(); + } +} diff --git a/apps/web/lib/demo/paths.ts b/apps/web/lib/demo/paths.ts new file mode 100644 index 00000000..678be9d3 --- /dev/null +++ b/apps/web/lib/demo/paths.ts @@ -0,0 +1,28 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const DEMO_SQLITE_FILENAME = 'demo.sqlite'; + +/** + * Resolve the absolute path for demo.sqlite based on PGLITE_DB_PATH. + * The demo file lives as a sibling of the PGlite data directory. + * + * Example: PGLITE_DB_PATH = "file:///app/data/dory" → "/app/data/demo.sqlite" + */ +export function resolveDemoSqlitePath(): string { + const raw = process.env.PGLITE_DB_PATH; + if (!raw) { + throw new Error('[demo] PGLITE_DB_PATH is not set, cannot resolve demo.sqlite path'); + } + + const fsPath = raw.startsWith('file:') ? fileURLToPath(raw) : decodeURIComponent(raw); + const parentDir = path.dirname(path.resolve(fsPath)); + return path.join(parentDir, DEMO_SQLITE_FILENAME); +} + +/** + * Get the demo.sqlite path from the environment variable set during bootstrap. + */ +export function getDemoSqlitePath(): string | undefined { + return process.env.DEMO_SQLITE_PATH || undefined; +} diff --git a/apps/web/package.json b/apps/web/package.json index f83a639a..e5d28c88 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -53,6 +53,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@electric-sql/pglite": "^0.2.17", + "@faker-js/faker": "^10.4.0", "@hookform/resolvers": "^5.2.2", "@monaco-editor/react": "^4.7.0", "@paper-design/shaders-react": "^0.0.71", diff --git a/apps/web/scripts/bootstrap.ts b/apps/web/scripts/bootstrap.ts index 18809eab..44f797db 100644 --- a/apps/web/scripts/bootstrap.ts +++ b/apps/web/scripts/bootstrap.ts @@ -8,6 +8,8 @@ import { getDatabaseProvider } from '../lib/database/provider'; import { DEFAULT_PGLITE_DB_PATH, DESKTOP_PGLITE_DB_PATH } from '@/shared/data/app.data'; import { ensureFileUrl } from '@/lib/database/pglite/url'; import { isDesktopRuntime } from '@/lib/runtime/runtime'; +import { resolveDemoSqlitePath } from '@/lib/demo/paths'; +import { generateDemoSqlite } from '@/lib/demo/generate-demo-sqlite'; async function ensureDirForFile(filePath: string) { @@ -37,6 +39,17 @@ async function bootstrapPglite() { await migratePgliteDB(); } +function bootstrapDemoSqlite() { + try { + const demoPath = resolveDemoSqlitePath(); + generateDemoSqlite(demoPath); + process.env.DEMO_SQLITE_PATH = demoPath; + console.log('[bootstrap] DEMO_SQLITE_PATH =', demoPath); + } catch (error) { + console.warn('[bootstrap] skipping demo sqlite:', error); + } +} + export async function bootstrap() { const dbType = getDatabaseProvider(); console.log('[bootstrap] DB_TYPE =', dbType); @@ -46,6 +59,8 @@ export async function bootstrap() { } else { console.log('[bootstrap] skip bootstrap'); } + + bootstrapDemoSqlite(); } bootstrap().catch(err => { diff --git a/apps/web/scripts/dev-bootstrap.ts b/apps/web/scripts/dev-bootstrap.ts index 7e6501a7..02757634 100644 --- a/apps/web/scripts/dev-bootstrap.ts +++ b/apps/web/scripts/dev-bootstrap.ts @@ -6,6 +6,8 @@ import path from 'node:path'; import { migratePgliteDB } from '@/lib/database/pglite/migrate-pglite'; import { getDatabaseProvider } from '@/lib/database/provider'; import { ensureFileUrl, extractFilePath } from '@/lib/database/pglite/url'; +import { resolveDemoSqlitePath } from '@/lib/demo/paths'; +import { generateDemoSqlite } from '@/lib/demo/generate-demo-sqlite'; async function ensureDirForFile(filePath: string) { const dir = path.dirname(filePath); @@ -33,6 +35,17 @@ async function bootstrapPglite() { await migratePgliteDB(); } +function bootstrapDemoSqlite() { + try { + const demoPath = resolveDemoSqlitePath(); + generateDemoSqlite(demoPath); + process.env.DEMO_SQLITE_PATH = demoPath; + console.log('[dev] DEMO_SQLITE_PATH =', demoPath); + } catch (error) { + console.warn('[dev] skipping demo sqlite:', error); + } +} + export async function bootstrapLocalDev() { const dbType = getDatabaseProvider(); @@ -44,6 +57,8 @@ export async function bootstrapLocalDev() { // Other types (postgres / mysql) usually don't need bootstrap in dev console.log('[dev] skip dev bootstrap'); } + + bootstrapDemoSqlite(); } // Allow direct tsx execution diff --git a/yarn.lock b/yarn.lock index 9061305c..687eb600 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3359,6 +3359,13 @@ __metadata: languageName: node linkType: hard +"@faker-js/faker@npm:^10.4.0": + version: 10.4.0 + resolution: "@faker-js/faker@npm:10.4.0" + checksum: 10c0/29f15e46f91757d654f6f42ac8313ac4aebb6591e2ebae7fdd8d36d5017490327ea67be37cff06d18303db54abf468524bcc7e4b1de53be4eb884c1f71d571c1 + languageName: node + linkType: hard + "@floating-ui/core@npm:^1.7.5": version: 1.7.5 resolution: "@floating-ui/core@npm:1.7.5" @@ -23207,6 +23214,7 @@ __metadata: "@dnd-kit/utilities": "npm:^3.2.2" "@electric-sql/pglite": "npm:^0.2.17" "@eslint/eslintrc": "npm:^3" + "@faker-js/faker": "npm:^10.4.0" "@hookform/resolvers": "npm:^5.2.2" "@ianvs/prettier-plugin-sort-imports": "npm:^3.7.2" "@monaco-editor/react": "npm:^4.7.0"