21. Schema Migrations

Alex wants schema changes that are versioned, reviewable, and repeatable across environments.

Migrations are versioned SQL files in your project's migrations/ directory, applied in order against your project's database schema. The platform records what has been applied, so migrations up only runs what's pending — re-running it is always safe, locally or in CI. Use migrations for anything the table editor doesn't cover: composite unique constraints, CHECK constraints, partial indexes, triggers, backfills.

The workflow

# 1. Create the next numbered migration file
eurobase migrations new create_listings
# 2. Write your SQL in migrations/0001_create_listings.sql, then apply
eurobase migrations up
# 3. See what's applied vs pending
eurobase migrations status

A realistic example

migrations/0001_create_listings.sql — a table with a composite natural key, a CHECK constraint, a partial index, and an RLS policy:

CREATE TABLE listings (
    id          uuid PRIMARY KEY DEFAULT public.uuid_generate_v4(),
    user_id     uuid NOT NULL,
    source      text NOT NULL,
    source_id   text NOT NULL,
    status      text NOT NULL DEFAULT 'active'
                CHECK (status IN ('active', 'expired', 'flagged')),
    price       numeric(8,2),
    created_at  timestamptz NOT NULL DEFAULT now(),
    UNIQUE (source, source_id)          -- composite natural key: no dupes per source
);

CREATE INDEX idx_listings_active
    ON listings (user_id, created_at)
    WHERE status = 'active';            -- partial index: hot rows only

ALTER TABLE listings ENABLE ROW LEVEL SECURITY;
CREATE POLICY listings_owner ON listings
    USING (public.is_service_role() OR user_id = public.current_end_user_id())
    WITH CHECK (public.is_service_role() OR user_id = public.current_end_user_id());

How it executes

  • Each migration runs in one transaction — if any statement fails, nothing from that file is applied.
  • It runs inside your project schema: use unqualified table names. Multi-statement files are expected.
  • Applied versions are recorded with a checksum. Re-applying an identical file is a no-op; editing an already-applied file is rejected — add a new version instead.
  • There are no down migrations — to undo something, write a new forward migration.
  • Concurrent runs are serialized per project, so two CI jobs can't race each other.

Running migrations in CI

Authenticate with a Personal Access Token (Account → Tokens) stored as a CI secret. Example GitHub Actions step:

- name: Apply database migrations
  run: |
    brew install stgime/tap/eurobase   # or download the binary
    eurobase login --token "$EUROBASE_PAT"
    eurobase switch my-project
    eurobase migrations up
  env:
    EUROBASE_PAT: ${{ secrets.EUROBASE_PAT }}

What migration SQL can't do (and why)

Migrations run with your project's developer credentials (PAT or console session) — never with API keys: a leaked server key must not be able to alter your schema. Each migration executes under a database role scoped to only your project's schema, so it physically cannot read or write another project or the platform's own tables — that boundary is enforced by Postgres, not just by validation. On top of that, the platform rejects operations that try to reach outside your project before they run:

  • References to other schemas (public.*, pg_catalog, other tenants). Exception: the RLS helpers public.is_service_role(), public.current_end_user_id(), and public.uuid_generate_v4().
  • GRANT/REVOKE, SET ROLE/search_path — access control belongs to the platform.
  • Transaction control (COMMIT, BEGIN) — your file already runs in a transaction.
  • SECURITY DEFINER functions, CREATE EXTENSION, COPY.

Plain LANGUAGE plpgsql functions and triggers are fine. Bulk data loads belong in the SDK/REST data path, not migrations.