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:
2026-05-03 20:36:51 +02:00
parent e0241bad64
commit da097051ca
5 changed files with 244 additions and 0 deletions

View 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;
}
}