v0.2.0 (9/N): pre-migration auto-backup of var/data.sqlite

PLAN.md §12 *Migrations on schema change* flagged this as a v1.0
prereq. SQLite has no transactional DDL — a half-applied migration
can corrupt the user's data with no rollback path. Cheapest defence
is a copy-aside before each migrate.

backupDatabase() runs at the head of runMigrations() in bundled
mode:
- skipped on first launch (no data.sqlite yet)
- copies var/data.sqlite to var/data.sqlite.<unix-timestamp>.bak
- trims to kMaxDatabaseBackups=5 most recent (mtime sort, oldest
  go first)
- copy failure logs a warning and continues; a missing safety-net
  is not a reason to refuse to boot

Dev mode is unaffected — developers own their var/data.sqlite
lifecycle and don't want a backup written every time `make dev`
restarts.

Integration test: bundled-supervisor.sh gained an assertion after
the 2nd-launch /healthz check that at least one
data.sqlite.*.bak file appears under the user data dir. Verified
locally — backup landed at the expected path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 20:31:50 +02:00
parent 1d014ae3b7
commit e0241bad64
4 changed files with 59 additions and 0 deletions

View File

@@ -18,6 +18,7 @@ This section tracks work landing on `dev` toward **v0.2.0** (next minor; pre-1.0
- **`make:bridge:resource --with-dto` opt-in.** Generates `Create<Name>Dto` + `Update<Name>Dto` under `src/Dto/` alongside the controller, and the controller dispatches via `#[MapRequestPayload]`. Closes the input-validation gap from the audit: malformed JSON, missing required fields, or `#[Assert\NotBlank]` violations now produce RFC 7807 `application/problem+json` automatically (Symfony's `RequestPayloadValueResolver`) — no more `if (isset($data['title']))` boilerplate, no silent type coercion. Update DTOs use nullable defaults so PATCH callers send only the fields they want changed. Without `--with-dto` the legacy template still ships unchanged. Maker fails loud if `symfony/validator` isn't autoloadable. Skeleton + example/todo composer.json pull `symfony/validator` so scaffolded apps work out of the box. Snapshot test exercises both modes. - **`make:bridge:resource --with-dto` opt-in.** Generates `Create<Name>Dto` + `Update<Name>Dto` under `src/Dto/` alongside the controller, and the controller dispatches via `#[MapRequestPayload]`. Closes the input-validation gap from the audit: malformed JSON, missing required fields, or `#[Assert\NotBlank]` violations now produce RFC 7807 `application/problem+json` automatically (Symfony's `RequestPayloadValueResolver`) — no more `if (isset($data['title']))` boilerplate, no silent type coercion. Update DTOs use nullable defaults so PATCH callers send only the fields they want changed. Without `--with-dto` the legacy template still ships unchanged. Maker fails loud if `symfony/validator` isn't autoloadable. Skeleton + example/todo composer.json pull `symfony/validator` so scaffolded apps work out of the box. Snapshot test exercises both modes.
- **`make:bridge:event <Name>` maker.** Generates a domain-event class (`src/Event/<Name>Event.php`, readonly value object), a subscriber (`src/EventSubscriber/<Name>Subscriber.php`) that republishes via `PublisherInterface` on `app://event/<kebab-name>`, and a QML stub (`{qml_path}/<Name>EventHandler.qml`) that listens via `MercureClient` and re-emits as a typed `signal`. Closes the third row of PLAN.md §8's makers table; pairs with the existing `make:bridge:resource` / `command` / `window` makers so domain events have a one-command path from PHP through to QML. - **`make:bridge:event <Name>` maker.** Generates a domain-event class (`src/Event/<Name>Event.php`, readonly value object), a subscriber (`src/EventSubscriber/<Name>Subscriber.php`) that republishes via `PublisherInterface` on `app://event/<kebab-name>`, and a QML stub (`{qml_path}/<Name>EventHandler.qml`) that listens via `MercureClient` and re-emits as a typed `signal`. Closes the third row of PLAN.md §8's makers table; pairs with the existing `make:bridge:resource` / `command` / `window` makers so domain events have a one-command path from PHP through to QML.
- **`make:bridge:read-model <Name>` maker.** Generates a query-only projection: `src/ReadModel/<Name>ReadModel.php` (query service stub injecting `EntityManagerInterface`), `src/Controller/<Name>Controller.php` (single GET handler at `/api/<kebab-plural>`), and `{qml_path}/<Name>List.qml` (`ReactiveListModel` bound to the route, deliberately *no* Mercure topic — read-models aren't auto-reactive; invalidation is event-driven via `make:bridge:event`). Closes the fourth row of PLAN.md §8's makers table. - **`make:bridge:read-model <Name>` maker.** Generates a query-only projection: `src/ReadModel/<Name>ReadModel.php` (query service stub injecting `EntityManagerInterface`), `src/Controller/<Name>Controller.php` (single GET handler at `/api/<kebab-plural>`), and `{qml_path}/<Name>List.qml` (`ReactiveListModel` bound to the route, deliberately *no* Mercure topic — read-models aren't auto-reactive; invalidation is event-driven via `make:bridge:event`). Closes the fourth row of PLAN.md §8's makers table.
- **Pre-migration auto-backup of `var/data.sqlite`.** Bundled-mode supervisor copies the SQLite file to `var/data.sqlite.<unix-timestamp>.bak` before invoking `doctrine:migrations:migrate`; trims to the 5 most recent. SQLite's lack of transactional DDL means a half-applied migration can corrupt the database with no rollback path; cheap insurance against that. Skipped on first launch (no DB to back up); failure to copy logs a warning and continues (a missing safety-net is not a reason to refuse to boot). Backup runs only in bundled mode — dev mode users own their `var/data.sqlite` lifecycle. Bundled-supervisor integration test gained an assertion that a `.bak` file appears under the user data dir on second launch.
### Changed ### Changed

View File

@@ -192,6 +192,18 @@ done
echo "$HEALTHZ2_BODY" | grep -q '"status":"ok"' \ echo "$HEALTHZ2_BODY" | grep -q '"status":"ok"' \
|| fail "2nd-launch /healthz didn't return status:ok" || fail "2nd-launch /healthz didn't return status:ok"
# ── Pre-migration auto-backup ─────────────────────────────────────────
# Launch 1 created data.sqlite; launch 2 should have copied it to
# data.sqlite.<unix-timestamp>.bak before re-running migrations.
step "verify pre-migration backup of data.sqlite was written"
shopt -s nullglob
backups=( "$USER_DATA"/var/data.sqlite.*.bak )
shopt -u nullglob
if [ "${#backups[@]}" -eq 0 ]; then
fail "expected at least one data.sqlite.*.bak under $USER_DATA/var after 2nd launch"
fi
step "found backup: ${backups[0]}"
# ── Clean shutdown: SIGTERM the host, assert no Qt warning + no orphan frankenphp ── # ── Clean shutdown: SIGTERM the host, assert no Qt warning + no orphan frankenphp ──
step "graceful shutdown — assert the supervisor kills its frankenphp child" step "graceful shutdown — assert the supervisor kills its frankenphp child"
SHUTDOWN_PID="$PID" SHUTDOWN_PID="$PID"

View File

@@ -1,8 +1,10 @@
#include "BackendConnection.h" #include "BackendConnection.h"
#include <QCoreApplication> #include <QCoreApplication>
#include <QDateTime>
#include <QDebug> #include <QDebug>
#include <QDir> #include <QDir>
#include <QFile>
#include <QFileInfo> #include <QFileInfo>
#include <QLoggingCategory> #include <QLoggingCategory>
#include <QNetworkAccessManager> #include <QNetworkAccessManager>
@@ -262,8 +264,47 @@ QString BackendConnection::databaseUrl() const
return QStringLiteral("sqlite:///%1/var/data.sqlite").arg(m_dataDir); return QStringLiteral("sqlite:///%1/var/data.sqlite").arg(m_dataDir);
} }
void BackendConnection::backupDatabase()
{
// Cheap insurance against a bad migration corrupting the user's
// data. Doctrine offers no rollback for a half-applied migration
// against SQLite (no transactional DDL), so the only safe undo is
// restoring a copy. PLAN.md §12 *Migrations on schema change*.
const QString sqlitePath = m_dataDir + QStringLiteral("/var/data.sqlite");
if (!QFileInfo::exists(sqlitePath)) {
return; // first launch — nothing to back up yet.
}
const QString varDir = m_dataDir + QStringLiteral("/var");
const qint64 stamp = QDateTime::currentSecsSinceEpoch();
const QString backupPath = QStringLiteral("%1/data.sqlite.%2.bak").arg(varDir).arg(stamp);
if (!QFile::copy(sqlitePath, backupPath)) {
// Don't fail the launch on a backup miss — log and continue.
// The user has a working DB; a missing safety-net is not a
// reason to refuse to boot the app.
qCWarning(lcBundled) << "pre-migration backup failed (continuing without):"
<< "could not copy" << sqlitePath << "" << backupPath;
return;
}
qCInfo(lcBundled) << "pre-migration backup written to" << backupPath;
// Trim to N most recent. QDir::Time sort is mtime descending.
const QStringList existing = QDir(varDir)
.entryList(QStringList{QStringLiteral("data.sqlite.*.bak")},
QDir::Files, QDir::Time);
for (int i = kMaxDatabaseBackups; i < existing.size(); ++i) {
const QString stale = varDir + QLatin1Char('/') + existing.at(i);
if (!QFile::remove(stale)) {
qCWarning(lcBundled) << "could not trim stale backup" << stale;
}
}
}
bool BackendConnection::runMigrations() bool BackendConnection::runMigrations()
{ {
backupDatabase();
QProcess proc; QProcess proc;
proc.setProgram(resolveFrankenphpBin()); proc.setProgram(resolveFrankenphpBin());
proc.setArguments({ proc.setArguments({

View File

@@ -118,6 +118,7 @@ private:
void initDevMode(); void initDevMode();
void initBundledMode(); void initBundledMode();
bool runMigrations(); bool runMigrations();
void backupDatabase();
bool spawnChild(QString* errorOut = nullptr); bool spawnChild(QString* errorOut = nullptr);
void teardownChild(); void teardownChild();
QString resolveFrankenphpBin() const; QString resolveFrankenphpBin() const;
@@ -154,6 +155,10 @@ private:
QQueue<QString> m_childLog; QQueue<QString> m_childLog;
QString m_childLogBuffer; QString m_childLogBuffer;
static constexpr int kChildLogMax = 500; static constexpr int kChildLogMax = 500;
/// Pre-migration auto-backup keeps the N most recent .bak files so
/// the user can roll back a bad migration without losing earlier
/// safety nets. PLAN.md §12 *Migrations on schema change*.
static constexpr int kMaxDatabaseBackups = 5;
QProcess* m_updateCheck = nullptr; QProcess* m_updateCheck = nullptr;
QProcess* m_updateApply = nullptr; QProcess* m_updateApply = nullptr;