瀏覽代碼

ci: check that upgrading a PostgreSQL database gives a fresh install's schema

The test suite runs on SQLite only and CI had no PostgreSQL at all; a fresh
database tests almost nothing, since create_all builds every table before
the migrations run. scripts/check_postgres_upgrade.py builds databases with
older releases' code (the first PostgreSQL release and the newest stable),
upgrades them with the current code and compares each with a fresh install:
columns (type, length, precision, element type, nullability, default) and
the count of every constraint and index. A restart must not change any
schema. Drift that exists today is listed exactly in
scripts/postgres_schema_drift.txt; new or changed drift fails, and so does a
line that no longer drifts. Runs in CI and, with Docker and the release
tags, in test_backend.sh.
maziggy 1 周之前
父節點
當前提交
1927e077f3

+ 52 - 0
.github/workflows/ci.yml

@@ -192,6 +192,58 @@ jobs:
             --splits 4 --group ${{ matrix.shard }} \
             --durations-path tests/.test_durations
 
+  backend-postgres-upgrade:
+    # The test suite runs on SQLite only. This builds PostgreSQL databases with
+    # older releases' code, upgrades them and compares each with a fresh
+    # install (see scripts/check_postgres_upgrade.py).
+    name: PostgreSQL Upgrade Check
+    runs-on: ubuntu-latest
+    if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
+    needs: backend-lint
+    services:
+      postgres:
+        # The version the docker-compose example and the wiki use.
+        image: postgres:16-alpine
+        env:
+          POSTGRES_PASSWORD: ci
+        ports:
+          - 5432:5432
+        options: >-
+          --health-cmd pg_isready
+          --health-interval 5s
+          --health-timeout 5s
+          --health-retries 20
+    steps:
+      - uses: actions/checkout@v6
+        with:
+          # The release tags: older releases' code builds the databases to upgrade.
+          fetch-depth: 0
+
+      - name: Set up Python
+        uses: actions/setup-python@v6
+        with:
+          python-version: ${{ env.PYTHON_VERSION }}
+
+      - name: Cache pip
+        uses: actions/cache@v5
+        with:
+          path: ~/.cache/pip
+          # Its own key: this job installs the lock only, and finishes long
+          # before the test shards, so sharing theirs would save a cache
+          # without the dev packages under their key.
+          key: ${{ runner.os }}-pip-lock-${{ hashFiles('requirements.lock') }}
+          restore-keys: |
+            ${{ runner.os }}-pip-lock-
+
+      - name: Install dependencies
+        run: |
+          python -m pip install --upgrade pip
+          pip install -r requirements.lock
+
+      - name: Upgrade from older releases and compare with a fresh install
+        timeout-minutes: 10
+        run: python scripts/check_postgres_upgrade.py --url postgresql://postgres:ci@localhost:5432/postgres
+
   # ============================================================================
   # Frontend Checks
   # ============================================================================

+ 23 - 2
CONTRIBUTING.md

@@ -348,7 +348,7 @@ The easiest way to run tests is with the provided scripts in the project root:
 
 ```bash
 ./test_frontend.sh    # TypeScript check + ESLint + Vitest
-./test_backend.sh     # Ruff lint/format + type check (if .typecheck-venv exists) + pytest (parallel)
+./test_backend.sh     # Ruff lint/format + type check (if .typecheck-venv exists) + PostgreSQL upgrade check (if Docker is available) + pytest (parallel)
 ./test_docker.sh      # Full Docker build, unit tests, and integration tests
 ./test_all.sh         # All of the above (frontend → backend → docker)
 ./test_security.sh    # Security scans (bandit, pip-audit, npm-audit)
@@ -375,6 +375,27 @@ against — it exercises code paths that open their own sessions, and some of th
 write. Don't undo that override to "test against real data": point `DATABASE_URL`
 at a copy instead.
 
+### Database migrations (PostgreSQL)
+
+The test suite runs on SQLite only. `scripts/check_postgres_upgrade.py` covers
+PostgreSQL: it builds a database with older releases' code (the first release
+with PostgreSQL support and the newest stable one), upgrades it with the current
+code twice, and compares its schema with a fresh install. `test_backend.sh`
+runs it in a throwaway `postgres:16-alpine` container when Docker is available
+and the clone has the release tags (`git fetch --tags`); CI runs it too.
+
+A fresh install gets every table and column straight from the models, while an
+existing database only gets what the migrations in `backend/app/core/database.py`
+add. So a new column, constraint or index needs a migration that builds it
+exactly as the model does, including nullability and default; the check fails
+when an upgraded database ends up different.
+
+`scripts/postgres_schema_drift.txt` lists the drift that already existed when
+the check was introduced, each line an object and exactly how it differs. A
+listed object that starts to differ in another way fails like new drift. The
+list only shrinks: the check also fails on a line that no longer drifts, so a
+fix removes its line.
+
 **Frontend** — tests use [Vitest](https://vitest.dev/) and are in `frontend/src/__tests__/`:
 
 ```bash
