dbtrail

Limitations

What dbtrail does not capture, cannot undo, or refuses to rebuild, and why. Read this before you rely on it.

dbtrail is a safety net for data changes, and a safety net is only useful if you know where its edges are. This page lists every limit worth knowing, grouped by the question you would actually ask. Some are deliberate design boundaries, some are gaps we have not closed yet. Each entry says which.

How to read this page

By design means the behaviour is intended and will not change. Not yet means it is a known gap; the linked issue tracks it. When dbtrail hits one of these limits it refuses out loud (an error, a warning, a status word) rather than returning something that looks complete but is not. If you ever see a silent partial result, that is a bug: report it.

What gets captured

Supported databases

SourceStatusNotes
MySQL 8.0 and 8.4, Percona ServerSupportedIncludes RDS, Aurora, Cloud SQL
PostgreSQL 14+BetaSeparate capture binary, nothing installed in your server. Beta limitations
MariaDB 10.6+AlphaAlpha limitations

The index database is always MySQL 8.0 or later (by design). Every source, PostgreSQL and MariaDB included, writes into the same MySQL index schema. Pointing the index at a PostgreSQL or MariaDB server is not supported. The bundled Compose stack ships a MySQL 8.4 index container so you do not have to run one yourself; a MySQL you already operate works too.

Source settings that must be right

  • Row-format binlog with full row images. binlog_format=ROW and binlog_row_image=FULL are required. With MINIMAL or NOBLOB there is no before-image, so nothing can be undone. Add server checks this and the stream refuses to start otherwise. (Managed MySQL defaults are fine.)
  • Partial JSON logging is skipped, not captured (not yet). If the source sets binlog_row_value_options=PARTIAL_JSON, MySQL logs JSON_SET, JSON_REPLACE and JSON_REMOVE updates as diffs dbtrail cannot apply. Those updates are skipped with a warning in the daemon log, and the Status view's doctor check warns about the setting. Clear it on the source (SET PERSIST binlog_row_value_options = '';) to capture JSON updates in full.
  • A crash on the source can lose the binlog tail (by design, a MySQL property). With sync_binlog other than 1, an OS crash can drop committed transactions before they were ever written to the binlog. dbtrail cannot capture what MySQL never logged. The doctor check warns; verification is the only way to notice it later.

Tables that are left out

  • Tables without a primary key, and tables not on InnoDB, are excluded from capture (by design). Without a primary key dbtrail cannot tell two identical rows apart, and non-InnoDB engines are not transactional, so interrupted statements leave half-logged changes. The schema snapshot skips these tables and records the reason; they never show up in Events. The quickstart has the queries that find them.
  • Schema changes need a fresh snapshot. After an ALTER TABLE, events for that table are skipped with a warning until the schema snapshot catches up. The daemon re-snapshots automatically when it sees DDL, so the window is normally seconds. A rename or a drop-and-add of a column at the same position, seen after the snapshot time, stops capture for that table with an error until you re-snapshot.

Changes that leave no row events

TRUNCATE TABLE, DROP TABLE and RENAME TABLE write no row-level events to the binlog (by design, again a MySQL property). dbtrail records that they happened, with a timestamp, under schema changes, but there are no rows to undo. The only way back is a rebuild from a backup taken before the statement: Backups → Build a .sql backup for any moment. Any rebuild that crosses one of these statements refuses instead of resurrecting rows that no longer exist.

On MySQL before 9.6, ON DELETE CASCADE and ON UPDATE CASCADE child rows are also invisible: InnoDB applies them inside the storage engine and logs nothing for them. Restore detects this and repairs the children automatically (the CASCADE banner), with two limits: a multi-column foreign key is skipped and flagged rather than guessed, and a parent key that moved twice inside the window is reported as INCOMPLETE RECOVERY. See the recovery guide.

Statement text

The originating SQL statement (shown by the EE forensics views) is captured only when the source enables it (binlog_rows_query_log_events=ON), and it is stored up to 16 KiB per statement. Longer statements are cut and marked as truncated.

