Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/release-dry-run.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,19 @@ jobs:
- name: npm publish --dry-run
working-directory: packages/cli
run: npm publish --dry-run --tag next

# The package as a user gets it: packed, installed elsewhere, run. A
# warning here reaches every user — oclif warned on every command, under
# NODE_ENV=development or test, that it could not find TypeScript.
- name: Install the packed package and run it
working-directory: packages/cli
run: |
npm pack --pack-destination "$RUNNER_TEMP"
mkdir "$RUNNER_TEMP/user" && cd "$RUNNER_TEMP/user"
npm init -y >/dev/null
npm install --no-audit --no-fund "$RUNNER_TEMP"/akalforge-supaforge-*.tgz
for env in production development test; do
NODE_ENV=$env npx supaforge --version 2> stderr.txt
NODE_ENV=$env npx supaforge diff --help > /dev/null 2>> stderr.txt
if [ -s stderr.txt ]; then echo "::error::NODE_ENV=$env wrote to stderr:"; cat stderr.txt; exit 1; fi
done
7 changes: 7 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,13 @@ npm run lint # type-check
| Database e2e | `npm run test:e2e` | Docker or Podman |
| Scenarios | `npx vitest run -c vitest.scenarios.config.ts` | Docker or Podman, `pg_dump` |
| Supabase e2e | `npm run test:e2e:supabase` | Supabase CLI |
| Release candidates | `scripts/test-release-candidates.sh --dbdiff ../DBDiff --pg-conformance ../pg-conformance` | Podman or Docker |

The release-candidates script builds DBDiff's binary and the npm packages
from local checkouts the way a release does, installs SupaForge against them
as a user would, and runs every suite above against that install. It tests
changes that span the three projects together, before any of them is
released.

The scenario suite migrates every case in the
[`@akalforge/pg-conformance`](https://www.npmjs.com/package/@akalforge/pg-conformance) corpus in both
Expand Down
9 changes: 8 additions & 1 deletion packages/cli/bin/run.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
#!/usr/bin/env node
import {execute} from '@oclif/core'
import {existsSync} from 'node:fs'
import {execute, settings} from '@oclif/core'

// The published package is compiled and ships no src/. Left on, oclif looks
// for TypeScript whenever NODE_ENV is development or test — as in many
// projects running npx supaforge — and warned on every command that it could
// not find it. A checkout keeps the lookup, which its tests rely on.
if (!existsSync(new URL('../src', import.meta.url))) settings.enableAutoTranspile = false

await execute({dir: import.meta.url}).catch((err) => {
// oclif handles CLIError internally — this catches truly unexpected errors.
Expand Down
194 changes: 194 additions & 0 deletions packages/cli/scripts/test-release-candidates.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
#!/usr/bin/env bash
set -euo pipefail

# Test SupaForge, DBDiff and pg-conformance together, as they would be
# released, before any of them is.
#
# Each repo's own CI tests it against the others' *published* versions, so a
# fix that needs a change in two of them is only ever tested together after
# both are released. This builds the release artifacts locally from the
# checkouts' current commits and runs SupaForge's suites through them:
#
# 1. DBDiff: the PHAR and the static linux-x64 binary, built the way the
# release workflow builds them (scripts/build-local.sh: Podman or Docker,
# no PHP needed; the first build takes 30-60 min, later ones minutes).
# 2. The npm packages @dbdiff/cli and @dbdiff/cli-linux-x64, versioned
# together and packed, and @akalforge/pg-conformance packed.
# 3. SupaForge built and packed against them, then installed into a scratch
# prefix the way a user installs it.
# 4. SupaForge's suites run against that installed package: unit and lint,
# integration, the database e2e suite and the scenario suites (twin
# servers, and PostgreSQL 17 → 15) drive its bin/run.js and its own
# @dbdiff/cli binary, not this checkout.
#
# Only committed work is tested: each checkout's HEAD is cloned. Linux x64.
#
# Usage:
# ./scripts/test-release-candidates.sh [--dbdiff PATH] [--pg-conformance PATH]
# [--work DIR] [--suites LIST] [--skip-build]
#
# --dbdiff PATH DBDiff checkout (default: ../../../DBDiff from here)
# --pg-conformance PATH pg-conformance checkout (default: the version in package.json)
# --work DIR Where to build (default: a new temp directory)
# --suites LIST Comma-separated: unit,integration,e2e,scenarios,cross-version
# (default: all)
# --skip-build Reuse the DBDiff artifacts already in --work
#
# Exit status is 0 only when every suite run passes.

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CLI_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
REPO_DIR="$(cd "$CLI_DIR/../.." && pwd)"

DBDIFF_SRC="$(cd "$REPO_DIR/.." && pwd)/DBDiff"
PGC_SRC=""
WORK=""
SUITES="unit,integration,e2e,scenarios,cross-version"
SKIP_BUILD=false
while [ $# -gt 0 ]; do
case "$1" in
--dbdiff) DBDIFF_SRC="$2"; shift 2 ;;
--pg-conformance) PGC_SRC="$2"; shift 2 ;;
--work) WORK="$2"; shift 2 ;;
--suites) SUITES="$2"; shift 2 ;;
--skip-build) SKIP_BUILD=true; shift ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
WORK="${WORK:-$(mktemp -d -t supaforge-rc-XXXXXX)}"
mkdir -p "$WORK/tgz"
WORK="$(cd "$WORK" && pwd)"
wants() { [[ ",$SUITES," == *",$1,"* ]]; }

