# Plenvo migrations

Versioned database migrations. Each file describes one schema change and is
applied **at most once per installation**, recorded in the `applied_migrations`
table. Trigger points: web boot (`index.php`), cron tick (`cron.php`), and
post-extraction step in `Updater::perform()`. All three call `Migrator::run()`,
which is a no-op when nothing is pending.

## File format

```
migrations/NNN_short_description.php
```

- `NNN` is a zero-padded sequence (`001`, `002`, ...). Order is lexical with
  natural sort, so `010` runs after `009` and before `011`.
- Choose a description that says **what** the migration does, not **why**. The
  PR description carries the why.
- The file MUST `return` a single closure with this signature:

```php
<?php
declare(strict_types=1);

return function (PDO $pdo): void {
    $pdo->exec("CREATE TABLE IF NOT EXISTS new_table (...)");
};
```

## Hard rules

0. **Add the same change to `Database::schema()` too.** New tables/columns belong
   in BOTH places: in `Database::schema()` (the declarative target snapshot used
   by fresh installs) AND in a versioned migration (the upgrade path for
   existing installs). Fresh installs run `Database::schema()` once and then
   `Migrator::markAllApplied()` skips every existing migration body — no
   redundant CREATE/ALTER chain. Existing installs never call `schema()` again
   and pick up only the new migration files. Both stay idempotent
   (`CREATE TABLE IF NOT EXISTS`, `try { ALTER ... } catch`), so an out-of-sync
   instance still self-heals on the next boot.

1. **Idempotent**. The migration must be safe to re-run on a database where
   the change is already present. Use `CREATE TABLE IF NOT EXISTS`,
   `CREATE INDEX IF NOT EXISTS`, and wrap `ALTER TABLE ADD COLUMN` in
   `try { ... } catch (\Throwable) {}`. The migrator already runs each file in
   a transaction, but idempotency protects against partial-replay edge cases
   (interrupted updates, manual reruns, mismatched legacy state on older
   instances).

2. **Forward-only**. Never write a migration that drops or renames a column
   another migration might still depend on. If a column truly needs to go,
   create a follow-up migration in a later release after consumers are gone.

3. **No data destruction without explicit user confirmation**. Schema work
   only. Backfills are fine when they are additive (e.g. populating a new
   column from an existing one).

4. **Skip-safe**. An installation may upgrade from any prior version directly
   to the current one. Every migration must work regardless of which other
   migrations ran before it. Do not assume "001 was applied last week, so
   table X already has column Y" — assume nothing about ordering relative to
   pre-migrator legacy schema.

5. **Failure-aware**. If the closure throws, the transaction rolls back, the
   migration is NOT marked applied, and the failure is persisted in
   `settings.migration_last_error` so the Health Check on `/admin/settings`
   surfaces it. Next boot retries the same file. This means: **a broken
   migration blocks all later migrations** until it is fixed. Test locally.

## Where the legacy lives

The `Database::__construct()` method still contains a block of idempotent
`CREATE TABLE IF NOT EXISTS` and `try { ALTER ... } catch` statements from
before this system existed. That block stays for backward compatibility and
runs on every boot. **Do not add new schema work there** — write a
versioned migration file instead.
