Canonical TSAPI series DB schema v1.0.0

Single source of truth for the timeseries PostgreSQL schema shared by the
Python (TSAPI_SERIESDB_PG_PY) and C++ (TSAPI_POSTGRES_CPP) libraries. SQL
seeded byte-for-byte from TSAPI_SERIESDB_PG_PY/tsapi_pgdb/sql on 2026-09-02.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
peter 2026-09-02 17:27:26 +01:00
commit eed06559c0
17 changed files with 2080 additions and 0 deletions

211
run_setup.py Normal file
View file

@ -0,0 +1,211 @@
"""
Provision the TSAPI series database schema from the canonical SQL files.
This is the SINGLE SOURCE OF TRUTH provisioner for the TSAPI timeseries
PostgreSQL schema. Both the Python library (TSAPI_SERIESDB_PG_PY) and the C++
library (TSAPI_POSTGRES_CPP) target the schema it creates. Do not fork the SQL
into either project -- edit it here.
It executes the numbered DDL files in ./sql/ in order:
00_extensions, 01_schemas, 02_sequences, 03_tables, 04_indexes,
05_procedures, 06_triggers, 07_reference_data
Connection settings come from (highest precedence first):
1. command-line flags (--host --port --dbname --user --password)
2. db_config.json next to this script
3. built-in defaults (localhost:5432 / timeseries_db__dev / tsapi)
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
"""
import sys
import json
import argparse
from pathlib import Path
from typing import Dict
import psycopg2
SQL_DIR = Path(__file__).parent / "sql"
CONFIG_FILE = Path(__file__).parent / "db_config.json"
# Executed in order. 08_script_log.sql and sql/migrate/*.sql are auxiliary and
# are intentionally NOT part of a fresh install.
SQL_FILES = [
"00_extensions.sql",
"01_schemas.sql",
"02_sequences.sql",
"03_tables.sql",
"04_indexes.sql",
"05_procedures.sql",
"06_triggers.sql",
"07_reference_data.sql",
]
DEFAULTS = {
"host": "localhost",
"port": 5432,
"database": "timeseries_db__dev",
"user": "tsapi",
"password": "tsapipwd",
}
def load_config(args) -> Dict:
config = dict(DEFAULTS)
if CONFIG_FILE.exists():
try:
config.update(json.loads(CONFIG_FILE.read_text()))
except json.JSONDecodeError as e:
print(f"[FAIL] Invalid JSON in {CONFIG_FILE}: {e}")
sys.exit(1)
# CLI overrides
for key, val in (
("host", args.host), ("port", args.port), ("database", args.dbname),
("user", args.user), ("password", args.password),
):
if val is not None:
config[key] = val
return config
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()
return True, f"[OK] Executed {sql_file.name}"
except Exception as e:
conn.rollback()
return False, f"[FAIL] {sql_file.name}: {e}"
def verify_setup(conn) -> None:
print("\n" + "=" * 70)
print("Verifying setup")
print("=" * 70)
with conn.cursor() as cur:
cur.execute(
"SELECT schema_name FROM information_schema.schemata "
"WHERE schema_name IN ('meta','series','log') ORDER BY 1"
)
print("[OK] Schemas:", ", ".join(r[0] for r in cur.fetchall()))
cur.execute("SELECT count(*) FROM information_schema.tables WHERE table_schema='meta'")
print("[OK] meta tables:", cur.fetchone()[0])
cur.execute("SELECT count(*) FROM information_schema.routines WHERE routine_schema IN ('meta','log')")
print("[OK] procedures:", cur.fetchone()[0])
cur.execute("SELECT count(*) FROM pg_event_trigger WHERE evtname='prevent_series_drops'")
print("[OK] event triggers:", cur.fetchone()[0])
cur.execute("SELECT count(*) FROM meta.timezones")
tz = cur.fetchone()[0]
cur.execute("SELECT count(*) FROM meta.currencies")
cu = cur.fetchone()[0]
print(f"[OK] reference data: {tz} timezones, {cu} currencies")
cur.execute("SELECT version, description FROM meta.schema_version ORDER BY installed_at DESC LIMIT 1")
row = cur.fetchone()
if row:
print(f"[OK] schema_version: {row[0]} ({row[1]})")
def clean_slate(conn) -> None:
print("Performing clean slate...")
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(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")
def main() -> int:
p = argparse.ArgumentParser(description="Provision the TSAPI series DB schema")
p.add_argument("--host")
p.add_argument("--port", type=int)
p.add_argument("--dbname")
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)")
p.add_argument("--yes", action="store_true", help="Skip confirmation prompts")
args = p.parse_args()
cfg = load_config(args)
print("=" * 70)
print("TSAPI series DB schema setup")
print("=" * 70)
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 input(f"This will DROP {what}. Continue? (yes/no): ").lower() != "yes":
print("Aborted.")
return 1
try:
conn = psycopg2.connect(
host=cfg["host"], port=cfg["port"], dbname=cfg["database"],
user=cfg["user"], password=cfg["password"],
)
except Exception as e:
print(f"[FAIL] connect: {e}")
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")
failed = []
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)
print(msg)
if not ok:
failed.append(name)
if failed:
print(f"\n[FAIL] {len(failed)} file(s) failed: {', '.join(failed)}")
return 1
verify_setup(conn)
print("\n[SUCCESS] schema setup complete")
return 0
finally:
conn.close()
if __name__ == "__main__":
sys.exit(main())