RT=""
for c in podman docker; do command -v "$c" >/dev/null 2>&1 && { RT="$c"; break; }; done
[ -n "$RT" ] || { echo "Podman or Docker is needed." >&2; exit 1; }

say() { printf '\n\033[1m== %s\033[0m\n' "$*"; }
RESULTS=()
record() { RESULTS+=("$1"); }

# ── 1. DBDiff: release artifacts from the checkout's HEAD ────────────────────
DBDIFF_SHA="$(git -C "$DBDIFF_SRC" rev-parse --short HEAD)"
VERSION="0.0.0-local.$DBDIFF_SHA"
if ! $SKIP_BUILD; then
say "DBDiff $DBDIFF_SHA ($(git -C "$DBDIFF_SRC" rev-parse --abbrev-ref HEAD)): PHAR and static linux-x64 binary"
rm -rf "$WORK/dbdiff"
git clone -q --local "$DBDIFF_SRC" "$WORK/dbdiff"
git -C "$WORK/dbdiff" checkout -q "$(git -C "$DBDIFF_SRC" rev-parse HEAD)"
(cd "$WORK/dbdiff" && bash scripts/build-local.sh)
fi
BIN="$WORK/dbdiff/packages/@dbdiff/cli-linux-x64/dbdiff"
[ -x "$BIN" ] && [ -f "$WORK/dbdiff/dist/dbdiff.phar" ] || { echo "DBDiff artifacts missing in $WORK/dbdiff" >&2; exit 1; }
say "DBDiff binary: $("$BIN" --version)"

