diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5915633..fe34481 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,3 +57,40 @@ jobs: cache: npm - run: npm ci --include=dev - run: npm run typecheck + + migrations: + name: migrations + runs-on: ubuntu-latest + needs: build + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: astroid + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres -d astroid" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/astroid?schema=public + SHADOW_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/astroid_shadow?schema=public + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci --include=dev + # The shadow database is an empty scratch PostgreSQL database that Prisma + # uses to rebuild the schema purely from the committed migrations. + - name: Create shadow database + run: | + sudo apt-get update && sudo apt-get install -y postgresql-client + PGPASSWORD=postgres createdb -h localhost -p 5432 -U postgres astroid_shadow + - name: Verify database migrations + run: npm run db:verify diff --git a/package.json b/package.json index 7859505..c1a5252 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,8 @@ "prisma:migrate": "prisma migrate dev", "prisma:deploy": "prisma migrate deploy", "prisma:seed": "ts-node prisma/seed.ts", - "db:seed": "ts-node prisma/seed.ts" + "db:seed": "ts-node prisma/seed.ts", + "db:verify": "scripts/verify-migrations.sh" }, "prisma": { "seed": "ts-node prisma/seed.ts" diff --git a/prisma/migrations/20260830174000_sync_schema/migration.sql b/prisma/migrations/20260830174000_sync_schema/migration.sql new file mode 100644 index 0000000..2e39e24 --- /dev/null +++ b/prisma/migrations/20260830174000_sync_schema/migration.sql @@ -0,0 +1,93 @@ +-- Sync schema to schema.prisma +CREATE TYPE "WebhookDeliveryStatus" AS ENUM ('PENDING', 'RETRYING', 'FAILED', 'DELIVERED'); + +-- AlterTable +ALTER TABLE "api_keys" ADD COLUMN "allowedIps" TEXT[] DEFAULT ARRAY[]::TEXT[]; + +-- AlterTable +ALTER TABLE "passkey_credentials" ADD COLUMN "userAgent" TEXT; + +-- AlterTable +ALTER TABLE "policies" ADD COLUMN "originalLimit" DECIMAL(30,7), +ADD COLUMN "overrideLimit" DECIMAL(30,7), +ADD COLUMN "overrideUntil" TIMESTAMP(3); + +-- CreateTable +CREATE TABLE "webhook_deliveries" ( + "id" TEXT NOT NULL, + "webhookId" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "eventName" TEXT NOT NULL, + "eventId" TEXT NOT NULL, + "payload" JSONB NOT NULL DEFAULT '{}', + "status" "WebhookDeliveryStatus" NOT NULL DEFAULT 'PENDING', + "attempts" INTEGER NOT NULL DEFAULT 0, + "lastError" TEXT, + "responseStatus" INTEGER, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "webhook_deliveries_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "passkey_challenges" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "challenge" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "passkey_challenges_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "outbox_events" ( + "id" TEXT NOT NULL, + "eventType" TEXT NOT NULL, + "payload" JSONB NOT NULL, + "status" TEXT NOT NULL DEFAULT 'PENDING', + "retryCount" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "processedAt" TIMESTAMP(3), + "error" TEXT, + + CONSTRAINT "outbox_events_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "webhook_deliveries_webhookId_idx" ON "webhook_deliveries"("webhookId"); + +-- CreateIndex +CREATE INDEX "webhook_deliveries_organizationId_idx" ON "webhook_deliveries"("organizationId"); + +-- CreateIndex +CREATE INDEX "webhook_deliveries_status_idx" ON "webhook_deliveries"("status"); + +-- CreateIndex +CREATE INDEX "webhook_deliveries_eventId_idx" ON "webhook_deliveries"("eventId"); + +-- CreateIndex +CREATE INDEX "webhook_deliveries_createdAt_idx" ON "webhook_deliveries"("createdAt"); + +-- CreateIndex +CREATE INDEX "passkey_challenges_userId_idx" ON "passkey_challenges"("userId"); + +-- CreateIndex +CREATE INDEX "passkey_challenges_expiresAt_idx" ON "passkey_challenges"("expiresAt"); + +-- CreateIndex +CREATE INDEX "outbox_events_status_createdAt_idx" ON "outbox_events"("status", "createdAt"); + +-- CreateIndex +CREATE INDEX "policies_overrideUntil_idx" ON "policies"("overrideUntil"); + +-- AddForeignKey +ALTER TABLE "webhook_deliveries" ADD CONSTRAINT "webhook_deliveries_webhookId_fkey" FOREIGN KEY ("webhookId") REFERENCES "webhooks"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "webhook_deliveries" ADD CONSTRAINT "webhook_deliveries_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "passkey_challenges" ADD CONSTRAINT "passkey_challenges_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; \ No newline at end of file diff --git a/prisma/migrations/20260830180000_add_audit_log_hash/migration.sql b/prisma/migrations/20260830180000_add_audit_log_hash/migration.sql new file mode 100644 index 0000000..7b0a824 --- /dev/null +++ b/prisma/migrations/20260830180000_add_audit_log_hash/migration.sql @@ -0,0 +1,3 @@ +-- Add integrity hash columns to audit_logs +ALTER TABLE "audit_logs" ADD COLUMN "hash" TEXT, +ADD COLUMN "previousHash" TEXT; \ No newline at end of file diff --git a/scripts/verify-migrations.sh b/scripts/verify-migrations.sh new file mode 100755 index 0000000..3ab4d48 --- /dev/null +++ b/scripts/verify-migrations.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# +# Verifies the Prisma database migrations for the Astroid API. +# +# What it does, in order: +# 1. Generates the Prisma client. +# 2. Applies every pending migration to the target database (idempotent). +# 3. Drift check: rebuilds the schema purely from the committed migrations +# (in an ephemeral shadow database) and fails if it does not match +# prisma/schema.prisma. This catches schema edits that were never +# captured in a migration. +# +# Env: +# DATABASE_URL (required) Target PostgreSQL the migrations are +# applied to — e.g. a fresh ephemeral CI database. +# SHADOW_DATABASE_URL (optional but recommended) An empty scratch database +# used for the drift check. When unset the drift check +# is skipped and only apply + status are verified. +# +# Exit code 0 when migrations apply cleanly and stay in sync with the schema. +set -euo pipefail + +: "${DATABASE_URL:?DATABASE_URL is required}" + +echo "==> Generating Prisma client" +npx prisma generate + +echo "==> Applying migrations to ${DATABASE_URL}" +npx prisma migrate deploy + +echo "==> Checking migration status" +npx prisma migrate status + +if [[ -n "${SHADOW_DATABASE_URL:-}" ]]; then + echo "==> Drift check: rebuilding schema from migrations only" + echo " shadow database: ${SHADOW_DATABASE_URL}" + # `--script` prints the SQL that would reconcile the migrations-built schema + # with schema.prisma: nothing when in sync, the full delta when out of sync. + # Strip blank lines and SQL comment markers so an "empty migration" counts as + # in sync. + drift="$(npx prisma migrate diff \ + --from-migrations prisma/migrations \ + --to-schema-datamodel prisma/schema.prisma \ + --script \ + --shadow-database-url "${SHADOW_DATABASE_URL}" \ + | grep -Ev '^[[:space:]]*$|^--' || true)" + + if [[ -n "${drift//[[:space:]]/}" ]]; then + echo "!! Schema drift detected — schema.prisma differs from the applied migrations." >&2 + echo "$drift" >&2 + exit 1 + fi + + echo "==> Migrations are in sync with the schema" +else + echo "!! SHADOW_DATABASE_URL unset — skipping drift check" >&2 +fi + +echo "==> Migration verification passed" \ No newline at end of file