Add game data capture vertical slice
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
"""Private SQLite setup and immutable ordered utility migrations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import sqlite3
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from stat import S_ISDIR, S_ISLNK
|
||||
|
||||
|
||||
class MigrationError(RuntimeError):
|
||||
"""The local migration history cannot safely be applied."""
|
||||
|
||||
|
||||
def _private_directory(path: Path) -> None:
|
||||
if path.is_symlink():
|
||||
raise MigrationError("utility root must not be a symlink")
|
||||
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
mode = path.lstat().st_mode
|
||||
if S_ISLNK(mode) or not S_ISDIR(mode):
|
||||
raise MigrationError("utility root must be a directory")
|
||||
path.chmod(0o700)
|
||||
|
||||
|
||||
class StorageDatabase:
|
||||
def __init__(self, root: Path, migrations_dir: Path) -> None:
|
||||
if not root.is_absolute() or not migrations_dir.is_absolute():
|
||||
raise ValueError("storage paths must be absolute")
|
||||
self.root = root
|
||||
self.migrations_dir = migrations_dir
|
||||
self.path = root / "capture.sqlite3"
|
||||
|
||||
def connect(self) -> sqlite3.Connection:
|
||||
_private_directory(self.root)
|
||||
if self.path.is_symlink():
|
||||
raise MigrationError("database file must not be a symlink")
|
||||
connection: sqlite3.Connection | None = None
|
||||
try:
|
||||
connection = sqlite3.connect(self.path, isolation_level=None, timeout=5.0)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA foreign_keys = ON")
|
||||
journal = connection.execute("PRAGMA journal_mode = WAL").fetchone()[0]
|
||||
connection.execute("PRAGMA busy_timeout = 5000")
|
||||
connection.execute("PRAGMA synchronous = FULL")
|
||||
checks = (
|
||||
connection.execute("PRAGMA foreign_keys").fetchone()[0] == 1,
|
||||
journal == "wal",
|
||||
connection.execute("PRAGMA busy_timeout").fetchone()[0] == 5000,
|
||||
connection.execute("PRAGMA synchronous").fetchone()[0] == 2,
|
||||
)
|
||||
if not all(checks):
|
||||
raise MigrationError("SQLite connection invariants were not enabled")
|
||||
for candidate in (
|
||||
self.path,
|
||||
self.path.with_name(self.path.name + "-wal"),
|
||||
self.path.with_name(self.path.name + "-shm"),
|
||||
):
|
||||
if candidate.exists() and not candidate.is_symlink():
|
||||
candidate.chmod(0o600)
|
||||
return connection
|
||||
except Exception:
|
||||
if connection is not None:
|
||||
connection.close()
|
||||
raise
|
||||
|
||||
def _migrations(self) -> list[tuple[int, Path, str]]:
|
||||
found: list[tuple[int, Path, str]] = []
|
||||
versions: set[int] = set()
|
||||
for path in self.migrations_dir.glob("*.sql"):
|
||||
if len(path.name) < 5 or not path.name[:3].isdigit() or path.name[3] != "_":
|
||||
raise MigrationError("migration filename is invalid")
|
||||
version = int(path.name[:3])
|
||||
if version in versions:
|
||||
raise MigrationError("migration versions must be unique")
|
||||
versions.add(version)
|
||||
checksum = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
found.append((version, path, checksum))
|
||||
found.sort(key=lambda item: item[0])
|
||||
if not found:
|
||||
raise MigrationError("no storage migrations found")
|
||||
return found
|
||||
|
||||
def migrate(self) -> None:
|
||||
migrations = self._migrations()
|
||||
with self.connect() as connection:
|
||||
connection.execute(
|
||||
"CREATE TABLE IF NOT EXISTS schema_migrations ("
|
||||
"version INTEGER PRIMARY KEY, filename TEXT NOT NULL UNIQUE, "
|
||||
"checksum TEXT NOT NULL, applied_at TEXT NOT NULL)"
|
||||
)
|
||||
applied = list(
|
||||
connection.execute(
|
||||
"SELECT version, filename, checksum FROM schema_migrations ORDER BY version"
|
||||
)
|
||||
)
|
||||
if len(applied) > len(migrations):
|
||||
raise MigrationError("applied migration is no longer present")
|
||||
expected_prefix = migrations[: len(applied)]
|
||||
for row, expected in zip(applied, expected_prefix, strict=True):
|
||||
version, path, checksum = expected
|
||||
if (row["version"], row["filename"], row["checksum"]) != (
|
||||
version,
|
||||
path.name,
|
||||
checksum,
|
||||
):
|
||||
raise MigrationError("migration history is not an exact ordered prefix")
|
||||
for version, path, checksum in migrations[len(applied) :]:
|
||||
sql = path.read_text(encoding="utf-8")
|
||||
try:
|
||||
applied_at = datetime.now(UTC).isoformat().replace("'", "''")
|
||||
connection.executescript(
|
||||
"BEGIN IMMEDIATE;\n"
|
||||
+ sql
|
||||
+ "\nINSERT INTO schema_migrations VALUES "
|
||||
+ f"({version}, '{path.name}', '{checksum}', '{applied_at}');"
|
||||
+ "\nCOMMIT;"
|
||||
)
|
||||
except Exception:
|
||||
if connection.in_transaction:
|
||||
connection.execute("ROLLBACK")
|
||||
raise
|
||||
os.chmod(self.path, 0o600)
|
||||
Reference in New Issue
Block a user