@@ -388,7 +409,7 @@ npm run test:coverage  # With coverage
 
 Pull requests trigger automated CI checks via GitHub Actions (`.github/workflows/ci.yml`):
 
-- **Backend**: Ruff lint + format check, basedpyright type check (against the baseline), unit/integration tests, pip-audit
+- **Backend**: Ruff lint + format check, basedpyright type check (against the baseline), unit/integration tests, PostgreSQL upgrade check, pip-audit
 - **Frontend**: ESLint, TypeScript type check, Vitest tests, production build
 - **Docker**: Full image build, backend/frontend tests in Docker, integration health checks
 - **Security**: CodeQL analysis, dependency audits

+ 82 - 0
backend/tests/unit/test_check_postgres_upgrade.py

@@ -0,0 +1,82 @@
+"""The comparison in scripts/check_postgres_upgrade.py.
+
+The script itself needs a PostgreSQL server and runs in CI; these cover how it
+decides: what counts as drift, and that the allowlist can only shrink.
+"""
+
+import importlib.util
+from pathlib import Path
+
+SCRIPT = Path(__file__).resolve().parents[3] / "scripts" / "check_postgres_upgrade.py"
+_spec = importlib.util.spec_from_file_location("check_postgres_upgrade", SCRIPT)
+assert _spec and _spec.loader
+check = importlib.util.module_from_spec(_spec)
+_spec.loader.exec_module(check)
+
+FK = "constraint print_log_entries: FOREIGN KEY (archive_id) REFERENCES print_archives(id) ON DELETE SET NULL"
+FRESH = {
+    "column spool.weight_used_baseline": "double precision (udt float8, precision 53) NOT NULL default None",
+    FK: "1x",
+}
+
+
+def test_identical_schemas_do_not_drift():
+    assert check.drift(FRESH, dict(FRESH)) == set()
+
+
+def test_a_column_built_differently_drifts_with_both_definitions():
+    upgraded = FRESH | {"column spool.weight_used_baseline": "real (udt float4, precision 24) NULL default 0"}
+    assert check.drift(FRESH, upgraded) == {
+        "column spool.weight_used_baseline | fresh install: double precision (udt float8, precision 53) NOT NULL "
+        "default None; upgraded: real (udt float4, precision 24) NULL default 0"
+    }
+
+
+def test_a_missing_constraint_drifts_and_so_does_an_extra_one():
+    upgraded = {k: v for k, v in FRESH.items() if k != FK} | {"column legacy.only_upgraded": "text NULL default None"}
+    assert check.drift(FRESH, upgraded) == {
+        f"{FK} | fresh install: 1x; upgraded: missing",
+        "column legacy.only_upgraded | fresh install: missing; upgraded: text NULL default None",
+    }
+
+
+def test_a_duplicated_constraint_drifts():
+    assert check.drift(FRESH, FRESH | {FK: "2x"}) == {f"{FK} | fresh install: 1x; upgraded: 2x"}
+
+
+def test_unlisted_drift_fails_and_listed_drift_passes():
+    found = {"v0.2.3": {"column a.x | one way", "column a.y | another"}}
+    assert check.compare(found, {"column a.x | one way", "column a.y | another"}) == []
+    assert check.compare(found, {"column a.x | one way"}) == ["upgraded from v0.2.3: column a.y | another"]
+
+
+def test_a_listed_object_that_now_differs_in_another_way_fails():
+    """The allowlist records how an object drifts, not just that it does: a new
+    regression on a listed column must not hide behind its old entry."""
+    found = {"v0.2.3": {"column a.x | fresh install: varchar(200); upgraded: varchar(30)"}}
+    problems = check.compare(found, {"column a.x | fresh install: varchar(30); upgraded: varchar(30) NULL"})
+    assert "upgraded from v0.2.3: column a.x | fresh install: varchar(200); upgraded: varchar(30)" in problems
+
+
+def test_a_listed_line_that_no_longer_drifts_from_any_release_fails():
+    """So the list only ever shrinks: a fix must remove its line."""
+    found = {"v0.2.3": {"column a.x | d"}, "v1.2.5.7": set()}
+    problems = check.compare(found, {"column a.x | d", "column a.fixed | d"})
+    assert problems == [f"no longer drifts, remove it from {check.ALLOWLIST.name}: column a.fixed | d"]
+
+
+def test_a_line_drifting_from_only_one_release_is_still_in_use():
+    found = {"v0.2.3": {"column a.x | d"}, "v1.2.5.7": set()}
+    assert check.compare(found, {"column a.x | d"}) == []
+
+
+def test_the_allowlist_skips_comment_lines_but_keeps_a_hash_inside_a_line(tmp_path):
+    path = tmp_path / "drift.txt"
+    path.write_text("# header\n\ncolumn a.x | default '#1'\n  # indented comment\n", encoding="utf-8")
+    assert check.read_allowlist(path) == {"column a.x | default '#1'"}
+
+
+def test_the_database_name_and_driver_are_replaced_in_the_url():
+    url = "postgresql+asyncpg://u:p@db:5432/postgres?sslmode=disable"
+    assert check.with_database(url, "x") == "postgresql://u:p@db:5432/x?sslmode=disable"
+    assert check.with_database(url, "x", "asyncpg") == "postgresql+asyncpg://u:p@db:5432/x?sslmode=disable"

