weightsdb 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
weightsdb/__about__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
weightsdb/__init__.py ADDED
@@ -0,0 +1,71 @@
1
+ """weightsdb — shared SQLAlchemy + Alembic plumbing: engines, sessions, pragmas, migrations, backup.
2
+
3
+ No application table, no shared schema (database standards §1). See
4
+ ``docs/packages/weightsdb/spec.md`` for the full contract.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from weightsdb.__about__ import __version__
10
+ from weightsdb.backup import (
11
+ BackupResult,
12
+ IntegrityResult,
13
+ RestoreResult,
14
+ backup,
15
+ checkpoint,
16
+ database_size_bytes,
17
+ integrity_check,
18
+ pg_restore_command,
19
+ restore,
20
+ )
21
+ from weightsdb.engine import create_engine_for
22
+ from weightsdb.errors import (
23
+ DatabaseError,
24
+ DatabaseUnavailable,
25
+ MigrationFailed,
26
+ MigrationRequired,
27
+ SchemaAhead,
28
+ StorageBusy,
29
+ StorageFull,
30
+ )
31
+ from weightsdb.health import DatabaseHealth, database_health, is_network_filesystem
32
+ from weightsdb.migrations import MigrationOutcome, MigrationRunner, ParityResult
33
+ from weightsdb.redaction import redact_url
34
+ from weightsdb.session import session_factory, session_scope, transaction
35
+ from weightsdb.types import PortableJSON, UtcDateTime, measurement_columns, ulid_primary_key, upsert
36
+
37
+ __all__ = [
38
+ "BackupResult",
39
+ "DatabaseError",
40
+ "DatabaseHealth",
41
+ "DatabaseUnavailable",
42
+ "IntegrityResult",
43
+ "MigrationFailed",
44
+ "MigrationOutcome",
45
+ "MigrationRequired",
46
+ "MigrationRunner",
47
+ "ParityResult",
48
+ "PortableJSON",
49
+ "RestoreResult",
50
+ "SchemaAhead",
51
+ "StorageBusy",
52
+ "StorageFull",
53
+ "UtcDateTime",
54
+ "__version__",
55
+ "backup",
56
+ "checkpoint",
57
+ "create_engine_for",
58
+ "database_health",
59
+ "database_size_bytes",
60
+ "integrity_check",
61
+ "is_network_filesystem",
62
+ "measurement_columns",
63
+ "pg_restore_command",
64
+ "redact_url",
65
+ "restore",
66
+ "session_factory",
67
+ "session_scope",
68
+ "transaction",
69
+ "ulid_primary_key",
70
+ "upsert",
71
+ ]
weightsdb/backup.py ADDED
@@ -0,0 +1,610 @@
1
+ """weightsdb.backup — backup, restore, rotation, integrity and size.
2
+
3
+ Spec §11.4: the automatic restore-on-failure guarantee is **SQLite-only**. On SQLite, a backup is a
4
+ byte-identical copy taken through the SQLite backup API (safe against a live writer), and a failed
5
+ migration restores it. On PostgreSQL there is no equivalent — ``pg_dump``/``pg_restore`` is not
6
+ byte-identical, a restore generally needs privileges a least-privileged role deliberately does not
7
+ hold, and a restore cannot run safely underneath a live database — so PostgreSQL's :func:`backup`
8
+ exists for an explicit caller only, :func:`restore` refuses and names the ``pg_restore`` invocation
9
+ instead, and :mod:`weightsdb.migrations` never restores automatically for this dialect.
10
+
11
+ **Restoring a SQLite database is a file-level operation, and WAL makes that subtle.** The engine
12
+ runs ``journal_mode=WAL`` (database standards §2), so committed data lives in a ``-wal`` sidecar
13
+ until a checkpoint folds it into the main file. Copying a backup over the main file alone leaves
14
+ that sidecar in place, and the next reader replays it straight back on top — the restore silently
15
+ does nothing to the very writes it was meant to undo. :func:`restore` therefore checkpoints,
16
+ disposes the pool and removes the sidecars before the swap, and verifies the result opens before
17
+ discarding the file it replaced.
18
+
19
+ Moved from FreeWeight's ``infrastructure.db.backup`` (ADR-0011); behaviour is unchanged.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import gzip
25
+ import logging
26
+ import os
27
+ import shutil
28
+ import sqlite3
29
+ import subprocess
30
+ from dataclasses import dataclass
31
+ from datetime import UTC, datetime
32
+ from pathlib import Path
33
+
34
+ from sqlalchemy import Engine, text
35
+ from sqlalchemy.exc import DBAPIError, SQLAlchemyError
36
+
37
+ from weightsdb.errors import DatabaseError, StorageFull
38
+
39
+ __all__ = [
40
+ "BackupResult",
41
+ "IntegrityResult",
42
+ "RestoreResult",
43
+ "backup",
44
+ "backup_revision",
45
+ "checkpoint",
46
+ "database_size_bytes",
47
+ "integrity_check",
48
+ "pg_restore_command",
49
+ "prune_backups",
50
+ "reclaimable_bytes",
51
+ "restore",
52
+ "sqlite_path",
53
+ ]
54
+
55
+ _LOG = logging.getLogger(__name__)
56
+
57
+ # Long enough that an ordinary concurrent writer finishes, short enough that a genuinely stuck
58
+ # database reports rather than hangs. Matches engine.create_engine_for's own busy_timeout default.
59
+ _SQLITE_BUSY_TIMEOUT_SECONDS = 5.0
60
+
61
+ _SIDECAR_SUFFIXES = ("-wal", "-shm")
62
+
63
+
64
+ @dataclass(frozen=True, slots=True)
65
+ class BackupResult:
66
+ """The outcome of a successful :func:`backup`.
67
+
68
+ Attributes:
69
+ path: Where the backup was written.
70
+ size_bytes: The backup file's size, for a "space this will use" report.
71
+ created_at: When the backup was taken.
72
+ dialect: ``"sqlite"`` or ``"postgresql"`` — which mechanism produced it, since the two are
73
+ not interchangeable (a ``pg_dump`` archive is never restored onto a SQLite file).
74
+ pruned: Automatic backups deleted by rotation as part of this call, oldest first. Empty
75
+ when rotation was not requested or nothing aged out.
76
+ """
77
+
78
+ path: Path
79
+ size_bytes: int
80
+ created_at: datetime
81
+ dialect: str
82
+ pruned: tuple[Path, ...] = ()
83
+
84
+
85
+ @dataclass(frozen=True, slots=True)
86
+ class RestoreResult:
87
+ """The outcome of a successful :func:`restore`.
88
+
89
+ Attributes:
90
+ path: The database file that was restored into.
91
+ source: The backup that was restored from.
92
+ restored_at: When the restore completed.
93
+ revision: The Alembic revision the restored database is at, or ``None`` if the backup
94
+ predates any migration.
95
+ """
96
+
97
+ path: Path
98
+ source: Path
99
+ restored_at: datetime
100
+ revision: str | None
101
+
102
+
103
+ @dataclass(frozen=True, slots=True)
104
+ class IntegrityResult:
105
+ """The outcome of :func:`integrity_check`.
106
+
107
+ Attributes:
108
+ ok: Whether the database passed its integrity check.
109
+ detail: The backend's own report — the raw ``PRAGMA integrity_check`` output on SQLite, or
110
+ a summary sentence on PostgreSQL.
111
+ """
112
+
113
+ ok: bool
114
+ detail: str
115
+
116
+
117
+ def sqlite_path(engine: Engine) -> Path:
118
+ """Return the on-disk path of a SQLite engine's database file.
119
+
120
+ Raises:
121
+ DatabaseError: The engine is not SQLite, or is the special in-memory database, which has
122
+ no file to back up.
123
+ """
124
+ if engine.dialect.name != "sqlite":
125
+ raise DatabaseError(
126
+ f"Expected a SQLite engine; got dialect {engine.dialect.name!r}.",
127
+ details={"dialect": engine.dialect.name},
128
+ )
129
+ # `URL.database` already applies the sqlite dialect's own rule for how many leading slashes
130
+ # separate "sqlite://" from an absolute path; parsing the URL string by hand here is how a
131
+ # "sqlite:////tmp/x" quietly becomes the relative "tmp/x".
132
+ database = engine.url.database
133
+ if not database or database == ":memory:":
134
+ raise DatabaseError(
135
+ "Cannot back up an in-memory SQLite database — it has no file to copy.",
136
+ details={"database_url": "sqlite:///:memory:"},
137
+ )
138
+ return Path(database)
139
+
140
+
141
+ def _sidecars(database: Path) -> tuple[Path, ...]:
142
+ """Return the ``-wal``/``-shm`` companion paths SQLite keeps beside ``database``."""
143
+ return tuple(Path(f"{database}{suffix}") for suffix in _SIDECAR_SUFFIXES)
144
+
145
+
146
+ def checkpoint(engine: Engine) -> None:
147
+ """Fold the WAL into the main database file and truncate the sidecar. A no-op off SQLite.
148
+
149
+ Needed wherever the *file* is the thing being measured or moved. Under ``journal_mode=WAL`` the
150
+ main file lags whatever is still in ``<db>-wal``, so a size read or a byte-for-byte copy taken
151
+ without checkpointing first describes neither the old state nor the new one.
152
+
153
+ Best-effort: a database too damaged to checkpoint is exactly the one a restore is about to
154
+ replace, so a failure here is logged and does not abort the caller.
155
+ """
156
+ if engine.dialect.name != "sqlite":
157
+ return
158
+ try:
159
+ # AUTOCOMMIT is load-bearing. Every ordinary connection from this engine opens its
160
+ # transaction with BEGIN IMMEDIATE, and `wal_checkpoint` cannot run inside a transaction —
161
+ # it reports busy in its result row rather than raising, so a checkpoint issued the
162
+ # ordinary way silently does nothing and every caller here quietly gets the
163
+ # un-checkpointed file it was trying to avoid.
164
+ with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as connection:
165
+ connection.exec_driver_sql("PRAGMA wal_checkpoint(TRUNCATE)")
166
+ except Exception: # noqa: BLE001 — a database too broken to checkpoint is one we still replace
167
+ _LOG.debug("WAL checkpoint failed; continuing", exc_info=True)
168
+
169
+
170
+ def _checkpoint_and_release(engine: Engine) -> None:
171
+ """Checkpoint, then drop every pooled connection to the database.
172
+
173
+ Both halves matter before a file-level swap: the checkpoint makes the main file complete (so
174
+ the copy taken of it is worth keeping as a rollback), and the dispose releases the handles that
175
+ would otherwise keep the old ``-wal`` alive underneath the new database.
176
+ """
177
+ checkpoint(engine)
178
+ engine.dispose()
179
+
180
+
181
+ def backup_revision(source: Path, *, version_table: str = "alembic_version") -> str | None:
182
+ """Return the Alembic revision recorded inside a SQLite backup file.
183
+
184
+ Args:
185
+ source: The backup file to inspect. Opened read-only; never modified.
186
+ version_table: The table Alembic records the revision in. Must be a plain identifier.
187
+
188
+ Returns:
189
+ The revision string, or ``None`` when the file carries no version table at all — a
190
+ legitimate state for a backup taken before the first migration ran.
191
+
192
+ Raises:
193
+ DatabaseError: ``version_table`` is not a plain identifier, or ``source`` cannot be opened.
194
+ """
195
+ if not version_table.isidentifier():
196
+ raise DatabaseError(f"Invalid version table name {version_table!r}.")
197
+ try:
198
+ connection = sqlite3.connect(f"file:{source}?mode=ro", uri=True)
199
+ except sqlite3.Error as exc:
200
+ raise DatabaseError(
201
+ f"Backup {source} could not be opened: {exc}", details={"source": str(source)}
202
+ ) from exc
203
+ try:
204
+ # `version_table` is validated as an identifier immediately above and is never
205
+ # caller-supplied in practice; SQLite has no parameter form for a table name.
206
+ row = connection.execute(f"SELECT version_num FROM {version_table}").fetchone() # noqa: S608
207
+ except sqlite3.OperationalError:
208
+ return None
209
+ finally:
210
+ connection.close()
211
+ return str(row[0]) if row else None
212
+
213
+
214
+ def backup(
215
+ engine: Engine,
216
+ destination: Path,
217
+ *,
218
+ compress: bool = False,
219
+ keep: int | None = None,
220
+ prefix: str | None = None,
221
+ ) -> BackupResult:
222
+ """Take a consistent backup of the database ``engine`` is connected to.
223
+
224
+ On SQLite this uses the SQLite backup API (:meth:`sqlite3.Connection.backup`), which is safe to
225
+ run against a database with an active writer — it never holds a lock for longer than a single
226
+ page copy. On PostgreSQL this shells out to ``pg_dump`` in the custom archive format.
227
+
228
+ Args:
229
+ engine: The engine to back up.
230
+ destination: Where to write the backup. Parent directories are created if missing; the
231
+ file is created with mode ``0600`` before a byte is written to it (security
232
+ standards), never widened first and narrowed afterwards.
233
+ compress: Gzip the SQLite backup after writing it. Ignored for PostgreSQL, whose custom
234
+ archive format is already compressed.
235
+ keep: Retain only this many backups matching ``prefix`` in ``destination``'s directory,
236
+ newest first, deleting the rest (database standards §7, default 5 for automatic
237
+ backups). ``None`` disables rotation, which is what an operator-chosen ``--output``
238
+ path gets — this function never deletes a file the operator named.
239
+ prefix: Filename prefix identifying the family of automatic backups ``keep`` applies to.
240
+ Required when ``keep`` is given.
241
+
242
+ Returns:
243
+ The :class:`BackupResult`.
244
+
245
+ Raises:
246
+ DatabaseError: The backup could not be taken, including
247
+ :class:`~weightsdb.errors.StorageFull` when the destination device is out of space
248
+ (the partial file is removed first). Also raised when the SQLite database file does
249
+ not exist — an empty backup of a database that was never created is worse than a
250
+ refusal, because it looks like a successful one.
251
+ """
252
+ if keep is not None and prefix is None:
253
+ raise DatabaseError("backup(keep=...) requires a prefix identifying which files to rotate.")
254
+
255
+ destination.parent.mkdir(parents=True, exist_ok=True)
256
+ dialect = engine.dialect.name
257
+
258
+ if dialect == "sqlite":
259
+ source_path = sqlite_path(engine)
260
+ if not source_path.is_file():
261
+ raise DatabaseError(
262
+ f"There is no database at {source_path} to back up.",
263
+ details={"source": str(source_path)},
264
+ )
265
+ _touch_private(destination)
266
+ source = sqlite3.connect(source_path, timeout=_SQLITE_BUSY_TIMEOUT_SECONDS)
267
+ try:
268
+ target = sqlite3.connect(destination)
269
+ try:
270
+ source.backup(target)
271
+ finally:
272
+ target.close()
273
+ except sqlite3.OperationalError as exc:
274
+ destination.unlink(missing_ok=True)
275
+ if "disk" in str(exc).lower() or "space" in str(exc).lower():
276
+ raise StorageFull(
277
+ f"No space left to write backup {destination}.",
278
+ details={"destination": str(destination)},
279
+ ) from exc
280
+ raise DatabaseError(
281
+ f"SQLite backup of {source_path} to {destination} failed: {exc}",
282
+ details={"source": str(source_path), "destination": str(destination)},
283
+ ) from exc
284
+ finally:
285
+ source.close()
286
+ if compress:
287
+ compressed = Path(f"{destination}.gz")
288
+ _touch_private(compressed)
289
+ try:
290
+ with destination.open("rb") as raw, gzip.open(compressed, "wb") as archive:
291
+ shutil.copyfileobj(raw, archive)
292
+ except OSError as exc:
293
+ # The uncompressed file is the one that is known-good; leaving a half-written
294
+ # ".gz" beside it would look like a backup and restore as nothing.
295
+ compressed.unlink(missing_ok=True)
296
+ raise DatabaseError(
297
+ f"Compressing backup {destination} failed: {exc}",
298
+ details={"destination": str(destination)},
299
+ ) from exc
300
+ destination.unlink()
301
+ destination = compressed
302
+ elif dialect == "postgresql":
303
+ _touch_private(destination)
304
+ url = engine.url
305
+ command = [
306
+ "pg_dump",
307
+ "--format=custom",
308
+ f"--file={destination}",
309
+ f"--host={url.host or 'localhost'}",
310
+ f"--port={url.port or 5432}",
311
+ f"--username={url.username}",
312
+ url.database or "",
313
+ ]
314
+ env = {**os.environ, "PGPASSWORD": url.password} if url.password else None
315
+ try:
316
+ subprocess.run(command, check=True, capture_output=True, env=env) # noqa: S603, S607
317
+ except FileNotFoundError as exc:
318
+ destination.unlink(missing_ok=True)
319
+ raise DatabaseError(
320
+ "pg_dump is not installed or not on PATH; PostgreSQL backups require the "
321
+ "PostgreSQL client tools.",
322
+ ) from exc
323
+ except subprocess.CalledProcessError as exc:
324
+ destination.unlink(missing_ok=True)
325
+ raise DatabaseError(
326
+ f"pg_dump failed with exit code {exc.returncode}: "
327
+ f"{exc.stderr.decode(errors='replace')}",
328
+ ) from exc
329
+ else:
330
+ raise DatabaseError(
331
+ f"Unsupported dialect {dialect!r}; only sqlite and postgresql are supported.",
332
+ details={"dialect": dialect},
333
+ )
334
+
335
+ pruned: tuple[Path, ...] = ()
336
+ if keep is not None and prefix is not None:
337
+ pruned = prune_backups(destination.parent, prefix=prefix, keep=keep)
338
+
339
+ return BackupResult(
340
+ path=destination,
341
+ size_bytes=destination.stat().st_size,
342
+ created_at=datetime.now(UTC),
343
+ dialect=dialect,
344
+ pruned=pruned,
345
+ )
346
+
347
+
348
+ def _touch_private(path: Path) -> None:
349
+ """Create ``path`` empty with mode ``0600``, before anything writes content into it.
350
+
351
+ ``chmod`` after the write leaves a window in which the file exists, holds the whole database,
352
+ and is world-readable. Creating it private first closes that window; the ``O_EXCL``-free open
353
+ is deliberate, since a backup destination may legitimately be overwritten.
354
+ """
355
+ path.unlink(missing_ok=True)
356
+ path.touch(mode=0o600)
357
+
358
+
359
+ def prune_backups(directory: Path, *, prefix: str, keep: int) -> tuple[Path, ...]:
360
+ """Delete all but the ``keep`` newest backups named ``prefix*`` in ``directory``.
361
+
362
+ Rotation is logged (database standards §7) so that a backup disappearing is always traceable to
363
+ a policy rather than looking like data loss.
364
+
365
+ Args:
366
+ directory: The backups directory. A missing directory prunes nothing.
367
+ prefix: Only files whose name starts with this are candidates — an operator's own
368
+ ``--output`` backup, named anything else, is never a candidate.
369
+ keep: How many to retain. ``0`` deletes every matching file; negative is refused.
370
+
371
+ Returns:
372
+ The deleted paths, oldest first.
373
+
374
+ Raises:
375
+ DatabaseError: ``keep`` is negative.
376
+ """
377
+ if keep < 0:
378
+ raise DatabaseError(f"backup retention must not be negative; got {keep}.")
379
+ if not directory.is_dir():
380
+ return ()
381
+ candidates = sorted(
382
+ (path for path in directory.iterdir() if path.is_file() and path.name.startswith(prefix)),
383
+ key=lambda path: (path.stat().st_mtime_ns, path.name),
384
+ )
385
+ doomed = candidates[: max(0, len(candidates) - keep)]
386
+ for path in doomed:
387
+ path.unlink(missing_ok=True)
388
+ _LOG.info("rotated out backup %s (retention: keep %d)", path, keep)
389
+ return tuple(doomed)
390
+
391
+
392
+ def pg_restore_command(engine: Engine, source: Path) -> str:
393
+ """Return the ``pg_restore`` invocation an operator runs to restore ``source`` by hand."""
394
+ url = engine.url
395
+ return (
396
+ f"pg_restore --clean --if-exists --host={url.host or 'localhost'} "
397
+ f"--port={url.port or 5432} --username={url.username} "
398
+ f"--dbname={url.database or ''} {source}"
399
+ )
400
+
401
+
402
+ def restore(
403
+ engine: Engine,
404
+ source: Path,
405
+ *,
406
+ confirm: bool,
407
+ known_revisions: frozenset[str] | None = None,
408
+ ) -> RestoreResult:
409
+ """Restore the database ``engine`` is connected to from a backup.
410
+
411
+ The backup is verified before anything is touched — it must open, pass its own integrity
412
+ check, and (when ``known_revisions`` is given) sit at a revision this build knows how to read.
413
+ The database being replaced is then checkpointed, released and copied aside as a
414
+ ``.pre-restore`` sibling, which is deleted only once the restored file has been opened and has
415
+ passed an integrity check of its own; if it does not, the ``.pre-restore`` copy is put back and
416
+ the failure is raised (database standards §7).
417
+
418
+ ``engine``'s connection pool is disposed as part of this — a file-level swap cannot happen
419
+ safely underneath live handles, and the stale ``-wal`` those handles keep alive would be
420
+ replayed on top of the restored file. The engine remains usable afterwards; SQLAlchemy opens a
421
+ fresh pool on next use.
422
+
423
+ Args:
424
+ engine: The engine whose database will be replaced. Must be SQLite — restoring a
425
+ PostgreSQL database from a ``pg_dump`` archive is a deliberate, privileged operation
426
+ this function does not perform, and refuses while naming the ``pg_restore`` command.
427
+ source: The backup file to restore from.
428
+ confirm: Must be ``True`` or the restore is refused. There is no implicit destructive path
429
+ (spec §14).
430
+ known_revisions: Every revision this build's migration history contains. When given, a
431
+ backup at a revision outside it is refused rather than restored into a build that
432
+ cannot read it. ``None`` skips the check, for callers that have no history to check
433
+ against.
434
+
435
+ Returns:
436
+ The :class:`RestoreResult`.
437
+
438
+ Raises:
439
+ DatabaseError: ``confirm`` is ``False``, ``engine`` is not SQLite, ``source`` does not
440
+ exist, ``source`` fails to open or fails its integrity check, ``source`` is at an
441
+ unknown revision, or the restored file itself fails to open — in which case the
442
+ original database has already been put back.
443
+ """
444
+ if not confirm:
445
+ raise DatabaseError(
446
+ "restore() requires confirm=True; there is no implicit destructive path.",
447
+ )
448
+ if engine.dialect.name == "postgresql":
449
+ raise DatabaseError(
450
+ "Restoring a PostgreSQL database is not performed in-process (spec §11.4). "
451
+ f"Run: {pg_restore_command(engine, source)}",
452
+ details={"command": pg_restore_command(engine, source), "source": str(source)},
453
+ )
454
+ if not source.is_file():
455
+ raise DatabaseError(
456
+ f"Backup file {source} does not exist.", details={"source": str(source)}
457
+ )
458
+
459
+ target_path = sqlite_path(engine)
460
+ _verify_backup_file(source)
461
+ revision = backup_revision(source)
462
+ if known_revisions is not None and revision is not None and revision not in known_revisions:
463
+ raise DatabaseError(
464
+ f"Backup {source} is at revision {revision!r}, which this build's migration history "
465
+ "does not contain; it was written by a newer version and restoring it would leave a "
466
+ "database this build cannot read.",
467
+ details={"source": str(source), "revision": revision},
468
+ )
469
+
470
+ _checkpoint_and_release(engine)
471
+
472
+ pre_restore = Path(f"{target_path}.pre-restore")
473
+ had_original = target_path.is_file()
474
+ if had_original:
475
+ shutil.copy2(target_path, pre_restore)
476
+ pre_restore.chmod(0o600)
477
+ for sidecar in _sidecars(target_path):
478
+ sidecar.unlink(missing_ok=True)
479
+
480
+ _touch_private(target_path)
481
+ shutil.copyfile(source, target_path)
482
+
483
+ verification = integrity_check(engine)
484
+ if not verification.ok:
485
+ engine.dispose()
486
+ for sidecar in _sidecars(target_path):
487
+ sidecar.unlink(missing_ok=True)
488
+ if had_original:
489
+ shutil.copyfile(pre_restore, target_path)
490
+ pre_restore.unlink(missing_ok=True)
491
+ else:
492
+ target_path.unlink(missing_ok=True)
493
+ raise DatabaseError(
494
+ f"Restored database from {source} failed its integrity check "
495
+ f"({verification.detail}); the original database has been put back.",
496
+ details={"source": str(source), "detail": verification.detail},
497
+ )
498
+
499
+ # Release the handle the verification above opened, so the restored database is left
500
+ # checkpointed and sidecar-free exactly as the backup was.
501
+ engine.dispose()
502
+ if had_original:
503
+ pre_restore.unlink(missing_ok=True)
504
+
505
+ return RestoreResult(
506
+ path=target_path, source=source, restored_at=datetime.now(UTC), revision=revision
507
+ )
508
+
509
+
510
+ def _verify_backup_file(source: Path) -> None:
511
+ """Raise unless ``source`` opens as SQLite and passes ``PRAGMA integrity_check``."""
512
+ try:
513
+ probe = sqlite3.connect(f"file:{source}?mode=ro", uri=True)
514
+ except sqlite3.Error as exc:
515
+ raise DatabaseError(
516
+ f"Backup {source} could not be opened: {exc}", details={"source": str(source)}
517
+ ) from exc
518
+ try:
519
+ row = probe.execute("PRAGMA integrity_check").fetchone()
520
+ except sqlite3.DatabaseError as exc:
521
+ # Damage bad enough that the pragma cannot run at all — a corrupt page 1, a file that was
522
+ # never SQLite — is reported by raising rather than by returning a row naming the bad
523
+ # pages. Which of the two shapes a given corruption produces is not stable across SQLite
524
+ # versions, so both say "failed its integrity check": the refusal is the same refusal.
525
+ raise DatabaseError(
526
+ f"Backup {source} failed its integrity check: it is not a readable SQLite "
527
+ f"database ({exc}).",
528
+ details={"source": str(source)},
529
+ ) from exc
530
+ finally:
531
+ probe.close()
532
+ if row is None or row[0] != "ok":
533
+ raise DatabaseError(
534
+ f"Backup {source} failed its integrity check: {row[0] if row else 'unreadable'}.",
535
+ details={"source": str(source)},
536
+ )
537
+
538
+
539
+ def integrity_check(engine: Engine) -> IntegrityResult:
540
+ """Run the database's own integrity check.
541
+
542
+ Args:
543
+ engine: The engine to check.
544
+
545
+ Returns:
546
+ The :class:`IntegrityResult`. On SQLite this runs ``PRAGMA integrity_check``. On
547
+ PostgreSQL, which has no equivalent single command, this reports ``ok`` from a successful
548
+ connection and a trivial query — a real corruption check there is an operator running
549
+ ``pg_amcheck`` or restoring onto a scratch instance, out of scope for an in-process call.
550
+
551
+ A SQLite database the pragma cannot run against at all — damaged beyond opening, or
552
+ otherwise refusing the statement — is reported as ``ok=False`` carrying the driver's own
553
+ message, never by raising: SQLite answers the same corruption either by listing the bad
554
+ pages in a row or by failing the statement with "database disk image is malformed", and
555
+ which one it picks varies with the damage and the SQLite version. Every caller here acts on
556
+ a failed check rather than propagating it, and neither can do that if half of all
557
+ corruptions arrive as an exception instead.
558
+ """
559
+ if engine.dialect.name == "sqlite":
560
+ try:
561
+ with engine.connect() as connection:
562
+ row = connection.execute(text("PRAGMA integrity_check")).fetchone()
563
+ except SQLAlchemyError as exc:
564
+ cause = exc.orig if isinstance(exc, DBAPIError) and exc.orig is not None else exc
565
+ return IntegrityResult(ok=False, detail=str(cause))
566
+ detail = row[0] if row else "unreadable"
567
+ return IntegrityResult(ok=detail == "ok", detail=str(detail))
568
+
569
+ with engine.connect() as connection:
570
+ connection.execute(text("SELECT 1"))
571
+ return IntegrityResult(ok=True, detail="connection and a trivial query succeeded")
572
+
573
+
574
+ def database_size_bytes(engine: Engine) -> int:
575
+ """Return how much space the database occupies.
576
+
577
+ On SQLite this is the main file plus its ``-wal``/``-shm`` sidecars, because those are real
578
+ bytes on the user's disk and a report that omitted them would understate a busy database. On
579
+ PostgreSQL it is ``pg_database_size(current_database())``.
580
+
581
+ Returns:
582
+ The size in bytes; ``0`` when the SQLite file does not exist yet.
583
+ """
584
+ if engine.dialect.name == "sqlite":
585
+ if not engine.url.database or engine.url.database == ":memory:":
586
+ # An in-memory database occupies no disk. Reporting that is right; raising, the way
587
+ # sqlite_path() does for callers that need a file to copy, would make a size report
588
+ # the thing that breaks a status command.
589
+ return 0
590
+ database = sqlite_path(engine)
591
+ paths = (database, *_sidecars(database))
592
+ return sum(path.stat().st_size for path in paths if path.is_file())
593
+ with engine.connect() as connection:
594
+ size = connection.execute(text("SELECT pg_database_size(current_database())")).scalar_one()
595
+ return int(size)
596
+
597
+
598
+ def reclaimable_bytes(engine: Engine) -> int:
599
+ """Estimate what a ``VACUUM`` would reclaim, so a caller can preview it before running.
600
+
601
+ On SQLite this is exact and cheap: the free page count times the page size. On PostgreSQL there
602
+ is no comparable cheap estimate — a real one needs ``pgstattuple`` — so this reports ``0``, and
603
+ the caller reports the actual before/after difference instead of a prediction.
604
+ """
605
+ if engine.dialect.name != "sqlite":
606
+ return 0
607
+ with engine.connect() as connection:
608
+ free_pages = connection.execute(text("PRAGMA freelist_count")).scalar_one()
609
+ page_size = connection.execute(text("PRAGMA page_size")).scalar_one()
610
+ return int(free_pages) * int(page_size)