diff --git a/lib/chat/search.ts b/lib/chat/search.ts index 9b7533ea..4a3f5f73 100644 --- a/lib/chat/search.ts +++ b/lib/chat/search.ts @@ -6,7 +6,7 @@ * shared by app/api/chat and app/api/professional-chat routes. */ -import type { SupabaseClient } from '@supabase/supabase-js'; +import type { AppSupabaseClient } from '@/lib/supabase'; import { logger } from '@/lib/logger'; import { truncateChunks, joinContext } from './context'; @@ -38,7 +38,7 @@ export interface DocumentSearchResult { * @param options - Optional filters (documentId, allowedDocumentIds, matchCount, maxContextChars) */ export async function searchUserDocuments( - supabase: SupabaseClient, + supabase: AppSupabaseClient, embedding: number[], userId: string, options: { diff --git a/lib/constants.ts b/lib/constants.ts index 1023fba8..bc2d1d33 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -8,6 +8,21 @@ * This file contains domain-specific constants. */ +/** + * The Postgres schema this app's tables live in. + * + * Botsmann shares one self-hosted Supabase database with orangecat, which owns + * `public` and its 128 tables. Our names collide with theirs — `conversations`, + * `documents`, `waitlist`, and a function `update_updated_at()` that public + * already defines — so our migrations apply into a schema of our own, exactly + * as printcraft's do. Every client must be told, or it queries orangecat's + * `public` and gets PGRST205 "Could not find the table" (which is the 503 + * /api/health served for months). + * + * SSOT: this constant and `supabase:botsmann` in fleetcrown's apps.conf. + */ +export const DB_SCHEMA = 'botsmann'; + // Domain-specific error messages (extends api-utils ERROR_MESSAGES) export const DOMAIN_ERRORS = { // Service errors diff --git a/lib/supabase-server.ts b/lib/supabase-server.ts index e14a1e56..e0465a52 100644 --- a/lib/supabase-server.ts +++ b/lib/supabase-server.ts @@ -10,6 +10,7 @@ import { createServerClient } from '@supabase/ssr'; import { cookies } from 'next/headers'; import { getClientEnv } from '@/lib/config/env'; +import { DB_SCHEMA } from '@/lib/constants'; /** * Create Supabase client for route handlers (API routes) @@ -24,6 +25,7 @@ export async function createRouteHandlerClient(_options?: { cookies?: unknown }) const cookieStore = await cookies(); return createServerClient(supabaseUrl, supabaseAnonKey, { + db: { schema: DB_SCHEMA }, cookies: { getAll() { return cookieStore.getAll(); diff --git a/lib/supabase.ts b/lib/supabase.ts index ff4e641f..ec0415f8 100644 --- a/lib/supabase.ts +++ b/lib/supabase.ts @@ -10,9 +10,10 @@ * Setup: https://supabase.com/dashboard */ -import { createClient, SupabaseClient } from '@supabase/supabase-js'; +import { createClient } from '@supabase/supabase-js'; import { createBrowserClient } from '@supabase/ssr'; import { getClientEnv, getServerEnv } from '@/lib/config/env'; +import { DB_SCHEMA } from '@/lib/constants'; // Database row types export interface ConsultationRow { @@ -65,18 +66,33 @@ export function isSupabaseConfigured(): boolean { return Boolean(supabaseUrl && supabaseAnonKey); } +function createAnonClient() { + const { NEXT_PUBLIC_SUPABASE_URL: supabaseUrl, NEXT_PUBLIC_SUPABASE_ANON_KEY: supabaseAnonKey } = + getClientEnv(); + + return createClient(supabaseUrl, supabaseAnonKey, { db: { schema: DB_SCHEMA } }); +} + +/** + * A client scoped to OUR schema. + * + * The bare `SupabaseClient` type hardcodes `'public'` as its schema, so + * annotating with it silently asserts we query orangecat's tables — and the + * compiler rejects the client we actually build. Deriving the type from the + * factory keeps the two in step, and survives supabase-js changing the order + * of its generic parameters. + */ +export type AppSupabaseClient = ReturnType; + // Lazy-loaded Supabase client singleton -let _supabaseClient: SupabaseClient | null = null; +let _supabaseClient: AppSupabaseClient | null = null; -export function getSupabaseClient(): SupabaseClient { +export function getSupabaseClient(): AppSupabaseClient { if (_supabaseClient) { return _supabaseClient; } - const { NEXT_PUBLIC_SUPABASE_URL: supabaseUrl, NEXT_PUBLIC_SUPABASE_ANON_KEY: supabaseAnonKey } = - getClientEnv(); - - _supabaseClient = createClient(supabaseUrl, supabaseAnonKey); + _supabaseClient = createAnonClient(); return _supabaseClient; } @@ -105,11 +121,12 @@ export const supabase = { }; // Server-side client with service role (for admin operations) -export function getServiceClient(): SupabaseClient { +export function getServiceClient() { const { NEXT_PUBLIC_SUPABASE_URL: supabaseUrl } = getClientEnv(); const { SUPABASE_SERVICE_ROLE_KEY: serviceRoleKey } = getServerEnv(); return createClient(supabaseUrl, serviceRoleKey, { + db: { schema: DB_SCHEMA }, auth: { autoRefreshToken: false, persistSession: false, @@ -187,5 +204,5 @@ export function createClientComponentClient() { return mockClient; } - return createBrowserClient(supabaseUrl, supabaseAnonKey); + return createBrowserClient(supabaseUrl, supabaseAnonKey, { db: { schema: DB_SCHEMA } }); } diff --git a/scripts/migrate-via-api.ts b/scripts/migrate-via-api.ts deleted file mode 100644 index ca3f12cd..00000000 --- a/scripts/migrate-via-api.ts +++ /dev/null @@ -1,146 +0,0 @@ -#!/usr/bin/env npx tsx -/** - * Run Migration via Supabase - Attempts multiple methods - */ - -import { createClient } from '@supabase/supabase-js'; -import { readFileSync } from 'fs'; -import { join } from 'path'; - -// Read .env.local manually -const envPath = join(process.cwd(), '.env.local'); -const envContent = readFileSync(envPath, 'utf-8'); -const envVars: Record = {}; -for (const line of envContent.split('\n')) { - const match = line.match(/^([^=]+)=(.*)$/); - if (match) { - envVars[match[1]] = match[2].replace(/^["']|["']$/g, ''); - } -} - -const supabaseUrl = envVars.NEXT_PUBLIC_SUPABASE_URL; -const supabaseKey = envVars.SUPABASE_SERVICE_ROLE_KEY; - -const supabase = createClient(supabaseUrl, supabaseKey, { - auth: { persistSession: false }, -}); - -// Read migration -const migrationPath = join(process.cwd(), 'supabase/migrations/009_user_centric.sql'); -const fullSql = readFileSync(migrationPath, 'utf-8'); - -// Split into statements more carefully -function splitStatements(sql: string): string[] { - const results: string[] = []; - let current = ''; - let inDollarQuote = false; - let dollarTag = ''; - - const lines = sql.split('\n'); - - for (const line of lines) { - const trimmed = line.trim(); - - // Skip comment-only lines - if (trimmed.startsWith('--') && !inDollarQuote) { - continue; - } - - // Check for dollar quote start/end - const dollarMatch = line.match(/\$([a-zA-Z_]*)\$/g); - if (dollarMatch) { - for (const match of dollarMatch) { - if (!inDollarQuote) { - inDollarQuote = true; - dollarTag = match; - } else if (match === dollarTag) { - inDollarQuote = false; - dollarTag = ''; - } - } - } - - current += line + '\n'; - - // Check for statement end (semicolon not in dollar quote) - if (!inDollarQuote && trimmed.endsWith(';')) { - const stmt = current.trim(); - if (stmt && !stmt.match(/^--/)) { - results.push(stmt); - } - current = ''; - } - } - - // Add any remaining statement - if (current.trim()) { - results.push(current.trim()); - } - - return results; -} - -async function main() { - console.log('šŸš€ Running migration 009_user_centric.sql\n'); - - const statements = splitStatements(fullSql); - console.log(`Found ${statements.length} statements to execute\n`); - - let success = 0; - let failed = 0; - let skipped = 0; - - for (let i = 0; i < statements.length; i++) { - const stmt = statements[i]; - const preview = stmt.substring(0, 60).replace(/\n/g, ' ').trim(); - - try { - // Try using rpc to execute SQL (requires a function to exist) - // This is a common pattern - create a function that can execute dynamic SQL - const { error } = await supabase.rpc('exec_sql', { sql_string: stmt }); - - if (error) { - // Check if it's just "function doesn't exist" - if (error.message.includes('function') && error.message.includes('does not exist')) { - // Try alternative: direct table operations for simple cases - throw new Error('exec_sql function not available'); - } - throw error; - } - - console.log(`āœ“ [${i + 1}/${statements.length}] ${preview}...`); - success++; - } catch (err: any) { - const errMsg = err?.message || String(err); - - // Check for "already exists" type errors - these are OK - if (errMsg.includes('already exists') || errMsg.includes('duplicate')) { - console.log(`ā—‹ [${i + 1}/${statements.length}] Skipped (exists): ${preview}...`); - skipped++; - } else if (errMsg.includes('exec_sql function not available')) { - // First failure due to missing function - try direct approach - console.log(`⚠ [${i + 1}/${statements.length}] Cannot execute DDL via API: ${preview}...`); - failed++; - } else { - console.log(`āœ— [${i + 1}/${statements.length}] Failed: ${errMsg.substring(0, 100)}`); - console.log(` Statement: ${preview}...`); - failed++; - } - } - } - - console.log('\n' + '='.repeat(60)); - console.log(`Results: ${success} success, ${skipped} skipped, ${failed} failed`); - - if (failed > 0) { - console.log('\nāš ļø Some statements could not be executed via the API.'); - console.log('This is expected - Supabase REST API cannot run DDL statements.'); - console.log('\nPlease run the migration manually in Supabase Dashboard:'); - - const projectRef = supabaseUrl.replace('https://', '').split('.')[0]; - console.log(`\nšŸ‘‰ https://supabase.com/dashboard/project/${projectRef}/sql/new`); - console.log('\nPaste the contents of: supabase/migrations/009_user_centric.sql'); - } -} - -main().catch(console.error); diff --git a/scripts/run-migration.ts b/scripts/run-migration.ts deleted file mode 100644 index cbc4baf7..00000000 --- a/scripts/run-migration.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * Run Database Migration - * - * Usage: npx tsx scripts/run-migration.ts - * - * Runs the user-centric migration (009_user_centric.sql) using - * the Supabase service role key from environment. - */ - -import { createClient } from '@supabase/supabase-js'; -import { readFileSync } from 'fs'; -import { join } from 'path'; - -async function runMigration() { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; - const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; - - if (!supabaseUrl || !supabaseServiceKey) { - console.error('Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY'); - process.exit(1); - } - - const supabase = createClient(supabaseUrl, supabaseServiceKey); - - // Read migration file - const migrationPath = join(process.cwd(), 'supabase/migrations/009_user_centric.sql'); - const sql = readFileSync(migrationPath, 'utf-8'); - - console.log('Running migration: 009_user_centric.sql'); - console.log('---'); - - // Split by statement (simple split - works for our migration) - // Note: This is a simple approach. For complex migrations, use proper SQL parsing. - const statements = sql - .split(/;\s*$/m) - .map((s) => s.trim()) - .filter((s) => s.length > 0 && !s.startsWith('--')); - - let successCount = 0; - let errorCount = 0; - - for (const statement of statements) { - if (!statement || statement.startsWith('--')) continue; - - try { - const { error } = await supabase.rpc('exec_sql', { sql_query: statement + ';' }); - - if (error) { - // Try direct query for DDL statements - const { error: directError } = await supabase.from('_exec').select().limit(0); - - // If RPC doesn't exist, we'll need to run statements differently - if (error.message.includes('function') || error.message.includes('does not exist')) { - console.log(`⚠ Statement needs manual execution (RPC not available)`); - console.log(` First 80 chars: ${statement.substring(0, 80)}...`); - errorCount++; - continue; - } - - throw error; - } - - successCount++; - const preview = statement.substring(0, 60).replace(/\n/g, ' '); - console.log(`āœ“ ${preview}...`); - } catch (err) { - const error = err as Error; - // Some errors are expected (e.g., "already exists") - if (error.message?.includes('already exists') || error.message?.includes('duplicate')) { - console.log(`ā—‹ Skipped (already exists): ${statement.substring(0, 50)}...`); - successCount++; - } else { - console.error(`āœ— Error: ${error.message}`); - console.error(` Statement: ${statement.substring(0, 100)}...`); - errorCount++; - } - } - } - - console.log('---'); - console.log(`Migration complete: ${successCount} succeeded, ${errorCount} failed`); - - if (errorCount > 0) { - console.log( - '\nSome statements failed. You may need to run them manually in Supabase Dashboard.', - ); - console.log('Go to: https://supabase.com/dashboard/project/_/sql'); - } -} - -runMigration().catch(console.error); diff --git a/scripts/test-db-connection.ts b/scripts/test-db-connection.ts index 1a619433..0426c9b1 100644 --- a/scripts/test-db-connection.ts +++ b/scripts/test-db-connection.ts @@ -9,6 +9,7 @@ */ import { createClient } from '@supabase/supabase-js'; +import { DB_SCHEMA } from '../lib/constants'; import * as dotenv from 'dotenv'; import * as path from 'path'; @@ -23,7 +24,9 @@ if (!supabaseUrl || !supabaseKey) { process.exit(1); } -const supabase = createClient(supabaseUrl, supabaseKey); +// Our tables live in our own schema, not orangecat's `public` — without this +// the diagnostic reports every table missing and looks like a broken database. +const supabase = createClient(supabaseUrl, supabaseKey, { db: { schema: DB_SCHEMA } }); async function testConnection() { console.log('šŸ” Testing Supabase Connection...\n'); diff --git a/tests/__tests__/lib/supabase-schema.test.ts b/tests/__tests__/lib/supabase-schema.test.ts new file mode 100644 index 00000000..08985883 --- /dev/null +++ b/tests/__tests__/lib/supabase-schema.test.ts @@ -0,0 +1,68 @@ +/** + * Every Supabase client must be told which schema we live in. + * + * Botsmann shares one self-hosted Supabase database with orangecat. orangecat + * owns `public`; our tables are in `botsmann`. A client created without + * `db: { schema }` silently queries orangecat's schema and gets PGRST205 + * "Could not find the table" — which is exactly the 503 /api/health served for + * months while eleven migrations sat unapplied. + * + * The failure is silent at compile time and at review time: the call looks + * completely ordinary. So it is pinned here, per file, by count — adding a new + * client factory without a schema fails this test rather than production. + */ + +import { readFileSync } from 'fs'; +import { join } from 'path'; + +import { DB_SCHEMA } from '@/lib/constants'; + +const ROOT = join(__dirname, '..', '..', '..'); + +// Comments are where someone documents the very call they are about to write, +// so counting them as code makes this guard lie in both directions. +function stripComments(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1'); +} + +const CLIENT_FILES = ['lib/supabase.ts', 'lib/supabase-server.ts']; + +const CREATE_CALL = /\bcreate(?:Browser|Server)?Client\s*\(/g; +const SCHEMA_OPT = /db:\s*\{\s*schema:\s*DB_SCHEMA\s*\}/g; + +describe('every Supabase client is scoped to our schema', () => { + it('names a schema that is not public', () => { + expect(DB_SCHEMA).toBe('botsmann'); + expect(DB_SCHEMA).not.toBe('public'); + }); + + it.each(CLIENT_FILES)('%s scopes every client it creates', (rel) => { + const code = stripComments(readFileSync(join(ROOT, rel), 'utf-8')); + + // The import itself is a `createClient(` -free line, but be explicit: only + // call sites count, not the `import { createClient }` statement. + const calls = (code.match(CREATE_CALL) ?? []).length; + const scoped = (code.match(SCHEMA_OPT) ?? []).length; + + expect(calls).toBeGreaterThan(0); + expect(scoped).toBe(calls); + }); + + it('uses the shared constant rather than a literal, in every client file', () => { + for (const rel of CLIENT_FILES) { + const code = stripComments(readFileSync(join(ROOT, rel), 'utf-8')); + expect(code).toContain('import { DB_SCHEMA }'); + // A second source of truth is how the first one goes stale. + expect(code).not.toMatch(/schema:\s*['"`]botsmann['"`]/); + } + }); + + it('leaves no hand-rolled migration runner that would write to public', () => { + // These applied SQL through the service-role client with no schema set, so + // they would create our tables inside orangecat's `public`. Schema is the + // deploy pipeline's job now (fleetcrown apply-schema.sh, `supabase:botsmann`). + for (const gone of ['scripts/run-migration.ts', 'scripts/migrate-via-api.ts']) { + expect(() => readFileSync(join(ROOT, gone), 'utf-8')).toThrow(); + } + }); +});