diff --git a/README.md b/README.md index 5d02ae8..6d2c750 100644 --- a/README.md +++ b/README.md @@ -52,10 +52,18 @@ cp db_config.json.example db_config.json # then edit host/user/password # 2. create the schema python run_setup.py # uses db_config.json / defaults python run_setup.py --host 192.168.1.7 --user claude --password claudepwd -python run_setup.py --drop-existing # drop meta+series+log first -python run_setup.py --clean-slate # drop ALL user objects first +python run_setup.py --drop-existing # drop the existing installation first + # (ALL series data); user schemas untouched ``` +The database may hold the user's own schemas and tables; provisioning never +touches them. It **refuses** a database that already holds the library's +objects -- `meta`, `log`, a non-empty `series` schema, or the +`prevent_series_drops` event trigger -- and reports the installed version. +There are no migrations: re-provision with `--drop-existing` (deletes all +series) or use another database. All files run in one transaction, so a +failure leaves the database untouched. + Requires `psycopg2` and a PostgreSQL user with CREATE privileges on the target database. diff --git a/run_setup.py b/run_setup.py index 6042d16..2a7a2f8 100644 --- a/run_setup.py +++ b/run_setup.py @@ -22,8 +22,12 @@ Connection settings come from (highest precedence first): Usage: python run_setup.py # use db_config.json / defaults python run_setup.py --host 192.168.1.7 --user claude --password claudepwd - python run_setup.py --drop-existing # drop meta+series schemas first - python run_setup.py --clean-slate # drop ALL user objects first + python run_setup.py --drop-existing # drop the existing installation first + # (all series!); user schemas untouched + +A database that already holds the library's objects (meta, log, a non-empty +series schema, the event trigger) is refused -- there are no migrations. +User schemas and tables may exist and are never touched. """ import sys @@ -82,8 +86,7 @@ def execute_sql_file(conn, sql_file: Path): try: sql = sql_file.read_text(encoding="utf-8") with conn.cursor() as cur: - cur.execute(sql) - conn.commit() + cur.execute(sql) # committed by the caller, all files at once return True, f"[OK] Executed {sql_file.name}" except Exception as e: conn.rollback() @@ -122,23 +125,72 @@ def verify_setup(conn) -> None: print(f"[OK] schema_version: {row[0]} ({row[1]})") -def clean_slate(conn) -> None: - print("Performing clean slate...") +# Pre-check, shared rule with the Python client's init_postgres_db(): +# provisioning needs the library's own objects to be ABSENT. User schemas and +# tables are irrelevant and never touched. Never drop-and-recreate: an +# existing installation is refused, because there are no migrations and +# re-provisioning deletes all series. +PRECHECK_SQL = """ +SELECT 'schema ' || nspname || ' exists' +FROM pg_namespace WHERE nspname IN ('meta', 'log') +UNION ALL +SELECT 'schema series is not empty (' || count(*) || ' objects)' +FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace +WHERE n.nspname = 'series' AND c.relkind IN ('r', 'p', 'v', 'm', 'f', 'S') +HAVING count(*) > 0 +UNION ALL +SELECT 'event trigger prevent_series_drops exists' +FROM pg_event_trigger WHERE evtname = 'prevent_series_drops' +""" + + +def existing_installation(conn) -> list: + """What would stop a fresh install; [] means go ahead.""" with conn.cursor() as cur: - cur.execute( - "SELECT schema_name FROM information_schema.schemata " - "WHERE schema_name NOT IN ('pg_catalog','information_schema','pg_toast','public') " - "AND schema_name NOT LIKE 'pg_temp_%' AND schema_name NOT LIKE 'pg_toast_temp_%'" - ) - for (schema,) in cur.fetchall(): - print(f" drop schema {schema}") + cur.execute(PRECHECK_SQL) + found = [r[0] for r in cur.fetchall()] + cur.execute("SELECT to_regclass('meta.schema_version') IS NOT NULL") + if cur.fetchone()[0]: + cur.execute("SELECT string_agg(version, ', ') FROM meta.schema_version") + version = cur.fetchone()[0] + found.append(f"installed schema version: {version or 'none recorded'}") + return found + + +def drop_existing(conn) -> None: + """Drop the library's objects: every registered series schema, meta, log, + the event trigger. Refuses if a series schema holds non-series objects -- + those are user data, which this never deletes.""" + with conn.cursor() as cur: + schemas = ['series'] + cur.execute("SELECT to_regclass('meta.series_schemas') IS NOT NULL") + if cur.fetchone()[0]: + cur.execute("SELECT name FROM meta.series_schemas") + schemas = sorted({r[0] for r in cur.fetchall()} | {'series'}) + cur.execute("SELECT to_regclass('meta.series') IS NOT NULL") + has_registry = cur.fetchone()[0] + stray = [] + for schema in schemas: + cur.execute( + "SELECT c.relname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace " + "WHERE n.nspname = %s AND c.relkind IN ('r','p','v','m','f','S')", (schema,)) + names = [r[0] for r in cur.fetchall()] + if has_registry: + cur.execute("SELECT table_name FROM meta.series WHERE db_schema = %s", (schema,)) + names = sorted(set(names) - {r[0] for r in cur.fetchall()}) + stray += [f"{schema}.{n}" for n in names] + if stray: + raise RuntimeError( + "refusing to drop: these objects in series schemas are not series " + f"(user data?): {', '.join(stray)}. Move or drop them first.") + cur.execute("DROP EVENT TRIGGER IF EXISTS prevent_series_drops") + for schema in schemas: cur.execute(f"DROP SCHEMA IF EXISTS {schema} CASCADE") - cur.execute("SELECT evtname FROM pg_event_trigger") - for (trig,) in cur.fetchall(): - cur.execute(f"DROP EVENT TRIGGER IF EXISTS {trig}") - cur.execute("CREATE SCHEMA IF NOT EXISTS public") - conn.commit() - print("[OK] clean slate done\n") + print(f" dropped schema {schema}") + cur.execute("DROP SCHEMA IF EXISTS meta CASCADE") + cur.execute("DROP SCHEMA IF EXISTS log CASCADE") + conn.commit() + print("[OK] existing installation dropped\n") def main() -> int: @@ -149,9 +201,8 @@ def main() -> int: p.add_argument("--user") p.add_argument("--password") p.add_argument("--drop-existing", action="store_true", - help="Drop meta and series schemas before creating (destructive)") - p.add_argument("--clean-slate", action="store_true", - help="Drop ALL user objects before creating (very destructive)") + help="Drop the existing installation first: every series schema " + "(ALL series data), meta, log. User schemas are untouched.") p.add_argument("--yes", action="store_true", help="Skip confirmation prompts") args = p.parse_args() @@ -170,8 +221,8 @@ def main() -> int: print(f"Target : {cfg['host']}:{cfg['port']}/{cfg['database']} (user {cfg['user']})") print(f"SQL dir: {SQL_DIR}") - if (args.clean_slate or args.drop_existing) and not args.yes: - what = "ALL user objects" if args.clean_slate else "meta and series schemas" + if args.drop_existing and not args.yes: + what = "the existing installation, including ALL series data" if input(f"This will DROP {what}. Continue? (yes/no): ").lower() != "yes": print("Aborted.") return 1 @@ -186,32 +237,34 @@ def main() -> int: return 1 try: - if args.clean_slate: - clean_slate(conn) - elif args.drop_existing: - with conn.cursor() as cur: - cur.execute("DROP SCHEMA IF EXISTS meta CASCADE") - cur.execute("DROP SCHEMA IF EXISTS series CASCADE") - cur.execute("DROP SCHEMA IF EXISTS log CASCADE") - cur.execute("DROP EVENT TRIGGER IF EXISTS prevent_series_drops") - conn.commit() - print("[OK] existing schemas dropped\n") + if args.drop_existing: + try: + drop_existing(conn) + except RuntimeError as e: + print(f"[FAIL] {e}") + return 1 - failed = [] + existing = existing_installation(conn) + if existing: + print("[FAIL] the database already holds a tsapi installation:") + for line in existing: + print(f" - {line}") + print(" There are no migrations. Re-provision with --drop-existing " + "(deletes all series), or use another database.") + return 1 + + missing = [n for n in SQL_FILES if not (SQL_DIR / n).exists()] + if missing: + print(f"[FAIL] missing {', '.join(missing)}") + return 1 + # One transaction: a failure part-way leaves the database untouched. for name in SQL_FILES: - f = SQL_DIR / name - if not f.exists(): - print(f"[FAIL] missing {name}") - failed.append(name) - continue - ok, msg = execute_sql_file(conn, f) + ok, msg = execute_sql_file(conn, SQL_DIR / name) print(msg) if not ok: - failed.append(name) - - if failed: - print(f"\n[FAIL] {len(failed)} file(s) failed: {', '.join(failed)}") - return 1 + print(f"\n[FAIL] {name} failed; nothing was installed") + return 1 + conn.commit() verify_setup(conn) print("\n[SUCCESS] schema setup complete") return 0