From 0d1506487b02a6d8ba8f1aa43f16c8a2d1bdca90 Mon Sep 17 00:00:00 2001 From: BroUnion Date: Fri, 20 Feb 2026 02:20:06 +0200 Subject: [PATCH 1/9] Fix reviewed CLI/template issues and harden auth/realtime flows --- betterbase/README.md | 13 +- betterbase/package.json | 2 +- betterbase/packages/cli/package.json | 2 +- betterbase/packages/cli/src/commands/auth.ts | 155 ++++++++++++++++-- .../packages/cli/src/commands/generate.ts | 59 ++++++- betterbase/packages/cli/src/commands/init.ts | 13 +- .../packages/cli/src/commands/migrate.ts | 69 +++++++- betterbase/packages/cli/src/index.ts | 38 ++++- .../cli/src/utils/context-generator.ts | 2 +- .../packages/cli/src/utils/route-scanner.ts | 3 +- betterbase/packages/cli/src/utils/scanner.ts | 85 ++++++---- .../cli/test/context-generator.test.ts | 2 + .../packages/cli/test/route-scanner.test.ts | 2 + betterbase/templates/base/.gitignore | 3 + betterbase/templates/base/README.md | 6 + betterbase/templates/base/package.json | 2 +- betterbase/templates/base/src/db/migrate.ts | 4 +- betterbase/templates/base/src/index.ts | 15 +- betterbase/templates/base/src/lib/realtime.ts | 154 ++++++++++++----- betterbase/tsconfig.base.json | 1 + 20 files changed, 509 insertions(+), 121 deletions(-) create mode 100644 betterbase/templates/base/.gitignore diff --git a/betterbase/README.md b/betterbase/README.md index c1bd43f..3a86c06 100644 --- a/betterbase/README.md +++ b/betterbase/README.md @@ -27,7 +27,7 @@ From the monorepo root: - `bun install` - `bun run dev` - `bun run build` -- `bun run typecheck` (runs `turbo run typecheck --filter '*'`) +- `bun run typecheck` (runs `turbo run typecheck --filter "*"`) > Note: `templates/base` is not in the root workspace graph (`apps/*`, `packages/*`), so run template checks separately (e.g. `cd templates/base && bun run typecheck`). @@ -41,3 +41,14 @@ From `templates/base`: - `bun run build` - `bun run start` - `bun run typecheck` + + +## CLI Highlights + +- `bb auth setup [project-root]` — scaffold BetterAuth tables, middleware, and routes. + - Example: `bun run --cwd packages/cli dev auth setup ../../templates/base` +- `bb generate crud [project-root]` — generate CRUD routes for a schema table. + - Example: `bun run --cwd packages/cli dev generate crud users ../../templates/base` + +Realtime support is built into the base template via `/ws` and `src/lib/realtime.ts`. Generated CRUD routes broadcast insert/update/delete events to subscribers. +For command details and flags, run `bb --help`, `bb auth --help`, and `bb generate --help`. diff --git a/betterbase/package.json b/betterbase/package.json index 34bc804..3765b1a 100644 --- a/betterbase/package.json +++ b/betterbase/package.json @@ -10,7 +10,7 @@ "build": "turbo run build", "dev": "turbo run dev --parallel", "lint": "turbo run lint", - "typecheck": "turbo run typecheck --filter '*'" + "typecheck": "turbo run typecheck --filter \"*\"" }, "devDependencies": { "turbo": "^2.0.0", diff --git a/betterbase/packages/cli/package.json b/betterbase/packages/cli/package.json index 54f6fb8..0d0e6cd 100644 --- a/betterbase/packages/cli/package.json +++ b/betterbase/packages/cli/package.json @@ -17,7 +17,7 @@ "commander": "^12.1.0", "inquirer": "^10.2.2", "zod": "^3.23.8", - "typescript": "^5.3.0" + "typescript": "^5.8.0" }, "devDependencies": { "@types/bun": "^1.3.9" diff --git a/betterbase/packages/cli/src/commands/auth.ts b/betterbase/packages/cli/src/commands/auth.ts index 86c4952..5aea32f 100644 --- a/betterbase/packages/cli/src/commands/auth.ts +++ b/betterbase/packages/cli/src/commands/auth.ts @@ -44,7 +44,12 @@ const loginSchema = z.object({ }); authRoute.post('/signup', async (c) => { - const body = signupSchema.parse(await c.req.json()); + const result = signupSchema.safeParse(await c.req.json()); + if (!result.success) { + return c.json({ error: 'Invalid signup payload', details: result.error.format() }, 400); + } + + const body = result.data; const passwordHash = await Bun.password.hash(body.password); const created = await db @@ -56,17 +61,27 @@ authRoute.post('/signup', async (c) => { }) .returning(); + const createdUser = created[0]; + if (!createdUser || typeof createdUser !== 'object') { + return c.json({ error: 'Failed to create user record' }, 500); + } + return c.json({ user: { - id: created[0].id, - email: created[0].email, - name: created[0].name, + id: createdUser.id, + email: createdUser.email, + name: createdUser.name, }, }, 201); }); authRoute.post('/login', async (c) => { - const body = loginSchema.parse(await c.req.json()); + const result = loginSchema.safeParse(await c.req.json()); + if (!result.success) { + return c.json({ error: 'Invalid login payload', details: result.error.format() }, 400); + } + + const body = result.data; const user = await db.select().from(users).where(eq(users.email, body.email)).limit(1); if (user.length === 0 || !user[0].passwordHash) { @@ -142,7 +157,16 @@ async function validateSession(token: string): Promise 0 ? user[0] : null; } @@ -196,14 +220,99 @@ function ensurePasswordHashColumn(schemaPath: string): void { return; } - const usersBlock = current.match(/export\s+const\s+users\s*=\s*sqliteTable\([^]+?\}\);/m); - if (!usersBlock) { + const usersExportIdx = current.search(/export\s+const\s+users\s*=\s*sqliteTable\s*\(/); + if (usersExportIdx === -1) { logger.warn('Could not find sqlite users table block; skipping passwordHash injection.'); return; } - const replacement = usersBlock[0].replace(/\n\}\);$/, "\n passwordHash: text('password_hash').notNull(),\n});"); - writeFileSync(schemaPath, current.replace(usersBlock[0], replacement)); + const callStart = current.indexOf('sqliteTable(', usersExportIdx); + if (callStart === -1) { + logger.warn('Could not locate sqliteTable call for users; skipping passwordHash injection.'); + return; + } + + let i = callStart; + let parenDepth = 0; + let inSingle = false; + let inDouble = false; + let inBacktick = false; + let escaped = false; + + while (i < current.length) { + const ch = current[i]; + const next = current[i + 1]; + + if (escaped) { + escaped = false; + i += 1; + continue; + } + + if ((inSingle || inDouble || inBacktick) && ch === '\\') { + escaped = true; + i += 1; + continue; + } + + if (!inDouble && !inBacktick && ch === "'") { + if (inSingle && next === "'") { + i += 2; + continue; + } + inSingle = !inSingle; + i += 1; + continue; + } + + if (!inSingle && !inBacktick && ch === '"') { + inDouble = !inDouble; + i += 1; + continue; + } + + if (!inSingle && !inDouble && ch === '`') { + inBacktick = !inBacktick; + i += 1; + continue; + } + + if (inSingle || inDouble || inBacktick) { + i += 1; + continue; + } + + if (ch === '(') { + parenDepth += 1; + } else if (ch === ')') { + parenDepth -= 1; + if (parenDepth === 0) { + break; + } + } + + i += 1; + } + + if (i >= current.length || parenDepth !== 0) { + logger.warn('Could not safely parse users sqliteTable block; skipping passwordHash injection.'); + return; + } + + const statementEnd = current.indexOf(';', i); + if (statementEnd === -1) { + logger.warn('Could not locate end of users sqliteTable statement; skipping passwordHash injection.'); + return; + } + + const usersBlock = current.slice(usersExportIdx, statementEnd + 1); + const replacement = usersBlock.replace(/\n\}\);\s*$/, "\n passwordHash: text('password_hash').notNull(),\n});"); + if (replacement === usersBlock) { + logger.warn('Could not inject passwordHash into users table; block layout was unexpected.'); + return; + } + + writeFileSync(schemaPath, `${current.slice(0, usersExportIdx)}${replacement}${current.slice(statementEnd + 1)}`); } function ensureAuthInConfig(projectRoot: string): void { @@ -253,17 +362,31 @@ function ensureRoutesIndexHook(projectRoot: string): void { const routesIndexPath = path.join(projectRoot, 'src/routes/index.ts'); if (!existsSync(routesIndexPath)) return; - let current = readFileSync(routesIndexPath, 'utf-8'); + const current = readFileSync(routesIndexPath, 'utf-8'); + const importAnchor = "import { usersRoute } from './users';"; + const routeAnchor = "app.route('/api/users', usersRoute);"; + + let next = current; - if (!current.includes("import { authRoute } from './auth';")) { - current = current.replace("import { usersRoute } from './users';", "import { usersRoute } from './users';\nimport { authRoute } from './auth';"); + if (!next.includes("import { authRoute } from './auth';")) { + if (next.includes(importAnchor)) { + next = next.replace(importAnchor, `${importAnchor}\nimport { authRoute } from './auth';`); + } else { + logger.warn(`Could not find import anchor in ${routesIndexPath}; skipping auth route import injection.`); + } } - if (!current.includes("app.route('/auth', authRoute);")) { - current = current.replace("app.route('/api/users', usersRoute);", "app.route('/api/users', usersRoute);\n app.route('/auth', authRoute);"); + if (!next.includes("app.route('/auth', authRoute);")) { + if (next.includes(routeAnchor)) { + next = next.replace(routeAnchor, `${routeAnchor}\n app.route('/auth', authRoute);`); + } else { + logger.warn(`Could not find route anchor in ${routesIndexPath}; skipping auth route registration injection.`); + } } - writeFileSync(routesIndexPath, current); + if (next !== current) { + writeFileSync(routesIndexPath, next); + } } export async function runAuthSetupCommand(projectRoot: string = process.cwd()): Promise { diff --git a/betterbase/packages/cli/src/commands/generate.ts b/betterbase/packages/cli/src/commands/generate.ts index 1735e30..c389e8f 100644 --- a/betterbase/packages/cli/src/commands/generate.ts +++ b/betterbase/packages/cli/src/commands/generate.ts @@ -72,19 +72,35 @@ ${updateShape} }); ${tableName}Route.get('/', async (c) => { - const limit = Number(c.req.query('limit') ?? 50); - const offset = Number(c.req.query('offset') ?? 0); - const safeLimit = Number.isFinite(limit) && limit >= 0 ? Math.min(limit, 100) : 50; - const safeOffset = Number.isFinite(offset) && offset >= 0 ? offset : 0; + const DEFAULT_LIMIT = 50; + const MAX_LIMIT = 100; + const DEFAULT_OFFSET = 0; + + const paginationSchema = z.object({ + limit: z.coerce.number().int().nonnegative().default(DEFAULT_LIMIT), + offset: z.coerce.number().int().nonnegative().default(DEFAULT_OFFSET), + }); const queryParams = c.req.query(); - const sort = queryParams.sort; + const paginationResult = paginationSchema.safeParse({ + limit: queryParams.limit, + offset: queryParams.offset, + }); + if (!paginationResult.success) { + return c.json({ error: 'Invalid pagination params', details: paginationResult.error.format() }, 400); + } + + const { limit, offset } = paginationResult.data; + const fetchLimit = Math.min(limit, MAX_LIMIT); + const sort = queryParams.sort; const filters = Object.entries(queryParams).filter(([key, value]) => key !== 'limit' && key !== 'offset' && key !== 'sort' && value !== undefined); let query = db.select().from(${tableName}).$dynamic(); if (filters.length > 0) { + // Security note: by default all table columns are filterable. Consider adding a schema scanner + // annotation (e.g., "filterable") and replacing this with an explicit allowlist for sensitive tables. const conditions = filters .filter(([key]) => key in ${tableName}) .map(([key, value]) => eq(${tableName}[key as keyof typeof ${tableName}] as never, value as never)); @@ -102,8 +118,13 @@ ${tableName}Route.get('/', async (c) => { } } - const items = await query.limit(safeLimit).offset(safeOffset); - return c.json({ ${tableName}: items, count: items.length, pagination: { limit: safeLimit, offset: safeOffset } }); + const items = await query.limit(fetchLimit + 1).offset(offset); + const hasMore = items.length > fetchLimit; + + return c.json({ + ${tableName}: items.slice(0, fetchLimit), + pagination: { limit: fetchLimit, offset, hasMore }, + }); }); ${tableName}Route.get('/:id', async (c) => { @@ -185,7 +206,7 @@ function ensureRealtimeUtility(projectRoot: string): void { const realtimePath = path.join(projectRoot, 'src/lib/realtime.ts'); if (existsSync(realtimePath)) return; - const canonicalRealtimePath = path.resolve(import.meta.dir, '../../../templates/base/src/lib/realtime.ts'); + const canonicalRealtimePath = path.resolve(import.meta.dir, '../../../../templates/base/src/lib/realtime.ts'); if (!existsSync(canonicalRealtimePath)) { throw new Error(`Canonical realtime template not found at ${canonicalRealtimePath}`); } @@ -195,6 +216,28 @@ function ensureRealtimeUtility(projectRoot: string): void { } async function ensureZodValidatorInstalled(projectRoot: string): Promise { + const packageJsonPath = path.join(projectRoot, 'package.json'); + const modulePath = path.join(projectRoot, 'node_modules', '@hono', 'zod-validator'); + + if (existsSync(modulePath)) { + return; + } + + if (existsSync(packageJsonPath)) { + try { + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')) as { + dependencies?: Record; + devDependencies?: Record; + }; + + if (packageJson.dependencies?.['@hono/zod-validator'] || packageJson.devDependencies?.['@hono/zod-validator']) { + return; + } + } catch { + // Fall through to install branch. + } + } + logger.info('Installing @hono/zod-validator...'); const process = Bun.spawn(['bun', 'add', '@hono/zod-validator'], { cwd: projectRoot, diff --git a/betterbase/packages/cli/src/commands/init.ts b/betterbase/packages/cli/src/commands/init.ts index 0e7c6ac..edd37cd 100644 --- a/betterbase/packages/cli/src/commands/init.ts +++ b/betterbase/packages/cli/src/commands/init.ts @@ -85,6 +85,8 @@ function buildPackageJson(projectName: string, databaseMode: DatabaseMode, useAu type: 'module', scripts: { dev: 'bun run src/index.ts', + build: 'bun build src/index.ts --outfile dist/index.js --target bun', + start: 'bun run dist/index.js', 'db:generate': 'drizzle-kit generate', 'db:push': 'bun run src/db/migrate.ts', }, @@ -107,7 +109,7 @@ function buildDrizzleConfig(databaseMode: DatabaseMode): string { }; const databaseUrl: Record = { - local: "process.env.DATABASE_URL || 'file:local.db'", + local: "process.env.DB_PATH ? `file:${process.env.DB_PATH}` : 'file:local.db'", neon: "process.env.DATABASE_URL || 'postgres://localhost'", turso: "process.env.DATABASE_URL || 'libsql://localhost'", }; @@ -255,9 +257,10 @@ try { return `import { Database } from 'bun:sqlite'; import { drizzle } from 'drizzle-orm/bun-sqlite'; import { migrate } from 'drizzle-orm/bun-sqlite/migrator'; +import { env } from '../lib/env'; try { - const sqlite = new Database(process.env.DB_PATH ?? 'local.db', { create: true }); + const sqlite = new Database(env.DB_PATH, { create: true }); const db = drizzle(sqlite); migrate(db, { migrationsFolder: './drizzle' }); @@ -300,9 +303,10 @@ export const db = drizzle(client, { schema }); return `import { Database } from 'bun:sqlite'; import { drizzle } from 'drizzle-orm/bun-sqlite'; +import { env } from '../lib/env'; import * as schema from './schema'; -const client = new Database(process.env.DB_PATH ?? 'local.db', { create: true }); +const client = new Database(env.DB_PATH, { create: true }); export const db = drizzle(client, { schema }); `; @@ -531,6 +535,9 @@ const DEFAULT_LIMIT = 25; const MAX_LIMIT = 100; const DEFAULT_OFFSET = 0; +// Intentionally permissive: undefined, non-integer, or negative inputs fall back to caller defaults +// (DEFAULT_LIMIT / DEFAULT_OFFSET with MAX_LIMIT clamping applied by caller). If strict validation +// is needed, callers should parse with Zod and return 400 instead of using this helper. function parseNonNegativeInt(value: string | undefined, fallback: number): number { if (!value) { return fallback; diff --git a/betterbase/packages/cli/src/commands/migrate.ts b/betterbase/packages/cli/src/commands/migrate.ts index af22934..3c2c975 100644 --- a/betterbase/packages/cli/src/commands/migrate.ts +++ b/betterbase/packages/cli/src/commands/migrate.ts @@ -263,10 +263,70 @@ async function restoreBackup(backup: MigrationBackup | null): Promise { } function splitStatements(sql: string): string[] { - return sql - .split(/;\s*/g) - .map((statement) => statement.trim()) - .filter((statement) => statement.length > 0); + const statements: string[] = []; + let current = ''; + let inSingle = false; + let inDouble = false; + let inBacktick = false; + let escapeNext = false; + + for (let i = 0; i < sql.length; i += 1) { + const ch = sql[i]; + const next = sql[i + 1]; + + if (escapeNext) { + current += ch; + escapeNext = false; + continue; + } + + if ((inSingle || inDouble || inBacktick) && ch === '\\') { + current += ch; + escapeNext = true; + continue; + } + + if (!inDouble && !inBacktick && ch === "'") { + current += ch; + if (inSingle && next === "'") { + current += next; + i += 1; + continue; + } + inSingle = !inSingle; + continue; + } + + if (!inSingle && !inBacktick && ch === '"') { + inDouble = !inDouble; + current += ch; + continue; + } + + if (!inSingle && !inDouble && ch === '`') { + inBacktick = !inBacktick; + current += ch; + continue; + } + + if (ch === ';' && !inSingle && !inDouble && !inBacktick) { + const statement = current.trim(); + if (statement.length > 0) { + statements.push(statement); + } + current = ''; + continue; + } + + current += ch; + } + + const tail = current.trim(); + if (tail.length > 0) { + statements.push(tail); + } + + return statements; } async function collectChangesFromGenerate(): Promise { @@ -324,6 +384,7 @@ export async function runMigrateCommand(rawOptions: MigrateCommandOptions): Prom } logger.info('Applying migrations with drizzle-kit push...'); + logger.info('Note: drizzle-kit generate produced files in drizzle/ for preview/diff analysis only; push is what applied changes in this run.'); const push = await runDrizzleKit(['push']); if (!push.success) { diff --git a/betterbase/packages/cli/src/index.ts b/betterbase/packages/cli/src/index.ts index 25e8e04..443baa2 100644 --- a/betterbase/packages/cli/src/index.ts +++ b/betterbase/packages/cli/src/index.ts @@ -33,7 +33,29 @@ export function createProgram(): Command { .description('Watch schema/routes and regenerate .betterbase-context.json') .argument('[project-root]', 'project root directory', process.cwd()) .action(async (projectRoot: string) => { - await runDevCommand(projectRoot); + const cleanup = await runDevCommand(projectRoot); + + const onExit = (): void => { + cleanup(); + process.off('SIGINT', onSigInt); + process.off('SIGTERM', onSigTerm); + process.off('exit', onProcessExit); + }; + const onSigInt = (): void => { + onExit(); + process.exit(0); + }; + const onSigTerm = (): void => { + onExit(); + process.exit(0); + }; + const onProcessExit = (): void => { + onExit(); + }; + + process.on('SIGINT', onSigInt); + process.on('SIGTERM', onSigTerm); + process.on('exit', onProcessExit); }); @@ -59,22 +81,22 @@ export function createProgram(): Command { await runGenerateCrudCommand(projectRoot, tableName); }); - program - .command('migrate') - .description('Generate and apply migrations for local development') + const migrate = program.command('migrate').description('Generate and apply migrations for local development'); + + migrate .action(async () => { await runMigrateCommand({}); }); - program - .command('migrate:preview') + migrate + .command('preview') .description('Preview migration diff without applying changes') .action(async () => { await runMigrateCommand({ preview: true }); }); - program - .command('migrate:production') + migrate + .command('production') .description('Apply migrations to production (requires confirmation)') .action(async () => { await runMigrateCommand({ production: true }); diff --git a/betterbase/packages/cli/src/utils/context-generator.ts b/betterbase/packages/cli/src/utils/context-generator.ts index 794c8ee..5c00c51 100644 --- a/betterbase/packages/cli/src/utils/context-generator.ts +++ b/betterbase/packages/cli/src/utils/context-generator.ts @@ -44,7 +44,7 @@ export class ContextGenerator { const outputPath = path.join(projectRoot, '.betterbase-context.json'); writeFileSync(outputPath, `${JSON.stringify(context, null, 2)}\n`); - console.log(`✅ Generated ${outputPath}`); + logger.success(`✅ Generated ${outputPath}`); return context; } diff --git a/betterbase/packages/cli/src/utils/route-scanner.ts b/betterbase/packages/cli/src/utils/route-scanner.ts index d5cd2c9..57b5c21 100644 --- a/betterbase/packages/cli/src/utils/route-scanner.ts +++ b/betterbase/packages/cli/src/utils/route-scanner.ts @@ -22,6 +22,8 @@ function isAuthLikeName(value: string): boolean { return /\bauth\b/i.test(value) || /^auth/i.test(value) || /^(authMiddleware|requireAuth)$/i.test(value); } +const httpMethods = new Set(['get', 'post', 'put', 'patch', 'delete', 'options', 'head']); + function collectTsFiles(dir: string): string[] { const files: string[] = []; @@ -108,7 +110,6 @@ export class RouteScanner { const visit = (node: ts.Node): void => { if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) { const method = node.expression.name.text.toLowerCase(); - const httpMethods = new Set(['get', 'post', 'put', 'patch', 'delete', 'options', 'head']); if (httpMethods.has(method)) { const [pathArg, ...handlerArgs] = node.arguments; diff --git a/betterbase/packages/cli/src/utils/scanner.ts b/betterbase/packages/cli/src/utils/scanner.ts index 0dee86d..76afbb1 100644 --- a/betterbase/packages/cli/src/utils/scanner.ts +++ b/betterbase/packages/cli/src/utils/scanner.ts @@ -1,22 +1,30 @@ import { readFileSync } from 'node:fs'; import * as ts from 'typescript'; +import { z } from 'zod'; -export interface ColumnInfo { - name: string; - type: string; - nullable: boolean; - unique: boolean; - primaryKey: boolean; - defaultValue?: string; - references?: string; -} +export const ColumnTypeSchema = z.enum(['text', 'integer', 'number', 'boolean', 'datetime', 'json', 'blob', 'unknown']); -export interface TableInfo { - name: string; - columns: Record; - relations: string[]; - indexes: string[]; -} +export const ColumnInfoSchema = z.object({ + name: z.string(), + type: ColumnTypeSchema, + nullable: z.boolean(), + unique: z.boolean(), + primaryKey: z.boolean(), + defaultValue: z.string().optional(), + references: z.string().optional(), +}); + +export const TableInfoSchema = z.object({ + name: z.string(), + columns: z.record(z.string(), ColumnInfoSchema), + relations: z.array(z.string()), + indexes: z.array(z.string()), +}); + +export const TablesRecordSchema = z.record(z.string(), TableInfoSchema); + +export type ColumnInfo = z.infer; +export type TableInfo = z.infer; function unwrapExpression(expression: ts.Expression): ts.Expression { let current = expression; @@ -27,8 +35,7 @@ function unwrapExpression(expression: ts.Expression): ts.Expression { ts.isTypeAssertionExpression(current) || ts.isSatisfiesExpression(current) ) { - current = (current as ts.ParenthesizedExpression | ts.AsExpression | ts.TypeAssertion | ts.SatisfiesExpression) - .expression; + current = (current as ts.ParenthesizedExpression | ts.AsExpression | ts.TypeAssertion | ts.SatisfiesExpression).expression; } return current; @@ -87,7 +94,9 @@ export class SchemaScanner { const functionName = getCallName(initializer); if (functionName === 'sqliteTable' || functionName === 'pgTable' || functionName === 'mysqlTable') { - tables[declaration.name.text] = this.parseTable(initializer); + const tableObj = this.parseTable(initializer); + const tableKey = tableObj.name || declaration.name.text; + tables[tableKey] = tableObj; } } } @@ -96,7 +105,13 @@ export class SchemaScanner { }; visit(this.sourceFile); - return tables; + + const validated = TablesRecordSchema.safeParse(tables); + if (!validated.success) { + throw new Error(`Schema scanner produced invalid output: ${JSON.stringify(validated.error.format())}`); + } + + return validated.data; } private parseTable(callExpression: ts.CallExpression): TableInfo { @@ -151,19 +166,25 @@ export class SchemaScanner { continue; } - const value = unwrapExpression(property.initializer); - if (!ts.isCallExpression(value)) { - continue; - } - - const callName = getCallName(value); - if (callName === 'index' || callName === 'uniqueIndex') { - const key = ts.isIdentifier(property.name) - ? property.name.text - : ts.isStringLiteral(property.name) + let value = unwrapExpression(property.initializer); + while (ts.isCallExpression(value)) { + const callName = getCallName(value); + if (callName === 'index' || callName === 'uniqueIndex') { + const key = ts.isIdentifier(property.name) ? property.name.text - : property.name.getText(this.sourceFile); - indexes.push(key); + : ts.isStringLiteral(property.name) + ? property.name.text + : property.name.getText(this.sourceFile); + indexes.push(key); + break; + } + + if (ts.isPropertyAccessExpression(value.expression)) { + value = unwrapExpression(value.expression.expression); + continue; + } + + break; } } }; @@ -192,7 +213,7 @@ export class SchemaScanner { } private parseColumn(columnName: string, expression: ts.Expression): ColumnInfo { - let type = 'unknown'; + let type: ColumnInfo['type'] = 'unknown'; let nullable = true; let unique = false; let primaryKey = false; diff --git a/betterbase/packages/cli/test/context-generator.test.ts b/betterbase/packages/cli/test/context-generator.test.ts index 2499430..3a5d013 100644 --- a/betterbase/packages/cli/test/context-generator.test.ts +++ b/betterbase/packages/cli/test/context-generator.test.ts @@ -86,6 +86,7 @@ describe('ContextGenerator', () => { const context = await new ContextGenerator().generate(root); expect(context.tables).toEqual({}); + expect(context.routes).toEqual({}); } finally { rmSync(root, { recursive: true, force: true }); } @@ -100,6 +101,7 @@ describe('ContextGenerator', () => { const context = await new ContextGenerator().generate(root); expect(context.tables).toEqual({}); + expect(context.routes).toEqual({}); } finally { rmSync(root, { recursive: true, force: true }); } diff --git a/betterbase/packages/cli/test/route-scanner.test.ts b/betterbase/packages/cli/test/route-scanner.test.ts index 9f56991..a1a76bf 100644 --- a/betterbase/packages/cli/test/route-scanner.test.ts +++ b/betterbase/packages/cli/test/route-scanner.test.ts @@ -36,6 +36,8 @@ describe('RouteScanner', () => { expect(routes['/users']).toBeDefined(); expect(routes['/users'].length).toBe(2); + expect(routes['/users'][0].method).toBe('GET'); + expect(routes['/users'][1].method).toBe('POST'); expect(routes['/users'][0].requiresAuth).toBe(true); expect(routes['/users'][1].inputSchema).toBe('createUserSchema'); } finally { diff --git a/betterbase/templates/base/.gitignore b/betterbase/templates/base/.gitignore new file mode 100644 index 0000000..a8b1485 --- /dev/null +++ b/betterbase/templates/base/.gitignore @@ -0,0 +1,3 @@ +dist/ +node_modules/ +.env diff --git a/betterbase/templates/base/README.md b/betterbase/templates/base/README.md index 8d481e3..d8349cd 100644 --- a/betterbase/templates/base/README.md +++ b/betterbase/templates/base/README.md @@ -38,3 +38,9 @@ drizzle.config.ts - Start production server: `bun run start` Environment variables are validated in `src/lib/env.ts` (`NODE_ENV`, `PORT`, `DB_PATH`). + + +## Realtime + +The template includes WebSocket realtime support at `GET /ws` using `src/lib/realtime.ts`. +Clients should provide an auth token (Bearer header or `?token=` query) before subscribing. diff --git a/betterbase/templates/base/package.json b/betterbase/templates/base/package.json index c74eee0..7f490b8 100644 --- a/betterbase/templates/base/package.json +++ b/betterbase/templates/base/package.json @@ -12,7 +12,7 @@ }, "dependencies": { "hono": "^4.6.10", - "zod": "^3.23.8", + "zod": "^4.0.0", "drizzle-orm": "^0.44.5" }, "devDependencies": { diff --git a/betterbase/templates/base/src/db/migrate.ts b/betterbase/templates/base/src/db/migrate.ts index 2bdd9bf..1d64b72 100644 --- a/betterbase/templates/base/src/db/migrate.ts +++ b/betterbase/templates/base/src/db/migrate.ts @@ -1,10 +1,10 @@ import { Database } from 'bun:sqlite'; import { drizzle } from 'drizzle-orm/bun-sqlite'; import { migrate } from 'drizzle-orm/bun-sqlite/migrator'; -import { DEFAULT_DB_PATH } from '../lib/env'; +import { env } from '../lib/env'; try { - const sqlite = new Database(DEFAULT_DB_PATH, { create: true }); + const sqlite = new Database(env.DB_PATH, { create: true }); const db = drizzle(sqlite); migrate(db, { migrationsFolder: './drizzle' }); diff --git a/betterbase/templates/base/src/index.ts b/betterbase/templates/base/src/index.ts index b8f9534..75b4ec1 100644 --- a/betterbase/templates/base/src/index.ts +++ b/betterbase/templates/base/src/index.ts @@ -1,5 +1,5 @@ import { Hono } from 'hono'; -import { upgradeWebSocket } from 'hono/bun'; +import { upgradeWebSocket, websocket } from 'hono/bun'; import { env } from './lib/env'; import { realtime } from './lib/realtime'; import { registerRoutes } from './routes'; @@ -8,9 +8,14 @@ const app = new Hono(); app.get( '/ws', - upgradeWebSocket(() => ({ + upgradeWebSocket((c) => { + const authHeaderToken = c.req.header('authorization')?.replace(/^Bearer\s+/i, ''); + const queryToken = c.req.query('token'); + const token = authHeaderToken ?? queryToken; + + return { onOpen(_event, ws) { - realtime.handleConnection(ws.raw); + realtime.handleConnection(ws.raw, token); }, onMessage(event, ws) { const message = typeof event.data === 'string' ? event.data : event.data.toString(); @@ -19,13 +24,15 @@ app.get( onClose(_event, ws) { realtime.handleClose(ws.raw); }, - })), + }; + }), ); registerRoutes(app); const server = Bun.serve({ fetch: app.fetch, + websocket, port: env.PORT, development: env.NODE_ENV === 'development', }); diff --git a/betterbase/templates/base/src/lib/realtime.ts b/betterbase/templates/base/src/lib/realtime.ts index ace9c71..199fe6f 100644 --- a/betterbase/templates/base/src/lib/realtime.ts +++ b/betterbase/templates/base/src/lib/realtime.ts @@ -1,4 +1,5 @@ import type { ServerWebSocket } from 'bun'; +import { z } from 'zod'; export interface Subscription { table: string; @@ -7,6 +8,8 @@ export interface Subscription { interface Client { ws: ServerWebSocket; + userId: string; + claims: string[]; subscriptions: Map; } @@ -18,47 +21,117 @@ interface RealtimeUpdatePayload { timestamp: string; } +interface RealtimeConfig { + maxClients: number; + maxSubscriptionsPerClient: number; + maxSubscribersPerTable: number; +} + +const messageSchema = z.union([ + z.object({ type: z.literal('subscribe'), table: z.string().min(1), filter: z.record(z.string(), z.unknown()).optional() }), + z.object({ type: z.literal('unsubscribe'), table: z.string().min(1) }), +]); + const realtimeLogger = { debug: (message: string): void => console.debug(`[realtime] ${message}`), info: (message: string): void => console.info(`[realtime] ${message}`), warn: (message: string): void => console.warn(`[realtime] ${message}`), }; +function deepEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (a == null || b == null) return a === b; + + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false; + return a.every((value, idx) => deepEqual(value, b[idx])); + } + + if (typeof a === 'object' && typeof b === 'object') { + const aObj = a as Record; + const bObj = b as Record; + const aKeys = Object.keys(aObj); + const bKeys = Object.keys(bObj); + if (aKeys.length !== bKeys.length) return false; + return aKeys.every((key) => deepEqual(aObj[key], bObj[key])); + } + + return false; +} + export class RealtimeServer { private clients = new Map, Client>(); private tableSubscribers = new Map>>(); + private config: RealtimeConfig; + + constructor(config?: Partial) { + this.config = { + maxClients: 1000, + maxSubscriptionsPerClient: 50, + maxSubscribersPerTable: 500, + ...config, + }; + } - handleConnection(ws: ServerWebSocket): void { - realtimeLogger.info('Client connected'); + authenticate(token: string | undefined): { userId: string; claims: string[] } | null { + if (!token || !token.trim()) return null; + return { userId: token.trim(), claims: ['realtime:*'] }; + } + + authorize(userId: string, claims: string[], table: string): boolean { + return Boolean(userId) && (claims.includes('realtime:*') || claims.includes(`realtime:${table}`)); + } + + handleConnection(ws: ServerWebSocket, token: string | undefined): boolean { + if (this.clients.size >= this.config.maxClients) { + realtimeLogger.warn('Rejecting realtime connection: max clients reached'); + this.safeSend(ws, { error: 'Server is busy. Try again later.' }); + ws.close(1013, 'Server busy'); + return false; + } + + const identity = this.authenticate(token); + if (!identity) { + realtimeLogger.warn('Rejecting unauthenticated realtime connection'); + this.safeSend(ws, { error: 'Unauthorized websocket connection' }); + ws.close(1008, 'Unauthorized'); + return false; + } + + realtimeLogger.info(`Client connected (${identity.userId})`); this.clients.set(ws, { ws, + userId: identity.userId, + claims: identity.claims, subscriptions: new Map(), }); + + return true; } handleMessage(ws: ServerWebSocket, rawMessage: string): void { - try { - const data = JSON.parse(rawMessage) as { type?: string; table?: string; filter?: Record }; + let parsedJson: unknown; - if (!data.type || !data.table) { - this.safeSend(ws, { error: 'Message must include type and table' }); - return; - } - - switch (data.type) { - case 'subscribe': - this.subscribe(ws, data.table, data.filter); - break; - case 'unsubscribe': - this.unsubscribe(ws, data.table); - break; - default: - this.safeSend(ws, { error: 'Unknown message type' }); - break; - } + try { + parsedJson = JSON.parse(rawMessage); } catch { this.safeSend(ws, { error: 'Invalid message format' }); + return; + } + + const result = messageSchema.safeParse(parsedJson); + if (!result.success) { + this.safeSend(ws, { error: 'Invalid message format', details: result.error.format() }); + return; + } + + const data = result.data; + if (data.type === 'subscribe') { + this.subscribe(ws, data.table, data.filter); + return; } + + this.unsubscribe(ws, data.table); } handleClose(ws: ServerWebSocket): void { @@ -85,8 +158,6 @@ export class RealtimeServer { return; } - const initialCount = subscribers.size; - const payload: RealtimeUpdatePayload = { type: 'update', table, @@ -109,30 +180,40 @@ export class RealtimeServer { this.handleClose(ws); } } - - realtimeLogger.debug(`Broadcasted ${event} on ${table} to ${initialCount} clients`); } private subscribe(ws: ServerWebSocket, table: string, filter?: Record): void { const client = this.clients.get(ws); if (!client) { + this.safeSend(ws, { error: 'Unauthorized client' }); + ws.close(1008, 'Unauthorized'); return; } - client.subscriptions.set(table, { table, filter }); + if (!this.authorize(client.userId, client.claims, table)) { + realtimeLogger.warn(`Subscription denied for ${client.userId} on ${table}`); + this.safeSend(ws, { error: 'Forbidden subscription' }); + return; + } - if (!this.tableSubscribers.has(table)) { - this.tableSubscribers.set(table, new Set()); + if (client.subscriptions.size >= this.config.maxSubscriptionsPerClient) { + realtimeLogger.warn(`Subscription limit reached for ${client.userId}`); + this.safeSend(ws, { error: 'Subscription limit reached' }); + return; } - this.tableSubscribers.get(table)?.add(ws); + const tableSet = this.tableSubscribers.get(table) ?? new Set>(); + if (tableSet.size >= this.config.maxSubscribersPerTable) { + realtimeLogger.warn(`Table subscriber cap reached for ${table}`); + this.safeSend(ws, { error: 'Table subscription limit reached' }); + return; + } - this.safeSend(ws, { - type: 'subscribed', - table, - filter, - }); + client.subscriptions.set(table, { table, filter }); + tableSet.add(ws); + this.tableSubscribers.set(table, tableSet); + this.safeSend(ws, { type: 'subscribed', table, filter }); realtimeLogger.debug(`Client subscribed to ${table}`); } @@ -150,10 +231,7 @@ export class RealtimeServer { this.tableSubscribers.delete(table); } - this.safeSend(ws, { - type: 'unsubscribed', - table, - }); + this.safeSend(ws, { type: 'unsubscribed', table }); } private matchesFilter(filter: Record | undefined, payload: unknown): boolean { @@ -166,7 +244,7 @@ export class RealtimeServer { } const data = payload as Record; - return Object.entries(filter).every(([key, value]) => data[key] === value); + return Object.entries(filter).every(([key, value]) => deepEqual(data[key], value)); } private safeSend(ws: ServerWebSocket, payload: object | string): boolean { diff --git a/betterbase/tsconfig.base.json b/betterbase/tsconfig.base.json index e86a10c..80f5eae 100644 --- a/betterbase/tsconfig.base.json +++ b/betterbase/tsconfig.base.json @@ -4,6 +4,7 @@ "module": "ESNext", "moduleResolution": "Bundler", "strict": true, + "jsx": "react-jsx", "esModuleInterop": true, "skipLibCheck": true, "resolveJsonModule": true, From b93f281dd25aa21a148c426c0f148ae2e81f5e06 Mon Sep 17 00:00:00 2001 From: BroUnion Date: Fri, 20 Feb 2026 02:38:44 +0200 Subject: [PATCH 2/9] Address follow-up review nits for auth JSON parsing and parser guards --- betterbase/packages/cli/src/commands/auth.ts | 20 +++++++++++++++++-- .../packages/cli/src/commands/migrate.ts | 14 +++++++++++-- betterbase/packages/cli/src/utils/scanner.ts | 9 ++++++++- betterbase/templates/base/src/lib/realtime.ts | 16 +++++++++++---- 4 files changed, 50 insertions(+), 9 deletions(-) diff --git a/betterbase/packages/cli/src/commands/auth.ts b/betterbase/packages/cli/src/commands/auth.ts index 5aea32f..d5b92db 100644 --- a/betterbase/packages/cli/src/commands/auth.ts +++ b/betterbase/packages/cli/src/commands/auth.ts @@ -44,7 +44,15 @@ const loginSchema = z.object({ }); authRoute.post('/signup', async (c) => { - const result = signupSchema.safeParse(await c.req.json()); + let rawBody: unknown; + try { + rawBody = await c.req.json(); + } catch (err) { + const details = err instanceof Error ? err.message : String(err); + return c.json({ error: 'Invalid JSON', details }, 400); + } + + const result = signupSchema.safeParse(rawBody); if (!result.success) { return c.json({ error: 'Invalid signup payload', details: result.error.format() }, 400); } @@ -76,7 +84,15 @@ authRoute.post('/signup', async (c) => { }); authRoute.post('/login', async (c) => { - const result = loginSchema.safeParse(await c.req.json()); + let rawBody: unknown; + try { + rawBody = await c.req.json(); + } catch (err) { + const details = err instanceof Error ? err.message : String(err); + return c.json({ error: 'Invalid JSON', details }, 400); + } + + const result = loginSchema.safeParse(rawBody); if (!result.success) { return c.json({ error: 'Invalid login payload', details: result.error.format() }, 400); } diff --git a/betterbase/packages/cli/src/commands/migrate.ts b/betterbase/packages/cli/src/commands/migrate.ts index 3c2c975..5e19272 100644 --- a/betterbase/packages/cli/src/commands/migrate.ts +++ b/betterbase/packages/cli/src/commands/migrate.ts @@ -298,14 +298,24 @@ function splitStatements(sql: string): string[] { } if (!inSingle && !inBacktick && ch === '"') { - inDouble = !inDouble; current += ch; + if (inDouble && next === '"') { + current += next; + i += 1; + continue; + } + inDouble = !inDouble; continue; } if (!inSingle && !inDouble && ch === '`') { - inBacktick = !inBacktick; current += ch; + if (inBacktick && next === '`') { + current += next; + i += 1; + continue; + } + inBacktick = !inBacktick; continue; } diff --git a/betterbase/packages/cli/src/utils/scanner.ts b/betterbase/packages/cli/src/utils/scanner.ts index 76afbb1..4f50ff6 100644 --- a/betterbase/packages/cli/src/utils/scanner.ts +++ b/betterbase/packages/cli/src/utils/scanner.ts @@ -95,7 +95,7 @@ export class SchemaScanner { const functionName = getCallName(initializer); if (functionName === 'sqliteTable' || functionName === 'pgTable' || functionName === 'mysqlTable') { const tableObj = this.parseTable(initializer); - const tableKey = tableObj.name || declaration.name.text; + const tableKey = tableObj.name ?? declaration.name.text; tables[tableKey] = tableObj; } } @@ -167,7 +167,14 @@ export class SchemaScanner { } let value = unwrapExpression(property.initializer); + const MAX_ITER = 50; + let iter = 0; + while (ts.isCallExpression(value)) { + iter += 1; + if (iter > MAX_ITER) { + break; + } const callName = getCallName(value); if (callName === 'index' || callName === 'uniqueIndex') { const key = ts.isIdentifier(property.name) diff --git a/betterbase/templates/base/src/lib/realtime.ts b/betterbase/templates/base/src/lib/realtime.ts index 199fe6f..eea5957 100644 --- a/betterbase/templates/base/src/lib/realtime.ts +++ b/betterbase/templates/base/src/lib/realtime.ts @@ -28,8 +28,8 @@ interface RealtimeConfig { } const messageSchema = z.union([ - z.object({ type: z.literal('subscribe'), table: z.string().min(1), filter: z.record(z.string(), z.unknown()).optional() }), - z.object({ type: z.literal('unsubscribe'), table: z.string().min(1) }), + z.object({ type: z.literal('subscribe'), table: z.string().min(1).max(255), filter: z.record(z.string(), z.unknown()).optional() }), + z.object({ type: z.literal('unsubscribe'), table: z.string().min(1).max(255) }), ]); const realtimeLogger = { @@ -75,7 +75,14 @@ export class RealtimeServer { authenticate(token: string | undefined): { userId: string; claims: string[] } | null { if (!token || !token.trim()) return null; - return { userId: token.trim(), claims: ['realtime:*'] }; + + // TODO: Replace this placeholder with real auth verification in production: + // verify signature/issuer, enforce expiry, and map claims/scopes from your auth provider. + const [userId, rawClaims] = token.trim().split(':', 2); + if (!userId) return null; + + const claims = rawClaims ? rawClaims.split(',').map((claim) => claim.trim()).filter(Boolean) : ['realtime:*']; + return { userId, claims }; } authorize(userId: string, claims: string[], table: string): boolean { @@ -168,7 +175,8 @@ export class RealtimeServer { const message = JSON.stringify(payload); - for (const ws of [...subscribers]) { + const subs = Array.from(subscribers); + for (const ws of subs) { const client = this.clients.get(ws); const subscription = client?.subscriptions.get(table); if (!this.matchesFilter(subscription?.filter, data)) { From b54ad98113cc76437296ceefcde92faf68005d33 Mon Sep 17 00:00:00 2001 From: BroUnion Date: Fri, 20 Feb 2026 02:50:20 +0200 Subject: [PATCH 3/9] Fix realtime resubscribe caps and polish scanner/migrate nits --- betterbase/packages/cli/src/commands/migrate.ts | 2 +- betterbase/packages/cli/src/utils/scanner.ts | 11 +++++------ betterbase/templates/base/src/lib/realtime.ts | 8 +++++--- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/betterbase/packages/cli/src/commands/migrate.ts b/betterbase/packages/cli/src/commands/migrate.ts index 5e19272..5fa0708 100644 --- a/betterbase/packages/cli/src/commands/migrate.ts +++ b/betterbase/packages/cli/src/commands/migrate.ts @@ -394,7 +394,7 @@ export async function runMigrateCommand(rawOptions: MigrateCommandOptions): Prom } logger.info('Applying migrations with drizzle-kit push...'); - logger.info('Note: drizzle-kit generate produced files in drizzle/ for preview/diff analysis only; push is what applied changes in this run.'); + logger.info('drizzle/ files are for preview; push applied changes.'); const push = await runDrizzleKit(['push']); if (!push.success) { diff --git a/betterbase/packages/cli/src/utils/scanner.ts b/betterbase/packages/cli/src/utils/scanner.ts index 4f50ff6..9686078 100644 --- a/betterbase/packages/cli/src/utils/scanner.ts +++ b/betterbase/packages/cli/src/utils/scanner.ts @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs'; import * as ts from 'typescript'; import { z } from 'zod'; +import * as logger from './logger'; export const ColumnTypeSchema = z.enum(['text', 'integer', 'number', 'boolean', 'datetime', 'json', 'blob', 'unknown']); @@ -106,12 +107,7 @@ export class SchemaScanner { visit(this.sourceFile); - const validated = TablesRecordSchema.safeParse(tables); - if (!validated.success) { - throw new Error(`Schema scanner produced invalid output: ${JSON.stringify(validated.error.format())}`); - } - - return validated.data; + return TablesRecordSchema.parse(tables); } private parseTable(callExpression: ts.CallExpression): TableInfo { @@ -173,6 +169,9 @@ export class SchemaScanner { while (ts.isCallExpression(value)) { iter += 1; if (iter > MAX_ITER) { + logger.warn( + `SchemaScanner parseIndexes reached MAX_ITER=${MAX_ITER} while scanning index chain: ${value.getText(this.sourceFile)}`, + ); break; } const callName = getCallName(value); diff --git a/betterbase/templates/base/src/lib/realtime.ts b/betterbase/templates/base/src/lib/realtime.ts index eea5957..9fffef2 100644 --- a/betterbase/templates/base/src/lib/realtime.ts +++ b/betterbase/templates/base/src/lib/realtime.ts @@ -81,7 +81,7 @@ export class RealtimeServer { const [userId, rawClaims] = token.trim().split(':', 2); if (!userId) return null; - const claims = rawClaims ? rawClaims.split(',').map((claim) => claim.trim()).filter(Boolean) : ['realtime:*']; + const claims = rawClaims ? rawClaims.split(',').map((claim) => claim.trim()).filter(Boolean) : []; return { userId, claims }; } @@ -204,14 +204,16 @@ export class RealtimeServer { return; } - if (client.subscriptions.size >= this.config.maxSubscriptionsPerClient) { + const existingSubscription = client.subscriptions.has(table); + if (!existingSubscription && client.subscriptions.size >= this.config.maxSubscriptionsPerClient) { realtimeLogger.warn(`Subscription limit reached for ${client.userId}`); this.safeSend(ws, { error: 'Subscription limit reached' }); return; } const tableSet = this.tableSubscribers.get(table) ?? new Set>(); - if (tableSet.size >= this.config.maxSubscribersPerTable) { + const alreadyInTableSet = tableSet.has(ws); + if (!alreadyInTableSet && tableSet.size >= this.config.maxSubscribersPerTable) { realtimeLogger.warn(`Table subscriber cap reached for ${table}`); this.safeSend(ws, { error: 'Table subscription limit reached' }); return; From e030affecd1b61022da04977e70d0edd8c01f77a Mon Sep 17 00:00:00 2001 From: BroUnion Date: Fri, 20 Feb 2026 03:11:38 +0200 Subject: [PATCH 4/9] feat(client): scaffold @betterbase/client sdk package --- betterbase/bun.lock | 175 ++++++++++++++++ betterbase/packages/client/README.md | 46 ++++- betterbase/packages/client/package.json | 37 ++++ betterbase/packages/client/src/auth.ts | 155 ++++++++++++++ betterbase/packages/client/src/build.ts | 49 +++++ betterbase/packages/client/src/client.ts | 49 +++++ betterbase/packages/client/src/errors.ts | 29 +++ betterbase/packages/client/src/index.ts | 15 ++ .../packages/client/src/query-builder.ts | 191 ++++++++++++++++++ betterbase/packages/client/src/realtime.ts | 128 ++++++++++++ betterbase/packages/client/src/types.ts | 35 ++++ .../packages/client/test/client.test.ts | 24 +++ betterbase/packages/client/tsconfig.json | 12 ++ 13 files changed, 943 insertions(+), 2 deletions(-) create mode 100644 betterbase/bun.lock create mode 100644 betterbase/packages/client/package.json create mode 100644 betterbase/packages/client/src/auth.ts create mode 100644 betterbase/packages/client/src/build.ts create mode 100644 betterbase/packages/client/src/client.ts create mode 100644 betterbase/packages/client/src/errors.ts create mode 100644 betterbase/packages/client/src/index.ts create mode 100644 betterbase/packages/client/src/query-builder.ts create mode 100644 betterbase/packages/client/src/realtime.ts create mode 100644 betterbase/packages/client/src/types.ts create mode 100644 betterbase/packages/client/test/client.test.ts create mode 100644 betterbase/packages/client/tsconfig.json diff --git a/betterbase/bun.lock b/betterbase/bun.lock new file mode 100644 index 0000000..7d7c59a --- /dev/null +++ b/betterbase/bun.lock @@ -0,0 +1,175 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "betterbase", + "devDependencies": { + "turbo": "^2.0.0", + "typescript": "^5.6.0", + }, + }, + "apps/cli": { + "name": "@betterbase/cli-legacy", + "version": "0.0.0", + "bin": { + "bb-legacy": "./dist/index.js", + }, + "dependencies": { + "@betterbase/cli": "workspace:*", + }, + "devDependencies": { + "typescript": "^5.9.3", + }, + }, + "packages/cli": { + "name": "@betterbase/cli", + "version": "0.1.0", + "bin": { + "bb": "./dist/index.js", + }, + "dependencies": { + "chalk": "^5.3.0", + "commander": "^12.1.0", + "inquirer": "^10.2.2", + "typescript": "^5.8.0", + "zod": "^3.23.8", + }, + "devDependencies": { + "@types/bun": "^1.3.9", + }, + }, + "packages/client": { + "name": "@betterbase/client", + "version": "0.1.0", + "devDependencies": { + "@types/bun": "^1.3.9", + "typescript": "^5.9.3", + }, + }, + }, + "packages": { + "@betterbase/cli": ["@betterbase/cli@workspace:packages/cli"], + + "@betterbase/cli-legacy": ["@betterbase/cli-legacy@workspace:apps/cli"], + + "@betterbase/client": ["@betterbase/client@workspace:packages/client"], + + "@inquirer/checkbox": ["@inquirer/checkbox@2.5.0", "", { "dependencies": { "@inquirer/core": "^9.1.0", "@inquirer/figures": "^1.0.5", "@inquirer/type": "^1.5.3", "ansi-escapes": "^4.3.2", "yoctocolors-cjs": "^2.1.2" } }, "sha512-sMgdETOfi2dUHT8r7TT1BTKOwNvdDGFDXYWtQ2J69SvlYNntk9I/gJe7r5yvMwwsuKnYbuRs3pNhx4tgNck5aA=="], + + "@inquirer/confirm": ["@inquirer/confirm@3.2.0", "", { "dependencies": { "@inquirer/core": "^9.1.0", "@inquirer/type": "^1.5.3" } }, "sha512-oOIwPs0Dvq5220Z8lGL/6LHRTEr9TgLHmiI99Rj1PJ1p1czTys+olrgBqZk4E2qC0YTzeHprxSQmoHioVdJ7Lw=="], + + "@inquirer/core": ["@inquirer/core@9.2.1", "", { "dependencies": { "@inquirer/figures": "^1.0.6", "@inquirer/type": "^2.0.0", "@types/mute-stream": "^0.0.4", "@types/node": "^22.5.5", "@types/wrap-ansi": "^3.0.0", "ansi-escapes": "^4.3.2", "cli-width": "^4.1.0", "mute-stream": "^1.0.0", "signal-exit": "^4.1.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^6.2.0", "yoctocolors-cjs": "^2.1.2" } }, "sha512-F2VBt7W/mwqEU4bL0RnHNZmC/OxzNx9cOYxHqnXX3MP6ruYvZUZAW9imgN9+h/uBT/oP8Gh888J2OZSbjSeWcg=="], + + "@inquirer/editor": ["@inquirer/editor@2.2.0", "", { "dependencies": { "@inquirer/core": "^9.1.0", "@inquirer/type": "^1.5.3", "external-editor": "^3.1.0" } }, "sha512-9KHOpJ+dIL5SZli8lJ6xdaYLPPzB8xB9GZItg39MBybzhxA16vxmszmQFrRwbOA918WA2rvu8xhDEg/p6LXKbw=="], + + "@inquirer/expand": ["@inquirer/expand@2.3.0", "", { "dependencies": { "@inquirer/core": "^9.1.0", "@inquirer/type": "^1.5.3", "yoctocolors-cjs": "^2.1.2" } }, "sha512-qnJsUcOGCSG1e5DTOErmv2BPQqrtT6uzqn1vI/aYGiPKq+FgslGZmtdnXbhuI7IlT7OByDoEEqdnhUnVR2hhLw=="], + + "@inquirer/figures": ["@inquirer/figures@1.0.15", "", {}, "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g=="], + + "@inquirer/input": ["@inquirer/input@2.3.0", "", { "dependencies": { "@inquirer/core": "^9.1.0", "@inquirer/type": "^1.5.3" } }, "sha512-XfnpCStx2xgh1LIRqPXrTNEEByqQWoxsWYzNRSEUxJ5c6EQlhMogJ3vHKu8aXuTacebtaZzMAHwEL0kAflKOBw=="], + + "@inquirer/number": ["@inquirer/number@1.1.0", "", { "dependencies": { "@inquirer/core": "^9.1.0", "@inquirer/type": "^1.5.3" } }, "sha512-ilUnia/GZUtfSZy3YEErXLJ2Sljo/mf9fiKc08n18DdwdmDbOzRcTv65H1jjDvlsAuvdFXf4Sa/aL7iw/NanVA=="], + + "@inquirer/password": ["@inquirer/password@2.2.0", "", { "dependencies": { "@inquirer/core": "^9.1.0", "@inquirer/type": "^1.5.3", "ansi-escapes": "^4.3.2" } }, "sha512-5otqIpgsPYIshqhgtEwSspBQE40etouR8VIxzpJkv9i0dVHIpyhiivbkH9/dGiMLdyamT54YRdGJLfl8TFnLHg=="], + + "@inquirer/prompts": ["@inquirer/prompts@5.5.0", "", { "dependencies": { "@inquirer/checkbox": "^2.5.0", "@inquirer/confirm": "^3.2.0", "@inquirer/editor": "^2.2.0", "@inquirer/expand": "^2.3.0", "@inquirer/input": "^2.3.0", "@inquirer/number": "^1.1.0", "@inquirer/password": "^2.2.0", "@inquirer/rawlist": "^2.3.0", "@inquirer/search": "^1.1.0", "@inquirer/select": "^2.5.0" } }, "sha512-BHDeL0catgHdcHbSFFUddNzvx/imzJMft+tWDPwTm3hfu8/tApk1HrooNngB2Mb4qY+KaRWF+iZqoVUPeslEog=="], + + "@inquirer/rawlist": ["@inquirer/rawlist@2.3.0", "", { "dependencies": { "@inquirer/core": "^9.1.0", "@inquirer/type": "^1.5.3", "yoctocolors-cjs": "^2.1.2" } }, "sha512-zzfNuINhFF7OLAtGHfhwOW2TlYJyli7lOUoJUXw/uyklcwalV6WRXBXtFIicN8rTRK1XTiPWB4UY+YuW8dsnLQ=="], + + "@inquirer/search": ["@inquirer/search@1.1.0", "", { "dependencies": { "@inquirer/core": "^9.1.0", "@inquirer/figures": "^1.0.5", "@inquirer/type": "^1.5.3", "yoctocolors-cjs": "^2.1.2" } }, "sha512-h+/5LSj51dx7hp5xOn4QFnUaKeARwUCLs6mIhtkJ0JYPBLmEYjdHSYh7I6GrLg9LwpJ3xeX0FZgAG1q0QdCpVQ=="], + + "@inquirer/select": ["@inquirer/select@2.5.0", "", { "dependencies": { "@inquirer/core": "^9.1.0", "@inquirer/figures": "^1.0.5", "@inquirer/type": "^1.5.3", "ansi-escapes": "^4.3.2", "yoctocolors-cjs": "^2.1.2" } }, "sha512-YmDobTItPP3WcEI86GvPo+T2sRHkxxOq/kXmsBjHS5BVXUgvgZ5AfJjkvQvZr03T81NnI3KrrRuMzeuYUQRFOA=="], + + "@inquirer/type": ["@inquirer/type@1.5.5", "", { "dependencies": { "mute-stream": "^1.0.0" } }, "sha512-MzICLu4yS7V8AA61sANROZ9vT1H3ooca5dSmI1FjZkzq7o/koMsRfQSzRtFo+F3Ao4Sf1C0bpLKejpKB/+j6MA=="], + + "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], + + "@types/mute-stream": ["@types/mute-stream@0.0.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-CPM9nzrCPPJHQNA9keH9CVkVI+WR5kMa+7XEs5jcGQ0VoAGnLv242w8lIVgwAEfmE4oufJRaTc9PNLQl0ioAow=="], + + "@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="], + + "@types/wrap-ansi": ["@types/wrap-ansi@3.0.0", "", {}, "sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g=="], + + "ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], + + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "chardet": ["chardet@0.7.0", "", {}, "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA=="], + + "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "external-editor": ["external-editor@3.1.0", "", { "dependencies": { "chardet": "^0.7.0", "iconv-lite": "^0.4.24", "tmp": "^0.0.33" } }, "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew=="], + + "iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], + + "inquirer": ["inquirer@10.2.2", "", { "dependencies": { "@inquirer/core": "^9.1.0", "@inquirer/prompts": "^5.5.0", "@inquirer/type": "^1.5.3", "@types/mute-stream": "^0.0.4", "ansi-escapes": "^4.3.2", "mute-stream": "^1.0.0", "run-async": "^3.0.0", "rxjs": "^7.8.1" } }, "sha512-tyao/4Vo36XnUItZ7DnUXX4f1jVao2mSrleV/5IPtW/XAEA26hRVsbc68nuTEKWcr5vMP/1mVoT2O7u8H4v1Vg=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "mute-stream": ["mute-stream@1.0.0", "", {}, "sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA=="], + + "os-tmpdir": ["os-tmpdir@1.0.2", "", {}, "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g=="], + + "run-async": ["run-async@3.0.0", "", {}, "sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q=="], + + "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "tmp": ["tmp@0.0.33", "", { "dependencies": { "os-tmpdir": "~1.0.2" } }, "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "turbo": ["turbo@2.8.10", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.10", "turbo-darwin-arm64": "2.8.10", "turbo-linux-64": "2.8.10", "turbo-linux-arm64": "2.8.10", "turbo-windows-64": "2.8.10", "turbo-windows-arm64": "2.8.10" }, "bin": { "turbo": "bin/turbo" } }, "sha512-OxbzDES66+x7nnKGg2MwBA1ypVsZoDTLHpeaP4giyiHSixbsiTaMyeJqbEyvBdp5Cm28fc+8GG6RdQtic0ijwQ=="], + + "turbo-darwin-64": ["turbo-darwin-64@2.8.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-A03fXh+B7S8mL3PbdhTd+0UsaGrhfyPkODvzBDpKRY7bbeac4MDFpJ7I+Slf2oSkCEeSvHKR7Z4U71uKRUfX7g=="], + + "turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-sidzowgWL3s5xCHLeqwC9M3s9M0i16W1nuQF3Mc7fPHpZ+YPohvcbVFBB2uoRRHYZg6yBnwD4gyUHKTeXfwtXA=="], + + "turbo-linux-64": ["turbo-linux-64@2.8.10", "", { "os": "linux", "cpu": "x64" }, "sha512-YK9vcpL3TVtqonB021XwgaQhY9hJJbKKUhLv16osxV0HkcQASQWUqR56yMge7puh6nxU67rQlTq1b7ksR1T3KA=="], + + "turbo-linux-arm64": ["turbo-linux-arm64@2.8.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-3+j2tL0sG95iBJTm+6J8/45JsETQABPqtFyYjVjBbi6eVGdtNTiBmHNKrbvXRlQ3ZbUG75bKLaSSDHSEEN+btQ=="], + + "turbo-windows-64": ["turbo-windows-64@2.8.10", "", { "os": "win32", "cpu": "x64" }, "sha512-hdeF5qmVY/NFgiucf8FW0CWJWtyT2QPm5mIsX0W1DXAVzqKVXGq+Zf+dg4EUngAFKjDzoBeN6ec2Fhajwfztkw=="], + + "turbo-windows-arm64": ["turbo-windows-arm64@2.8.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-QGdr/Q8LWmj+ITMkSvfiz2glf0d7JG0oXVzGL3jxkGqiBI1zXFj20oqVY0qWi+112LO9SVrYdpHS0E/oGFrMbQ=="], + + "type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], + + "yoctocolors-cjs": ["yoctocolors-cjs@2.1.3", "", {}, "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@inquirer/core/@inquirer/type": ["@inquirer/type@2.0.0", "", { "dependencies": { "mute-stream": "^1.0.0" } }, "sha512-XvJRx+2KR3YXyYtPUUy+qd9i7p+GO9Ko6VIIpWlBrpWwXDv8WLFeHTxz35CfQFUiBMLXlGHhGzys7lqit9gWag=="], + + "@inquirer/core/@types/node": ["@types/node@22.19.11", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w=="], + + "@inquirer/core/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + } +} diff --git a/betterbase/packages/client/README.md b/betterbase/packages/client/README.md index cce3839..84ecf5a 100644 --- a/betterbase/packages/client/README.md +++ b/betterbase/packages/client/README.md @@ -1,3 +1,45 @@ -# @betterbase/client (Scaffold) +# @betterbase/client -Client SDK package placeholder. +TypeScript client for BetterBase backends. + +## Installation + +```bash +bun add @betterbase/client +``` + +## Usage + +```typescript +import { createClient } from '@betterbase/client'; + +const betterbase = createClient({ + url: 'http://localhost:3000', + key: 'optional-api-key', +}); + +const { data, error } = await betterbase + .from('users') + .select('*') + .eq('status', 'active') + .limit(10) + .execute(); + +await betterbase.auth.signUp({ + email: 'user@example.com', + password: 'password123', + name: 'John Doe', +}); + +betterbase + .realtime + .from('posts') + .on('INSERT', (payload) => { + console.log('New post:', payload.data); + }) + .subscribe(); +``` + +## API Reference + +See [documentation](https://betterbase.dev/docs/client) for full API reference. diff --git a/betterbase/packages/client/package.json b/betterbase/packages/client/package.json new file mode 100644 index 0000000..f5a2935 --- /dev/null +++ b/betterbase/packages/client/package.json @@ -0,0 +1,37 @@ +{ + "name": "@betterbase/client", + "version": "0.1.0", + "description": "TypeScript client for BetterBase backends", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.cjs", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "bun run src/build.ts", + "dev": "bun run src/build.ts --watch", + "test": "bun test", + "typecheck": "tsc --noEmit" + }, + "keywords": [ + "betterbase", + "baas", + "backend", + "database", + "realtime" + ], + "files": [ + "dist", + "README.md" + ], + "devDependencies": { + "@types/bun": "^1.3.9", + "typescript": "^5.9.3" + } +} diff --git a/betterbase/packages/client/src/auth.ts b/betterbase/packages/client/src/auth.ts new file mode 100644 index 0000000..1ddb51a --- /dev/null +++ b/betterbase/packages/client/src/auth.ts @@ -0,0 +1,155 @@ +import type { BetterBaseResponse } from './types'; +import { AuthError, NetworkError } from './errors'; + +export interface AuthCredentials { + email: string; + password: string; + name?: string; +} + +export interface User { + id: string; + email: string; + name: string | null; +} + +export interface Session { + token: string; + user: User; +} + +function getStorage(): Storage | null { + if (typeof globalThis !== 'undefined' && 'localStorage' in globalThis) { + return globalThis.localStorage; + } + return null; +} + +export class AuthClient { + constructor( + private url: string, + private headers: Record, + private onAuthStateChange?: (token: string | null) => void, + private fetchImpl: typeof fetch = fetch + ) {} + + async signUp(credentials: AuthCredentials): Promise> { + const endpoint = `${this.url}/auth/signup`; + try { + const response = await this.fetchImpl(endpoint, { + method: 'POST', + headers: { ...this.headers, 'Content-Type': 'application/json' }, + body: JSON.stringify(credentials), + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Signup failed' })); + return { data: null, error: new AuthError(error.error || 'Failed to sign up', error) }; + } + const session = (await response.json()) as Session; + getStorage()?.setItem('betterbase_token', session.token); + this.onAuthStateChange?.(session.token); + return { data: session, error: null }; + } catch (error) { + return { + data: null, + error: new NetworkError(error instanceof Error ? error.message : 'Network request failed', error), + }; + } + } + + async signIn(credentials: Omit): Promise> { + const endpoint = `${this.url}/auth/login`; + try { + const response = await this.fetchImpl(endpoint, { + method: 'POST', + headers: { ...this.headers, 'Content-Type': 'application/json' }, + body: JSON.stringify(credentials), + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Login failed' })); + return { data: null, error: new AuthError(error.error || 'Invalid credentials', error) }; + } + const session = (await response.json()) as Session; + getStorage()?.setItem('betterbase_token', session.token); + this.onAuthStateChange?.(session.token); + return { data: session, error: null }; + } catch (error) { + return { + data: null, + error: new NetworkError(error instanceof Error ? error.message : 'Network request failed', error), + }; + } + } + + async signOut(): Promise> { + const endpoint = `${this.url}/auth/logout`; + const token = getStorage()?.getItem('betterbase_token') ?? null; + try { + const response = await this.fetchImpl(endpoint, { + method: 'POST', + headers: { + ...this.headers, + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + }); + + getStorage()?.removeItem('betterbase_token'); + this.onAuthStateChange?.(null); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Logout failed' })); + return { data: null, error: new AuthError(error.error || 'Failed to sign out', error) }; + } + + return { data: null, error: null }; + } catch (error) { + getStorage()?.removeItem('betterbase_token'); + this.onAuthStateChange?.(null); + return { + data: null, + error: new NetworkError(error instanceof Error ? error.message : 'Network request failed', error), + }; + } + } + + async getUser(): Promise> { + const endpoint = `${this.url}/auth/me`; + const token = getStorage()?.getItem('betterbase_token') ?? null; + + if (!token) { + return { data: null, error: new AuthError('Not authenticated') }; + } + + try { + const response = await this.fetchImpl(endpoint, { + method: 'GET', + headers: { ...this.headers, Authorization: `Bearer ${token}` }, + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Failed to get user' })); + return { data: null, error: new AuthError(error.error || 'Failed to get user', error) }; + } + const result = await response.json(); + return { data: result.user, error: null }; + } catch (error) { + return { + data: null, + error: new NetworkError(error instanceof Error ? error.message : 'Network request failed', error), + }; + } + } + + getToken(): string | null { + return getStorage()?.getItem('betterbase_token') ?? null; + } + + setToken(token: string | null): void { + const storage = getStorage(); + if (token) { + storage?.setItem('betterbase_token', token); + } else { + storage?.removeItem('betterbase_token'); + } + this.onAuthStateChange?.(token); + } +} diff --git a/betterbase/packages/client/src/build.ts b/betterbase/packages/client/src/build.ts new file mode 100644 index 0000000..f22a625 --- /dev/null +++ b/betterbase/packages/client/src/build.ts @@ -0,0 +1,49 @@ +import path from 'node:path'; + +const moduleDir = import.meta.dir; +const entrypoint = path.resolve(moduleDir, 'index.ts'); +const outdir = path.resolve(moduleDir, '../dist'); + +const esmResult = await Bun.build({ + entrypoints: [entrypoint], + outdir, + target: 'browser', + format: 'esm', + minify: false, + sourcemap: 'external', + naming: 'index.js', +}); + +if (!esmResult.success) { + console.error('ESM build failed:', esmResult.logs); + process.exit(1); +} + +const cjsResult = await Bun.build({ + entrypoints: [entrypoint], + outdir, + target: 'node', + format: 'cjs', + minify: false, + sourcemap: 'external', + naming: 'index.cjs', +}); + +if (!cjsResult.success) { + console.error('CJS build failed:', cjsResult.logs); + process.exit(1); +} + +const proc = Bun.spawn(['bunx', 'tsc', '--emitDeclarationOnly', '--outDir', outdir], { + cwd: path.resolve(moduleDir, '..'), + stdout: 'inherit', + stderr: 'inherit', +}); + +const exitCode = await proc.exited; +if (exitCode !== 0) { + console.error('TypeScript declaration generation failed'); + process.exit(1); +} + +console.log('✅ Build complete!'); diff --git a/betterbase/packages/client/src/client.ts b/betterbase/packages/client/src/client.ts new file mode 100644 index 0000000..1cc5ac3 --- /dev/null +++ b/betterbase/packages/client/src/client.ts @@ -0,0 +1,49 @@ +import type { BetterBaseConfig } from './types'; +import { QueryBuilder } from './query-builder'; +import { AuthClient } from './auth'; +import { RealtimeClient } from './realtime'; + +export class BetterBaseClient { + private headers: Record; + private fetchImpl: typeof fetch; + private url: string; + public auth: AuthClient; + public realtime: RealtimeClient; + + constructor(config: BetterBaseConfig) { + this.url = config.url.replace(/\/$/, ''); + this.headers = { + 'Content-Type': 'application/json', + ...(config.key ? { 'X-BetterBase-Key': config.key } : {}), + }; + this.fetchImpl = config.fetch ?? fetch; + + this.auth = new AuthClient( + this.url, + this.headers, + (token) => { + if (token) { + this.headers.Authorization = `Bearer ${token}`; + } else { + delete this.headers.Authorization; + } + }, + this.fetchImpl + ); + + this.realtime = new RealtimeClient(this.url); + + const token = this.auth.getToken(); + if (token) { + this.headers.Authorization = `Bearer ${token}`; + } + } + + from(table: string): QueryBuilder { + return new QueryBuilder(this.url, table, this.headers, this.fetchImpl); + } +} + +export function createClient(config: BetterBaseConfig): BetterBaseClient { + return new BetterBaseClient(config); +} diff --git a/betterbase/packages/client/src/errors.ts b/betterbase/packages/client/src/errors.ts new file mode 100644 index 0000000..a270bd8 --- /dev/null +++ b/betterbase/packages/client/src/errors.ts @@ -0,0 +1,29 @@ +export class BetterBaseError extends Error { + constructor( + message: string, + public code?: string, + public details?: unknown, + public status?: number + ) { + super(message); + this.name = 'BetterBaseError'; + } +} + +export class NetworkError extends BetterBaseError { + constructor(message: string, details?: unknown) { + super(message, 'NETWORK_ERROR', details); + } +} + +export class AuthError extends BetterBaseError { + constructor(message: string, details?: unknown) { + super(message, 'AUTH_ERROR', details, 401); + } +} + +export class ValidationError extends BetterBaseError { + constructor(message: string, details?: unknown) { + super(message, 'VALIDATION_ERROR', details, 400); + } +} diff --git a/betterbase/packages/client/src/index.ts b/betterbase/packages/client/src/index.ts new file mode 100644 index 0000000..fc070a7 --- /dev/null +++ b/betterbase/packages/client/src/index.ts @@ -0,0 +1,15 @@ +export { createClient, BetterBaseClient } from './client'; +export { QueryBuilder } from './query-builder'; +export { AuthClient } from './auth'; +export { RealtimeClient } from './realtime'; +export { BetterBaseError, NetworkError, AuthError, ValidationError } from './errors'; + +export type { + BetterBaseConfig, + BetterBaseResponse, + QueryOptions, + RealtimeCallback, + RealtimeSubscription, +} from './types'; + +export type { User, Session, AuthCredentials } from './auth'; diff --git a/betterbase/packages/client/src/query-builder.ts b/betterbase/packages/client/src/query-builder.ts new file mode 100644 index 0000000..62e478e --- /dev/null +++ b/betterbase/packages/client/src/query-builder.ts @@ -0,0 +1,191 @@ +import type { BetterBaseResponse, QueryOptions } from './types'; +import { BetterBaseError, NetworkError } from './errors'; + +export class QueryBuilder { + private filters: Record = {}; + private options: QueryOptions = {}; + private selectFields = '*'; + + constructor( + private url: string, + private table: string, + private headers: Record, + private fetchImpl: typeof fetch = fetch + ) {} + + select(fields = '*'): this { + this.selectFields = fields; + return this; + } + + eq(column: string, value: unknown): this { + this.filters[column] = value; + return this; + } + + in(column: string, values: unknown[]): this { + this.filters[`${column}_in`] = values; + return this; + } + + limit(count: number): this { + this.options.limit = count; + return this; + } + + offset(count: number): this { + this.options.offset = count; + return this; + } + + order(column: string, direction: 'asc' | 'desc' = 'asc'): this { + this.options.orderBy = { column, direction }; + return this; + } + + async execute(): Promise> { + const params = new URLSearchParams(); + params.append('select', this.selectFields); + + for (const [key, value] of Object.entries(this.filters)) { + params.append(key, String(value)); + } + + if (this.options.limit !== undefined) params.append('limit', String(this.options.limit)); + if (this.options.offset !== undefined) params.append('offset', String(this.options.offset)); + if (this.options.orderBy) { + params.append('sort', `${this.options.orderBy.column}:${this.options.orderBy.direction}`); + } + + const endpoint = `${this.url}/api/${this.table}?${params.toString()}`; + + try { + const response = await this.fetchImpl(endpoint, { + method: 'GET', + headers: this.headers, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Unknown error' })); + return { + data: null, + error: new BetterBaseError( + error.error || `Request failed with status ${response.status}`, + 'REQUEST_FAILED', + error, + response.status + ), + }; + } + + const result = await response.json(); + return { + data: result[this.table] || result.data || [], + error: null, + count: result.count, + pagination: result.pagination, + }; + } catch (error) { + return { + data: null, + error: new NetworkError(error instanceof Error ? error.message : 'Network request failed', error), + }; + } + } + + async single(id: string): Promise> { + const endpoint = `${this.url}/api/${this.table}/${id}`; + try { + const response = await this.fetchImpl(endpoint, { method: 'GET', headers: this.headers }); + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Not found' })); + return { + data: null, + error: new BetterBaseError(error.error || 'Resource not found', 'NOT_FOUND', error, response.status), + }; + } + const result = await response.json(); + const singularKey = this.table.endsWith('s') ? this.table.slice(0, -1) : this.table; + return { data: result[singularKey] || result.data || null, error: null }; + } catch (error) { + return { + data: null, + error: new NetworkError(error instanceof Error ? error.message : 'Network request failed', error), + }; + } + } + + async insert(data: Partial): Promise> { + const endpoint = `${this.url}/api/${this.table}`; + try { + const response = await this.fetchImpl(endpoint, { + method: 'POST', + headers: { ...this.headers, 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Insert failed' })); + return { + data: null, + error: new BetterBaseError(error.error || 'Failed to insert record', 'INSERT_FAILED', error, response.status), + }; + } + const result = await response.json(); + const singularKey = this.table.endsWith('s') ? this.table.slice(0, -1) : this.table; + return { data: result[singularKey] || result.data || null, error: null }; + } catch (error) { + return { + data: null, + error: new NetworkError(error instanceof Error ? error.message : 'Network request failed', error), + }; + } + } + + async update(id: string, data: Partial): Promise> { + const endpoint = `${this.url}/api/${this.table}/${id}`; + try { + const response = await this.fetchImpl(endpoint, { + method: 'PATCH', + headers: { ...this.headers, 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Update failed' })); + return { + data: null, + error: new BetterBaseError(error.error || 'Failed to update record', 'UPDATE_FAILED', error, response.status), + }; + } + const result = await response.json(); + const singularKey = this.table.endsWith('s') ? this.table.slice(0, -1) : this.table; + return { data: result[singularKey] || result.data || null, error: null }; + } catch (error) { + return { + data: null, + error: new NetworkError(error instanceof Error ? error.message : 'Network request failed', error), + }; + } + } + + async delete(id: string): Promise> { + const endpoint = `${this.url}/api/${this.table}/${id}`; + try { + const response = await this.fetchImpl(endpoint, { method: 'DELETE', headers: this.headers }); + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Delete failed' })); + return { + data: null, + error: new BetterBaseError(error.error || 'Failed to delete record', 'DELETE_FAILED', error, response.status), + }; + } + const result = await response.json(); + const singularKey = this.table.endsWith('s') ? this.table.slice(0, -1) : this.table; + return { data: result[singularKey] || result.data || null, error: null }; + } catch (error) { + return { + data: null, + error: new NetworkError(error instanceof Error ? error.message : 'Network request failed', error), + }; + } + } +} diff --git a/betterbase/packages/client/src/realtime.ts b/betterbase/packages/client/src/realtime.ts new file mode 100644 index 0000000..63fe2da --- /dev/null +++ b/betterbase/packages/client/src/realtime.ts @@ -0,0 +1,128 @@ +import type { RealtimeCallback, RealtimeSubscription } from './types'; + +type RealtimeEvent = 'INSERT' | 'UPDATE' | 'DELETE' | '*'; + +export class RealtimeClient { + private ws: WebSocket | null = null; + private subscriptions = new Map>(); + private reconnectTimeout: ReturnType | null = null; + private reconnectAttempts = 0; + private maxReconnectAttempts = 5; + + constructor(private url: string) {} + + private connect(): void { + if (typeof WebSocket === 'undefined') { + return; + } + + if (this.ws?.readyState === WebSocket.OPEN || this.ws?.readyState === WebSocket.CONNECTING) { + return; + } + + const wsUrl = this.url.replace(/^http/, 'ws') + '/ws'; + + this.ws = new WebSocket(wsUrl); + + this.ws.onopen = () => { + this.reconnectAttempts = 0; + for (const table of this.subscriptions.keys()) { + this.sendSubscribe(table); + } + }; + + this.ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data as string); + if (data.type !== 'update') return; + + const callbacks = this.subscriptions.get(data.table); + if (callbacks) { + for (const callback of callbacks) { + callback({ event: data.event, data: data.data, timestamp: data.timestamp }); + } + } + } catch { + // noop + } + }; + + this.ws.onclose = () => { + this.ws = null; + if (this.reconnectAttempts < this.maxReconnectAttempts && this.subscriptions.size > 0) { + const delay = Math.min(1000 * 2 ** this.reconnectAttempts, 10000); + this.reconnectTimeout = setTimeout(() => { + this.reconnectAttempts++; + this.connect(); + }, delay); + } + }; + } + + private sendSubscribe(table: string, filter?: Record): void { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify({ type: 'subscribe', table, filter })); + } + } + + private sendUnsubscribe(table: string): void { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify({ type: 'unsubscribe', table })); + } + } + + from(table: string): { + on: (event: RealtimeEvent, callback: RealtimeCallback) => { + subscribe: (filter?: Record) => RealtimeSubscription; + }; + } { + return { + on: (event, callback) => ({ + subscribe: (filter) => { + this.connect(); + + const wrappedCallback: RealtimeCallback = (payload) => { + if (event === '*' || payload.event === event) { + callback(payload as Parameters[0]); + } + }; + + if (!this.subscriptions.has(table)) { + this.subscriptions.set(table, new Set()); + } + + this.subscriptions.get(table)?.add(wrappedCallback); + this.sendSubscribe(table, filter); + + return { + unsubscribe: () => { + const callbacks = this.subscriptions.get(table); + callbacks?.delete(wrappedCallback); + + if (callbacks && callbacks.size === 0) { + this.subscriptions.delete(table); + this.sendUnsubscribe(table); + + if (this.subscriptions.size === 0) { + this.disconnect(); + } + } + }, + }; + }, + }), + }; + } + + disconnect(): void { + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; + } + + this.ws?.close(); + this.ws = null; + this.subscriptions.clear(); + this.reconnectAttempts = 0; + } +} diff --git a/betterbase/packages/client/src/types.ts b/betterbase/packages/client/src/types.ts new file mode 100644 index 0000000..c6784be --- /dev/null +++ b/betterbase/packages/client/src/types.ts @@ -0,0 +1,35 @@ +import type { BetterBaseError } from './errors'; + +export interface BetterBaseConfig { + url: string; + key?: string; + schema?: string; + fetch?: typeof fetch; +} + +export interface QueryOptions { + limit?: number; + offset?: number; + orderBy?: { column: string; direction: 'asc' | 'desc' }; +} + +export interface BetterBaseResponse { + data: T | null; + error: BetterBaseError | null; + count?: number; + pagination?: { + limit: number; + offset: number; + hasMore: boolean; + }; +} + +export interface RealtimeSubscription { + unsubscribe: () => void; +} + +export type RealtimeCallback = (payload: { + event: 'INSERT' | 'UPDATE' | 'DELETE'; + data: T; + timestamp: string; +}) => void; diff --git a/betterbase/packages/client/test/client.test.ts b/betterbase/packages/client/test/client.test.ts new file mode 100644 index 0000000..bc05995 --- /dev/null +++ b/betterbase/packages/client/test/client.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from 'bun:test'; +import { createClient } from '../src'; + +describe('@betterbase/client', () => { + test('creates client with config', () => { + const client = createClient({ + url: 'http://localhost:3000', + key: 'test-key', + }); + + expect(client).toBeDefined(); + expect(client.auth).toBeDefined(); + expect(client.realtime).toBeDefined(); + }); + + test('from creates query builder', () => { + const client = createClient({ url: 'http://localhost:3000' }); + const query = client.from('users'); + + expect(query).toBeDefined(); + expect(query.select).toBeDefined(); + expect(query.eq).toBeDefined(); + }); +}); diff --git a/betterbase/packages/client/tsconfig.json b/betterbase/packages/client/tsconfig.json new file mode 100644 index 0000000..40e831f --- /dev/null +++ b/betterbase/packages/client/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "declaration": true, + "declarationMap": true, + "lib": ["ES2022", "DOM"], + "types": ["bun"] + }, + "include": ["src/**/*", "test/**/*"], + "exclude": ["node_modules", "dist"] +} From 2a923cc03e61a95ecd7b08d5e4b4e63c2c73c757 Mon Sep 17 00:00:00 2001 From: BroUnion Date: Fri, 20 Feb 2026 03:52:33 +0200 Subject: [PATCH 5/9] fix(client): harden storage access and preserve realtime filters --- betterbase/packages/client/package.json | 2 +- betterbase/packages/client/src/auth.ts | 12 +++-- betterbase/packages/client/src/build.ts | 2 +- betterbase/packages/client/src/realtime.ts | 48 ++++++++++++++----- betterbase/packages/client/tsconfig.json | 4 +- betterbase/packages/client/tsconfig.test.json | 9 ++++ 6 files changed, 57 insertions(+), 20 deletions(-) create mode 100644 betterbase/packages/client/tsconfig.test.json diff --git a/betterbase/packages/client/package.json b/betterbase/packages/client/package.json index f5a2935..3b7f219 100644 --- a/betterbase/packages/client/package.json +++ b/betterbase/packages/client/package.json @@ -17,7 +17,7 @@ "build": "bun run src/build.ts", "dev": "bun run src/build.ts --watch", "test": "bun test", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --project tsconfig.test.json" }, "keywords": [ "betterbase", diff --git a/betterbase/packages/client/src/auth.ts b/betterbase/packages/client/src/auth.ts index 1ddb51a..7461eee 100644 --- a/betterbase/packages/client/src/auth.ts +++ b/betterbase/packages/client/src/auth.ts @@ -19,10 +19,16 @@ export interface Session { } function getStorage(): Storage | null { - if (typeof globalThis !== 'undefined' && 'localStorage' in globalThis) { - return globalThis.localStorage; + try { + if (typeof globalThis === 'undefined') { + return null; + } + + const storage = globalThis.localStorage; + return storage ?? null; + } catch { + return null; } - return null; } export class AuthClient { diff --git a/betterbase/packages/client/src/build.ts b/betterbase/packages/client/src/build.ts index f22a625..a856ac6 100644 --- a/betterbase/packages/client/src/build.ts +++ b/betterbase/packages/client/src/build.ts @@ -34,7 +34,7 @@ if (!cjsResult.success) { process.exit(1); } -const proc = Bun.spawn(['bunx', 'tsc', '--emitDeclarationOnly', '--outDir', outdir], { +const proc = Bun.spawn(['bunx', 'tsc', '--project', 'tsconfig.json', '--emitDeclarationOnly', '--outDir', outdir], { cwd: path.resolve(moduleDir, '..'), stdout: 'inherit', stderr: 'inherit', diff --git a/betterbase/packages/client/src/realtime.ts b/betterbase/packages/client/src/realtime.ts index 63fe2da..ffdcf40 100644 --- a/betterbase/packages/client/src/realtime.ts +++ b/betterbase/packages/client/src/realtime.ts @@ -2,9 +2,15 @@ import type { RealtimeCallback, RealtimeSubscription } from './types'; type RealtimeEvent = 'INSERT' | 'UPDATE' | 'DELETE' | '*'; +interface TableSubscription { + callbacks: Set; + filter?: Record; + refCount: number; +} + export class RealtimeClient { private ws: WebSocket | null = null; - private subscriptions = new Map>(); + private subscriptions = new Map(); private reconnectTimeout: ReturnType | null = null; private reconnectAttempts = 0; private maxReconnectAttempts = 5; @@ -26,8 +32,8 @@ export class RealtimeClient { this.ws.onopen = () => { this.reconnectAttempts = 0; - for (const table of this.subscriptions.keys()) { - this.sendSubscribe(table); + for (const [table, subscription] of this.subscriptions.entries()) { + this.sendSubscribe(table, subscription.filter); } }; @@ -36,9 +42,9 @@ export class RealtimeClient { const data = JSON.parse(event.data as string); if (data.type !== 'update') return; - const callbacks = this.subscriptions.get(data.table); - if (callbacks) { - for (const callback of callbacks) { + const subscription = this.subscriptions.get(data.table); + if (subscription) { + for (const callback of subscription.callbacks) { callback({ event: data.event, data: data.data, timestamp: data.timestamp }); } } @@ -87,25 +93,41 @@ export class RealtimeClient { } }; - if (!this.subscriptions.has(table)) { - this.subscriptions.set(table, new Set()); + const subscription = this.subscriptions.get(table) ?? { + callbacks: new Set(), + refCount: 0, + filter, + }; + + subscription.callbacks.add(wrappedCallback); + subscription.refCount += 1; + + if (filter !== undefined) { + subscription.filter = filter; } - this.subscriptions.get(table)?.add(wrappedCallback); - this.sendSubscribe(table, filter); + this.subscriptions.set(table, subscription); + this.sendSubscribe(table, subscription.filter); return { unsubscribe: () => { - const callbacks = this.subscriptions.get(table); - callbacks?.delete(wrappedCallback); + const current = this.subscriptions.get(table); + if (!current) { + return; + } + + current.callbacks.delete(wrappedCallback); + current.refCount = Math.max(0, current.refCount - 1); - if (callbacks && callbacks.size === 0) { + if (current.refCount === 0 || current.callbacks.size === 0) { this.subscriptions.delete(table); this.sendUnsubscribe(table); if (this.subscriptions.size === 0) { this.disconnect(); } + } else { + this.subscriptions.set(table, current); } }, }; diff --git a/betterbase/packages/client/tsconfig.json b/betterbase/packages/client/tsconfig.json index 40e831f..c4c4354 100644 --- a/betterbase/packages/client/tsconfig.json +++ b/betterbase/packages/client/tsconfig.json @@ -7,6 +7,6 @@ "lib": ["ES2022", "DOM"], "types": ["bun"] }, - "include": ["src/**/*", "test/**/*"], - "exclude": ["node_modules", "dist"] + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test/**/*"] } diff --git a/betterbase/packages/client/tsconfig.test.json b/betterbase/packages/client/tsconfig.test.json new file mode 100644 index 0000000..f3e3655 --- /dev/null +++ b/betterbase/packages/client/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": false, + "declarationMap": false, + "noEmit": true + }, + "include": ["src/**/*", "test/**/*"] +} From 6d8b0b048256610194938ade89c1b3da59dcec11 Mon Sep 17 00:00:00 2001 From: BroUnion Date: Fri, 20 Feb 2026 04:25:58 +0200 Subject: [PATCH 6/9] fix(client): support multi-filter realtime subscriptions --- betterbase/packages/client/package.json | 15 ++- betterbase/packages/client/src/realtime.ts | 148 +++++++++++++-------- 2 files changed, 104 insertions(+), 59 deletions(-) diff --git a/betterbase/packages/client/package.json b/betterbase/packages/client/package.json index 3b7f219..fdd5544 100644 --- a/betterbase/packages/client/package.json +++ b/betterbase/packages/client/package.json @@ -2,20 +2,29 @@ "name": "@betterbase/client", "version": "0.1.0", "description": "TypeScript client for BetterBase backends", + "license": "MIT", + "author": "BetterBase", + "repository": { + "type": "git", + "url": "https://github.com/betterbase/betterbase.git" + }, + "engines": { + "node": ">=18" + }, "type": "module", "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { ".": { + "types": "./dist/index.d.ts", "import": "./dist/index.js", - "require": "./dist/index.cjs", - "types": "./dist/index.d.ts" + "require": "./dist/index.cjs" } }, "scripts": { "build": "bun run src/build.ts", - "dev": "bun run src/build.ts --watch", + "dev": "bun --watch run src/build.ts", "test": "bun test", "typecheck": "tsc --project tsconfig.test.json" }, diff --git a/betterbase/packages/client/src/realtime.ts b/betterbase/packages/client/src/realtime.ts index ffdcf40..181fc68 100644 --- a/betterbase/packages/client/src/realtime.ts +++ b/betterbase/packages/client/src/realtime.ts @@ -2,24 +2,67 @@ import type { RealtimeCallback, RealtimeSubscription } from './types'; type RealtimeEvent = 'INSERT' | 'UPDATE' | 'DELETE' | '*'; -interface TableSubscription { - callbacks: Set; +interface SubscriberEntry { + callback: RealtimeCallback; + event: RealtimeEvent; filter?: Record; - refCount: number; } export class RealtimeClient { private ws: WebSocket | null = null; - private subscriptions = new Map(); + private subscriptions = new Map>(); private reconnectTimeout: ReturnType | null = null; private reconnectAttempts = 0; private maxReconnectAttempts = 5; + private subscriberSequence = 0; constructor(private url: string) {} + private scheduleReconnect(): void { + if (this.reconnectTimeout || this.subscriptions.size === 0) { + return; + } + + if (this.reconnectAttempts >= this.maxReconnectAttempts) { + return; + } + + const delay = Math.min(1000 * 2 ** this.reconnectAttempts, 10000); + this.reconnectTimeout = setTimeout(() => { + this.reconnectTimeout = null; + this.reconnectAttempts += 1; + this.connect(); + }, delay); + } + + private sendSubscribe(table: string, filter?: Record): void { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify({ type: 'subscribe', table, filter })); + } + } + + private sendUnsubscribe(table: string): void { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify({ type: 'unsubscribe', table })); + } + } + + private sendSubscribeAll(table: string): void { + const tableSubscribers = this.subscriptions.get(table); + if (!tableSubscribers || tableSubscribers.size === 0) { + return; + } + + for (const subscriber of tableSubscribers.values()) { + this.sendSubscribe(table, subscriber.filter); + } + } + private connect(): void { if (typeof WebSocket === 'undefined') { - return; + const message = '[BetterBase] WebSocket is not available in this environment'; + console.warn(message); + throw new Error(message); } if (this.ws?.readyState === WebSocket.OPEN || this.ws?.readyState === WebSocket.CONNECTING) { @@ -27,13 +70,17 @@ export class RealtimeClient { } const wsUrl = this.url.replace(/^http/, 'ws') + '/ws'; - this.ws = new WebSocket(wsUrl); this.ws.onopen = () => { this.reconnectAttempts = 0; - for (const [table, subscription] of this.subscriptions.entries()) { - this.sendSubscribe(table, subscription.filter); + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; + } + + for (const table of this.subscriptions.keys()) { + this.sendSubscribeAll(table); } }; @@ -42,10 +89,14 @@ export class RealtimeClient { const data = JSON.parse(event.data as string); if (data.type !== 'update') return; - const subscription = this.subscriptions.get(data.table); - if (subscription) { - for (const callback of subscription.callbacks) { - callback({ event: data.event, data: data.data, timestamp: data.timestamp }); + const tableSubscribers = this.subscriptions.get(data.table); + if (!tableSubscribers) { + return; + } + + for (const subscriber of tableSubscribers.values()) { + if (subscriber.event === '*' || subscriber.event === data.event) { + subscriber.callback({ event: data.event, data: data.data, timestamp: data.timestamp }); } } } catch { @@ -53,28 +104,20 @@ export class RealtimeClient { } }; - this.ws.onclose = () => { + this.ws.onerror = (error) => { + console.error('[BetterBase] WebSocket error:', error); this.ws = null; - if (this.reconnectAttempts < this.maxReconnectAttempts && this.subscriptions.size > 0) { - const delay = Math.min(1000 * 2 ** this.reconnectAttempts, 10000); - this.reconnectTimeout = setTimeout(() => { - this.reconnectAttempts++; - this.connect(); - }, delay); + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; } + this.scheduleReconnect(); }; - } - private sendSubscribe(table: string, filter?: Record): void { - if (this.ws?.readyState === WebSocket.OPEN) { - this.ws.send(JSON.stringify({ type: 'subscribe', table, filter })); - } - } - - private sendUnsubscribe(table: string): void { - if (this.ws?.readyState === WebSocket.OPEN) { - this.ws.send(JSON.stringify({ type: 'unsubscribe', table })); - } + this.ws.onclose = () => { + this.ws = null; + this.scheduleReconnect(); + }; } from(table: string): { @@ -87,48 +130,41 @@ export class RealtimeClient { subscribe: (filter) => { this.connect(); - const wrappedCallback: RealtimeCallback = (payload) => { - if (event === '*' || payload.event === event) { - callback(payload as Parameters[0]); - } - }; + const tableSubscribers = this.subscriptions.get(table) ?? new Map(); + const id = `${table}:${this.subscriberSequence++}`; - const subscription = this.subscriptions.get(table) ?? { - callbacks: new Set(), - refCount: 0, + tableSubscribers.set(id, { + event, filter, - }; + callback: (payload) => callback(payload as Parameters[0]), + }); - subscription.callbacks.add(wrappedCallback); - subscription.refCount += 1; - - if (filter !== undefined) { - subscription.filter = filter; - } - - this.subscriptions.set(table, subscription); - this.sendSubscribe(table, subscription.filter); + this.subscriptions.set(table, tableSubscribers); + this.sendSubscribe(table, filter); return { unsubscribe: () => { - const current = this.subscriptions.get(table); - if (!current) { + const currentSubscribers = this.subscriptions.get(table); + if (!currentSubscribers) { return; } - current.callbacks.delete(wrappedCallback); - current.refCount = Math.max(0, current.refCount - 1); + currentSubscribers.delete(id); + + this.sendUnsubscribe(table); - if (current.refCount === 0 || current.callbacks.size === 0) { + if (currentSubscribers.size === 0) { this.subscriptions.delete(table); - this.sendUnsubscribe(table); if (this.subscriptions.size === 0) { this.disconnect(); } - } else { - this.subscriptions.set(table, current); + + return; } + + this.subscriptions.set(table, currentSubscribers); + this.sendSubscribeAll(table); }, }; }, From 987d1854f95c21a1784089f7eb13e11bae7f03da Mon Sep 17 00:00:00 2001 From: BroUnion Date: Fri, 20 Feb 2026 04:33:48 +0200 Subject: [PATCH 7/9] fix(client): avoid resubscribe churn on unsubscribe --- betterbase/packages/client/package.json | 4 +++- betterbase/packages/client/src/realtime.ts | 4 +--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/betterbase/packages/client/package.json b/betterbase/packages/client/package.json index fdd5544..5e6fe17 100644 --- a/betterbase/packages/client/package.json +++ b/betterbase/packages/client/package.json @@ -26,7 +26,9 @@ "build": "bun run src/build.ts", "dev": "bun --watch run src/build.ts", "test": "bun test", - "typecheck": "tsc --project tsconfig.test.json" + "typecheck": "tsc --noEmit --project tsconfig.json", + "lint": "tsc --noEmit --project tsconfig.test.json", + "typecheck:test": "tsc --project tsconfig.test.json" }, "keywords": [ "betterbase", diff --git a/betterbase/packages/client/src/realtime.ts b/betterbase/packages/client/src/realtime.ts index 181fc68..8c5fce3 100644 --- a/betterbase/packages/client/src/realtime.ts +++ b/betterbase/packages/client/src/realtime.ts @@ -151,10 +151,9 @@ export class RealtimeClient { currentSubscribers.delete(id); - this.sendUnsubscribe(table); - if (currentSubscribers.size === 0) { this.subscriptions.delete(table); + this.sendUnsubscribe(table); if (this.subscriptions.size === 0) { this.disconnect(); @@ -164,7 +163,6 @@ export class RealtimeClient { } this.subscriptions.set(table, currentSubscribers); - this.sendSubscribeAll(table); }, }; }, From b441a21c2e8d9bc01adb8fdb44d43a9b1b62263e Mon Sep 17 00:00:00 2001 From: BroUnion Date: Fri, 20 Feb 2026 05:05:40 +0200 Subject: [PATCH 8/9] Fix CLI and client issues from QA review --- betterbase/packages/cli/package.json | 6 +- betterbase/packages/cli/src/commands/auth.ts | 7 +- .../packages/cli/src/commands/generate.ts | 156 +++++++++++------- betterbase/packages/cli/src/commands/init.ts | 14 +- .../packages/cli/src/commands/migrate.ts | 37 ++++- betterbase/packages/cli/src/index.ts | 12 +- .../cli/src/utils/context-generator.ts | 2 +- betterbase/packages/cli/src/utils/scanner.ts | 2 +- betterbase/packages/client/package.json | 12 +- betterbase/packages/client/src/auth.ts | 50 ++++-- betterbase/packages/client/src/build.ts | 4 +- betterbase/packages/client/src/client.ts | 32 +++- betterbase/packages/client/src/errors.ts | 2 +- .../packages/client/src/query-builder.ts | 57 +++++-- betterbase/packages/client/src/realtime.ts | 40 +++-- betterbase/packages/client/src/types.ts | 5 + .../packages/client/test/client.test.ts | 36 +++- betterbase/packages/client/tsconfig.json | 20 ++- betterbase/packages/client/tsconfig.test.json | 6 +- betterbase/templates/base/.gitignore | 4 +- betterbase/templates/base/README.md | 1 + betterbase/templates/base/package.json | 3 +- betterbase/templates/base/src/index.ts | 5 + betterbase/templates/base/src/lib/realtime.ts | 33 ++-- betterbase/tsconfig.base.json | 1 - 25 files changed, 378 insertions(+), 169 deletions(-) diff --git a/betterbase/packages/cli/package.json b/betterbase/packages/cli/package.json index 0d0e6cd..f297fe2 100644 --- a/betterbase/packages/cli/package.json +++ b/betterbase/packages/cli/package.json @@ -16,11 +16,11 @@ "chalk": "^5.3.0", "commander": "^12.1.0", "inquirer": "^10.2.2", - "zod": "^3.23.8", - "typescript": "^5.8.0" + "zod": "^3.23.8" }, "devDependencies": { - "@types/bun": "^1.3.9" + "@types/bun": "^1.3.9", + "typescript": "^5.8.0" }, "exports": { ".": "./src/index.ts" diff --git a/betterbase/packages/cli/src/commands/auth.ts b/betterbase/packages/cli/src/commands/auth.ts index d5b92db..9f89137 100644 --- a/betterbase/packages/cli/src/commands/auth.ts +++ b/betterbase/packages/cli/src/commands/auth.ts @@ -70,7 +70,7 @@ authRoute.post('/signup', async (c) => { .returning(); const createdUser = created[0]; - if (!createdUser || typeof createdUser !== 'object') { + if (!createdUser) { return c.json({ error: 'Failed to create user record' }, 500); } @@ -257,7 +257,6 @@ function ensurePasswordHashColumn(schemaPath: string): void { while (i < current.length) { const ch = current[i]; - const next = current[i + 1]; if (escaped) { escaped = false; @@ -272,10 +271,6 @@ function ensurePasswordHashColumn(schemaPath: string): void { } if (!inDouble && !inBacktick && ch === "'") { - if (inSingle && next === "'") { - i += 2; - continue; - } inSingle = !inSingle; i += 1; continue; diff --git a/betterbase/packages/cli/src/commands/generate.ts b/betterbase/packages/cli/src/commands/generate.ts index c389e8f..4614254 100644 --- a/betterbase/packages/cli/src/commands/generate.ts +++ b/betterbase/packages/cli/src/commands/generate.ts @@ -6,26 +6,11 @@ import * as logger from '../utils/logger'; function toSingular(name: string): string { const lower = name.toLowerCase(); const invariants = new Set(['status', 'news', 'series']); - if (invariants.has(lower)) { - return name; - } - - if (/men$/i.test(name)) { - return name.replace(/men$/i, 'man'); - } - - if (/ies$/i.test(name)) { - return name.replace(/ies$/i, 'y'); - } - - if (/(ses|xes|zes|ches|shes)$/i.test(name)) { - return name.replace(/es$/i, ''); - } - - if (name.endsWith('s') && !name.endsWith('ss')) { - return name.slice(0, -1); - } - + if (invariants.has(lower)) return name; + if (/men$/i.test(name)) return name.replace(/men$/i, 'man'); + if (/ies$/i.test(name)) return name.replace(/ies$/i, 'y'); + if (/(ses|xes|zes|ches|shes)$/i.test(name)) return name.replace(/es$/i, ''); + if (name.endsWith('s') && !name.endsWith('ss')) return name.slice(0, -1); return `${name}Item`; } @@ -48,12 +33,28 @@ function buildSchemaShape(table: TableInfo, mode: 'create' | 'update'): string { .join(',\n'); } +function buildFilterableColumns(table: TableInfo): string { + return Object.entries(table.columns) + .filter(([, column]) => !column.primaryKey) + .map(([column]) => ` '${column}',`) + .join('\n'); +} + +function buildFilterCoercers(table: TableInfo): string { + return Object.entries(table.columns) + .filter(([, column]) => !column.primaryKey) + .map(([column, info]) => ` ${column}: ${schemaTypeToZod(info.type)},`) + .join('\n'); +} + function generateRouteFile(tableName: string, table: TableInfo): string { const singular = toSingular(tableName); const createShape = buildSchemaShape(table, 'create'); const updateShape = buildSchemaShape(table, 'update'); + const filterableColumns = buildFilterableColumns(table); + const filterCoercers = buildFilterCoercers(table); - return `import { and, asc, desc, eq } from 'drizzle-orm'; + return `import { and, asc, desc, eq, inArray } from 'drizzle-orm'; import { Hono } from 'hono'; import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; @@ -71,16 +72,24 @@ const updateSchema = z.object({ ${updateShape} }); -${tableName}Route.get('/', async (c) => { - const DEFAULT_LIMIT = 50; - const MAX_LIMIT = 100; - const DEFAULT_OFFSET = 0; +const DEFAULT_LIMIT = 50; +const MAX_LIMIT = 100; +const DEFAULT_OFFSET = 0; - const paginationSchema = z.object({ - limit: z.coerce.number().int().nonnegative().default(DEFAULT_LIMIT), - offset: z.coerce.number().int().nonnegative().default(DEFAULT_OFFSET), - }); +const paginationSchema = z.object({ + limit: z.coerce.number().int().nonnegative().default(DEFAULT_LIMIT), + offset: z.coerce.number().int().nonnegative().default(DEFAULT_OFFSET), +}); + +const FILTERABLE_COLUMNS = new Set([ +${filterableColumns} +]); + +const FILTER_COERCE = { +${filterCoercers} +} as const; +${tableName}Route.get('/', async (c) => { const queryParams = c.req.query(); const paginationResult = paginationSchema.safeParse({ limit: queryParams.limit, @@ -94,16 +103,37 @@ ${tableName}Route.get('/', async (c) => { const { limit, offset } = paginationResult.data; const fetchLimit = Math.min(limit, MAX_LIMIT); const sort = queryParams.sort; - const filters = Object.entries(queryParams).filter(([key, value]) => key !== 'limit' && key !== 'offset' && key !== 'sort' && value !== undefined); + const filters = Object.entries(queryParams).filter( + ([key, value]) => key !== 'limit' && key !== 'offset' && key !== 'sort' && value !== undefined, + ); let query = db.select().from(${tableName}).$dynamic(); if (filters.length > 0) { - // Security note: by default all table columns are filterable. Consider adding a schema scanner - // annotation (e.g., "filterable") and replacing this with an explicit allowlist for sensitive tables. const conditions = filters - .filter(([key]) => key in ${tableName}) - .map(([key, value]) => eq(${tableName}[key as keyof typeof ${tableName}] as never, value as never)); + .flatMap(([rawKey, value]) => { + if (rawKey.endsWith('_in')) { + const key = rawKey.slice(0, -3); + if (!FILTERABLE_COLUMNS.has(key)) return []; + + try { + const parsedInValues = JSON.parse(String(value)); + if (!Array.isArray(parsedInValues)) return []; + return [inArray(${tableName}[key as keyof typeof ${tableName}] as never, parsedInValues as never[])]; + } catch { + return []; + } + } + + if (!FILTERABLE_COLUMNS.has(rawKey)) return []; + const schema = FILTER_COERCE[rawKey as keyof typeof FILTER_COERCE]; + if (!schema) return []; + + const parsed = schema.safeParse(value); + if (!parsed.success) return []; + + return [eq(${tableName}[rawKey as keyof typeof ${tableName}] as never, parsed.data as never)]; + }); if (conditions.length > 0) { query = query.where(and(...conditions)); @@ -185,7 +215,9 @@ function updateMainRouter(projectRoot: string, tableName: string): void { if (!router.includes(importLine)) { const firstRouteImport = /import\s+\{\s*healthRoute\s*\}\s+from\s+'\.\/health';/; - router = firstRouteImport.test(router) ? router.replace(firstRouteImport, (m) => `${m}\n${importLine}`) : `${importLine}\n${router}`; + router = firstRouteImport.test(router) + ? router.replace(firstRouteImport, (m) => `${m}\n${importLine}`) + : `${importLine}\n${router}`; } if (!router.includes(routeLine)) { @@ -216,39 +248,49 @@ function ensureRealtimeUtility(projectRoot: string): void { } async function ensureZodValidatorInstalled(projectRoot: string): Promise { - const packageJsonPath = path.join(projectRoot, 'package.json'); - const modulePath = path.join(projectRoot, 'node_modules', '@hono', 'zod-validator'); - - if (existsSync(modulePath)) { - return; - } - - if (existsSync(packageJsonPath)) { - try { - const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')) as { - dependencies?: Record; - devDependencies?: Record; - }; - - if (packageJson.dependencies?.['@hono/zod-validator'] || packageJson.devDependencies?.['@hono/zod-validator']) { - return; + let current = path.resolve(projectRoot); + + while (true) { + const modulePath = path.join(current, 'node_modules', '@hono', 'zod-validator'); + if (existsSync(modulePath)) return; + + const packageJsonPath = path.join(current, 'package.json'); + if (existsSync(packageJsonPath)) { + try { + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')) as { + dependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; + }; + + if ( + packageJson.dependencies?.['@hono/zod-validator'] + || packageJson.devDependencies?.['@hono/zod-validator'] + || packageJson.peerDependencies?.['@hono/zod-validator'] + ) { + return; + } + } catch { + // Fall through to install branch. } - } catch { - // Fall through to install branch. } + + const parent = path.dirname(current); + if (parent === current) break; + current = parent; } logger.info('Installing @hono/zod-validator...'); - const process = Bun.spawn(['bun', 'add', '@hono/zod-validator'], { + const child = Bun.spawn(['bun', 'add', '@hono/zod-validator'], { cwd: projectRoot, stdout: 'pipe', stderr: 'pipe', }); const [exitCode, stdout, stderr] = await Promise.all([ - process.exited, - new Response(process.stdout).text(), - new Response(process.stderr).text(), + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), ]); if (exitCode !== 0) { diff --git a/betterbase/packages/cli/src/commands/init.ts b/betterbase/packages/cli/src/commands/init.ts index edd37cd..97bd374 100644 --- a/betterbase/packages/cli/src/commands/init.ts +++ b/betterbase/packages/cli/src/commands/init.ts @@ -123,6 +123,7 @@ export default defineConfig({ out: './drizzle', dialect: '${dialect[databaseMode]}', dbCredentials: { + // Keep local fallback in sync with src/lib/env.ts DEFAULT_DB_PATH url: ${databaseUrl[databaseMode]},${tursoAuthTokenLine} }, }); @@ -539,7 +540,7 @@ const DEFAULT_OFFSET = 0; // (DEFAULT_LIMIT / DEFAULT_OFFSET with MAX_LIMIT clamping applied by caller). If strict validation // is needed, callers should parse with Zod and return 400 instead of using this helper. function parseNonNegativeInt(value: string | undefined, fallback: number): number { - if (!value) { + if (!value || value.trim() === '') { return fallback; } @@ -595,11 +596,14 @@ usersRoute.post('/', async (c) => { const body = await c.req.json(); const parsed = parseBody(createUserSchema, body); - // TODO: persist parsed user via db.insert(users) or a dedicated UsersService. + const created = await db.insert(users).values(parsed).returning(); + if (created.length === 0) { + throw new HTTPException(500, { message: 'Failed to persist user' }); + } + return c.json({ - message: 'User payload validated (not persisted)', - user: parsed, - }); + user: created[0], + }, 201); } catch (error) { if (error instanceof HTTPException) { throw error; diff --git a/betterbase/packages/cli/src/commands/migrate.ts b/betterbase/packages/cli/src/commands/migrate.ts index 5fa0708..f8e98be 100644 --- a/betterbase/packages/cli/src/commands/migrate.ts +++ b/betterbase/packages/cli/src/commands/migrate.ts @@ -268,21 +268,43 @@ function splitStatements(sql: string): string[] { let inSingle = false; let inDouble = false; let inBacktick = false; - let escapeNext = false; + let inLineComment = false; + let inBlockComment = false; for (let i = 0; i < sql.length; i += 1) { const ch = sql[i]; const next = sql[i + 1]; - if (escapeNext) { + if (inLineComment) { current += ch; - escapeNext = false; + if (ch === ' +') { + inLineComment = false; + } continue; } - if ((inSingle || inDouble || inBacktick) && ch === '\\') { + if (inBlockComment) { current += ch; - escapeNext = true; + if (ch === '*' && next === '/') { + current += next; + i += 1; + inBlockComment = false; + } + continue; + } + + if (!inSingle && !inDouble && !inBacktick && ch === '-' && next === '-') { + current += ch + next; + i += 1; + inLineComment = true; + continue; + } + + if (!inSingle && !inDouble && !inBacktick && ch === '/' && next === '*') { + current += ch + next; + i += 1; + inBlockComment = true; continue; } @@ -319,7 +341,7 @@ function splitStatements(sql: string): string[] { continue; } - if (ch === ';' && !inSingle && !inDouble && !inBacktick) { + if (ch === ';' && !inSingle && !inDouble && !inBacktick && !inLineComment && !inBlockComment) { const statement = current.trim(); if (statement.length > 0) { statements.push(statement); @@ -394,7 +416,7 @@ export async function runMigrateCommand(rawOptions: MigrateCommandOptions): Prom } logger.info('Applying migrations with drizzle-kit push...'); - logger.info('drizzle/ files are for preview; push applied changes.'); + logger.info('drizzle/ files are for preview; running push will apply changes.'); const push = await runDrizzleKit(['push']); if (!push.success) { @@ -411,5 +433,6 @@ export async function runMigrateCommand(rawOptions: MigrateCommandOptions): Prom throw new Error(`Migration push failed.\n${push.stderr || push.stdout}`); } + logger.info('drizzle-kit push completed; changes applied.'); logger.success('Migration complete!'); } diff --git a/betterbase/packages/cli/src/index.ts b/betterbase/packages/cli/src/index.ts index 443baa2..b0d3b9c 100644 --- a/betterbase/packages/cli/src/index.ts +++ b/betterbase/packages/cli/src/index.ts @@ -35,8 +35,18 @@ export function createProgram(): Command { .action(async (projectRoot: string) => { const cleanup = await runDevCommand(projectRoot); + let cleanedUp = false; const onExit = (): void => { - cleanup(); + if (!cleanedUp) { + cleanedUp = true; + try { + cleanup(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.warn(`Dev cleanup failed: ${message}`); + } + } + process.off('SIGINT', onSigInt); process.off('SIGTERM', onSigTerm); process.off('exit', onProcessExit); diff --git a/betterbase/packages/cli/src/utils/context-generator.ts b/betterbase/packages/cli/src/utils/context-generator.ts index 5c00c51..3bdf904 100644 --- a/betterbase/packages/cli/src/utils/context-generator.ts +++ b/betterbase/packages/cli/src/utils/context-generator.ts @@ -44,7 +44,7 @@ export class ContextGenerator { const outputPath = path.join(projectRoot, '.betterbase-context.json'); writeFileSync(outputPath, `${JSON.stringify(context, null, 2)}\n`); - logger.success(`✅ Generated ${outputPath}`); + logger.success(`Generated ${outputPath}`); return context; } diff --git a/betterbase/packages/cli/src/utils/scanner.ts b/betterbase/packages/cli/src/utils/scanner.ts index 9686078..7165c56 100644 --- a/betterbase/packages/cli/src/utils/scanner.ts +++ b/betterbase/packages/cli/src/utils/scanner.ts @@ -96,7 +96,7 @@ export class SchemaScanner { const functionName = getCallName(initializer); if (functionName === 'sqliteTable' || functionName === 'pgTable' || functionName === 'mysqlTable') { const tableObj = this.parseTable(initializer); - const tableKey = tableObj.name ?? declaration.name.text; + const tableKey = tableObj.name || declaration.name.text; tables[tableKey] = tableObj; } } diff --git a/betterbase/packages/client/package.json b/betterbase/packages/client/package.json index 5e6fe17..f00a6fb 100644 --- a/betterbase/packages/client/package.json +++ b/betterbase/packages/client/package.json @@ -6,10 +6,11 @@ "author": "BetterBase", "repository": { "type": "git", - "url": "https://github.com/betterbase/betterbase.git" + "url": "https://github.com/weroperking/Betterbase.git" }, "engines": { - "node": ">=18" + "node": ">=18", + "bun": ">=1.0.0" }, "type": "module", "main": "./dist/index.cjs", @@ -27,8 +28,8 @@ "dev": "bun --watch run src/build.ts", "test": "bun test", "typecheck": "tsc --noEmit --project tsconfig.json", - "lint": "tsc --noEmit --project tsconfig.test.json", - "typecheck:test": "tsc --project tsconfig.test.json" + "lint": "biome check src test", + "typecheck:test": "tsc --noEmit --project tsconfig.test.json" }, "keywords": [ "betterbase", @@ -43,6 +44,7 @@ ], "devDependencies": { "@types/bun": "^1.3.9", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "@biomejs/biome": "^1.9.4" } } diff --git a/betterbase/packages/client/src/auth.ts b/betterbase/packages/client/src/auth.ts index 7461eee..808f4e9 100644 --- a/betterbase/packages/client/src/auth.ts +++ b/betterbase/packages/client/src/auth.ts @@ -1,5 +1,6 @@ +import { z } from 'zod'; import type { BetterBaseResponse } from './types'; -import { AuthError, NetworkError } from './errors'; +import { AuthError, NetworkError, ValidationError } from './errors'; export interface AuthCredentials { email: string; @@ -18,6 +19,17 @@ export interface Session { user: User; } +interface StorageAdapter { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +} + +const credentialsSchema = z.object({ + email: z.string().email(), + password: z.string().min(8), +}); + function getStorage(): Storage | null { try { if (typeof globalThis === 'undefined') { @@ -36,23 +48,29 @@ export class AuthClient { private url: string, private headers: Record, private onAuthStateChange?: (token: string | null) => void, - private fetchImpl: typeof fetch = fetch + private fetchImpl: typeof fetch = fetch, + private storage: StorageAdapter | null = getStorage() ) {} async signUp(credentials: AuthCredentials): Promise> { + const parsed = credentialsSchema.safeParse(credentials); + if (!parsed.success) { + return { data: null, error: new ValidationError('Invalid sign up credentials', parsed.error.format()) }; + } + const endpoint = `${this.url}/auth/signup`; try { const response = await this.fetchImpl(endpoint, { method: 'POST', headers: { ...this.headers, 'Content-Type': 'application/json' }, - body: JSON.stringify(credentials), + body: JSON.stringify({ ...credentials, ...parsed.data }), }); if (!response.ok) { const error = await response.json().catch(() => ({ error: 'Signup failed' })); return { data: null, error: new AuthError(error.error || 'Failed to sign up', error) }; } const session = (await response.json()) as Session; - getStorage()?.setItem('betterbase_token', session.token); + this.storage?.setItem('betterbase_token', session.token); this.onAuthStateChange?.(session.token); return { data: session, error: null }; } catch (error) { @@ -64,19 +82,24 @@ export class AuthClient { } async signIn(credentials: Omit): Promise> { + const parsed = credentialsSchema.safeParse(credentials); + if (!parsed.success) { + return { data: null, error: new ValidationError('Invalid sign in credentials', parsed.error.format()) }; + } + const endpoint = `${this.url}/auth/login`; try { const response = await this.fetchImpl(endpoint, { method: 'POST', headers: { ...this.headers, 'Content-Type': 'application/json' }, - body: JSON.stringify(credentials), + body: JSON.stringify(parsed.data), }); if (!response.ok) { const error = await response.json().catch(() => ({ error: 'Login failed' })); return { data: null, error: new AuthError(error.error || 'Invalid credentials', error) }; } const session = (await response.json()) as Session; - getStorage()?.setItem('betterbase_token', session.token); + this.storage?.setItem('betterbase_token', session.token); this.onAuthStateChange?.(session.token); return { data: session, error: null }; } catch (error) { @@ -89,7 +112,7 @@ export class AuthClient { async signOut(): Promise> { const endpoint = `${this.url}/auth/logout`; - const token = getStorage()?.getItem('betterbase_token') ?? null; + const token = this.storage?.getItem('betterbase_token') ?? null; try { const response = await this.fetchImpl(endpoint, { method: 'POST', @@ -99,7 +122,7 @@ export class AuthClient { }, }); - getStorage()?.removeItem('betterbase_token'); + this.storage?.removeItem('betterbase_token'); this.onAuthStateChange?.(null); if (!response.ok) { @@ -109,7 +132,7 @@ export class AuthClient { return { data: null, error: null }; } catch (error) { - getStorage()?.removeItem('betterbase_token'); + this.storage?.removeItem('betterbase_token'); this.onAuthStateChange?.(null); return { data: null, @@ -120,7 +143,7 @@ export class AuthClient { async getUser(): Promise> { const endpoint = `${this.url}/auth/me`; - const token = getStorage()?.getItem('betterbase_token') ?? null; + const token = this.storage?.getItem('betterbase_token') ?? null; if (!token) { return { data: null, error: new AuthError('Not authenticated') }; @@ -146,15 +169,14 @@ export class AuthClient { } getToken(): string | null { - return getStorage()?.getItem('betterbase_token') ?? null; + return this.storage?.getItem('betterbase_token') ?? null; } setToken(token: string | null): void { - const storage = getStorage(); if (token) { - storage?.setItem('betterbase_token', token); + this.storage?.setItem('betterbase_token', token); } else { - storage?.removeItem('betterbase_token'); + this.storage?.removeItem('betterbase_token'); } this.onAuthStateChange?.(token); } diff --git a/betterbase/packages/client/src/build.ts b/betterbase/packages/client/src/build.ts index a856ac6..d67ec45 100644 --- a/betterbase/packages/client/src/build.ts +++ b/betterbase/packages/client/src/build.ts @@ -15,7 +15,7 @@ const esmResult = await Bun.build({ }); if (!esmResult.success) { - console.error('ESM build failed:', esmResult.logs); + console.error(`ESM build failed: ${esmResult.logs.map((log) => log.toString()).join('\n')}`); process.exit(1); } @@ -30,7 +30,7 @@ const cjsResult = await Bun.build({ }); if (!cjsResult.success) { - console.error('CJS build failed:', cjsResult.logs); + console.error(`CJS build failed: ${cjsResult.logs.map((log) => log.toString()).join('\n')}`); process.exit(1); } diff --git a/betterbase/packages/client/src/client.ts b/betterbase/packages/client/src/client.ts index 1cc5ac3..158d711 100644 --- a/betterbase/packages/client/src/client.ts +++ b/betterbase/packages/client/src/client.ts @@ -1,8 +1,21 @@ +import { z } from 'zod'; import type { BetterBaseConfig } from './types'; -import { QueryBuilder } from './query-builder'; +import { QueryBuilder, type QueryBuilderOptions } from './query-builder'; import { AuthClient } from './auth'; import { RealtimeClient } from './realtime'; +const BetterBaseConfigSchema = z.object({ + url: z.string().url(), + key: z.string().min(1).optional(), + schema: z.string().optional(), + fetch: z.function().optional(), + storage: z.object({ + getItem: z.function(), + setItem: z.function(), + removeItem: z.function(), + }).optional(), +}); + export class BetterBaseClient { private headers: Record; private fetchImpl: typeof fetch; @@ -11,12 +24,13 @@ export class BetterBaseClient { public realtime: RealtimeClient; constructor(config: BetterBaseConfig) { - this.url = config.url.replace(/\/$/, ''); + const parsed = BetterBaseConfigSchema.parse(config); + this.url = parsed.url.replace(/\/$/, ''); this.headers = { 'Content-Type': 'application/json', - ...(config.key ? { 'X-BetterBase-Key': config.key } : {}), + ...(parsed.key ? { 'X-BetterBase-Key': parsed.key } : {}), }; - this.fetchImpl = config.fetch ?? fetch; + this.fetchImpl = parsed.fetch ?? fetch; this.auth = new AuthClient( this.url, @@ -27,11 +41,13 @@ export class BetterBaseClient { } else { delete this.headers.Authorization; } + this.realtime.setToken(token); }, - this.fetchImpl + this.fetchImpl, + parsed.storage ); - this.realtime = new RealtimeClient(this.url); + this.realtime = new RealtimeClient(this.url, this.auth.getToken()); const token = this.auth.getToken(); if (token) { @@ -39,8 +55,8 @@ export class BetterBaseClient { } } - from(table: string): QueryBuilder { - return new QueryBuilder(this.url, table, this.headers, this.fetchImpl); + from(table: string, options?: QueryBuilderOptions): QueryBuilder { + return new QueryBuilder(this.url, table, this.headers, this.fetchImpl, options); } } diff --git a/betterbase/packages/client/src/errors.ts b/betterbase/packages/client/src/errors.ts index a270bd8..28b2ffe 100644 --- a/betterbase/packages/client/src/errors.ts +++ b/betterbase/packages/client/src/errors.ts @@ -6,7 +6,7 @@ export class BetterBaseError extends Error { public status?: number ) { super(message); - this.name = 'BetterBaseError'; + this.name = this.constructor.name; } } diff --git a/betterbase/packages/client/src/query-builder.ts b/betterbase/packages/client/src/query-builder.ts index 62e478e..2a60b35 100644 --- a/betterbase/packages/client/src/query-builder.ts +++ b/betterbase/packages/client/src/query-builder.ts @@ -1,49 +1,78 @@ +import { z } from 'zod'; import type { BetterBaseResponse, QueryOptions } from './types'; -import { BetterBaseError, NetworkError } from './errors'; +import { BetterBaseError, NetworkError, ValidationError } from './errors'; + +export interface QueryBuilderOptions { + singularKey?: string; +} + +const stringSchema = z.string().min(1); +const valuesSchema = z.array(z.unknown()); +const nonNegativeIntSchema = z.number().int().nonnegative(); export class QueryBuilder { private filters: Record = {}; private options: QueryOptions = {}; private selectFields = '*'; + private executed = false; constructor( private url: string, private table: string, private headers: Record, - private fetchImpl: typeof fetch = fetch + private fetchImpl: typeof fetch = fetch, + private builderOptions: QueryBuilderOptions = {} ) {} + private assertMutable(): void { + if (this.executed) { + throw new Error('QueryBuilder instances are single-use; create a new one via from().'); + } + } + select(fields = '*'): this { - this.selectFields = fields; + this.assertMutable(); + this.selectFields = stringSchema.parse(fields); return this; } eq(column: string, value: unknown): this { - this.filters[column] = value; + this.assertMutable(); + this.filters[stringSchema.parse(column)] = value; return this; } in(column: string, values: unknown[]): this { - this.filters[`${column}_in`] = values; + this.assertMutable(); + const parsedValues = valuesSchema.parse(values); + this.filters[`${stringSchema.parse(column)}_in`] = JSON.stringify(parsedValues); return this; } limit(count: number): this { - this.options.limit = count; + this.assertMutable(); + this.options.limit = nonNegativeIntSchema.parse(count); return this; } offset(count: number): this { - this.options.offset = count; + this.assertMutable(); + this.options.offset = nonNegativeIntSchema.parse(count); return this; } order(column: string, direction: 'asc' | 'desc' = 'asc'): this { - this.options.orderBy = { column, direction }; + this.assertMutable(); + this.options.orderBy = { column: stringSchema.parse(column), direction }; return this; } async execute(): Promise> { + if (this.executed) { + return { data: null, error: new ValidationError('QueryBuilder instances are single-use; create a new one via from().') }; + } + this.executed = true; + const params = new URLSearchParams(); params.append('select', this.selectFields); @@ -93,6 +122,10 @@ export class QueryBuilder { } } + private getSingularKey(): string { + return this.builderOptions.singularKey || (this.table.endsWith('s') ? this.table.slice(0, -1) : this.table); + } + async single(id: string): Promise> { const endpoint = `${this.url}/api/${this.table}/${id}`; try { @@ -105,7 +138,7 @@ export class QueryBuilder { }; } const result = await response.json(); - const singularKey = this.table.endsWith('s') ? this.table.slice(0, -1) : this.table; + const singularKey = this.getSingularKey(); return { data: result[singularKey] || result.data || null, error: null }; } catch (error) { return { @@ -131,7 +164,7 @@ export class QueryBuilder { }; } const result = await response.json(); - const singularKey = this.table.endsWith('s') ? this.table.slice(0, -1) : this.table; + const singularKey = this.getSingularKey(); return { data: result[singularKey] || result.data || null, error: null }; } catch (error) { return { @@ -157,7 +190,7 @@ export class QueryBuilder { }; } const result = await response.json(); - const singularKey = this.table.endsWith('s') ? this.table.slice(0, -1) : this.table; + const singularKey = this.getSingularKey(); return { data: result[singularKey] || result.data || null, error: null }; } catch (error) { return { @@ -179,7 +212,7 @@ export class QueryBuilder { }; } const result = await response.json(); - const singularKey = this.table.endsWith('s') ? this.table.slice(0, -1) : this.table; + const singularKey = this.getSingularKey(); return { data: result[singularKey] || result.data || null, error: null }; } catch (error) { return { diff --git a/betterbase/packages/client/src/realtime.ts b/betterbase/packages/client/src/realtime.ts index 8c5fce3..b624fa1 100644 --- a/betterbase/packages/client/src/realtime.ts +++ b/betterbase/packages/client/src/realtime.ts @@ -15,11 +15,19 @@ export class RealtimeClient { private reconnectAttempts = 0; private maxReconnectAttempts = 5; private subscriberSequence = 0; + private disabled = false; + private token: string | null; - constructor(private url: string) {} + constructor(private url: string, token: string | null = null) { + this.token = token; + } + + setToken(token: string | null): void { + this.token = token; + } private scheduleReconnect(): void { - if (this.reconnectTimeout || this.subscriptions.size === 0) { + if (this.disabled || this.reconnectTimeout || this.subscriptions.size === 0) { return; } @@ -36,12 +44,14 @@ export class RealtimeClient { } private sendSubscribe(table: string, filter?: Record): void { + if (this.disabled) return; if (this.ws?.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: 'subscribe', table, filter })); } } private sendUnsubscribe(table: string): void { + if (this.disabled) return; if (this.ws?.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: 'unsubscribe', table })); } @@ -60,16 +70,17 @@ export class RealtimeClient { private connect(): void { if (typeof WebSocket === 'undefined') { - const message = '[BetterBase] WebSocket is not available in this environment'; - console.warn(message); - throw new Error(message); + this.disabled = true; + console.warn('[BetterBase] WebSocket is not available in this environment; realtime disabled'); + return; } if (this.ws?.readyState === WebSocket.OPEN || this.ws?.readyState === WebSocket.CONNECTING) { return; } - const wsUrl = this.url.replace(/^http/, 'ws') + '/ws'; + const baseUrl = this.url.replace(/^http/, 'ws') + '/ws'; + const wsUrl = this.token ? `${baseUrl}?token=${encodeURIComponent(this.token)}` : baseUrl; this.ws = new WebSocket(wsUrl); this.ws.onopen = () => { @@ -106,11 +117,12 @@ export class RealtimeClient { this.ws.onerror = (error) => { console.error('[BetterBase] WebSocket error:', error); - this.ws = null; + this.ws?.close(); if (this.reconnectTimeout) { clearTimeout(this.reconnectTimeout); this.reconnectTimeout = null; } + this.ws = null; this.scheduleReconnect(); }; @@ -128,7 +140,9 @@ export class RealtimeClient { return { on: (event, callback) => ({ subscribe: (filter) => { - this.connect(); + if (!this.disabled) { + this.connect(); + } const tableSubscribers = this.subscriptions.get(table) ?? new Map(); const id = `${table}:${this.subscriberSequence++}`; @@ -140,7 +154,9 @@ export class RealtimeClient { }); this.subscriptions.set(table, tableSubscribers); - this.sendSubscribe(table, filter); + if (!this.disabled) { + this.sendSubscribe(table, filter); + } return { unsubscribe: () => { @@ -153,9 +169,11 @@ export class RealtimeClient { if (currentSubscribers.size === 0) { this.subscriptions.delete(table); - this.sendUnsubscribe(table); + if (!this.disabled) { + this.sendUnsubscribe(table); + } - if (this.subscriptions.size === 0) { + if (this.subscriptions.size === 0 && !this.disabled) { this.disconnect(); } diff --git a/betterbase/packages/client/src/types.ts b/betterbase/packages/client/src/types.ts index c6784be..b11b24c 100644 --- a/betterbase/packages/client/src/types.ts +++ b/betterbase/packages/client/src/types.ts @@ -5,6 +5,11 @@ export interface BetterBaseConfig { key?: string; schema?: string; fetch?: typeof fetch; + storage?: { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; + }; } export interface QueryOptions { diff --git a/betterbase/packages/client/test/client.test.ts b/betterbase/packages/client/test/client.test.ts index bc05995..0603c6c 100644 --- a/betterbase/packages/client/test/client.test.ts +++ b/betterbase/packages/client/test/client.test.ts @@ -1,6 +1,10 @@ -import { describe, expect, test } from 'bun:test'; +import { afterEach, describe, expect, mock, test } from 'bun:test'; import { createClient } from '../src'; +afterEach(() => { + mock.restore(); +}); + describe('@betterbase/client', () => { test('creates client with config', () => { const client = createClient({ @@ -21,4 +25,34 @@ describe('@betterbase/client', () => { expect(query.select).toBeDefined(); expect(query.eq).toBeDefined(); }); + + test('execute sends chained query with key header', async () => { + const fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit) => { + expect(String(input)).toContain('/api/users?'); + expect(String(input)).toContain('select=id%2Cemail'); + expect(String(input)).toContain('email=test%40example.com'); + expect(init?.method).toBe('GET'); + expect((init?.headers as Record)['X-BetterBase-Key']).toBe('test-key'); + return new Response(JSON.stringify({ users: [] }), { status: 200 }); + }); + + const client = createClient({ url: 'http://localhost:3000', key: 'test-key', fetch: fetchMock as typeof fetch }); + const res = await client.from('users').select('id,email').eq('email', 'test@example.com').execute(); + + expect(res.error).toBeNull(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + test('execute sends simple request', async () => { + const fetchMock = mock(async (input: RequestInfo | URL) => { + expect(String(input)).toBe('http://localhost:3000/api/users?select=*'); + return new Response(JSON.stringify({ users: [{ id: '1' }] }), { status: 200 }); + }); + + const client = createClient({ url: 'http://localhost:3000', fetch: fetchMock as typeof fetch }); + const res = await client.from<{ id: string }>('users').execute(); + + expect(res.error).toBeNull(); + expect(res.data).toEqual([{ id: '1' }]); + }); }); diff --git a/betterbase/packages/client/tsconfig.json b/betterbase/packages/client/tsconfig.json index c4c4354..dc3b91d 100644 --- a/betterbase/packages/client/tsconfig.json +++ b/betterbase/packages/client/tsconfig.json @@ -3,10 +3,20 @@ "compilerOptions": { "outDir": "./dist", "declaration": true, - "declarationMap": true, - "lib": ["ES2022", "DOM"], - "types": ["bun"] + "lib": [ + "ES2022", + "DOM" + ], + "types": [ + "bun" + ] }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "test/**/*"] + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "dist", + "test/**/*" + ] } diff --git a/betterbase/packages/client/tsconfig.test.json b/betterbase/packages/client/tsconfig.test.json index f3e3655..1209859 100644 --- a/betterbase/packages/client/tsconfig.test.json +++ b/betterbase/packages/client/tsconfig.test.json @@ -2,8 +2,10 @@ "extends": "./tsconfig.json", "compilerOptions": { "declaration": false, - "declarationMap": false, "noEmit": true }, - "include": ["src/**/*", "test/**/*"] + "include": [ + "src/**/*", + "test/**/*" + ] } diff --git a/betterbase/templates/base/.gitignore b/betterbase/templates/base/.gitignore index a8b1485..2cef73e 100644 --- a/betterbase/templates/base/.gitignore +++ b/betterbase/templates/base/.gitignore @@ -1,3 +1,3 @@ -dist/ -node_modules/ .env +.env.* +!.env.example diff --git a/betterbase/templates/base/README.md b/betterbase/templates/base/README.md index d8349cd..eb1a6ff 100644 --- a/betterbase/templates/base/README.md +++ b/betterbase/templates/base/README.md @@ -22,6 +22,7 @@ src/ validation.ts lib/ env.ts + realtime.ts index.ts betterbase.config.ts drizzle.config.ts diff --git a/betterbase/templates/base/package.json b/betterbase/templates/base/package.json index 7f490b8..5d0970c 100644 --- a/betterbase/templates/base/package.json +++ b/betterbase/templates/base/package.json @@ -13,7 +13,8 @@ "dependencies": { "hono": "^4.6.10", "zod": "^4.0.0", - "drizzle-orm": "^0.44.5" + "drizzle-orm": "^0.44.5", + "fast-deep-equal": "^3.1.3" }, "devDependencies": { "@types/bun": "^1.3.9", diff --git a/betterbase/templates/base/src/index.ts b/betterbase/templates/base/src/index.ts index 75b4ec1..8ed7caa 100644 --- a/betterbase/templates/base/src/index.ts +++ b/betterbase/templates/base/src/index.ts @@ -10,9 +10,14 @@ app.get( '/ws', upgradeWebSocket((c) => { const authHeaderToken = c.req.header('authorization')?.replace(/^Bearer\s+/i, ''); + // Prefer Authorization header. Query token is compatibility fallback and should be short-lived in production. const queryToken = c.req.query('token'); const token = authHeaderToken ?? queryToken; + if (!authHeaderToken && queryToken) { + console.warn('WebSocket auth using query token fallback; prefer header/cookie/subprotocol in production.'); + } + return { onOpen(_event, ws) { realtime.handleConnection(ws.raw, token); diff --git a/betterbase/templates/base/src/lib/realtime.ts b/betterbase/templates/base/src/lib/realtime.ts index 9fffef2..0d7ccc1 100644 --- a/betterbase/templates/base/src/lib/realtime.ts +++ b/betterbase/templates/base/src/lib/realtime.ts @@ -1,4 +1,5 @@ import type { ServerWebSocket } from 'bun'; +import deepEqual from 'fast-deep-equal'; import { z } from 'zod'; export interface Subscription { @@ -38,33 +39,16 @@ const realtimeLogger = { warn: (message: string): void => console.warn(`[realtime] ${message}`), }; -function deepEqual(a: unknown, b: unknown): boolean { - if (a === b) return true; - if (a == null || b == null) return a === b; - - if (Array.isArray(a) || Array.isArray(b)) { - if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false; - return a.every((value, idx) => deepEqual(value, b[idx])); - } - - if (typeof a === 'object' && typeof b === 'object') { - const aObj = a as Record; - const bObj = b as Record; - const aKeys = Object.keys(aObj); - const bKeys = Object.keys(bObj); - if (aKeys.length !== bKeys.length) return false; - return aKeys.every((key) => deepEqual(aObj[key], bObj[key])); - } - - return false; -} - export class RealtimeServer { private clients = new Map, Client>(); private tableSubscribers = new Map>>(); private config: RealtimeConfig; constructor(config?: Partial) { + if (process.env.NODE_ENV !== 'development' && process.env.ENABLE_DEV_AUTH !== 'true') { + realtimeLogger.warn('Realtime auth verifier is not configured; dev token parser is disabled. Configure a real verifier for production.'); + } + this.config = { maxClients: 1000, maxSubscriptionsPerClient: 50, @@ -76,8 +60,11 @@ export class RealtimeServer { authenticate(token: string | undefined): { userId: string; claims: string[] } | null { if (!token || !token.trim()) return null; - // TODO: Replace this placeholder with real auth verification in production: - // verify signature/issuer, enforce expiry, and map claims/scopes from your auth provider. + const allowDevAuth = process.env.NODE_ENV === 'development' || process.env.ENABLE_DEV_AUTH === 'true'; + if (!allowDevAuth) { + return null; + } + const [userId, rawClaims] = token.trim().split(':', 2); if (!userId) return null; diff --git a/betterbase/tsconfig.base.json b/betterbase/tsconfig.base.json index 80f5eae..e86a10c 100644 --- a/betterbase/tsconfig.base.json +++ b/betterbase/tsconfig.base.json @@ -4,7 +4,6 @@ "module": "ESNext", "moduleResolution": "Bundler", "strict": true, - "jsx": "react-jsx", "esModuleInterop": true, "skipLibCheck": true, "resolveJsonModule": true, From aa4a42e2b4a40bc9af80beb2cb15b942c717a458 Mon Sep 17 00:00:00 2001 From: BroUnion Date: Fri, 20 Feb 2026 05:14:22 +0200 Subject: [PATCH 9/9] Fix bun types version and coerce generated _in filters --- betterbase/packages/cli/src/commands/generate.ts | 13 ++++++++++++- betterbase/packages/client/package.json | 2 +- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/betterbase/packages/cli/src/commands/generate.ts b/betterbase/packages/cli/src/commands/generate.ts index 4614254..874642f 100644 --- a/betterbase/packages/cli/src/commands/generate.ts +++ b/betterbase/packages/cli/src/commands/generate.ts @@ -116,10 +116,21 @@ ${tableName}Route.get('/', async (c) => { const key = rawKey.slice(0, -3); if (!FILTERABLE_COLUMNS.has(key)) return []; + const schema = FILTER_COERCE[key as keyof typeof FILTER_COERCE]; + if (!schema) return []; + try { const parsedInValues = JSON.parse(String(value)); if (!Array.isArray(parsedInValues)) return []; - return [inArray(${tableName}[key as keyof typeof ${tableName}] as never, parsedInValues as never[])]; + + const coercedValues = parsedInValues + .map((item) => schema.safeParse(item)) + .filter((result) => result.success) + .map((result) => result.data); + + if (coercedValues.length === 0) return []; + + return [inArray(${tableName}[key as keyof typeof ${tableName}] as never, coercedValues as never[])]; } catch { return []; } diff --git a/betterbase/packages/client/package.json b/betterbase/packages/client/package.json index f00a6fb..7a886a7 100644 --- a/betterbase/packages/client/package.json +++ b/betterbase/packages/client/package.json @@ -43,7 +43,7 @@ "README.md" ], "devDependencies": { - "@types/bun": "^1.3.9", + "@types/bun": "^1.3.8", "typescript": "^5.9.3", "@biomejs/biome": "^1.9.4" }