Ctrl K
ComunidadGuías…

scripts/local-db-setup.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env node
/**
 * Brings the local Supabase stack up in a state the app and the integration
 * tests can actually use.
 *
 * Two things stand between `supabase start` and a working local database, and
 * both of them are invisible until you hit them:
 *
 *   1. Superseded migrations fail on a fresh database. The one that started
 *      this (the orphan-span-link registration) omits a NOT NULL column, so
 *      Postgres rolls back and `supabase start` aborts partway through the
 *      migration chain. In production such a migration was marked applied by
 *      hand and replaced by a later file, so nothing there notices. CI deletes
 *      them before running. Locally, until now, you had to know that.
 *
 *      They cannot simply be fixed in place: the files are tracked, already
 *      fake-applied in production, and the repo forbids editing a merged
 *      migration because the remote history is keyed by file hash. So this
 *      script moves every file on supabase/superseded-migrations.txt aside
 *      and puts it back, including on failure.
 *
 *   2. The local CLI image sets a more restrictive default ACL for the
 *      `postgres` role than the hosted platform does. Tables created by
 *      migrations therefore land with no INSERT/SELECT/UPDATE/DELETE for
 *      `service_role`, and every server call fails with "permission denied".
 *      Production grants the full set, so this only ever bites locally.
 *
 *      Fixing that has to reproduce production, not just "grant enough".
 *      Production is the hosted creation-time grants MINUS whatever the
 *      migrations then revoke. This script used to stop at the first half
 *      (GRANT ALL on every table and function to anon and authenticated),
 *      which quietly undid every REVOKE in the migrations: locally the anon
 *      key could call ensure_requests_partitions() and write any table,
 *      while production could not, so local testing could neither reproduce
 *      nor rule out a permissions bug. It now applies the hosted grants and
 *      then calls public.enforce_client_privileges(), the same function the
 *      migrations use to take them back (20260929100000).
 *
 * Usage:
 *   pnpm db:local           start (if needed), then align grants with production
 *   pnpm db:local --reset   also run `supabase db reset --no-seed` first
 *
 * On a stack that was already running, this does not apply new migrations.
 * If the privilege function is missing the script stops and says so, rather
 * than leaving the database with the hosted grants and none of the revokes.
 *
 * The integration suite needs this to have run. See
 * apps/server/vitest.integration.config.ts.
 */

import { execFileSync, execSync } from 'node:child_process'
import { existsSync, renameSync } from 'node:fs'
import { join } from 'node:path'

import { readSupersededMigrations } from './superseded-migrations.mjs'

const PARK_DIR = process.env['TEMP'] ?? '/tmp'
const SUPERSEDED = readSupersededMigrations().map((name) => ({
  file: `supabase/migrations/${name}`,
  parked: join(PARK_DIR, `spanlens-${name}.parked`),
}))

const DB_CONTAINER = 'supabase_db_spanlens'
const LOCAL_DB = 'postgresql://postgres:postgres@127.0.0.1:5432/postgres'

/** Defined by 20260929100000_revoke_client_direct_writes.sql. */
const PRIVILEGE_MODEL_FN = 'public.enforce_client_privileges()'

/**
 * Two steps, in the order production went through them, run as one
 * transaction so a failure leaves the grants exactly as they were.
 *
 * Step 1 is what the hosted platform grants when a migration creates an
 * object. Without it, `service_role` inherits only TRUNCATE/REFERENCES/TRIGGER
 * on anything a migration created, which reads as a baffling permissions
 * error rather than an environment difference.
 *
 * Step 2 is what the migrations then take back: no client-role writes, no
 * client access to the requests tables, no client-callable functions except
 * is_org_member(). Skipping it is how local permissions drifted from
 * production before.
 */
const GRANTS = `
GRANT ALL ON ALL TABLES    IN SCHEMA public TO anon, authenticated, service_role;
GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO anon, authenticated, service_role;
GRANT ALL ON ALL FUNCTIONS IN SCHEMA public TO anon, authenticated, service_role;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES    TO anon, authenticated, service_role;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO anon, authenticated, service_role;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON FUNCTIONS TO anon, authenticated, service_role;

DO $$ BEGIN PERFORM ${PRIVILEGE_MODEL_FN}; END $$;
`

function run(cmd, args, opts = {}) {
  return execFileSync(cmd, args, { stdio: 'inherit', shell: true, ...opts })
}

/**
 * psql inside the DB container, without a shell in between. The SQL goes in
 * on stdin, so its quotes, parentheses and `$` never meet a shell's parser.
 */
function psql(args, opts = {}) {
  return execFileSync(
    'docker',
    ['exec', '-i', DB_CONTAINER, 'psql', LOCAL_DB, '-v', 'ON_ERROR_STOP=1', ...args],
    opts,
  )
}

function privilegeModelInstalled() {
  const out = psql(['-Atc', `SELECT to_regprocedure('${PRIVILEGE_MODEL_FN}') IS NOT NULL`], {
    encoding: 'utf8',
  })
  return out.trim() === 't'
}

function alignGrants() {
  if (!privilegeModelInstalled()) {
    throw new Error(
      `${PRIVILEGE_MODEL_FN} is missing, so this database is behind supabase/migrations. ` +
        'Apply the pending migrations (npx supabase migration up) or rerun with --reset, then run this again. ' +
        'Grants were left untouched.',
    )
  }
  psql(['--single-transaction', '-q', '-f', '-'], {
    input: GRANTS,
    stdio: ['pipe', 'inherit', 'inherit'],
  })
}

function dbIsRunning() {
  try {
    const out = execSync(`docker ps --filter name=${DB_CONTAINER} --format "{{.Names}}"`, {
      encoding: 'utf8',
    })
    return out.includes(DB_CONTAINER)
  } catch {
    return false
  }
}

/** Moves every superseded migration aside. Returns the ones it moved. */
function park() {
  const moved = SUPERSEDED.filter((entry) => existsSync(entry.file))
  for (const entry of moved) renameSync(entry.file, entry.parked)
  return moved
}

function unpark(parkedEntries) {
  // Restoring matters more than anything else this script does: leaving a
  // file parked would show up as a deleted migration in `git status` and,
  // if committed, would desync every other checkout and the deploy job.
  for (const entry of parkedEntries) {
    if (existsSync(entry.parked)) renameSync(entry.parked, entry.file)
  }
}

const wantsReset = process.argv.includes('--reset')
let parked = []

try {
  parked = park()
  for (const entry of parked) {
    console.log(`[local-db] parked ${entry.file} (see the note at the top of this script)`)
  }

  if (!dbIsRunning()) {
    console.log('[local-db] starting Supabase...')
    run('npx', ['supabase', 'start'])
  } else {
    console.log('[local-db] Supabase already running')
  }

  if (wantsReset) {
    console.log('[local-db] resetting database (migrations only, no seed)...')
    run('npx', ['supabase', 'db', 'reset', '--no-seed'])
  }

  console.log('[local-db] aligning grants with production (hosted grants, then revokes)...')
  alignGrants()

  console.log('\n[local-db] ready.')
  console.log('[local-db] integration tests (no env needed, they default to this stack):')
  console.log('  pnpm --filter server test:integration')
} finally {
  unpark(parked)
  for (const entry of parked) console.log(`[local-db] restored ${entry.file}`)
}