feat(523): restore reads the backup tar; python -m scribe.restore brings a fresh instance back
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Failing after 1m8s
CI & Build / Python tests (push) Successful in 1m54s
CI & Build / Build & push image (push) Skipped

The restore route takes the tar as a raw body beside the JSON download.
scribe/restore.py reads either format, refuses a database that already
has an account (a restore re-creates everything with new ids, so into a
live instance it would duplicate rather than replace), has no size
limit, and says to restart so search indexes rebuild. An integration
test restores a written tar through the real restore_full_backup and
checks every id is remapped. docs/backups.md explains the file, why it
is uncompressed, both destinations, Fabled Archives as the worked
example, and restoring.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 17:40:24 -04:00
co-authored by Claude Opus 5.5
parent be59829dde
commit b45fc9efba
5 changed files with 377 additions and 3 deletions
+158
View File
@@ -0,0 +1,158 @@
# Backups
Scribe can back itself up every day to a **folder** or an **S3-compatible
bucket**. Each run adds one file; Scribe never deletes an old one. Keeping and
thinning the history is the destination's job.
Set it up under **Settings → Data → Scheduled backups** (admins only).
## What a backup is
One file per run, named for the moment it was taken in UTC:
```
scribe-20261015T020000Z.tar
```
It is a plain, **uncompressed** tar:
- `manifest.json` comes first. It holds the backup format version, the Scribe
version, when the backup was taken, and how many rows each table has.
- Then there is one `<table>.jsonl` per table, one row per line.
It holds everything the admin **Full Backup** download holds, laid out
differently. It does **not** hold:
- **Search indexes.** They are rebuilt from the records when Scribe starts.
- **Sharing:** groups, project shares and note shares.
- **Credentials:** API keys and forge connections. After a restore, issue new
API keys and add forge connections again.
- **Logs and notifications.**
- **The backup destination's own S3 secret.**
The file contains hashed passwords, so store it somewhere only trusted people
can read.
### Why it is shaped this way
The file is meant to be copied off-site by a deduplicating tool such as
restic, which stores a chunk of data only once no matter how many backups
contain it. Every choice serves that:
- **It is never compressed.** Compression changes every byte after the first
edit, so nothing would match the day before. restic compresses its own
repository after deduplicating, so nothing is lost.
- **Rows are in primary-key order, with their keys sorted.** A row that did
not change is the same bytes in the same place every day. An edited row
changes one line, and new rows land at the end of their table's file.
- **Each table is its own file**, so a busy table never shifts the bytes of a
quiet one.
- **The parts that change every run are kept in `manifest.json`**: the time
and the counts.
- **Each run gets a new, dated name.** Overwriting one name would keep one
copy and no history.
Day to day, the off-site copy then stores roughly what changed, not the whole
file again.
## Destinations
### A folder
A path **inside the Scribe container**, normally a share mounted there:
```yaml
services:
scribe:
volumes:
- /nfs/backup/scribe:/backups
```
Scribe writes each file under a hidden name (`.scribe-….tar.part`) and renames
it once it is complete. A program watching the folder never sees half a
backup.
### An S3-compatible bucket
You need four things:
- **Address:** the service itself, like `https://backup.example.com`. Don't
include the bucket; Scribe addresses buckets by path.
- **Bucket.**
- **Access key.**
- **Secret key.**
Scribe uploads and lists; it never deletes. A key that may **only add
objects** is therefore enough, and it is the safer key to hand out: a stolen
one cannot wipe the history.
The secret is stored encrypted, under a key derived from `SECRET_KEY`. It is
never shown again, and never included in a backup. If an instance's
`SECRET_KEY` changes, for example after a restore onto a new instance, the
page asks for the secret again.
**Test destination** checks the destination without sending a backup:
- a folder: it writes and removes a hidden file;
- a bucket: it only lists, because an add-only key could never remove a test
file.
## Schedule and history
- Backups run once a day at the hour you choose, in UTC. The default is 02:00,
before the nightly trash purge and database maintenance.
- **Back up now** runs one straight away.
- The page leads with when the last good backup was taken. It says
**Overdue** when backups are on and none has worked for 26 hours.
- It lists the last 30 runs, and gives the reason for each failure in words.
- A run that hasn't finished after an hour is abandoned and recorded as
failed.
## Example: Fabled Archives
[Fabled Archives](https://git.fabledsword.com/bvandeusen/fabledarchives)
keeps service backups as "backup targets". Either destination works with it.
1. On Archives' **Backups** page, make a target for Scribe, named `scribe`
for example.
2. Choose how Scribe's files arrive:
- **S3**: turn on the S3 way in for the target and make an **append-only**
S3 key. In Scribe, choose **An S3 bucket**:
- address: Archives' backup address (`http://<archives>:8081`);
- bucket: the target's name;
- access key and secret key: the key you just made.
- **A folder**: mount the share Archives watches into Scribe as
`/backups/scribe`, and point the target at that folder. In Scribe, choose
**A folder** with `/backups/scribe`.
3. Set the target's **Retention** to **Archives**. Archives then keeps the
newest backups and thins the rest by day, week and month. Scribe only adds.
4. Give the target an expected cadence of **daily**, so a missed backup turns
it late in Archives as well as overdue in Scribe.
5. Add an off-site **restic** replication job for the target. The uncompressed
layout above is what lets that job send only what changed each day.
## Restoring
### Into a fresh instance
Start Scribe on an empty database, copy the backup file into the container,
and run:
```sh
docker exec -it scribe python -m scribe.restore /backups/scribe-20261015T020000Z.tar
```
- The command refuses a database that already has an account. A restore
re-creates everything with new ids, so on a live instance it would add a
second copy instead of replacing anything.
- Nothing limits the file's size.
- It also reads the JSON from the admin **Full Backup** download.
Afterwards, **restart Scribe** so the search indexes are rebuilt. Then issue
new API keys, add forge connections again, and set up any sharing.
### From the web
**Settings → Data → Restore from Backup** takes the `.tar` as well as the
JSON download. The upload is held to the server's request-size limit. For a
bigger file, use the command above.
+6 -2
View File
@@ -127,8 +127,12 @@ Every read and mutation is scoped by owner + direct shares + group shares.
- **Personal export** — download all your notes/tasks as a Markdown ZIP (YAML - **Personal export** — download all your notes/tasks as a Markdown ZIP (YAML
frontmatter) or a JSON array. frontmatter) or a JSON array.
- **Admin backup** — full application backup/restore (format v4) with ID remapping on - **Admin backup** — full application backup/restore with ID remapping on
restore for cross-instance migration. restore for cross-instance migration.
- **Scheduled backups** — a daily, dated, uncompressed `.tar` to a folder or an
S3-compatible bucket, laid out so a deduplicating tool such as restic stores
only what changed; `python -m scribe.restore` brings a fresh instance back
from one. See [Backups](backups.md).
## Progressive Web App ## Progressive Web App
@@ -148,7 +152,7 @@ reset, and session invalidation are included. See [SSO / OAuth](sso-oauth.md).
| Account | Email change, password change, session invalidation | | Account | Email change, password change, session invalidation |
| Profile | Per-user profile fields | | Profile | Per-user profile fields |
| Integrations | SearXNG status | | Integrations | SearXNG status |
| Data | Personal export; backup / restore (admin) | | Data | Personal export; backup / restore and scheduled backups (admin) |
| API Keys | Create/revoke `fmcp_` keys for the MCP endpoint | | API Keys | Create/revoke `fmcp_` keys for the MCP endpoint |
| Config (admin) | Base URL, SMTP, DB-maintenance schedule | | Config (admin) | Base URL, SMTP, DB-maintenance schedule |
| Users (admin) | User list, invite links, registration toggle | | Users (admin) | User list, invite links, registration toggle |
+95
View File
@@ -0,0 +1,95 @@
"""Restore a Scribe backup into an empty instance, from the command line.
python -m scribe.restore /backups/scribe-20261015T020000Z.tar
This is how a fresh instance is brought back after losing the old one. Run it
inside the Scribe container, which already has the database settings and has
already migrated the schema on start. It reads either the scheduled backup's
tar (services/backup_file.py) or the JSON the admin download produces.
It refuses a database that already holds an account. A restore re-creates
every user, project and record from the file with new ids; into a live
instance it would add a second copy of everything rather than replace it.
(Settings → Backup & Restore still offers the upload for that case, on
purpose and with its own warning.)
Unlike the upload, nothing limits the file's size here, so this is also the
way in for a backup too big for the web upload.
Afterwards, restart Scribe: search indexes are not part of a backup and are
rebuilt from the restored records at startup.
"""
from __future__ import annotations
import argparse
import asyncio
import json
import sys
from pathlib import Path
from sqlalchemy import func, select
def load_backup(path: Path) -> dict:
"""The payload in `path`, whichever of the two backup formats it is."""
from scribe.services.backup_file import is_archive, read_archive
with open(path, "rb") as f:
if is_archive(f):
return read_archive(f)
return json.load(f)
async def _account_count() -> int:
from scribe.models import async_session
from scribe.models.user import User
async with async_session() as session:
return (await session.execute(select(func.count()).select_from(User))).scalar_one()
async def run(path: Path) -> int:
from scribe.models.engine import engine
from scribe.services.backup import restore_full_backup
from scribe.services.backup_file import ArchiveError
try:
try:
payload = load_backup(path)
except FileNotFoundError:
print(f"There is no file at {path}.", file=sys.stderr)
return 2
except (ArchiveError, ValueError) as exc:
print(f"{path} isn't a backup this Scribe can read: {exc}", file=sys.stderr)
return 2
accounts = await _account_count()
if accounts:
print(
f"This database already holds {accounts} account(s). A restore adds a second "
"copy of everything rather than replacing it, so it only runs into an empty "
"instance. Start Scribe on an empty database and run this again.",
file=sys.stderr,
)
return 3
stats = await restore_full_backup(payload)
print(json.dumps({"restored": stats}, indent=2, default=str))
print("Restored. Restart Scribe so the search indexes are rebuilt.", file=sys.stderr)
return 0
finally:
await engine.dispose()
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="python -m scribe.restore",
description="Restore a Scribe backup (.tar or .json) into an empty instance.",
)
parser.add_argument("file", type=Path, help="the backup file")
args = parser.parse_args(argv)
return asyncio.run(run(args.file))
if __name__ == "__main__":
sys.exit(main())
+15 -1
View File
@@ -59,7 +59,21 @@ async def backup():
@admin_bp.route("/restore", methods=["POST"]) @admin_bp.route("/restore", methods=["POST"])
@admin_required @admin_required
async def restore(): async def restore():
data = await request.get_json() """Restore from an uploaded backup: the JSON download (application/json)
or the scheduled backup's tar (sent as the raw body). The upload is held
to the app's request-size limit; `python -m scribe.restore` has none, and
is the way in for a bigger file or a fresh instance."""
if request.mimetype == "application/json":
data = await request.get_json()
else:
import io
from scribe.services.backup_file import ArchiveError, read_archive
try:
data = read_archive(io.BytesIO(await request.get_data()))
except ArchiveError as exc:
return jsonify({"error": f"That file isn't a Scribe backup: {exc}"}), 400
if not data: if not data:
return jsonify({"error": "No backup data provided"}), 400 return jsonify({"error": "No backup data provided"}), 400
@@ -0,0 +1,103 @@
"""Real-Postgres proof that the scheduled backup's FILE restores (milestone
523): a payload written as the tar, read back through `scribe.restore`'s
loader, and fed to the real `restore_full_backup`.
The unit lane already shows the tar hands back the dict it was given. What
only this lane shows is that the dict it hands back is one the restore
accepts — every id remapped to the restored rows, nothing dropped — and that
the command-line restore refuses a database that already has accounts.
"""
import pytest
import pytest_asyncio
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.milestone import Milestone
from scribe.models.note import Note
from scribe.models.project import Project
from scribe.models.user import User
from scribe.restore import load_backup, run
from scribe.services import backup
from scribe.services.backup_file import write_archive
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
OWNER = "backup_file_restore_owner"
RESTORED = "backup_file_restore_restored"
async def _purge(username: str) -> None:
async with async_session() as s:
for user in (await s.execute(select(User).where(User.username == username))).scalars().all():
for model in (Note, Milestone, Project):
for row in (await s.execute(
select(model).where(model.user_id == user.id)
)).scalars().all():
await s.delete(row)
await s.flush()
await s.delete(user)
await s.commit()
@pytest_asyncio.fixture(autouse=True)
async def _no_leftovers():
"""Setup only — see the rule_version roundtrip module for why."""
await _purge(RESTORED)
await _purge(OWNER)
@pytest_asyncio.fixture
async def written(tmp_path):
"""A project with a milestone and a task in it, exported with the real
row builders, renamed so the restore mints a separate user, and written
as the backup tar."""
async with async_session() as s:
user = await ensure_user(s, OWNER)
project = Project(user_id=user.id, title="Restore drill")
s.add(project)
await s.flush()
milestone = Milestone(user_id=user.id, project_id=project.id, title="Plan")
s.add(milestone)
await s.flush()
note = Note(user_id=user.id, project_id=project.id, milestone_id=milestone.id,
title="Prove the restore", body="From the tar, not the JSON.",
status="todo", note_type="note", task_kind="work")
s.add(note)
await s.commit()
payload = {
"version": backup.BACKUP_VERSION,
"scope": "full",
"users": backup._user_rows([user]),
"projects": backup._project_rows([project]),
"milestones": backup._milestone_rows([milestone]),
"notes": backup._note_rows([note]),
}
payload["users"][0]["username"] = RESTORED
from datetime import datetime, timezone
path = write_archive(payload, tmp_path, datetime.now(timezone.utc), app_version="test")
yield path
await _purge(RESTORED)
await _purge(OWNER)
async def test_the_tar_restores_with_every_id_remapped(written):
await backup.restore_full_backup(load_backup(written))
async with async_session() as s:
user = (await s.execute(select(User).where(User.username == RESTORED))).scalars().one()
project = (await s.execute(select(Project).where(Project.user_id == user.id))).scalars().one()
milestone = (await s.execute(select(Milestone).where(Milestone.user_id == user.id))).scalars().one()
note = (await s.execute(select(Note).where(Note.user_id == user.id))).scalars().one()
assert project.title == "Restore drill"
assert (note.project_id, note.milestone_id) == (project.id, milestone.id)
assert note.body == "From the tar, not the JSON."
assert note.status == "todo"
async def test_the_command_refuses_a_database_with_accounts(written):
"""The source user exists, so this database is not empty: the command
must stop before restoring anything."""
assert await run(written) == 3
async with async_session() as s:
restored = (await s.execute(select(User).where(User.username == RESTORED))).scalars().all()
assert restored == []