From 439c91cbbfc79df093bd94ea222709468bac5723 Mon Sep 17 00:00:00 2001 From: Marco Sadjadi Date: Tue, 19 May 2026 00:21:18 +0200 Subject: [PATCH] feat(db): drizzle schema and client (orgs, servers, builds, oauth, metrics, audit) --- packages/db/drizzle.config.ts | 12 ++ packages/db/package.json | 28 ++++ packages/db/src/index.ts | 30 ++++ packages/db/src/migrate.ts | 17 +++ packages/db/src/schema.ts | 251 ++++++++++++++++++++++++++++++++++ packages/db/tsconfig.json | 9 ++ 6 files changed, 347 insertions(+) create mode 100644 packages/db/drizzle.config.ts create mode 100644 packages/db/package.json create mode 100644 packages/db/src/index.ts create mode 100644 packages/db/src/migrate.ts create mode 100644 packages/db/src/schema.ts create mode 100644 packages/db/tsconfig.json diff --git a/packages/db/drizzle.config.ts b/packages/db/drizzle.config.ts new file mode 100644 index 0000000..65f7b6c --- /dev/null +++ b/packages/db/drizzle.config.ts @@ -0,0 +1,12 @@ +import type { Config } from 'drizzle-kit'; + +export default { + schema: './src/schema.ts', + out: './drizzle', + dialect: 'postgresql', + dbCredentials: { + url: process.env.DATABASE_URL ?? 'postgresql://bmm:bmm@localhost:5432/bmm', + }, + strict: true, + verbose: true, +} satisfies Config; diff --git a/packages/db/package.json b/packages/db/package.json new file mode 100644 index 0000000..7b79a32 --- /dev/null +++ b/packages/db/package.json @@ -0,0 +1,28 @@ +{ + "name": "@bmm/db", + "version": "0.1.0", + "type": "module", + "private": true, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./schema": "./src/schema.ts" + }, + "scripts": { + "generate": "drizzle-kit generate", + "migrate": "tsx src/migrate.ts", + "push": "drizzle-kit push", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "drizzle-orm": "0.36.4", + "postgres": "3.4.5" + }, + "devDependencies": { + "@types/node": "22.10.2", + "drizzle-kit": "0.30.0", + "tsx": "4.19.2", + "typescript": "5.7.2" + } +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts new file mode 100644 index 0000000..c264681 --- /dev/null +++ b/packages/db/src/index.ts @@ -0,0 +1,30 @@ +import postgres from 'postgres'; +import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js'; +import * as schema from './schema.js'; + +export * from './schema.js'; +export { schema }; +export { sql, eq, and, or, desc, asc, inArray, gte, lte, lt, gt, isNull, isNotNull, count } from 'drizzle-orm'; + +export type Database = PostgresJsDatabase; + +let cached: { client: postgres.Sql; db: Database } | null = null; + +export function createDb(connectionString?: string): Database { + if (cached) return cached.db; + const url = connectionString ?? process.env.DATABASE_URL; + if (!url) { + throw new Error('DATABASE_URL not set'); + } + const client = postgres(url, { max: 10, prepare: false }); + const db = drizzle(client, { schema }); + cached = { client, db }; + return db; +} + +export async function closeDb(): Promise { + if (cached) { + await cached.client.end({ timeout: 5 }); + cached = null; + } +} diff --git a/packages/db/src/migrate.ts b/packages/db/src/migrate.ts new file mode 100644 index 0000000..3e228cc --- /dev/null +++ b/packages/db/src/migrate.ts @@ -0,0 +1,17 @@ +import { migrate } from 'drizzle-orm/postgres-js/migrator'; +import postgres from 'postgres'; +import { drizzle } from 'drizzle-orm/postgres-js'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const url = process.env.DATABASE_URL ?? 'postgresql://bmm:bmm@localhost:5432/bmm'; +const client = postgres(url, { max: 1, prepare: false }); +const db = drizzle(client); + +const here = path.dirname(fileURLToPath(import.meta.url)); +const migrationsFolder = path.resolve(here, '..', 'drizzle'); + +console.log(`[db] migrating from ${migrationsFolder}...`); +await migrate(db, { migrationsFolder }); +console.log('[db] migrations done.'); +await client.end(); diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts new file mode 100644 index 0000000..2ad9d6c --- /dev/null +++ b/packages/db/src/schema.ts @@ -0,0 +1,251 @@ +import { + bigint, + boolean, + index, + integer, + jsonb, + pgEnum, + pgTable, + text, + timestamp, + uuid, + varchar, +} from 'drizzle-orm/pg-core'; + +export const planEnum = pgEnum('plan', ['hobby', 'pro', 'team', 'enterprise']); + +export const serverStatusEnum = pgEnum('server_status', [ + 'draft', + 'queued', + 'generating', + 'building', + 'deploying', + 'live', + 'failed', + 'paused', +]); + +export const buildStatusEnum = pgEnum('build_status', [ + 'queued', + 'generating', + 'building', + 'deploying', + 'success', + 'failed', + 'cancelled', +]); + +export const organizations = pgTable('organizations', { + id: uuid('id').defaultRandom().primaryKey(), + slug: varchar('slug', { length: 64 }).notNull().unique(), + name: varchar('name', { length: 128 }).notNull(), + plan: planEnum('plan').default('hobby').notNull(), + stripeCustomerId: varchar('stripe_customer_id', { length: 128 }), + stripeSubscriptionId: varchar('stripe_subscription_id', { length: 128 }), + monthlyCallQuota: bigint('monthly_call_quota', { mode: 'number' }).default(100_000).notNull(), + callsThisPeriod: bigint('calls_this_period', { mode: 'number' }).default(0).notNull(), + periodStartsAt: timestamp('period_starts_at').defaultNow().notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), +}); + +export const users = pgTable('users', { + id: uuid('id').defaultRandom().primaryKey(), + email: varchar('email', { length: 255 }).notNull().unique(), + name: varchar('name', { length: 128 }), + avatarUrl: text('avatar_url'), + emailVerified: boolean('email_verified').default(false).notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), +}); + +export const sessions = pgTable( + 'sessions', + { + id: uuid('id').defaultRandom().primaryKey(), + userId: uuid('user_id') + .references(() => users.id, { onDelete: 'cascade' }) + .notNull(), + tokenHash: text('token_hash').notNull().unique(), + expiresAt: timestamp('expires_at').notNull(), + ipAddress: varchar('ip_address', { length: 64 }), + userAgent: text('user_agent'), + createdAt: timestamp('created_at').defaultNow().notNull(), + }, + (t) => ({ + userIdx: index('idx_sessions_user').on(t.userId), + }), +); + +export const magicLinks = pgTable('magic_links', { + id: uuid('id').defaultRandom().primaryKey(), + email: varchar('email', { length: 255 }).notNull(), + tokenHash: text('token_hash').notNull().unique(), + expiresAt: timestamp('expires_at').notNull(), + consumedAt: timestamp('consumed_at'), + createdAt: timestamp('created_at').defaultNow().notNull(), +}); + +export const memberships = pgTable('memberships', { + id: uuid('id').defaultRandom().primaryKey(), + orgId: uuid('org_id') + .references(() => organizations.id, { onDelete: 'cascade' }) + .notNull(), + userId: uuid('user_id') + .references(() => users.id, { onDelete: 'cascade' }) + .notNull(), + role: varchar('role', { length: 32 }).default('owner').notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), +}); + +export const mcpServers = pgTable( + 'mcp_servers', + { + id: uuid('id').defaultRandom().primaryKey(), + orgId: uuid('org_id') + .references(() => organizations.id, { onDelete: 'cascade' }) + .notNull(), + slug: varchar('slug', { length: 64 }).notNull(), + name: varchar('name', { length: 128 }).notNull(), + description: text('description'), + status: serverStatusEnum('status').default('draft').notNull(), + currentVersion: integer('current_version').default(0).notNull(), + containerId: varchar('container_id', { length: 128 }), + hostPort: integer('host_port'), + publicUrl: text('public_url'), + toolsSchema: jsonb('tools_schema'), + oauthEnabled: boolean('oauth_enabled').default(true).notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), + }, + (t) => ({ + orgSlugIdx: index('idx_servers_org_slug').on(t.orgId, t.slug), + }), +); + +export const builds = pgTable( + 'builds', + { + id: uuid('id').defaultRandom().primaryKey(), + serverId: uuid('server_id') + .references(() => mcpServers.id, { onDelete: 'cascade' }) + .notNull(), + version: integer('version').notNull(), + prompt: text('prompt').notNull(), + generatedSpec: jsonb('generated_spec'), + generatedCode: text('generated_code'), + status: buildStatusEnum('status').default('queued').notNull(), + errorMessage: text('error_message'), + startedAt: timestamp('started_at'), + finishedAt: timestamp('finished_at'), + createdAt: timestamp('created_at').defaultNow().notNull(), + }, + (t) => ({ + serverIdx: index('idx_builds_server').on(t.serverId), + }), +); + +export const buildLogs = pgTable( + 'build_logs', + { + id: uuid('id').defaultRandom().primaryKey(), + buildId: uuid('build_id') + .references(() => builds.id, { onDelete: 'cascade' }) + .notNull(), + level: varchar('level', { length: 16 }).default('info').notNull(), + message: text('message').notNull(), + timestamp: timestamp('timestamp').defaultNow().notNull(), + }, + (t) => ({ + buildIdx: index('idx_logs_build').on(t.buildId, t.timestamp), + }), +); + +export const secrets = pgTable('secrets', { + id: uuid('id').defaultRandom().primaryKey(), + serverId: uuid('server_id') + .references(() => mcpServers.id, { onDelete: 'cascade' }) + .notNull(), + key: varchar('key', { length: 128 }).notNull(), + encryptedValue: text('encrypted_value').notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), +}); + +export const oauthClients = pgTable('oauth_clients', { + id: uuid('id').defaultRandom().primaryKey(), + serverId: uuid('server_id') + .references(() => mcpServers.id, { onDelete: 'cascade' }) + .notNull(), + clientId: varchar('client_id', { length: 128 }).notNull().unique(), + clientSecretHash: text('client_secret_hash'), + redirectUris: jsonb('redirect_uris').notNull(), + metadata: jsonb('metadata'), + createdAt: timestamp('created_at').defaultNow().notNull(), +}); + +export const oauthCodes = pgTable('oauth_codes', { + id: uuid('id').defaultRandom().primaryKey(), + clientDbId: uuid('client_db_id') + .references(() => oauthClients.id, { onDelete: 'cascade' }) + .notNull(), + code: varchar('code', { length: 128 }).notNull().unique(), + codeChallenge: varchar('code_challenge', { length: 256 }).notNull(), + codeChallengeMethod: varchar('code_challenge_method', { length: 16 }).notNull(), + redirectUri: text('redirect_uri').notNull(), + scope: text('scope'), + resource: text('resource'), + userId: uuid('user_id').references(() => users.id, { onDelete: 'set null' }), + expiresAt: timestamp('expires_at').notNull(), + consumedAt: timestamp('consumed_at'), + createdAt: timestamp('created_at').defaultNow().notNull(), +}); + +export const oauthTokens = pgTable('oauth_tokens', { + id: uuid('id').defaultRandom().primaryKey(), + clientDbId: uuid('client_db_id') + .references(() => oauthClients.id, { onDelete: 'cascade' }) + .notNull(), + accessTokenHash: text('access_token_hash').notNull(), + refreshTokenHash: text('refresh_token_hash'), + scope: text('scope'), + resource: text('resource'), + expiresAt: timestamp('expires_at').notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), +}); + +export const toolCallMetrics = pgTable( + 'tool_call_metrics', + { + id: uuid('id').defaultRandom().primaryKey(), + serverId: uuid('server_id') + .references(() => mcpServers.id, { onDelete: 'cascade' }) + .notNull(), + toolName: varchar('tool_name', { length: 128 }).notNull(), + durationMs: integer('duration_ms'), + success: boolean('success').notNull(), + errorCode: varchar('error_code', { length: 64 }), + timestamp: timestamp('timestamp').defaultNow().notNull(), + }, + (t) => ({ + serverTimeIdx: index('idx_metrics_server_time').on(t.serverId, t.timestamp), + }), +); + +export const auditLog = pgTable('audit_log', { + id: uuid('id').defaultRandom().primaryKey(), + orgId: uuid('org_id').references(() => organizations.id, { onDelete: 'set null' }), + userId: uuid('user_id').references(() => users.id, { onDelete: 'set null' }), + action: varchar('action', { length: 128 }).notNull(), + resourceType: varchar('resource_type', { length: 64 }), + resourceId: varchar('resource_id', { length: 128 }), + metadata: jsonb('metadata'), + ipAddress: varchar('ip_address', { length: 64 }), + createdAt: timestamp('created_at').defaultNow().notNull(), +}); + +export type Organization = typeof organizations.$inferSelect; +export type User = typeof users.$inferSelect; +export type Session = typeof sessions.$inferSelect; +export type McpServer = typeof mcpServers.$inferSelect; +export type Build = typeof builds.$inferSelect; +export type BuildLog = typeof buildLogs.$inferSelect; +export type Secret = typeof secrets.$inferSelect; +export type OAuthClient = typeof oauthClients.$inferSelect; diff --git a/packages/db/tsconfig.json b/packages/db/tsconfig.json new file mode 100644 index 0000000..4b1980d --- /dev/null +++ b/packages/db/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "types": ["node"] + }, + "include": ["src/**/*"] +}