From c20a951f3c988d64b854eb220d1e34ece0ff3da7 Mon Sep 17 00:00:00 2001 From: Natalie Rose Date: Fri, 18 Sep 2026 12:56:04 +1000 Subject: [PATCH] Handle initial schema version on existing db --- CLAUDE.md | 24 ++++++++++++----- bin/stamp_db_version.pl | 36 +++++++++++++++++++++++++ lib/Lacuna/DB/Migrate.pm | 57 +++++++++++++++++++++++++++++++++++++--- 3 files changed, 107 insertions(+), 10 deletions(-) create mode 100644 bin/stamp_db_version.pl diff --git a/CLAUDE.md b/CLAUDE.md index bfdb87e9..d5137ee3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,21 +90,31 @@ class. Schema changes are managed with `DBIx::Class::DeploymentHandler` (`Lacuna::DB::Migrate`), independent of the game's own `$Lacuna::VERSION` — `$Lacuna::DB::VERSION` is the schema version, bumped only when a migration is needed. Deploy -and upgrade SQL lives under `var/ddl/`. +and upgrade SQL lives under `var/ddl/` (`var/ddl/MySQL/deploy//` for a full install, `.../upgrade/-/` +for a diff) and is committed to the repo — it's generated once and then frozen, not regenerated at deploy time. -To make a schema change: edit the `Result` class(es), bump `$Lacuna::DB::VERSION` in `lib/Lacuna/DB.pm`, then -generate the upgrade SQL (e.g. `DBIx::Class::DeploymentHandler->new({schema => Lacuna->db, script_directory => -'var/ddl', databases => 'MySQL'})->prepare_upgrade({from_version => $old, to_version => $new})`) and commit the -generated files under `var/ddl/_common/upgrade/-/` alongside the code change. +To make a schema change: edit the `Result` class(es), bump `$Lacuna::DB::VERSION` in `lib/Lacuna/DB.pm`, run +`perl bin/prepare_db_migration.pl ` (needs a DB connection — run it inside the `server` container) to +generate the upgrade SQL under `var/ddl/`, and commit the generated files alongside the code change. -- `bin/migrate_db.pl` installs (fresh DB) or applies pending upgrades (existing DB); idempotent, safe on every boot. - Run automatically by `bin/start_lacuna.sh` before the app starts serving. +- `bin/migrate_db.pl` applies pending upgrades on an existing, *tracked* database, or installs from scratch on a + genuinely empty one; idempotent, safe on every boot. Run automatically by `bin/start_lacuna.sh` before the app + starts serving. - `bin/check_db_migration.pl` exits non-zero if the database isn't at the version the code expects, without touching it. Run automatically by `bin/startdev.sh`, so a dev server refuses to boot against a stale schema — run `bin/migrate_db.pl` by hand to fix. - `bin/setup/init_lacuna.pl` (fresh dev bootstrap / full star-map reset) uses `Lacuna::DB::Migrate::reinstall`, which drops and redeploys the whole schema from the current `Result` classes rather than applying migrations. +**Safety rail:** `bin/migrate_db.pl` distinguishes "no version-tracking table" from "empty database" by checking +whether the database has any tables at all. If it has tables but isn't tracked, it refuses to run rather than +guessing — install DDL drops and recreates every table, which is only correct for a genuinely empty database, and +"no version table" alone doesn't prove that (this is exactly how a September 2026 incident wiped production: the +live DB predated this tooling and had no version table, so it was treated as fresh and reinstalled over). A database +in that state (predates this tooling, or was just restored from a backup) has to be stamped once, deliberately, by a +human who has confirmed what version its schema actually matches: `perl bin/stamp_db_version.pl ` — this +only creates the version-tracking table and records that one row, it never touches any other table. + To add a new building, follow the checklist in `info/add_a_building.txt` (DB::Result class, entry in `Lacuna::DB::Result::Building` class types, RPC class, entry in `bin/lacuna.psgi`, `Lacuna::DB::Result::Medals`, images, and — if buildable — `Lacuna::Constants::BUILDABLE_CLASSES`; plus `SPACE_STATION_MODULES` if it's a station module). diff --git a/bin/stamp_db_version.pl b/bin/stamp_db_version.pl new file mode 100644 index 00000000..b458d523 --- /dev/null +++ b/bin/stamp_db_version.pl @@ -0,0 +1,36 @@ +#!/usr/bin/env perl + +# One-time recovery/bootstrap tool: marks an existing database as already +# being at a given schema version, without running any deploy DDL against it +# -- it only creates the version-tracking table and records one row. Use +# this to bring an untracked-but-populated database (one that predates +# DeploymentHandler, or was just restored from a backup) under migration +# tracking before bin/migrate_db.pl can run against it. +# +# bin/migrate_db.pl deliberately refuses to run install() by itself against +# a database that has tables but no version-tracking table -- that's not a +# fresh database, and guessing wrong would drop and recreate every table. +# See Lacuna::DB::Migrate::stamp. +# +# Usage: perl bin/stamp_db_version.pl + +use 5.010; +use strict; +use lib '/home/lacuna/server/lib'; +use Lacuna::DB::Migrate; + +my ($version) = @ARGV; +die "Usage: $0 \n" unless defined $version && length $version; + +say "About to stamp this database as schema version $version, WITHOUT changing"; +say "any table in it. Only do this if you've personally confirmed the database's"; +say "current schema actually matches that version -- if it doesn't, later"; +say "migrations will be skipped and the schema will silently drift from the code."; +say ''; +print "Type the version number again to confirm: "; +my $confirm = ; +chomp $confirm if defined $confirm; +die "Confirmation didn't match, aborting.\n" unless defined $confirm && $confirm eq $version; + +my $result = Lacuna::DB::Migrate::stamp($version); +say "Database stamped at version $result. It's now safe to run bin/migrate_db.pl."; diff --git a/lib/Lacuna/DB/Migrate.pm b/lib/Lacuna/DB/Migrate.pm index 30deba0e..c0f4b7a1 100644 --- a/lib/Lacuna/DB/Migrate.pm +++ b/lib/Lacuna/DB/Migrate.pm @@ -53,9 +53,29 @@ sub pending { return $db_version ne schema_version(); } -# Safe to call on every boot: installs from scratch on a brand new database, -# applies whatever upgrade steps are outstanding on an existing one, and does -# nothing if the database is already current. Returns the resulting version. +# True if the connected database has any tables at all. Used to tell a +# genuinely brand new database apart from one that simply predates +# DeploymentHandler tracking -- "no version table" alone conflates the two, +# and treating the latter as the former means running install()'s +# DROP-TABLE-then-CREATE-TABLE deploy DDL over real data. See upgrade(). +sub _database_has_tables { + my $dbh = Lacuna->db->storage->dbh; + my ($count) = $dbh->selectrow_array( + 'SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE()' + ); + return $count > 0; +} + +# Safe to call on every boot: installs from scratch on a brand new (empty) +# database, applies whatever upgrade steps are outstanding on a tracked one, +# and does nothing if the database is already current. Returns the resulting +# version. +# +# Refuses to run at all -- rather than guessing -- if the database has +# tables but no version-tracking table: that's not a fresh database, it's an +# untracked one (e.g. one that predates this tooling, or was just restored +# from a backup), and installing over it would drop and recreate every +# table. Use stamp() once, deliberately, to bring it under tracking first. sub upgrade { my $dbh = Lacuna->db->storage->dbh; @@ -68,6 +88,16 @@ sub upgrade { if ($dh->version_storage_is_installed) { $dh->upgrade; } + elsif (_database_has_tables()) { + die "Lacuna::DB::Migrate: this database has existing tables but no " + . VERSION_TABLE . " -- refusing to install, which would drop and " + . "recreate every table and destroy the data in them.\n" + . "This looks like a database that predates DeploymentHandler " + . "tracking (or was restored from a backup taken before it). " + . "Stamp it at the version its current schema actually matches, " + . "then re-run this:\n" + . " perl bin/stamp_db_version.pl \n"; + } else { $dh->install; } @@ -81,6 +111,27 @@ sub upgrade { return $result; } +# One-time recovery/bootstrap primitive for a database that already has +# tables but was never deployed through DeploymentHandler. Creates *only* +# the version-tracking table and records the given version -- unlike +# install(), it never touches any other table. upgrade() refuses to run +# until this (or a real install against a genuinely empty database) has +# happened, so this has to be a deliberate, human-run step: see +# bin/stamp_db_version.pl. +sub stamp { + my ($version) = @_; + die "Lacuna::DB::Migrate::stamp: version is required\n" unless defined $version; + + my $dh = _dh(); + die 'Lacuna::DB::Migrate: version storage is already installed (at version ' + . $dh->database_version . ") -- refusing to overwrite it.\n" + if $dh->version_storage_is_installed; + + $dh->install_version_storage; + $dh->add_database_version({ version => $version }); + return $dh->database_version; +} + # Dev-only, destructive: drops the version-tracking table (if present) and # redeploys the whole schema from scratch, dropping/recreating every table. # Used by bin/setup/init_lacuna.pl, which already wipes the star map on every -- 2.51.2