diff --git a/CHANGELOG.md b/CHANGELOG.md index 765f55a..fee2fdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ This section tracks work landing on `dev` toward **v0.2.0** (next minor; pre-1.0 - **`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. +- **`bridge:export` console command + QML hook.** New `bin/console bridge:export ` copies the active SQLite database to a user-chosen path (overwrites if the destination exists; reads the source path from `DATABASE_URL` so it works in both dev and bundled mode). Mirrored on the QML side as `BackendConnection.exportDatabase(path)` (`Q_INVOKABLE bool`) returning success synchronously and emitting `databaseExported(path)` / `databaseExportFailed(reason)` for async UX. QML callers typically pair it with `Qt.labs.platform.FileDialog` (see `docs/native-dialogs.md`). 4 unit tests cover the command's success / non-SQLite-URL / missing-source / overwrite paths. ### Changed diff --git a/framework/php/src/Command/BridgeExportCommand.php b/framework/php/src/Command/BridgeExportCommand.php new file mode 100644 index 0000000..c9f70e0 --- /dev/null +++ b/framework/php/src/Command/BridgeExportCommand.php @@ -0,0 +1,109 @@ +addArgument( + 'destination', + InputArgument::REQUIRED, + 'Where to write the exported file (overwrites if it exists).', + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $source = $this->resolveSqlitePath(); + if (null === $source) { + $io->error([ + 'DATABASE_URL is not a sqlite:/// URL.', + 'bridge:export only handles SQLite — other drivers need driver-specific dumps.', + ]); + + return Command::FAILURE; + } + if (!is_file($source)) { + $io->error("SQLite file does not exist: {$source}"); + + return Command::FAILURE; + } + + $destination = (string) $input->getArgument('destination'); + if ('' === $destination) { + $io->error('Destination path cannot be empty.'); + + return Command::FAILURE; + } + + // Snapshot semantics: for a *running* application, SQLite's WAL + // journal may hold uncommitted writes that a plain copy misses. + // The desktop case is single-process, single-writer; flushing + // is a no-op here (Doctrine commits per request, no long-running + // transactions). If that ever changes, swap to `sqlite3_backup` + // via PDO::sqliteCreateFunction for a consistent online copy. + if (!@copy($source, $destination)) { + $err = error_get_last()['message'] ?? 'unknown error'; + $io->error("Could not copy {$source} → {$destination}: {$err}"); + + return Command::FAILURE; + } + + $io->success("Exported {$source} → {$destination}."); + + return Command::SUCCESS; + } + + private function resolveSqlitePath(): ?string + { + // parse_url() rejects `sqlite:///abs/path` (host-less triple-slash) + // on PHP 8.5+, so do the strip manually. Doctrine's DBAL only + // emits two URL shapes for SQLite: sqlite:///abs/path (absolute, + // PLAN.md §3 *Startup* uses this) and sqlite://relative/path. + if (str_starts_with($this->databaseUrl, 'sqlite:///')) { + return '/'.substr($this->databaseUrl, \strlen('sqlite:///')); + } + if (str_starts_with($this->databaseUrl, 'sqlite://')) { + return substr($this->databaseUrl, \strlen('sqlite://')); + } + + return null; + } +} diff --git a/framework/php/tests/Command/BridgeExportCommandTest.php b/framework/php/tests/Command/BridgeExportCommandTest.php new file mode 100644 index 0000000..627e241 --- /dev/null +++ b/framework/php/tests/Command/BridgeExportCommandTest.php @@ -0,0 +1,82 @@ +tmpDir = sys_get_temp_dir().'/bridge-export-test-'.uniqid(); + mkdir($this->tmpDir, 0o700, true); + } + + protected function tearDown(): void + { + // Brutal but adequate for a flat test dir. + foreach (glob($this->tmpDir.'/*') ?: [] as $f) { + @unlink($f); + } + @rmdir($this->tmpDir); + } + + public function testCopiesSqliteFileToDestination(): void + { + $source = $this->tmpDir.'/source.sqlite'; + file_put_contents($source, 'fake-sqlite-bytes'); + + $tester = new CommandTester(new BridgeExportCommand("sqlite:///{$source}")); + $destination = $this->tmpDir.'/exported.sqlite'; + + $exitCode = $tester->execute(['destination' => $destination]); + + self::assertSame(0, $exitCode); + self::assertFileExists($destination); + self::assertSame('fake-sqlite-bytes', file_get_contents($destination)); + self::assertStringContainsString('Exported', $tester->getDisplay()); + } + + public function testFailsForNonSqliteUrl(): void + { + $tester = new CommandTester(new BridgeExportCommand('postgres://user@host/db')); + + $exitCode = $tester->execute(['destination' => $this->tmpDir.'/x.sqlite']); + + self::assertSame(1, $exitCode); + self::assertStringContainsString('not a sqlite:/// URL', $tester->getDisplay()); + } + + public function testFailsWhenSourceMissing(): void + { + $tester = new CommandTester(new BridgeExportCommand("sqlite:///{$this->tmpDir}/no-such.sqlite")); + + $exitCode = $tester->execute(['destination' => $this->tmpDir.'/x.sqlite']); + + self::assertSame(1, $exitCode); + self::assertStringContainsString('does not exist', $tester->getDisplay()); + } + + public function testOverwritesExistingDestination(): void + { + $source = $this->tmpDir.'/source.sqlite'; + file_put_contents($source, 'new-bytes'); + + $destination = $this->tmpDir.'/exported.sqlite'; + file_put_contents($destination, 'old-bytes'); + + $tester = new CommandTester(new BridgeExportCommand("sqlite:///{$source}")); + $exitCode = $tester->execute(['destination' => $destination]); + + self::assertSame(0, $exitCode); + self::assertSame('new-bytes', file_get_contents($destination)); + } +} diff --git a/framework/qml/src/BackendConnection.cpp b/framework/qml/src/BackendConnection.cpp index 0a429a9..8af03d1 100644 --- a/framework/qml/src/BackendConnection.cpp +++ b/framework/qml/src/BackendConnection.cpp @@ -472,6 +472,46 @@ QStringList BackendConnection::childLogTail() const return out; } +bool BackendConnection::exportDatabase(const QString& destination) +{ + if (m_mode != Mode::Bundled) { + emit databaseExportFailed(QStringLiteral("dev mode: copy var/data.sqlite directly")); + return false; + } + + const QString src = m_dataDir + QStringLiteral("/var/data.sqlite"); + if (!QFileInfo::exists(src)) { + const QString msg = QStringLiteral("source not found: %1 (no data yet?)").arg(src); + emit databaseExportFailed(msg); + return false; + } + + // QML's FileDialog returns `file://` URLs; unwrap to a local path. + QString dst = destination; + if (dst.startsWith(QStringLiteral("file://"))) { + dst = QUrl(destination).toLocalFile(); + } + if (dst.isEmpty()) { + emit databaseExportFailed(QStringLiteral("destination path is empty")); + return false; + } + + // QFile::copy refuses to overwrite. The FileDialog the user picked + // through has already confirmed any overwrite, so unlink first. + if (QFileInfo::exists(dst) && !QFile::remove(dst)) { + emit databaseExportFailed(QStringLiteral("could not remove existing destination: ") + dst); + return false; + } + if (!QFile::copy(src, dst)) { + emit databaseExportFailed(QStringLiteral("copy failed: ") + src + QStringLiteral(" → ") + dst); + return false; + } + + qCInfo(lcBundled) << "exportDatabase:" << src << "→" << dst; + emit databaseExported(dst); + return true; +} + void BackendConnection::onChildFinished(int exitCode, QProcess::ExitStatus status) { Q_UNUSED(status); diff --git a/framework/qml/src/BackendConnection.h b/framework/qml/src/BackendConnection.h index 42e4bd1..2521f8c 100644 --- a/framework/qml/src/BackendConnection.h +++ b/framework/qml/src/BackendConnection.h @@ -85,6 +85,15 @@ public: /// mode. Used by `DevConsole.qml` to seed its view on first show. Q_INVOKABLE QStringList childLogTail() const; + /// Bundled mode: copy `var/data.sqlite` to a user-chosen path + /// (typically a `Qt.labs.platform.FileDialog` result). Returns + /// true on success; on failure emits `databaseExportFailed` with + /// a human-readable reason. Use for an "Export my data" menu + /// item paired with the `bridge:export` console command for + /// CLI parity. Dev mode: returns false (developers own their + /// var/data.sqlite lifecycle directly). + Q_INVOKABLE bool exportDatabase(const QString& destination); + signals: void urlChanged(); void tokenChanged(); @@ -101,6 +110,9 @@ signals: void updateApplied(); void updateApplyFailed(const QString& reason); + void databaseExported(const QString& destination); + void databaseExportFailed(const QString& reason); + /// Emitted for each newline-terminated chunk read from the bundled /// FrankenPHP child's merged stdout+stderr stream. DevConsole.qml /// listens for these to populate its log view live.