# Port: `edbackup` → `extensions/backup`

2026-08-11

The seventh port. The six before it were all about the site being *found* or
*read* — SEO, Related, Trending, Signal. This one is about the site still
existing tomorrow.

Convoro ships nothing for this today. There is no backup command, no admin
screen, and no answer to "the host says my disk failed" other than whatever the
host happened to keep. For a platform whose whole install story is *upload a zip
to shared hosting*, that is the wrong gap to leave open: the people most likely
to choose Convoro are exactly the people least likely to have a managed backup.

## 🚨 Why it cannot shell out

`exec`, `shell_exec`, `passthru`, `popen` and `proc_open` are disabled on any
host that has taken the standard security advice — and on most shared hosting
they were never available. So `mysqldump` is unreachable on precisely the
servers this has to work on, and every "just run mysqldump" answer is wrong
there.

The dump is therefore pure PHP: `SHOW CREATE TABLE` for structure, `SELECT` in
chunks for rows, written straight into a gzip stream.

## 🚨 The archive stops at the table prefix

This is the port's biggest departure, and the dev database is the argument.

    179 tables. 108 are Convoro's. 71 belong to something else.

The other 71 are the old Laravel application still sharing that database. A
whole-database dump would quietly back up somebody else's application — and,
far worse, a whole-database *restore* would replace it. Shared hosting hands out
one database per account; two applications in one database is the normal case,
not the exotic one, and Convoro has a table prefix precisely because it does not
assume it owns the schema.

So the archive is scoped to the prefix, the screen says how many tables that is
and how many it is leaving alone, and the restore inherits the same boundary. If
the prefix is empty the whole database is taken, and the screen says *that*
instead — silently doing one thing while the operator assumes the other is the
failure mode worth engineering against.

## Where backups live, and the check that is not a probe

`storage/backups/`. Convoro serves `public/`, and `storage/` is its sibling, so
an archive is unreachable over HTTP **by construction** rather than by
configuration.

The original probed: it asked the server to fetch its own archive over HTTP and
reported "not exposed" when the request failed. It was wrong — a server often
cannot reach its own public address, and the archive was downloadable the whole
time. Here the check is structural, `realpath()` against `realpath()`, and it
answers honestly in the one case it can be wrong about — a layout where
`storage` has ended up inside the served root gets a warning that names the
directory, not a reassurance.

🚨 It also cannot be a probe for a second reason: **nothing outbound inside a
visitor's request.** That rule exists because breaking it took a live site down.

## Two modes, and every archive says which it is

- **One pass, in a transaction.** `START TRANSACTION WITH CONSISTENT SNAPSHOT`
  means every table is read as of the same instant. A genuine point-in-time
  backup, used when the data is small enough to finish inside one request.
- **Queued in steps.** Beyond that, the work goes through Convoro's queue a
  table at a time. This *cannot* be point-in-time — no transaction survives the
  end of a request — so the archive is flagged and the list says
  "taken in stages" rather than pretending.

Deciding between them errs pessimistic. An optimistic single pass that dies half
way leaves a truncated file; a pessimistic queued run produces a complete backup
that is merely honest about not being a snapshot.

## A backup nobody has read is not a backup

Verification opens the finished file and counts what is actually in it — not
what the dumper believed it wrote, since comparing the dumper's counter to the
dumper's manifest proves nothing at all. Per-table row counts, a `sha256`, and
an end marker that is the last thing written, so its presence is proof the
stream was not cut short.

Three things follow from that:

- **Only a verified archive counts towards the retention limit.** Deleting a
  good backup because a newer broken one exists is the worst thing this could
  possibly do.
- **The schedule is measured from the last SUCCESS**, not the last attempt, so a
  run that keeps failing cannot quietly satisfy it forever.
- **The list is rebuilt from the directory, not trusted from the table.** Files
  found with no row are adopted by reading them; rows whose file has gone are
  marked missing. That is what makes the app survive its own restore — a full
  restore replaces the backup table too, with the row as it was *mid-dump*:
  status "running", no manifest, zero rows. The original needed a repair pass
  for this. Here the directory is simply the source of truth, and the table is a
  cache, so the failure cannot happen.

## Restoring

**One table, from inside the admin panel.** This is the case that actually
happens — somebody deleted a forum and wants that back, not the whole community
rolled back to last night. The inventory shows what each table held in the
archive against what it holds now, sorted by what is missing, because that is
what somebody in this situation is looking for.

It refuses the tables the request itself is standing on — the queue and the
backup table — because dropping those mid-request ends the request, and with it
the restore.

**Everything, from a generated script.** A full restore drops and recreates
every table, including ones the running code is using. So it is not a button:
Convoro writes a standalone PHP script that boots nothing, reads `.env` for
credentials, opens its own connection and replays the archive. It has no
dependency on anything it is about to replace, so it survives its own work.

🚨 That script is a way to overwrite the database, so:

- unguessable filename, and a separate key it will not run without;
- it refuses unless told twice — generating it restores nothing;
- it refuses when tables exist here that are not in the archive, unless forced,
  because that usually means the archive came from a different install;
- **it expires an hour after it is written**, and deletes itself when it does;
- it deletes itself on success.

The last two are not in the original, and they are the difference between a
tool and a back door somebody forgot about.

## Four traps that corrupt silently, all found by actually restoring

These were paid for once already, in the app this is a port of. They are
restated here because every one of them produces a backup that looks fine and
is not:

1. **`gzdecode()` reads only the first member of a multi-member gzip**, and
   reports no error. Chunked writing produces exactly that. On a real archive it
   is the difference between the whole database and its first few hundred rows.
   Always `gzopen` and read in a loop.
2. **`'0x' . bin2hex('')` is `0x`**, which MySQL reads as an identifier. Every
   empty blob in the database fails to restore. An empty binary value is `''`.
3. **Statement splitting must strip leading comments, not skip the chunk.** The
   dump writes a `-- tablename` banner before each `DROP`, so chunks *start*
   with a comment and end with real SQL. Skipping them threw away every `DROP`,
   so every `CREATE` failed with "table already exists" — a restore reporting
   hundreds of errors and changing nothing.
4. **`ANSI_QUOTES` makes `SHOW CREATE TABLE` emit `"double quoted"`
   identifiers**, which only parse in another `ANSI_QUOTES` session — and the
   standalone restore deliberately is not one. Convoro does not set that mode,
   but a shared host can set it globally in `my.cnf`, so it is dropped for that
   one statement and put back, rather than assumed absent.

The row-counting verifier depends on one more fact, and it is worth stating
because it is load-bearing: **a tuple is always exactly one line.** `PDO::quote`
turns a newline inside a value into a literal `\n`, and binary is written as
hex, so no value can contain a raw newline. That is what makes the count exact
rather than a guess, and there is a test that asserts it rather than trusting it.

## Not in this version

**Uploaded files.** Attachments, avatars and images are usually far larger than
the database and take much longer, and an archive that sometimes times out is
worse than one that never claims to hold them. The setting is not there rather
than there and off — a switch that produces a backup which silently omits half
of what its name promises is not a feature.

**Remote storage.** Convoro has no storage abstraction to hand it to yet. A
backup on the same disk as the community does not survive losing that disk, and
the screen says so plainly rather than implying the problem is solved.
