import { readFile } from 'node:fs/promises' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import postgres from 'postgres' const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') async function loadRootEnvironment() { let contents try { contents = await readFile(join(root, '.env'), 'utf8') } catch (error) { if (error && typeof error === 'object' && error.code === 'ENOENT') return throw error } for (const line of contents.split(/\r?\n/)) { const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/) if (!match || process.env[match[1]] !== undefined) continue let value = match[2].trim() if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { value = value.slice(1, -1) } process.env[match[1]] = value } } function required(name) { const value = process.env[name]?.trim() if (!value) throw new Error(`[database] ${name} is required.`) return value } function assertMatchingProject(databaseUrl, supabaseUrl) { let database let api try { database = new URL(databaseUrl) api = new URL(supabaseUrl) } catch { throw new Error('[database] SUPABASE_DB_URL and SUPABASE_URL must be valid URLs.') } if (!['postgres:', 'postgresql:'].includes(database.protocol)) { throw new Error('[database] SUPABASE_DB_URL must use the postgres:// or postgresql:// protocol.') } if (/\[(?:your-?)?password\]/i.test(databaseUrl) || /YOUR_PASSWORD/i.test(databaseUrl)) { throw new Error('[database] Replace the password placeholder in SUPABASE_DB_URL with your database password.') } const projectRef = api.hostname.split('.')[0] const directMatch = database.hostname === `db.${projectRef}.supabase.co` const poolerMatch = database.username === `postgres.${projectRef}` if (!directMatch && !poolerMatch) { throw new Error('[database] SUPABASE_DB_URL does not belong to the project configured by SUPABASE_URL.') } } async function dataApiHasSchema(supabaseUrl, serviceKey) { const base = supabaseUrl.replace(/\/$/, '') const headers = { apikey: serviceKey, Authorization: `Bearer ${serviceKey}`, 'Content-Type': 'application/json' } const [profiles, workerRpc, retryRpc, versionRpc] = await Promise.all([ fetch(`${base}/rest/v1/profiles?select=id&limit=1`, { headers }), fetch(`${base}/rest/v1/rpc/claim_ai_job`, { method: 'POST', headers, body: JSON.stringify({ p_worker_id: '' }), }), fetch(`${base}/rest/v1/rpc/stage_two_retry_failed_round`, { method: 'POST', headers, body: JSON.stringify({ p_round_id: '00000000-0000-0000-0000-000000000000', p_owner_id: '00000000-0000-0000-0000-000000000000', }), }), fetch(`${base}/rest/v1/rpc/dng_schema_version`, { method: 'POST', headers, body: '{}', }), ]) if ([profiles, workerRpc, retryRpc, versionRpc].some(response => response.status === 401 || response.status === 403)) { throw new Error('SUPABASE_SERVICE_ROLE_KEY was rejected by the configured project.') } if ([profiles, workerRpc, retryRpc, versionRpc].some(response => response.status === 404)) return false if (profiles.status !== 200 || workerRpc.status !== 400 || retryRpc.status !== 400 || versionRpc.status !== 200) { throw new Error( `Unexpected Supabase preflight response (profiles ${profiles.status}, claim_ai_job ${workerRpc.status}, retry_round ${retryRpc.status}, schema_version ${versionRpc.status}).`, ) } return true } async function ensureSchema() { await loadRootEnvironment() const supabaseUrl = required('SUPABASE_URL') const serviceKey = required('SUPABASE_SERVICE_ROLE_KEY') try { if (await dataApiHasSchema(supabaseUrl, serviceKey)) { console.log('[database] Supabase schema is ready.') return } } catch (error) { console.warn(`[database] Data API preflight failed: ${error instanceof Error ? error.message : String(error)}`) } const databaseUrl = process.env.SUPABASE_DB_URL?.trim() if (!databaseUrl) { throw new Error( '[database] The Supabase schema is missing. Add SUPABASE_DB_URL from Dashboard → Connect → Session pooler to the root .env; the next start will create it automatically.', ) } assertMatchingProject(databaseUrl, supabaseUrl) const sql = postgres(databaseUrl, { max: 1, prepare: false, ssl: 'require', connect_timeout: 15, idle_timeout: 5, }) try { const [state] = await sql.unsafe(` select to_regclass('public.profiles')::text as profiles, to_regclass('public.ai_jobs')::text as ai_jobs, to_regtype('public.member_role')::text as member_role, to_regprocedure('public.claim_ai_job(text)')::text as worker_rpc, to_regprocedure('public.stage_two_create_campaign(uuid,uuid,text)')::text as campaign_rpc, to_regprocedure('public.stage_two_retry_failed_round(uuid,uuid)')::text as retry_rpc, to_regprocedure('public.dng_schema_version()')::text as version_rpc `) const baseEntries = Object.entries(state).filter(([name]) => !['retry_rpc', 'version_rpc'].includes(name)) if (baseEntries.every(([, value]) => Boolean(value)) && state.retry_rpc && state.version_rpc) { await sql.unsafe("notify pgrst, 'reload schema'") console.log('[database] Supabase schema is ready; PostgREST cache reload requested.') return } if (baseEntries.every(([, value]) => Boolean(value)) && (!state.retry_rpc || !state.version_rpc)) { console.log('[database] Applying the pending failed-round recovery upgrade…') const migration = await readFile(join(root, 'supabase', 'migrations', '0006_retry_job_compatibility.sql'), 'utf8') await sql.begin(async transaction => { await transaction.unsafe(migration) }) const [verified] = await sql.unsafe(` select to_regprocedure('public.stage_two_retry_failed_round(uuid,uuid)') is not null as retry_rpc, to_regprocedure('public.dng_schema_version()') is not null as version_rpc `) if (!verified.retry_rpc || !verified.version_rpc) throw new Error('[database] Failed-round recovery upgrade verification failed.') await sql.unsafe("notify pgrst, 'reload schema'") console.log('[database] Supabase schema upgraded successfully.') return } if (baseEntries.some(([, value]) => Boolean(value))) { const missing = baseEntries.filter(([, value]) => !value).map(([name]) => name).join(', ') throw new Error(`[database] A partial D&G schema exists; automatic bootstrap stopped without changing data. Missing: ${missing}.`) } console.log('[database] No D&G schema found. Applying the transactional bootstrap…') const bootstrap = await readFile(join(root, 'supabase', 'bootstrap.sql'), 'utf8') await sql.unsafe(bootstrap) const [verified] = await sql.unsafe(` select to_regclass('public.profiles') is not null as profiles, to_regclass('public.ai_jobs') is not null as ai_jobs, to_regprocedure('public.claim_ai_job(text)') is not null as worker_rpc, to_regprocedure('public.stage_two_create_campaign(uuid,uuid,text)') is not null as campaign_rpc, to_regprocedure('public.stage_two_retry_failed_round(uuid,uuid)') is not null as retry_rpc, to_regprocedure('public.dng_schema_version()') is not null as version_rpc `) if (!Object.values(verified).every(Boolean)) { throw new Error('[database] Bootstrap completed but schema verification failed.') } console.log('[database] Dungeons & Ground schema created successfully.') } finally { await sql.end({ timeout: 5 }) } } await ensureSchema().catch((error) => { console.error(error instanceof Error ? error.message : error) process.exitCode = 1 })