+ 231 - 0
scripts/check_postgres_upgrade.py

@@ -0,0 +1,231 @@
+"""Check that upgrading a PostgreSQL database gives the schema of a fresh install.
+
+A fresh install gets every table from the models (``create_all``); an existing
+database only gets what the migrations in ``core/database.py`` add to it. Run
+on an empty database, as a plain migration check would, ``create_all`` builds
+everything first and the migrations have nothing left to do, so a migration
+that is missing or adds a column differently goes unnoticed.
+
+This builds a database with an older release's code, upgrades it with the
+current code twice, and compares its schema with a fresh install: every column
+(type with length, precision and element type, nullability, default), and how
+many of each constraint and index there are. Constraints and indexes are
+compared by definition, so only a name differing is not drift. A restart must
+leave every schema, fresh or upgraded, exactly as the first start left it.
+
+Drift that existed when this check was introduced is listed in
+scripts/postgres_schema_drift.txt, each line the object and exactly how it
+differs. Drift not listed there fails, including a listed object that now
+differs in another way, and so does a listed line that no longer drifts:
+remove it.
+
+Usage (needs git with the release tags and a PostgreSQL server the given user
+may create databases on; every database it creates is dropped again):
+
+    python scripts/check_postgres_upgrade.py --url postgresql://user:pw@host:5432/postgres
+
+Never point it at a server holding data you care about.
+"""
+
+import argparse
+import asyncio
+import os
+import re
+import subprocess
+import sys
+import tarfile
+import tempfile
+from collections import Counter
+from pathlib import Path
+from urllib.parse import urlsplit, urlunsplit
+
+REPO = Path(__file__).resolve().parents[1]
+ALLOWLIST = REPO / "scripts" / "postgres_schema_drift.txt"
+# The first release with PostgreSQL support: the oldest schema an install can have.
+FIRST_PG_RELEASE = "v0.2.3"
+
+_INIT_DB = """
+import asyncio
+from backend.app.core import database
+from backend.app.core.config import settings
+assert settings.database_url.startswith("postgresql+asyncpg://"), settings.database_url
+asyncio.run(database.init_db())
+"""
+
+
+def newest_stable_release() -> str:
+    tags = subprocess.run(
+        ["git", "tag", "--list", "v*", "--sort=-v:refname"], cwd=REPO, check=True, capture_output=True, text=True
+    ).stdout.split()
+    stable = [t for t in tags if re.fullmatch(r"v\d+(\.\d+)+", t)]
+    if not stable:
+        sys.exit("no release tags found; fetch them (git fetch --tags)")
+    return stable[0]
+
+
+def extract_release(tag: str, into: Path) -> Path:
+    """The release's backend/ as a directory to run init_db from (git archive, read-only)."""
+    archive = into / f"{tag}.tar"
+    with archive.open("wb") as out:
+        subprocess.run(["git", "archive", tag, "backend"], cwd=REPO, check=True, stdout=out)
+    target = into / tag
+    with tarfile.open(archive) as tar:
+        tar.extractall(target, filter="data")
+    return target
+
+
+def run_init_db(code_root: Path, url: str, label: str) -> None:
+    """Run init_db (create_all + migrations) of the code under code_root against url."""
+    with tempfile.TemporaryDirectory() as data_dir:
+        env = {**os.environ, "DATABASE_URL": url, "DATA_DIR": data_dir, "PYTHONPATH": str(code_root)}
+        done = subprocess.run([sys.executable, "-c", _INIT_DB], cwd=data_dir, env=env, capture_output=True, text=True)
+    if done.returncode != 0:
+        sys.exit(f"init_db failed ({label}):\n{done.stdout}{done.stderr}")
+
+
+def with_database(url: str, name: str, driver: str = "") -> str:
+    parts = urlsplit(url)
+    scheme = "postgresql" + (f"+{driver}" if driver else "")
+    return urlunsplit((scheme, parts.netloc, f"/{name}", parts.query, parts.fragment))
+
+
+async def dump_schema(url: str) -> dict[str, str]:
+    """Every column, constraint and index of the public schema, as {object: definition}."""
+    import asyncpg
+
+    conn = await asyncpg.connect(url)
+    try:
+        schema: dict[str, str] = {}
+        for row in await conn.fetch(
+            "SELECT table_name, column_name, data_type, udt_name, character_maximum_length, numeric_precision, "
+            "numeric_scale, datetime_precision, is_nullable, column_default "
+            "FROM information_schema.columns WHERE table_schema = 'public'"
+        ):
+            table, column, data_type, udt, length, precision, scale, dt_precision, nullable, default = row
+            # udt_name names what data_type leaves generic: the element type of an
+            # ARRAY (_int4), the enum or domain of a USER-DEFINED column.
+            attributes = [f"udt {udt}"]
+            if length is not None:
+                attributes.append(f"length {length}")
+            if precision is not None:
+                attributes.append(f"precision {precision}")
+            if scale is not None:
+                attributes.append(f"scale {scale}")
+            if dt_precision is not None:
+                attributes.append(f"time precision {dt_precision}")
+            schema[f"column {table}.{column}"] = (
+                f"{data_type} ({', '.join(attributes)})"
+                + (" NULL" if nullable == "YES" else " NOT NULL")
+                + f" default {default}"
+            )
+        # Counted: two identical constraints or indexes are drift too (an
+        # unnamed constraint that every start adds again, say).
+        objects: Counter[str] = Counter()
+        for row in await conn.fetch(
+            "SELECT conrelid::regclass::text, pg_get_constraintdef(oid) FROM pg_constraint "
+            "WHERE connamespace = 'public'::regnamespace"
+        ):
+            objects[f"constraint {row[0]}: {row[1]}"] += 1
+        for row in await conn.fetch("SELECT tablename, indexdef FROM pg_indexes WHERE schemaname = 'public'"):
+            definition = re.sub(r"^CREATE (UNIQUE )?INDEX \S+ ON ", r"CREATE \1INDEX ON ", row[1])
+            objects[f"index {row[0]}: {definition}"] += 1
+        schema.update({key: f"{count}x" for key, count in objects.items()})
+        return schema
+    finally:
+        await conn.close()
+
+
+def drift(
+    fresh: dict[str, str], upgraded: dict[str, str], labels: tuple[str, str] = ("fresh install", "upgraded")
+) -> set[str]:
+    """One line per object that differs between the two schemas: the object and how."""
+    return {
+        f"{key} | {labels[0]}: {fresh.get(key) or 'missing'}; {labels[1]}: {upgraded.get(key) or 'missing'}"
+        for key in fresh.keys() | upgraded.keys()
+        if fresh.get(key) != upgraded.get(key)
+    }
+
+
+def read_allowlist(path: Path) -> set[str]:
+    """The allowlist's lines; '#' starts a comment only at the beginning of a line."""
+    if not path.exists():
+        return set()
+    lines = (line.strip() for line in path.read_text(encoding="utf-8").splitlines())
+    return {line for line in lines if line and not line.startswith("#")}
+
+
+def compare(found: dict[str, set[str]], allowed: set[str]) -> list[str]:
+    """Problems: drift (per start release) not in the allowlist, and allowlist lines no start shows."""
+    problems = []
+    for release, lines in sorted(found.items()):
+        for line in sorted(lines - allowed):
+            problems.append(f"upgraded from {release}: {line}")
+    seen = set().union(*found.values()) if found else set()
+    for line in sorted(allowed - seen):
+        problems.append(f"no longer drifts, remove it from {ALLOWLIST.name}: {line}")
+    return problems
+
+
+def start_twice(url: str, label: str) -> tuple[dict[str, str], list[str]]:
+    """Start the current code on url twice; the schema, and what the restart changed."""
+    run_init_db(REPO, url, label)
+    first = asyncio.run(dump_schema(with_database(url, urlsplit(url).path.lstrip("/"))))
+    run_init_db(REPO, url, f"{label}, restarted")
+    second = asyncio.run(dump_schema(with_database(url, urlsplit(url).path.lstrip("/"))))
+    changed = drift(first, second, ("first start", "restart"))
+    return second, [f"{label}: a restart changed the schema: {line}" for line in sorted(changed)]
+
+
+async def _admin(url: str, *statements: str) -> None:
+    import asyncpg
+
+    conn = await asyncpg.connect(url)
+    try:
+        for statement in statements:
+            await conn.execute(statement)
+    finally:
+        await conn.close()
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
+    parser.add_argument("--url", required=True, help="PostgreSQL server URL (its database is only used to connect)")
+    parser.add_argument(
+        "--from-release", action="append", help=f"start release(s); default {FIRST_PG_RELEASE} and the newest stable"
+    )
+    args = parser.parse_args()
+    releases = args.from_release or sorted({FIRST_PG_RELEASE, newest_stable_release()})
+    admin_url = with_database(args.url, urlsplit(args.url).path.lstrip("/") or "postgres")
+
+    databases = {"fresh": "bambuddy_check_fresh"} | {r: "bambuddy_check_" + re.sub(r"\W", "_", r) for r in releases}
+    asyncio.run(_admin(admin_url, *(f"DROP DATABASE IF EXISTS {db}" for db in databases.values())))
+    asyncio.run(_admin(admin_url, *(f"CREATE DATABASE {db}" for db in databases.values())))
+    try:
+        with tempfile.TemporaryDirectory() as tmp:
+            fresh, problems = start_twice(with_database(args.url, databases["fresh"], "asyncpg"), "fresh install")
+
+            found = {}
+            for release in releases:
+                url = with_database(args.url, databases[release], "asyncpg")
+                run_init_db(extract_release(release, Path(tmp)), url, f"{release} install")
+                upgraded, changed = start_twice(url, f"upgrade from {release}")
+                problems += changed
+                found[release] = drift(fresh, upgraded)
+                print(f"{release}: upgrade ok, {len(found[release])} object(s) differ from a fresh install")
+    finally:
+        asyncio.run(_admin(admin_url, *(f"DROP DATABASE IF EXISTS {db}" for db in databases.values())))
+
+    problems += compare(found, read_allowlist(ALLOWLIST))
+    if problems:
+        print("\nSchema problems:\n  " + "\n  ".join(problems))
+        print(
+            "\nA new column or constraint needs a migration in core/database.py that builds it exactly as the "
+            "model does. See the docstring of scripts/check_postgres_upgrade.py."
+        )
+        return 1
+    print("Upgraded schemas match a fresh install (apart from the listed known drift).")
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 67 - 0
scripts/postgres_schema_drift.txt

