Backup, restore & export
Logical JSONL backups (manual + scheduled) with additive restore, 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 — re-insert a dump’s rows. Additive: missing/deleted rows come back; existing rows are never overwritten or removed.
- 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
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":{…}} — so a restore can stream it without
loading the whole file.
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
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
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.
Restore
POST /api/admin/db/backups/{id}/restoreX-Backlex-Confirm: yesThe confirm header is required (same guard as raw SQL writes). Restore:
- Streams 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). - Re-inserts every row with
INSERT … ON CONFLICT DO NOTHING, parents before children.
Because conflicts are skipped, restore is safe to run against a live
database: it can only add rows that are missing, never overwrite or delete
existing data. The response reports { tableCount, rowCount, skipped }. A clean
point-in-time rollback (overwriting current state) is a separate, destructive
operation outside this path.
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
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
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/i18n_text); 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.
SDK
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 headerawait backlex.backups.setConfig({ schedule: "daily", retain: 14 });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),setBackupConfig(data)mutations.confirm: truemirrors REST’sX-Backlex-Confirm: yesheader. - 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).