Undoing changes (Restore)

  • dbtrail never runs the undo for you (by design). Restore produces a SQL script; you review it and apply it with your own client. The console has no write path to your database.
  • Triggers fire again when you apply the script (by design, a MySQL property). Re-inserting a deleted row runs the table's AFTER INSERT triggers like any insert would. MySQL has no session switch to suppress triggers; PostgreSQL does, and the PostgreSQL undo can use it.
  • AUTO_INCREMENT is not put back. The rows come back with their original ids, but the counter for the next id is not rewound. The script appends a commented-out ALTER TABLE ... AUTO_INCREMENT you can fill in if it matters.
  • Result caps. The Events view shows 100 changes by default and up to 1,000; an undo script covers up to 1,000 changes by default and 10,000 at most. Narrow by table, row or time to stay under the cap, or use the CLI (recover --limit) for larger scripts.

Rebuilding a table or a row at a moment (time-travel)

A rebuild merges a saved backup with the changes recorded after it. These limits follow from that.

  • You need a backup taken at or before the moment you want. Without one, only rows that changed inside the retained history can be shown; rows that never changed are unknown. The console says which backup it used, and refuses a full-table rebuild rather than returning a partial table that looks complete.
  • Some primary key types cannot be matched (by design, refused). The join between a backup row and its recorded changes is done on the primary key, so the key must be written the same way on both sides. Supported: integers, strings, ENUM/SET, DATETIME/TIMESTAMP/DATE, YEAR, DECIMAL, and the BINARY/VARBINARY/BLOB family. Refused: FLOAT, DOUBLE, TIME, BIT, JSON, spatial types, and a generated column inside the key.
  • A primary key that changed value inside the window stops the rebuild. An UPDATE that rewrites the key makes the row untraceable; the rebuild refuses and names the row.
  • A column added or dropped after the backup stops the rebuild. Emitting the table would silently mix two schemas. Take a new backup after the change.
  • The cut point for a single row is transaction-safe; for a whole table it is per row (not yet). Single-row time-travel includes or excludes each transaction as a whole. The full-table rebuild (the .sql export and the time-travel SQL port's table queries) still cuts per row, so a transaction that straddles the requested second can be half-applied. Recorded timestamps have one-second resolution.
  • ENUM and SET values are decoded with the schema in effect at each change. A change made between an ALTER and the next snapshot decodes with the old definition; a value the definition does not know is shown as the raw number.
  • Time-travel SQL through ProxySQL is in beta: single-column primary keys, one equality predicate, no joins or aggregates inside the query. Beta limitations.

Verification

The Verification page proves the rebuild chain works, within bounds. In short: a MATCH is a strong signal, not a certificate. The fingerprint it compares is not cryptographic, it checks row content only (not indexes, triggers or AUTO_INCREMENT), the default mode does not exercise the undo inputs (that is a separate check on the same page), and comparing against the live database needs a table nobody is writing to. Full detail in the verification guide.

Retention and storage

  • History is only as long as you keep it. Rotation drops old hourly partitions from the index. Archiving them to Parquet (local or S3) keeps them queryable; without archiving, rotated changes are gone. Set the destination under Manage servers → Edit → Advanced and the policy under Rotation.
  • The oldest moment you can rebuild to is the later of two floors: the oldest backup you still have, and the oldest change still kept. Overview shows restore coverage per table; there is no single number for the combined floor yet.
  • You operate the index database (by design, and the whole point of the paid plans). Disk, backups of the index itself, upgrades and corruption of the index MySQL are yours in the free core. Support scope.
  • Several servers sharing one index share the live partitions. When archives cannot be attributed to a single server, backup staleness is reported as not evaluable rather than guessed either way.

Who changed it (forensics, EE)

  • Attribution is MySQL-only (by design, settled). PostgreSQL's replication stream carries no session id, and dbtrail installs nothing in a source to get one. Undo, time-travel and verification work the same for PostgreSQL; only "which session did this" is missing.
  • Exact attribution needs an audit plugin on the source. Without one, the performance_schema fallback is best-effort: short history, no time-of-day filtering, cannot be enabled after the fact. How the tiers differ.

Security boundaries

These are design decisions, listed so you can plan around them.

  • dbtrail connects to your source as a read-only replication client and never writes to it, never installs anything in it, and never runs a generated script.
  • The console binds to localhost in the bundled stack. To expose it, set a password first and serve it over HTTPS, either with its own certificate (CLI: --console-tls-cert/--console-tls-key) or behind a reverse proxy.
  • The time-travel SQL port speaks plain MySQL protocol with no TLS. Keep it on localhost or terminate TLS at ProxySQL.
  • The Claude connection (MCP) is read-only and cannot point the console at a different database.

Something missing?

If you hit a limit that is not on this page, or one listed as "not yet" that blocks you, open an issue. Limits that block real workloads move up the list.

On this page