168 lines
4.7 KiB
Python
168 lines
4.7 KiB
Python
#!/usr/bin/env python3
|
|
"""Wait for PostgreSQL, load schema.sql, apply numbered SQL migrations. Fail-fast."""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
from sql_compat import postgres_connect_kwargs, split_sql, sqlite_schema_to_postgres, use_postgres
|
|
|
|
SCHEMA_PATH = Path(__file__).resolve().parent / "schema.sql"
|
|
MIGRATIONS_DIR = Path(__file__).resolve().parent / "migrations"
|
|
SQLITE_HISTORY_MIGRATIONS = (
|
|
"001_frame",
|
|
"002_platform",
|
|
"003_dialogue_memory",
|
|
"004_mvp_journal",
|
|
"005_provider_settings",
|
|
"006_writing_profile_dialogue_style",
|
|
"007_journal_day_scratch",
|
|
"008_journal_source_refs",
|
|
"009_conversation_signals",
|
|
"010_profile_governance",
|
|
"011_profile_review",
|
|
"012_profile_shell",
|
|
"013_journal_generate_narration",
|
|
"014_identity_registry",
|
|
"015_journal_generation_settings",
|
|
"016_generation_instruction_fragments",
|
|
"017_generation_guidelines",
|
|
"018_debug_runs",
|
|
"019_debug_run_placement",
|
|
"020_voice_style_context",
|
|
"021_voice_legacy_immutable",
|
|
)
|
|
_LEADING_DIGITS = re.compile(r"^(\d{3})_.*\.sql$")
|
|
_schema_ready = False
|
|
|
|
|
|
def mark_schema_dirty() -> None:
|
|
"""Call after DROP SCHEMA so the next init_db reloads schema.sql."""
|
|
global _schema_ready
|
|
_schema_ready = False
|
|
|
|
|
|
def _connect_raw():
|
|
import psycopg
|
|
|
|
kwargs = postgres_connect_kwargs()
|
|
if "conninfo" in kwargs:
|
|
return psycopg.connect(kwargs["conninfo"], autocommit=False)
|
|
return psycopg.connect(autocommit=False, **kwargs)
|
|
|
|
|
|
def wait_for_postgres(max_retries: int = 30) -> None:
|
|
print("Checking PostgreSQL connection...")
|
|
last_error = None
|
|
for attempt in range(1, max_retries + 1):
|
|
try:
|
|
conn = _connect_raw()
|
|
conn.close()
|
|
print("PostgreSQL ready")
|
|
return
|
|
except Exception as exc: # noqa: BLE001 — fail-fast after retries
|
|
last_error = exc
|
|
print(f" waiting ({attempt}/{max_retries})")
|
|
time.sleep(2)
|
|
print(f"PostgreSQL not ready: {last_error}")
|
|
sys.exit(1)
|
|
|
|
|
|
def _table_exists(conn, name: str) -> bool:
|
|
row = conn.execute(
|
|
"""
|
|
SELECT 1 FROM information_schema.tables
|
|
WHERE table_schema = 'public' AND table_name = %s
|
|
""",
|
|
(name,),
|
|
).fetchone()
|
|
return row is not None
|
|
|
|
|
|
def _load_schema(conn) -> None:
|
|
sql = sqlite_schema_to_postgres(SCHEMA_PATH.read_text(encoding="utf-8"))
|
|
for statement in split_sql(sql):
|
|
conn.execute(statement)
|
|
print("Schema loaded from schema.sql (Postgres dialect)")
|
|
|
|
|
|
def _applied_ids(conn) -> set[str]:
|
|
rows = conn.execute("SELECT id FROM schema_migrations").fetchall()
|
|
return {row[0] for row in rows}
|
|
|
|
|
|
def _record(conn, migration_id: str) -> None:
|
|
conn.execute(
|
|
"INSERT INTO schema_migrations (id) VALUES (%s) ON CONFLICT (id) DO NOTHING",
|
|
(migration_id,),
|
|
)
|
|
|
|
|
|
def _migration_files() -> list[tuple[str, Path]]:
|
|
if not MIGRATIONS_DIR.is_dir():
|
|
return []
|
|
rows: list[tuple[str, Path]] = []
|
|
for path in sorted(MIGRATIONS_DIR.iterdir()):
|
|
match = _LEADING_DIGITS.match(path.name)
|
|
if not match or path.suffix != ".sql":
|
|
continue
|
|
rows.append((path.stem, path))
|
|
return rows
|
|
|
|
|
|
def apply_schema_and_migrations() -> None:
|
|
conn = _connect_raw()
|
|
try:
|
|
if not _table_exists(conn, "schema_migrations"):
|
|
_load_schema(conn)
|
|
conn.commit()
|
|
applied = _applied_ids(conn)
|
|
if "001_frame" not in applied:
|
|
for migration_id in SQLITE_HISTORY_MIGRATIONS:
|
|
_record(conn, migration_id)
|
|
conn.commit()
|
|
print("Recorded SQLite history 001-021 as applied (greenfield Postgres schema)")
|
|
applied = _applied_ids(conn)
|
|
for stem, path in _migration_files():
|
|
if stem in applied:
|
|
continue
|
|
sql = path.read_text(encoding="utf-8")
|
|
for statement in split_sql(sql):
|
|
conn.execute(statement)
|
|
_record(conn, stem)
|
|
conn.commit()
|
|
print(f"Applied migration {stem}")
|
|
except Exception as exc:
|
|
conn.rollback()
|
|
print(f"Database initialization failed: {exc}")
|
|
raise
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def ensure_postgres_ready() -> None:
|
|
global _schema_ready
|
|
if not use_postgres():
|
|
return
|
|
if _schema_ready:
|
|
return
|
|
wait_for_postgres()
|
|
try:
|
|
apply_schema_and_migrations()
|
|
except Exception:
|
|
sys.exit(1)
|
|
_schema_ready = True
|
|
|
|
|
|
def main() -> None:
|
|
os.environ.setdefault("KANSHO_DB_BACKEND", "postgres")
|
|
ensure_postgres_ready()
|
|
print("db_init complete")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|