@@ -0,0 +1,67 @@
+# Known schema drift between a PostgreSQL database upgraded from an older
+# release and a fresh install, as found when scripts/check_postgres_upgrade.py
+# was introduced (2026-10-10). Each line is an object and exactly how it
+# differs ("<object> | fresh install: ...; upgraded: ..."), as the check prints
+# it. The check fails on drift not listed here (also a listed object that now
+# differs in another way) and on a line that no longer drifts: a fix that brings
+# an upgraded database in line with the models removes its line.
+# Comment lines start with "#".
+
+# constraints
+constraint print_archives: FOREIGN KEY (library_file_id) REFERENCES library_files(id) ON DELETE SET NULL | fresh install: 1x; upgraded: missing
+constraint print_log_entries: FOREIGN KEY (archive_id) REFERENCES print_archives(id) ON DELETE SET NULL | fresh install: 1x; upgraded: missing
+constraint print_log_entries: FOREIGN KEY (created_by_id) REFERENCES users(id) ON DELETE SET NULL | fresh install: 1x; upgraded: missing
+
+# columns
+column active_print_spoolman.tray_remain_start | fresh install: json (udt json) NULL default None; upgraded: text (udt text) NULL default None
+column api_keys.can_access_cloud | fresh install: boolean (udt bool) NOT NULL default None; upgraded: boolean (udt bool) NULL default false
+column api_keys.can_manage_archives | fresh install: boolean (udt bool) NOT NULL default None; upgraded: boolean (udt bool) NULL default true
+column api_keys.can_manage_inventory | fresh install: boolean (udt bool) NOT NULL default None; upgraded: boolean (udt bool) NULL default true
+column api_keys.can_manage_library | fresh install: boolean (udt bool) NOT NULL default None; upgraded: boolean (udt bool) NULL default true
+column api_keys.can_manage_maintenance | fresh install: boolean (udt bool) NOT NULL default None; upgraded: boolean (udt bool) NULL default true
+column api_keys.can_manage_projects | fresh install: boolean (udt bool) NOT NULL default None; upgraded: boolean (udt bool) NULL default true
+column api_keys.can_send_notifications | fresh install: boolean (udt bool) NOT NULL default None; upgraded: boolean (udt bool) NULL default false
+column api_keys.can_update_energy_cost | fresh install: boolean (udt bool) NOT NULL default None; upgraded: boolean (udt bool) NULL default false
+column github_backup_config.allow_insecure_http | fresh install: boolean (udt bool) NOT NULL default None; upgraded: boolean (udt bool) NULL default false
+column github_backup_config.provider | fresh install: character varying (udt varchar, length 30) NOT NULL default None; upgraded: character varying (udt varchar, length 30) NULL default 'github'::character varying
+column groups.restrict_printers | fresh install: boolean (udt bool) NOT NULL default false; upgraded: boolean (udt bool) NULL default false
+column library_files.variant_position | fresh install: integer (udt int4, precision 32, scale 0) NOT NULL default 0; upgraded: integer (udt int4, precision 32, scale 0) NULL default 0
+column library_folders.shared | fresh install: boolean (udt bool) NOT NULL default false; upgraded: boolean (udt bool) NULL default false
+column notification_providers.attach_photo | fresh install: boolean (udt bool) NULL default None; upgraded: boolean (udt bool) NULL default true
+column notification_providers.on_ai_failure_detection | fresh install: boolean (udt bool) NULL default None; upgraded: boolean (udt bool) NULL default false
+column notification_providers.on_ams_drying_suspended | fresh install: boolean (udt bool) NULL default None; upgraded: boolean (udt bool) NULL default true
+column notification_providers.on_app_message | fresh install: boolean (udt bool) NULL default None; upgraded: boolean (udt bool) NULL default false
+column notification_providers.on_billing_charge_failed | fresh install: boolean (udt bool) NULL default None; upgraded: boolean (udt bool) NULL default true
+column notification_providers.on_ha_sensor_alert | fresh install: boolean (udt bool) NULL default None; upgraded: boolean (udt bool) NULL default false
+column notification_providers.on_location_ha_sensor_alert | fresh install: boolean (udt bool) NULL default None; upgraded: boolean (udt bool) NULL default false
+column notification_providers.on_plate_clear_required | fresh install: boolean (udt bool) NULL default None; upgraded: boolean (udt bool) NULL default false
+column notification_providers.on_print_confirm_request | fresh install: boolean (udt bool) NULL default None; upgraded: boolean (udt bool) NULL default true
+column notification_providers.on_stock_break_alert | fresh install: boolean (udt bool) NULL default None; upgraded: boolean (udt bool) NULL default false
+column notification_providers.on_stock_reorder_alert | fresh install: boolean (udt bool) NULL default None; upgraded: boolean (udt bool) NULL default false
+column notification_providers.telegram_verdict_mode | fresh install: character varying (udt varchar, length 16) NULL default None; upgraded: character varying (udt varchar, length 16) NULL default 'buttons'::character varying
+column oidc_providers.email_claim | fresh install: character varying (udt varchar, length 64) NOT NULL default None; upgraded: character varying (udt varchar, length 64) NULL default 'email'::character varying
+column oidc_providers.is_autologin | fresh install: boolean (udt bool) NOT NULL default false; upgraded: boolean (udt bool) NULL default false
+column oidc_providers.is_env_managed | fresh install: boolean (udt bool) NOT NULL default false; upgraded: boolean (udt bool) NULL default false
+column oidc_providers.require_email_verified | fresh install: boolean (udt bool) NOT NULL default None; upgraded: boolean (udt bool) NULL default true
+column print_archives.bambuddy_forced_timelapse | fresh install: boolean (udt bool) NOT NULL default false; upgraded: boolean (udt bool) NULL default false
+column print_archives.wallet_charge_skipped | fresh install: boolean (udt bool) NOT NULL default None; upgraded: boolean (udt bool) NULL default false
+column print_queue.bed_levelling | fresh install: character varying (udt varchar, length 8) NOT NULL default None; upgraded: character varying (udt varchar, length 8) NOT NULL default 'auto'::character varying
+column print_queue.cleanup_library_after_dispatch | fresh install: boolean (udt bool) NOT NULL default None; upgraded: boolean (udt bool) NULL default false
+column print_queue.confirm_outcome | fresh install: boolean (udt bool) NOT NULL default false; upgraded: boolean (udt bool) NULL default false
+column print_queue.dispatch_attempts | fresh install: integer (udt int4, precision 32, scale 0) NOT NULL default 0; upgraded: integer (udt int4, precision 32, scale 0) NULL default 0
+column print_queue.filament_short | fresh install: boolean (udt bool) NOT NULL default None; upgraded: boolean (udt bool) NULL default false
+column print_queue.flow_cali | fresh install: character varying (udt varchar, length 8) NOT NULL default None; upgraded: character varying (udt varchar, length 8) NOT NULL default 'auto'::character varying
+column print_queue.gate_acknowledged | fresh install: boolean (udt bool) NOT NULL default None; upgraded: boolean (udt bool) NULL default false
+column print_queue.nozzle_offset_cali | fresh install: character varying (udt varchar, length 8) NOT NULL default None; upgraded: character varying (udt varchar, length 8) NULL default 'auto'::character varying
+column print_queue.preheat_override | fresh install: character varying (udt varchar, length 10) NOT NULL default None; upgraded: character varying (udt varchar, length 10) NULL default 'inherit'::character varying
+column print_queue.skip_filament_check | fresh install: boolean (udt bool) NOT NULL default None; upgraded: boolean (udt bool) NULL default false
+column printers.camera_light_auto | fresh install: boolean (udt bool) NOT NULL default None; upgraded: boolean (udt bool) NULL default false
+column smart_plugs.auto_off_after_drying | fresh install: boolean (udt bool) NOT NULL default false; upgraded: boolean (udt bool) NULL default false
+column smart_plugs.controls_printer_power | fresh install: boolean (udt bool) NOT NULL default true; upgraded: boolean (udt bool) NULL default true
+column smart_plugs.off_delay_after_drying_minutes | fresh install: integer (udt int4, precision 32, scale 0) NOT NULL default 10; upgraded: integer (udt int4, precision 32, scale 0) NULL default 10
+column smart_plugs.rest_energy_total_multiplier | fresh install: double precision (udt float8, precision 53) NOT NULL default '1'::double precision; upgraded: real (udt float4, precision 24) NULL default 1.0
+column spool.weight_used_baseline | fresh install: double precision (udt float8, precision 53) NOT NULL default None; upgraded: real (udt float4, precision 24) NULL default 0
+column virtual_printers.gcode_injection | fresh install: boolean (udt bool) NOT NULL default false; upgraded: boolean (udt bool) NULL default false
+column virtual_printers.queue_force_color_match | fresh install: boolean (udt bool) NOT NULL default false; upgraded: boolean (udt bool) NULL default false
+column virtual_printers.save_ams_mapping | fresh install: boolean (udt bool) NOT NULL default false; upgraded: boolean (udt bool) NULL default false
+column virtual_printers.tailscale_disabled | fresh install: boolean (udt bool) NOT NULL default true; upgraded: boolean (udt bool) NULL default true

