""" Pluggable data layer for Garmin Health Lab. Supports both SQLite (stdlib, local dev) and MariaDB (PyMySQL, NAS production) through a single unified API: init_db() -> create tables if missing execute(sql, params) -> INSERT/UPDATE/DELETE, returns {id, changes} query_one(sql, params) -> one row as dict or None query_all(sql, params) -> list of row dicts Both backends accept `?` placeholders; the SQL is translated to `%s` for MariaDB automatically. Upserts must use backend-specific SQL (see services). """ import os import sqlite3 import threading import queue import datetime from config import ( DB_TYPE, SQLITE_PATH, MARIADB_SOCKET, MARIADB_HOST, MARIADB_PORT, MARIADB_USER, MARIADB_PASSWORD, MARIADB_DATABASE, ) SCHEMA = """ CREATE TABLE IF NOT EXISTS users ( id VARCHAR(64) PRIMARY KEY, email VARCHAR(255) NOT NULL UNIQUE, garmin_email VARCHAR(255) NOT NULL, garmin_password_hash TEXT NOT NULL, jwt_token TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS health_data ( id VARCHAR(64) PRIMARY KEY, user_id VARCHAR(64) NOT NULL, date DATE NOT NULL, steps INT, heart_rate INT, heart_rate_variability DOUBLE, blood_pressure_systolic INT, blood_pressure_diastolic INT, sleep_duration INT, sleep_quality DOUBLE, stress INT, calories_burned DOUBLE, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(user_id, date), FOREIGN KEY (user_id) REFERENCES users(id) ); CREATE TABLE IF NOT EXISTS activities ( id VARCHAR(64) PRIMARY KEY, user_id VARCHAR(64) NOT NULL, activity_type VARCHAR(255) NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, duration INT, distance DOUBLE, calories DOUBLE, heart_rate_average INT, heart_rate_max INT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ); CREATE TABLE IF NOT EXISTS sync_status ( user_id VARCHAR(64) PRIMARY KEY, last_sync_time DATETIME, status VARCHAR(32) DEFAULT 'idle', last_error TEXT, records_synced INT DEFAULT 0, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ); -- One cached LLM answer per user. Generating one takes minutes against a -- large reasoning model, which is far too slow to sit in a page load, so the -- result is stored and reused until the underlying data changes. -- `fingerprint` identifies the health data the advice was derived from. CREATE TABLE IF NOT EXISTS ai_recommendations ( user_id VARCHAR(64) PRIMARY KEY, fingerprint VARCHAR(64) NOT NULL, model VARCHAR(64), upstream VARCHAR(64), days INT, payload TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ); """ # --- MariaDB pool (lazy) ---------------------------------------------------- _mariadb_pool = None _pool_lock = threading.Lock() def _new_mariadb_conn(): import pymysql from pymysql.cursors import DictCursor kwargs = dict( user=MARIADB_USER, password=MARIADB_PASSWORD, database=MARIADB_DATABASE, charset="utf8mb4", autocommit=True, cursorclass=DictCursor, connect_timeout=10, ) if MARIADB_SOCKET: kwargs["unix_socket"] = MARIADB_SOCKET else: kwargs["host"] = MARIADB_HOST kwargs["port"] = MARIADB_PORT return pymysql.connect(**kwargs) def _mariadb_acquire(): global _mariadb_pool if _mariadb_pool is None: with _pool_lock: if _mariadb_pool is None: _mariadb_pool = queue.Queue(maxsize=10) for _ in range(10): _mariadb_pool.put(_new_mariadb_conn()) try: return _mariadb_pool.get(block=False) except queue.Empty: return _new_mariadb_conn() def _mariadb_release(conn): try: conn.ping(reconnect=False) _mariadb_pool.put(conn) except Exception: try: conn.close() except Exception: pass # --- SQLite connection ------------------------------------------------------ def _sqlite_connect(): data_dir = os.path.dirname(SQLITE_PATH) if data_dir and not os.path.exists(data_dir): os.makedirs(data_dir, exist_ok=True) conn = sqlite3.connect(SQLITE_PATH, isolation_level=None) conn.row_factory = sqlite3.Row conn.execute("PRAGMA foreign_keys = ON") return conn def _connect(): if DB_TYPE == "mariadb": return _mariadb_acquire() return _sqlite_connect() def _disconnect(conn): if DB_TYPE == "mariadb": _mariadb_release(conn) else: conn.close() def _adapt_sql(sql): # pymysql uses %s placeholders; sqlite3 uses ?. Business code writes ?. return sql.replace("?", "%s") if DB_TYPE == "mariadb" else sql def _serialize(value): if value is None: return None if isinstance(value, (datetime.datetime, datetime.date)): return value.isoformat() return value def _row_to_dict(row): if row is None: return None if isinstance(row, dict): return {k: _serialize(v) for k, v in row.items()} return {k: _serialize(row[k]) for k in row.keys()} # --- Public API ------------------------------------------------------------- def init_db(): conn = _connect() try: cur = conn.cursor() for stmt in SCHEMA.split(";"): stmt = stmt.strip() if not stmt: continue cur.execute(_adapt_sql(stmt)) finally: _disconnect(conn) def execute(sql, params=None): params = params or [] conn = _connect() try: cur = conn.cursor() cur.execute(_adapt_sql(sql), params) return {"id": cur.lastrowid, "changes": cur.rowcount} finally: _disconnect(conn) def query_one(sql, params=None): params = params or [] conn = _connect() try: cur = conn.cursor() cur.execute(_adapt_sql(sql), params) return _row_to_dict(cur.fetchone()) finally: _disconnect(conn) def query_all(sql, params=None): params = params or [] conn = _connect() try: cur = conn.cursor() cur.execute(_adapt_sql(sql), params) return [_row_to_dict(r) for r in cur.fetchall()] finally: _disconnect(conn)