# ── 2. npm packages, versioned together as a release stamps them ─────────────
say "Packing @dbdiff/cli $VERSION, @dbdiff/cli-linux-x64 $VERSION"
rm -f "$WORK"/tgz/*.tgz
cp "$WORK/dbdiff/dist/dbdiff.phar" "$WORK/dbdiff/packages/@dbdiff/cli/dbdiff.phar"
for pkg in cli cli-linux-x64; do
node -e '
const fs = require("fs"), [file, v] = process.argv.slice(1)
const j = JSON.parse(fs.readFileSync(file, "utf8"))
j.version = v
for (const k of Object.keys(j.optionalDependencies ?? {})) j.optionalDependencies[k] = v
fs.writeFileSync(file, JSON.stringify(j, null, 2) + "\n")
' "$WORK/dbdiff/packages/@dbdiff/$pkg/package.json" "$VERSION"
(cd "$WORK/dbdiff/packages/@dbdiff/$pkg" && npm pack --silent --pack-destination "$WORK/tgz" >/dev/null)
done
if [ -n "$PGC_SRC" ]; then
say "Packing @akalforge/pg-conformance from $PGC_SRC ($(git -C "$PGC_SRC" rev-parse --short HEAD))"
rm -rf "$WORK/pg-conformance"
git clone -q --local "$PGC_SRC" "$WORK/pg-conformance"
git -C "$WORK/pg-conformance" checkout -q "$(git -C "$PGC_SRC" rev-parse HEAD)"
(cd "$WORK/pg-conformance" && npm pack --silent --pack-destination "$WORK/tgz" >/dev/null)
fi
DEPS=("$WORK"/tgz/dbdiff-cli-*.tgz)
[ -n "$PGC_SRC" ] && DEPS+=("$WORK"/tgz/akalforge-pg-conformance-*.tgz)

# ── 3. SupaForge built against them, packed and installed ───────────────────
say "SupaForge $(git -C "$REPO_DIR" rev-parse --short HEAD) ($(git -C "$REPO_DIR" rev-parse --abbrev-ref HEAD)), built against those"
rm -rf "$WORK/supaforge"
git clone -q --local "$REPO_DIR" "$WORK/supaforge"
git -C "$WORK/supaforge" checkout -q "$(git -C "$REPO_DIR" rev-parse HEAD)"
SF="$WORK/supaforge/packages/cli"
# Pinned to the candidates as a release bump would pin them, so the packed
# package depends on these versions and not on local paths.
PGC_VERSION="$( [ -n "$PGC_SRC" ] && node -p 'require(process.argv[1]).version' "$WORK/pg-conformance/package.json" || true)"
(cd "$SF" && npm ci --silent --no-audit --no-fund)
node -e '
const fs = require("fs"), [file, dbdiff, pgc] = process.argv.slice(1)
const j = JSON.parse(fs.readFileSync(file, "utf8"))
j.dependencies["@dbdiff/cli"] = dbdiff
if (pgc) j.dependencies["@akalforge/pg-conformance"] = pgc
fs.writeFileSync(file, JSON.stringify(j, null, 2) + "\n")
' "$SF/package.json" "$VERSION" "$PGC_VERSION"
(cd "$SF" && npm install --silent --no-audit --no-fund --no-save "${DEPS[@]}" && npm run build --silent)
(cd "$SF" && npm pack --silent --pack-destination "$WORK/tgz" >/dev/null)
rm -rf "$WORK/prefix" && mkdir -p "$WORK/prefix"
(cd "$WORK/prefix" && npm init -y >/dev/null && npm install --silent --no-audit --no-fund "$WORK"/tgz/akalforge-supaforge-*.tgz "${DEPS[@]}")
INSTALLED="$WORK/prefix/node_modules/@akalforge/supaforge"
say "Installed: $(node "$INSTALLED/bin/run.js" --version) with $("$WORK/prefix/node_modules/.bin/dbdiff" --version)"
for env in production development test; do
if NODE_ENV=$env node "$INSTALLED/bin/run.js" --version 2>&1 >/dev/null | grep -q .; then
record "FAIL the installed CLI writes to stderr under NODE_ENV=$env"
fi
done
export SUPAFORGE_E2E_CLI="$INSTALLED/bin/run.js"
cd "$SF"

# A proof across versions copies the source with pg_dump, which must be at
# least as new as the source server. Use PostgreSQL 18's client tools from a
# container when the host's are older.
if wants scenarios || wants cross-version; then
if [ "$(pg_dump --version 2>/dev/null | grep -oE '[0-9]+' | head -1 || echo 0)" -lt 18 ]; then
mkdir -p "$WORK/pgbin"
for t in pg_dump pg_restore psql; do
printf '#!/bin/sh\nexec %s run --rm -i --network=host -v /tmp:/tmp docker.io/library/postgres:18 %s "$@"\n' "$RT" "$t" > "$WORK/pgbin/$t"
chmod +x "$WORK/pgbin/$t"
done
export PATH="$WORK/pgbin:$PATH"
fi
fi

run_suite() {
local name=$1; shift
say "$name"
if "$@"; then record "PASS $name"; else record "FAIL $name"; fi
}

if wants unit; then
run_suite "unit" npx vitest run
run_suite "lint" npm run lint --silent
fi

if wants integration || wants e2e; then
trap '$RT rm -f sf-rc-source sf-rc-target >/dev/null 2>&1 || true' EXIT
for p in 15432:source 15433:target; do
$RT rm -f "sf-rc-${p##*:}" >/dev/null 2>&1 || true
$RT run -d --rm --name "sf-rc-${p##*:}" --network=host -e POSTGRES_PASSWORD="${p##*:}-test-pass" \
docker.io/library/postgres:15 -c listen_addresses=127.0.0.1 -c port="${p%%:*}" >/dev/null
done
until $RT exec sf-rc-source pg_isready -p 15432 -q 2>/dev/null && $RT exec sf-rc-target pg_isready -p 15433 -q 2>/dev/null; do sleep 1; done
export SUPAFORGE_TEST_SOURCE_URL=postgresql://postgres:source-test-pass@127.0.0.1:15432/postgres
export SUPAFORGE_TEST_TARGET_URL=postgresql://postgres:target-test-pass@127.0.0.1:15433/postgres
seed() {
$RT exec -i -e PGPASSWORD=source-test-pass sf-rc-source psql -h 127.0.0.1 -p 15432 -U postgres -q -v ON_ERROR_STOP=1 < tests/fixtures/seed-source.sql >/dev/null 2>&1
$RT exec -i -e PGPASSWORD=target-test-pass sf-rc-target psql -h 127.0.0.1 -p 15433 -U postgres -q -v ON_ERROR_STOP=1 < tests/fixtures/seed-target.sql >/dev/null 2>&1
}
if wants integration; then seed; run_suite "integration" npx vitest run --config vitest.integration.config.ts; fi
if wants e2e; then seed; run_suite "database e2e" npx vitest run test/e2e; fi
fi

if wants scenarios; then
run_suite "scenarios (twin servers)" env SCENARIO_REQUIRE_PROOF=1 npx vitest run -c vitest.scenarios.config.ts
fi
if wants cross-version; then
run_suite "scenarios (PostgreSQL 17 → 15)" env SCENARIO_REQUIRE_PROOF=1 \
SCENARIO_SOURCE_IMAGE=postgres:17 SCENARIO_TARGET_IMAGE=postgres:15 npx vitest run -c vitest.scenarios.config.ts
fi

say "Release candidates: SupaForge $(git -C "$REPO_DIR" rev-parse --short HEAD), DBDiff $DBDIFF_SHA${PGC_SRC:+, pg-conformance $(git -C "$PGC_SRC" rev-parse --short HEAD)}"
printf '%s\n' "${RESULTS[@]}"
printf '\nArtifacts in %s\n' "$WORK"
! printf '%s\n' "${RESULTS[@]}" | grep -q '^FAIL'
19 changes: 19 additions & 0 deletions packages/cli/src/base-command.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { formatWithOptions } from 'node:util'
import { Command } from '@oclif/core'
import { loadConfig, validateConfig, validateSingleEnvConfig } from './config.js'
import { Preflight } from './preflight.js'
Expand All @@ -16,6 +17,24 @@ export abstract class BaseCommand extends Command {
private _logWritten = false
private _checkSummaries?: RunLogCheckSummary[]

/**
* Under --json, stdout carries the JSON and nothing else: every other line a
* command prints — preflight checks, progress — goes to stderr. Before, the
* text came first and the output did not parse.
*/
override log(message = '', ...args: unknown[]): void {
if (this.argv.includes('--json')) {
process.stderr.write(formatWithOptions({ colors: false }, message, ...args) + '\n')
return
}
super.log(message, ...args)
}

