v0.2.0 (10/N): bridge:export console command + QML hook
PLAN.md §12 *Data backup / export* called for "a bridge:export console command and a UI hook for backup-to-file from v1". Shipping both now so the v0.2.0 surface has the data-portability story end-to-end: PHP side — `bin/console bridge:export <destination>`: - Reads the source path from DATABASE_URL so it works in dev mode (developer's source-tree var/data.sqlite) and bundled mode (user data dir SQLite) without environment-aware logic. - SQLite-only by design (PLAN.md §6 — single-instance SQLite-first); emits a clear error for non-sqlite:// URLs rather than pretending to support drivers that need driver-specific dump tooling. - Overwrites the destination if it exists (the FileDialog or shell redirect that produced the path has already confirmed). - 4 unit tests: happy path, non-SQLite URL, missing source, overwrite. Test count 24 → 28. QML side — Q_INVOKABLE BackendConnection.exportDatabase(path): - Bundled mode only; dev mode emits databaseExportFailed and returns false (developers own their SQLite directly). - Accepts both filesystem paths and `file://` URLs (FileDialog results). - Returns synchronously with bool but also emits async signals databaseExported(dst) / databaseExportFailed(reason) so QML can drive a snackbar / log without polling the return value. - Removes any existing destination first (QFile::copy refuses to overwrite); the picker has already confirmed the choice. Drive-by: parse_url() rejects sqlite:///abs/path on PHP 8.5+ (the host-less triple-slash trips its strictness). Switched to a prefix-strip — Doctrine DBAL only emits two URL shapes for SQLite anyway (sqlite:///abs and sqlite://relative). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,7 @@ This section tracks work landing on `dev` toward **v0.2.0** (next minor; pre-1.0
|
||||
- **`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.
|
||||
- **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.
|
||||
- **`bridge:export` console command + QML hook.** New `bin/console bridge:export <destination>` 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
|
||||
|
||||
|
||||
109
framework/php/src/Command/BridgeExportCommand.php
Normal file
109
framework/php/src/Command/BridgeExportCommand.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace PhpQml\Bridge\Command;
|
||||
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
/**
|
||||
* Copies the active SQLite database to a destination path. Paired with
|
||||
* the QML `BackendConnection.exportDatabase()` hook so end users can
|
||||
* "Export my data" before a machine move or before authorising a
|
||||
* risky migration.
|
||||
*
|
||||
* Driven from `DATABASE_URL` so it works identically in dev mode
|
||||
* (developer's source-tree var/data.sqlite) and bundled mode (user
|
||||
* data dir under XDG/Library/AppData). Non-SQLite drivers are out
|
||||
* of scope — exposing them would require driver-specific dump
|
||||
* tooling and the framework is single-instance SQLite-first by
|
||||
* design (PLAN.md §6).
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'bridge:export',
|
||||
description: 'Copy the SQLite database to a destination path (snapshot, not a live dump).',
|
||||
)]
|
||||
final class BridgeExportCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire('%env(default::DATABASE_URL)%')]
|
||||
private readonly string $databaseUrl,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
82
framework/php/tests/Command/BridgeExportCommandTest.php
Normal file
82
framework/php/tests/Command/BridgeExportCommandTest.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace PhpQml\Bridge\Tests\Command;
|
||||
|
||||
use PhpQml\Bridge\Command\BridgeExportCommand;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
#[CoversClass(BridgeExportCommand::class)]
|
||||
final class BridgeExportCommandTest extends TestCase
|
||||
{
|
||||
private string $tmpDir = '';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user