+ 30 - 0
test_backend.sh

@@ -42,6 +42,36 @@ else
 echo "Skipping the type check: no .typecheck-venv (see CONTRIBUTING.md)."
 fi
 
+# PostgreSQL upgrade check, as CI runs it, in a throwaway container (the tests
+# below run on SQLite only). Needs Docker and the release tags.
+if ! command -v docker >/dev/null 2>&1 || ! docker info >/dev/null 2>&1; then
+echo "Skipping the PostgreSQL upgrade check: Docker is not available."
+elif [ -z "$(git tag --list v0.2.3)" ]; then
+echo "Skipping the PostgreSQL upgrade check: no release tags in this clone (git fetch --tags)."
+else
+(
+    cd .. || exit 1
+    name="bambuddy-pg-check-$$"
+    port=$(venv/bin/python -c 'import socket; s = socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1])') || exit 1
+    docker run -d --rm --name "$name" --tmpfs /var/lib/postgresql/data -e POSTGRES_PASSWORD=check \
+        -p "127.0.0.1:$port:5432" postgres:16-alpine >/dev/null || exit 1
+    # dash runs no EXIT trap when interrupted, so remove the container on a
+    # signal too (and then exit as interrupted).
+    trap 'docker rm -f "$name" >/dev/null 2>&1' EXIT
+    trap 'docker rm -f "$name" >/dev/null 2>&1; exit 130' INT TERM
+    ready=0
+    for _ in $(seq 60); do
+        if docker exec "$name" pg_isready -U postgres -h 127.0.0.1 >/dev/null 2>&1; then ready=1; break; fi
+        sleep 1
+    done
+    if [ "$ready" != 1 ]; then
+        echo "PostgreSQL upgrade check: the container did not become ready."
+        exit 1
+    fi
+    venv/bin/python scripts/check_postgres_upgrade.py --url "postgresql://postgres:check@127.0.0.1:$port/postgres"
+) || exit 1
+fi
+
 if [ "$1" = "--full" ]; then
 ../venv/bin/python3 -m pytest tests/ -v -n 30
 else