Backup, restore & export
Logical JSONL backups (manual + scheduled), additive or overwrite restore, pre-drop safety copies for destructive schema changes, plus per-collection CSV/JSON export-import.
Backlex ships a logical backup system: every backup is a portable JSONL dump of your data, written through the storage adapter (R2/S3/filesystem). It works identically on every runtime and both dialects — unlike a Cloudflare-D1 point-in-time snapshot, a logical dump moves cleanly between Postgres and SQLite/D1.
Three things live here:
- Backups — manual or scheduled JSONL dumps of every system table + your
c_*collection tables. - Restore — write a dump’s rows back. Additive by default (missing rows
come back, existing rows are untouched);
mode=overwritealso restates rows that still exist, which is what undoes a bad write. - Pre-drop snapshots — the data a destructive schema change is about to destroy, captured automatically so the change is recoverable.
- Per-collection export/import — pull one collection’s rows out as JSON or CSV, and bulk-load rows back in through the normal create path.
All endpoints are admin-only and workspace-scoped.
Backups
Section titled “Backups”Manual
Section titled “Manual”POST /api/admin/db/backups/now{ "label": "before-migration" }Inserts a tracking row, then dumps inline (synchronously) so the response
already carries the final done/failed status. The dump is one JSONL object
per row — {"table":"<name>","row":{…}}, a format a restore could process
line by line. It does not today: both the dump and the restore hold the whole
document in memory, which is what BACKUP_MAX_ROWS below bounds.
List + download:
GET /api/admin/db/backups # newest first, this workspaceGET /api/admin/db/backups/{id}/download # streams the JSONL fileAuth-internal tables (sessions, accounts, verifications, passkeys) are intentionally excluded — they hold short-lived secrets and re-syncing them across a restore is more harmful than helpful.
Scheduled
Section titled “Scheduled”Set a per-workspace schedule and retention count:
PUT /api/admin/db/backups/config{ "schedule": "daily", "retain": 7, "retainDays": 30 }# schedule: off | daily | weekly# retain: keep this many newest auto backups (1–365)# retainDays: ALSO prune autos older than N days (1–3650); null = count-onlyGET /api/admin/db/backups/configThe cron tick runs a throttled sweep (maybeRunScheduledBackups) that, for
each workspace with a non-off schedule, checks the age of the most recent
auto backup against the interval, runs one if it’s due, then prunes auto
backups beyond the newest retain — and, when retainDays is set, any older
than that many days regardless of count (deleting their storage objects too).
Manual backups are never pruned.
In the admin, this is the Database → Backups tab: the schedule selector (Off / Daily / Weekly), the retention input, Back up now, and per-row Download / Restore.
Failure alerting
Section titled “Failure alerting”A backup that fails (manual or scheduled) marks its row failed with the error,
writes a backup.failed audit row, and publishes system:backup.failed on
the event channel (payload: backupId, tenantId, label, storageKey,
error). Subscribe an outbound webhook to system:backup.failed (or system:*)
to alert your team — an unattended scheduled backup that silently stops running
is exactly the kind of failure you want pushed, not polled.
What counts as a failure
Section titled “What counts as a failure”A table that exists and cannot be read fails the whole backup. It used to be
swallowed: the rows silently vanished from the dump and the run still reported
done, which is the worst possible outcome for the one artifact recovery
depends on.
A table that is absent is tolerated — a partial migration or a table dropped
outside backlex — but it is now recorded rather than inferred. Absent tables are
listed in missing_tables on the backup row and returned as missingTables.
Two bounds sit on top of that:
BACKUP_MAX_ROWS(default500000). The dump is assembled in memory, so past some size a large workspace does not produce a bad backup — it OOMs the isolate, and an OOM never reaches the failure handler, leaving the row stuck atrunningforever. The budget converts that into afailedrow an operator can see. Raise it where the runtime has the headroom.- Any row left at
runningfor over an hour is flipped tofailedby the scheduled sweep. Nothing else can close those out — the process that owned them is gone.
Neither the dump nor the restore streams today; both hold the whole document in
memory. That is what BACKUP_MAX_ROWS bounds, and it is the reason the row
budget exists rather than a note telling you to be careful.
Restore
Section titled “Restore”POST /api/admin/db/backups/{id}/restoreX-Backlex-Confirm: yesThe confirm header is required in either mode (same guard as raw SQL writes). Restore:
- Reads the JSONL from storage.
- Recreates any missing managed
c_*physical tables from thecollectionsmetadata in the dump (via the additive schema applier — no-op on adopted tables). - Writes every row, parents before children.
The response reports { tableCount, rowCount, skipped, overwritten, keptAdditive }.
Two modes
Section titled “Two modes”?mode= | What happens to a row that still exists | Use it for |
|---|---|---|
additive (default) | Nothing — INSERT … ON CONFLICT DO NOTHING | Bringing back deleted rows against a live database. Cannot destroy anything. |
overwrite | Restated to its backup-era values | Undoing a bad write: a wrong bulk update, a dropped column’s data, a botched migration. Can destroy current data. |
overwrite is the only path that can undo an edit. Additive restore skips
every row that still exists, which is why re-adding a dropped column and running
a plain restore brings the column back empty.
Narrow it with ?onlyTables=a,b. Always do this for a targeted recovery —
otherwise a single collection’s rollback also drags app_settings, auth_config
and api_keys back to backup time.
POST /api/admin/db/backups/{id}/restore?mode=overwrite&onlyTables=c_ab12cd34ef56_ordersX-Backlex-Confirm: yesTwo things worth knowing about overwrite:
- It is UPDATE-then-INSERT, not
ON CONFLICT (id) DO UPDATE. A pre-drop snapshot holds only(id, <column>), and inserting a partial row trips the table’sNOT NULLconstraints before any conflict clause is reached. The UPDATE names only the columns present; the INSERT after it is the “row was deleted” arm and is allowed to fail on its own. - Tables with no single-column
idcannot name a conflict target and stay additive. They are listed inkeptAdditiverather than silently downgraded —user_rolesis the standing example, keyed(user_id, role_id). - Two things are never restated, and both are about reach rather than keys.
A
usersrow is a global identity — the same person can belong to several workspaces, so overwriting one from a single workspace’s backup would be visible in all of them. And an instance-global system row (tenant_id IS NULL) is skipped too: every workspace’s dump deliberately carries those (the default email templates, globalapp_settings, instance-wideapi_keys), and restating them would revert instance configuration from an operation scoped to one workspace. Both appear inkeptAdditive. Rows in your own dynamicc_*tables are unaffected, which is what makes pre-drop recovery work.
Every restore writes a backup.restored audit row recording the mode, the
tables, and the counts.
There is still no point-in-time rollback in the sense of “return the database to
exactly 14:03” — overwrite restates the rows a backup contains and does not
delete rows created since.
Workspace scoping
Section titled “Workspace scoping”Both directions are scoped to one workspace, and the scoping is derived from the schema rather than probed at runtime:
- Dump. Most system tables filter on their own
tenant_id. Four don’t have one, so they’re scoped through the relation that does —usersviatenant_members,user_rolesandpermissionsviaroles.tenant_id, andtenantsby its own id. Globally-seeded rows (tenant_id IS NULL, e.g. the default email templates) are included in every workspace’s backup. Dynamicc_*tables filter ontenant_idwhen the collection is tenant-scoped and are dumped whole when it isn’t. - Restore. Every row is checked against the target workspace before it is
written, not just the
collectionsmetadata: rows carrying a foreigntenant_idare counted inskippedand never inserted, anduser_roles/permissionsare accepted only when they point at a role in the target workspace.
A backup taken with no workspace (tenantId: null — the instance-wide scheduled
backup) contains everything and restores everything, which is the intended
disaster-recovery path.
Destructive schema changes
Section titled “Destructive schema changes”Dropping a field or deleting a collection is DDL — it cannot be rolled back. The data it destroys now can be.
Before either operation runs, backlex captures a pre-drop backup: for a field
drop, (id, <column>) for every row where the column holds a value; for a
collection delete, the whole table. It uses the same JSONL format and the same
backups row as any other backup, so recovery is the ordinary restore above.
# 1. What would this destroy? (changes nothing)DELETE /api/collections/orders/fields/notes?dryRun=1→ { "dryRun": true, "rows": 4210, "nonNull": 3987, "table": "c_ab12…_orders" }
# 2. Confirm — the response hands back the snapshot idDELETE /api/collections/orders/fields/notesX-Backlex-Confirm: yes→ { "ok": true, "nonNull": 3987, "snapshotId": "b1f0…" }
# 3. Re-add the field, then put the values backPOST /api/admin/db/backups/b1f0…/restore?mode=overwrite&onlyTables=c_ab12…_ordersX-Backlex-Confirm: yesStep 3 must be mode=overwrite. The rows still exist, so an additive restore
skips every one of them and the column comes back empty.
The confirm gate is conditional
Section titled “The confirm gate is conditional”X-Backlex-Confirm: yes is required only when the operation would actually
destroy data — a column where some row holds a value, or a collection whose
table holds rows. Dropping an empty column or deleting an empty collection works
exactly as it always did, with no header.
That is deliberate: CI, template automation and dev scripts drop scaffolding columns constantly, and an unconditional gate would break all of them for no safety gained. A refusal names the count, so it is actionable rather than a wall.
It is not route-local
Section titled “It is not route-local”POST /api/admin/schema/apply reaches the same dropField / dropCollection
through the schema-versions diff engine, and that endpoint is reachable from
REST, the SDK, the CLI, MCP and GraphQL. It captures the same snapshots and
returns their ids as dataSnapshotIds, alongside the safetySnapshotId it
already returned.
Note the difference between the two: safetySnapshotId is a schema snapshot
— it records what the columns were, not what was in them. On its own it makes
an apply reversible in shape and irreversible in content.
Retention
Section titled “Retention”pre-drop backups are never pruned by the scheduled sweep, which only
touches kind = "auto". An artifact created because something was destroyed
should not expire on the backup schedule’s clock. The trade-off is that they
accumulate — delete them from Database → Backups when you no longer need them.
Per-collection export / import
Section titled “Per-collection export / import”Move one collection’s data in and out — handy for spreadsheets, seeding a new workspace, or a quick offsite copy.
Export
Section titled “Export”GET /api/items/{slug}/export?format=json # default; a JSON arrayGET /api/items/{slug}/export?format=csv # spreadsheet-friendly CSVExport honors the exact read-filter stack a list call would — permission
condition + field allow-list, tenant scope, soft-delete, and draft visibility —
so an export never leaks a row you couldn’t already read. CSV cells that contain
commas, quotes or newlines are quoted (RFC 4180-style); object/array fields are
serialized as JSON.
Import
Section titled “Import”POST /api/items/{slug}/import?format=json # body: a JSON array (or {data:[…]})POST /api/items/{slug}/import?format=csv # body: raw CSV (text/csv)Each row runs through the normal create path — validation, the permission field allow-list, relation checks, revisions, events, and search/vector indexing all apply. Row-level failures are captured, not fatal: the response is
{ "inserted": 42, "failed": 2, "total": 44, "errors": [{ "row": 7, "error": "…" }] }Notes:
- System/managed columns in the payload (
id,created_at,updated_at,tenant_id,_status, …) are stripped — every imported row gets a fresh id. This is what lets an export round-trip straight back into a collection. - CSV cells are coerced to the field’s type (numbers, booleans, JSON for
json/relation_many); empty cells are dropped so column defaults apply. - Unknown user columns still fail their row (a typo’d header surfaces in
errorsrather than being silently dropped). - Capped at 5000 rows per call — chunk larger loads client-side.
import { createClient } from "backlex";const backlex = createClient({ url, token });
const csv = await backlex.collection("posts").exportItems("csv");const summary = await backlex .collection("posts") .importItems([{ title: "Hello" }, { title: "World" }]); // JSON by default// → { inserted, failed, total, errors }importItems also accepts a raw string plus "csv" to upload a spreadsheet
verbatim.
Backups themselves are on client.backups:
const { data: b } = await backlex.backups.run({ label: "before-migration" });await backlex.backups.list(); // tracking rows, newest firstawait backlex.backups.restore(b.id); // additive; the SDK sends the confirm header// Undo a bad write, narrowed to one table:await backlex.backups.restore(b.id, { mode: "overwrite", onlyTables: ["c_ab12cd34ef56_orders"] });await backlex.backups.setConfig({ schedule: "daily", retain: 14 });Other surfaces
Section titled “Other surfaces”Backup/restore has full multi-surface parity (gate:
apps/web/tests/backup-surfaces.test.ts):
- GraphQL —
backups/backupConfigqueries;runBackup(label),restoreBackup(id, confirm: true, overwrite, onlyTables),setBackupConfig(data)mutations.confirm: truemirrors REST’sX-Backlex-Confirm: yesheader;overwrite: truemirrors?mode=overwrite. - MCP —
backups.list,backups.run,backups.restore(requiresconfirm: true),backups.get_config,backups.set_config. - CLI —
backlex backup <list|now|download|restore|config>(seedocs/sdk-and-cli.md).