224 lines
10 KiB
JavaScript
224 lines
10 KiB
JavaScript
import { readFile, readdir } 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)), '..')
|
|
const migrationDirectory = join(root, 'supabase', 'migrations')
|
|
const migrationNames = (await readdir(migrationDirectory))
|
|
.filter(name => /^\d+_.+\.sql$/.test(name))
|
|
.sort((left, right) => left.localeCompare(right))
|
|
const latestSchemaVersion = Math.max(...migrationNames.map(name => Number(name.slice(0, 4))))
|
|
|
|
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 dataApiSchemaState(supabaseUrl, serviceKey) {
|
|
const base = supabaseUrl.replace(/\/$/, '')
|
|
const headers = {
|
|
apikey: serviceKey,
|
|
...(serviceKey.split('.').length === 3 ? { 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 { installed: false, version: 0 }
|
|
}
|
|
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}).`,
|
|
)
|
|
}
|
|
const version = Number(await versionRpc.json())
|
|
if (!Number.isInteger(version) || version < 1) throw new Error('Supabase returned an invalid D&G schema version.')
|
|
return { installed: true, version }
|
|
}
|
|
|
|
async function ensureSchema() {
|
|
await loadRootEnvironment()
|
|
const supabaseUrl = required('SUPABASE_URL')
|
|
const serviceKey = required('SUPABASE_SERVICE_ROLE_KEY')
|
|
|
|
try {
|
|
const state = await dataApiSchemaState(supabaseUrl, serviceKey)
|
|
if (state.installed && state.version === latestSchemaVersion) {
|
|
console.log(`[database] Supabase schema v${state.version} is ready.`)
|
|
return
|
|
}
|
|
if (state.version > latestSchemaVersion) {
|
|
throw new Error(`Database schema v${state.version} is newer than this checkout (v${latestSchemaVersion}).`)
|
|
}
|
|
if (state.installed) console.log(`[database] Supabase schema v${state.version} needs upgrade to v${latestSchemaVersion}.`)
|
|
} 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 or out of date (target v${latestSchemaVersion}). Add SUPABASE_DB_URL from Dashboard → Connect → Session pooler to the root .env; the next start will create or upgrade 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))) {
|
|
let currentVersion = 4
|
|
if (state.version_rpc) {
|
|
const [row] = await sql.unsafe('select public.dng_schema_version() as version')
|
|
currentVersion = Number(row.version)
|
|
} else if (state.retry_rpc) {
|
|
currentVersion = 5
|
|
}
|
|
if (!Number.isInteger(currentVersion) || currentVersion < 1) throw new Error('[database] Invalid installed schema version.')
|
|
if (currentVersion > latestSchemaVersion) {
|
|
throw new Error(`[database] Schema v${currentVersion} is newer than this checkout (v${latestSchemaVersion}).`)
|
|
}
|
|
const pending = migrationNames.filter(name => Number(name.slice(0, 4)) > currentVersion)
|
|
if (!pending.length) {
|
|
await sql.unsafe("notify pgrst, 'reload schema'")
|
|
console.log(`[database] Supabase schema v${currentVersion} is ready; PostgREST cache reload requested.`)
|
|
return
|
|
}
|
|
console.log(`[database] Applying ${pending.length} migration(s): ${pending.join(', ')}…`)
|
|
await sql.begin(async transaction => {
|
|
for (const name of pending) {
|
|
const migration = await readFile(join(migrationDirectory, name), 'utf8')
|
|
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,
|
|
to_regprocedure('public.stage_four_create_character(uuid,uuid,text,text,text,jsonb,integer,integer,integer,integer,jsonb,jsonb,jsonb)') is not null as character_rpc,
|
|
public.dng_schema_version() as version
|
|
`)
|
|
if (!verified.retry_rpc || !verified.version_rpc || !verified.character_rpc || Number(verified.version) !== latestSchemaVersion) {
|
|
throw new Error('[database] Schema upgrade verification failed.')
|
|
}
|
|
await sql.unsafe("notify pgrst, 'reload schema'")
|
|
console.log(`[database] Supabase schema upgraded successfully to v${latestSchemaVersion}.`)
|
|
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.stage_four_create_character(uuid,uuid,text,text,text,jsonb,integer,integer,integer,integer,jsonb,jsonb,jsonb)') is not null as character_rpc,
|
|
to_regprocedure('public.dng_schema_version()') is not null as version_rpc,
|
|
public.dng_schema_version() as version
|
|
`)
|
|
if (!verified.profiles || !verified.ai_jobs || !verified.worker_rpc || !verified.campaign_rpc
|
|
|| !verified.retry_rpc || !verified.character_rpc || !verified.version_rpc
|
|
|| Number(verified.version) !== latestSchemaVersion) {
|
|
throw new Error('[database] Bootstrap completed but schema verification failed.')
|
|
}
|
|
console.log(`[database] Dungeons & Ground schema v${latestSchemaVersion} created successfully.`)
|
|
} finally {
|
|
await sql.end({ timeout: 5 })
|
|
}
|
|
}
|
|
|
|
await ensureSchema().catch((error) => {
|
|
console.error(error instanceof Error ? error.message : error)
|
|
process.exitCode = 1
|
|
})
|