/** The command's result as JSON, on stdout. */
protected json(value: unknown): void {
process.stdout.write(JSON.stringify(value, null, 2) + '\n')
}

/** Call after scan() to attach per-check metadata to the run log entry. */
protected setCheckSummaries(summaries: RunLogCheckSummary[]): void {
this._checkSummaries = summaries
Expand Down
20 changes: 10 additions & 10 deletions packages/cli/src/branch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type { EnvironmentConfig, SupaForgeConfig } from './types/config'
import { checkPgDumpCompat } from './pg-tools'
import { BRANCHES_FILE, PG_PIPELINE_TIMEOUT_MS, CLONE_PROGRESS_INTERVAL_MS } from './constants'
import { CLONE_STUBS_SQL } from './stubs'
import { quoteIdent } from './utils/sql'
import { quoteIdent, quoteName } from './utils/sql'
import { SUPABASE_PLATFORM_SCHEMAS } from './defaults'

/** Prefix for branch database names created by SupaForge. */
Expand Down Expand Up @@ -188,7 +188,7 @@ async function tryTemplateCopy(sourceUrl: string, newDb: string): Promise<boolea
const client = new pg.Client(pgClientConfig(maintenanceUrl))
try {
await client.connect()
await client.query(`CREATE DATABASE "${newDb}" TEMPLATE "${database}"`)
await client.query(`CREATE DATABASE ${quoteName(newDb)} TEMPLATE ${quoteName(database)}`)
return true
} catch {
return false
Expand All @@ -211,7 +211,7 @@ async function tryDumpRestore(
const client = new pg.Client(pgClientConfig(maintenanceUrl))
try {
await client.connect()
await client.query(`CREATE DATABASE "${newDb}"`)
await client.query(`CREATE DATABASE ${quoteName(newDb)}`)
} catch {
return false
} finally {
Expand Down Expand Up @@ -268,7 +268,7 @@ async function tryDumpRestore(
const cleanup = new pg.Client(pgClientConfig(maintenanceUrl))
try {
await cleanup.connect()
await cleanup.query(`DROP DATABASE IF EXISTS "${newDb}"`)
await cleanup.query(`DROP DATABASE IF EXISTS ${quoteName(newDb)}`)
} finally {
await cleanup.end()
}
Expand Down Expand Up @@ -409,7 +409,7 @@ export async function deleteBranch(
`SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`,
[branch.dbName],
)
await client.query(`DROP DATABASE IF EXISTS "${branch.dbName}"`)
await client.query(`DROP DATABASE IF EXISTS ${quoteName(branch.dbName)}`)
} finally {
await client.end()
}
Expand Down Expand Up @@ -474,10 +474,10 @@ export async function cloneRemoteToLocal(opts: CloneRemoteOptions): Promise<Clon
`SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`,
[opts.localDbName],
)
await client.query(`DROP DATABASE IF EXISTS "${opts.localDbName}"`)
await client.query(`DROP DATABASE IF EXISTS ${quoteName(opts.localDbName)}`)
}

await client.query(`CREATE DATABASE "${opts.localDbName}"`)
await client.query(`CREATE DATABASE ${quoteName(opts.localDbName)}`)
} catch (err) {
const msg = (err as Error).message ?? ''
if (msg.includes('already exists')) {
Expand Down Expand Up @@ -619,7 +619,7 @@ export async function cloneRemoteToLocal(opts: CloneRemoteOptions): Promise<Clon
const cleanup = new pg.Client(pgClientConfig(localMaintenanceUrl))
try {
await cleanup.connect()
await cleanup.query(`DROP DATABASE IF EXISTS "${opts.localDbName}"`)
await cleanup.query(`DROP DATABASE IF EXISTS ${quoteName(opts.localDbName)}`)
} catch { /* best effort */ } finally {
await cleanup.end()
}
Expand Down Expand Up @@ -787,8 +787,8 @@ async function installRemoteExtensions(
for (const { extname, schema } of wanted) {
if (!available.has(extname)) continue
try {
await local.query(`CREATE SCHEMA IF NOT EXISTS ${quoteIdent(schema)}`)
await local.query(`CREATE EXTENSION IF NOT EXISTS ${quoteIdent(extname)} WITH SCHEMA ${quoteIdent(schema)} CASCADE`)
await local.query(`CREATE SCHEMA IF NOT EXISTS ${quoteName(schema)}`)
await local.query(`CREATE EXTENSION IF NOT EXISTS ${quoteName(extname)} WITH SCHEMA ${quoteName(schema)} CASCADE`)
installed.push(extname)
} catch { /* reported by the restore, with what needed it */ }
}
Expand Down
9 changes: 5 additions & 4 deletions packages/cli/src/checks/extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { QueryFn } from '../db'
import { pgQuery } from '../db'
import type { DriftIssue } from '../types/drift'
import { Check, type CheckContext } from './base'
import { quoteLiteral, quoteName } from '../utils/sql.js'

interface PgExtension {
name: string
Expand Down Expand Up @@ -54,8 +55,8 @@ export function diffExtensions(source: PgExtension[], target: PgExtension[]): Dr
description: `Extension "${name}" (v${ext.version}) is enabled in source (schema: ${ext.schema}) but not in target.`,
sourceValue: ext,
sql: {
up: `CREATE EXTENSION IF NOT EXISTS "${name}"${ext.schema !== 'public' ? ` SCHEMA "${ext.schema}"` : ''};`,
down: `DROP EXTENSION IF EXISTS "${name}";`,
up: `CREATE EXTENSION IF NOT EXISTS ${quoteName(name)}${ext.schema !== 'public' ? ` SCHEMA ${quoteName(ext.schema)}` : ''};`,
down: `DROP EXTENSION IF EXISTS ${quoteName(name)};`,
},
})
}
Expand Down Expand Up @@ -90,8 +91,8 @@ export function diffExtensions(source: PgExtension[], target: PgExtension[]): Dr
sourceValue: srcExt,
targetValue: tgtExt,
sql: {
up: `ALTER EXTENSION "${name}" UPDATE TO '${srcExt.version}';`,
down: `ALTER EXTENSION "${name}" UPDATE TO '${tgtExt.version}';`,
up: `ALTER EXTENSION ${quoteName(name)} UPDATE TO ${quoteLiteral(srcExt.version)};`,
down: `ALTER EXTENSION ${quoteName(name)} UPDATE TO ${quoteLiteral(tgtExt.version)};`,
},
})
}
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/checks/rls-coverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { QueryFn } from '../db'
import { pgQuery } from '../db'
import type { DriftIssue } from '../types/drift'
import { Check, type CheckContext } from './base'
import { quoteName } from '../utils/sql'

interface TableInfo {
schemaname: string
Expand Down Expand Up @@ -59,7 +60,7 @@ export class RlsCoverageCheck extends Check {
}

function buildIssue(t: TableInfo): DriftIssue {
const tableRef = `"${t.schemaname}"."${t.tablename}"`
const tableRef = `${quoteName(t.schemaname)}.${quoteName(t.tablename)}`
return {
id: `rls-coverage-${t.schemaname}.${t.tablename}`,
check: 'rls-coverage',
Expand Down
Loading
Loading