From e0241bad64f5834dbc72435411069bb20b08bf63 Mon Sep 17 00:00:00 2001 From: magdev Date: Sun, 3 May 2026 20:31:50 +0200 Subject: [PATCH] v0.2.0 (9/N): pre-migration auto-backup of var/data.sqlite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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..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) --- CHANGELOG.md | 1 + examples/todo/tests/bundled-supervisor.sh | 12 +++++++ framework/qml/src/BackendConnection.cpp | 41 +++++++++++++++++++++++ framework/qml/src/BackendConnection.h | 5 +++ 4 files changed, 59 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a782fe2..765f55a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `CreateDto` + `UpdateDto` 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 ` maker.** Generates a domain-event class (`src/Event/Event.php`, readonly value object), a subscriber (`src/EventSubscriber/Subscriber.php`) that republishes via `PublisherInterface` on `app://event/`, and a QML stub (`{qml_path}/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 ` maker.** Generates a query-only projection: `src/ReadModel/ReadModel.php` (query service stub injecting `EntityManagerInterface`), `src/Controller/Controller.php` (single GET handler at `/api/`), and `{qml_path}/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..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 diff --git a/examples/todo/tests/bundled-supervisor.sh b/examples/todo/tests/bundled-supervisor.sh index b6f2be0..72dde97 100755 --- a/examples/todo/tests/bundled-supervisor.sh +++ b/examples/todo/tests/bundled-supervisor.sh @@ -192,6 +192,18 @@ done echo "$HEALTHZ2_BODY" | grep -q '"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..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 ── step "graceful shutdown — assert the supervisor kills its frankenphp child" SHUTDOWN_PID="$PID" diff --git a/framework/qml/src/BackendConnection.cpp b/framework/qml/src/BackendConnection.cpp index 981744b..0a429a9 100644 --- a/framework/qml/src/BackendConnection.cpp +++ b/framework/qml/src/BackendConnection.cpp @@ -1,8 +1,10 @@ #include "BackendConnection.h" #include +#include #include #include +#include #include #include #include @@ -262,8 +264,47 @@ QString BackendConnection::databaseUrl() const 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() { + backupDatabase(); + QProcess proc; proc.setProgram(resolveFrankenphpBin()); proc.setArguments({ diff --git a/framework/qml/src/BackendConnection.h b/framework/qml/src/BackendConnection.h index de56c4a..42e4bd1 100644 --- a/framework/qml/src/BackendConnection.h +++ b/framework/qml/src/BackendConnection.h @@ -118,6 +118,7 @@ private: void initDevMode(); void initBundledMode(); bool runMigrations(); + void backupDatabase(); bool spawnChild(QString* errorOut = nullptr); void teardownChild(); QString resolveFrankenphpBin() const; @@ -154,6 +155,10 @@ private: QQueue m_childLog; QString m_childLogBuffer; 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_updateApply = nullptr;