-- Google Fit local archive — SQLite schema (source-agnostic). -- -- Design note: one normalized store serves BOTH ingestion paths — the Google Fit -- REST API adapter and the Google Takeout importer (deprecation hedge, see README). -- Time-series points, workout/sleep sessions, and sleep stages each get a table; -- every write is an idempotent UPSERT keyed on the natural Fit identity of the row, -- so re-running a sync over an overlapping window never duplicates data. -- -- Why SQLite and not InfluxDB: single-user, daily-cadence health data is low volume -- (thousands of rows/day at most); the Agap/OpenClaw storage doctrine is SQLite-only; -- and a normalized relational store answers the "sessions + metadata + series" query -- mix better than a pure TSDB would. Adding an always-on InfluxDB service would be -- operational cost with no payoff at this scale. (Task listed both as options.) PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; -- Time-series metric points: steps, heart rate, calories, distance, weight, etc. -- One row per (metric, interval, source). `metric` is our normalized name -- (e.g. 'steps', 'heart_rate_avg'), decoupled from Google's data type strings. CREATE TABLE IF NOT EXISTS data_points ( metric TEXT NOT NULL, -- normalized: steps, heart_rate_avg, calories, ... data_type_name TEXT NOT NULL, -- raw Google Fit data type (provenance) start_ns INTEGER NOT NULL, -- interval start, epoch nanoseconds end_ns INTEGER NOT NULL, -- interval end, epoch nanoseconds start_time TEXT NOT NULL, -- ISO8601 UTC (human/SQL friendly) end_time TEXT NOT NULL, value_int INTEGER, -- populated for integer metrics value_float REAL, -- populated for float metrics value_str TEXT, -- populated for string/enum metrics unit TEXT, -- count, bpm, kcal, m, kg, min, ... data_source_id TEXT NOT NULL DEFAULT '', -- originating Fit stream (may be '') source TEXT NOT NULL DEFAULT 'ha', -- ha | takeout ingested_at TEXT NOT NULL, PRIMARY KEY (metric, start_ns, end_ns, data_source_id) ); CREATE INDEX IF NOT EXISTS idx_dp_metric_time ON data_points (metric, start_ns); CREATE INDEX IF NOT EXISTS idx_dp_start_time ON data_points (start_time); -- Workouts / activities / sleep sessions (com.google.session). CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, -- Fit session id (stable, upsert key) name TEXT, description TEXT, activity_type INTEGER, -- Fit activity type enum activity_name TEXT, -- resolved label (e.g. 'Running', 'Sleep') start_ns INTEGER NOT NULL, end_ns INTEGER NOT NULL, start_time TEXT NOT NULL, end_time TEXT NOT NULL, modified_ns INTEGER, application TEXT, -- packageName that wrote the session source TEXT NOT NULL DEFAULT 'ha', raw_json TEXT, -- full session payload for reprocessing ingested_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_sessions_start ON sessions (start_ns); CREATE INDEX IF NOT EXISTS idx_sessions_type ON sessions (activity_type); -- Sleep stage segments (com.google.sleep.segment). Stage enum decoded to a label. CREATE TABLE IF NOT EXISTS sleep_segments ( start_ns INTEGER NOT NULL, end_ns INTEGER NOT NULL, start_time TEXT NOT NULL, end_time TEXT NOT NULL, stage INTEGER NOT NULL, -- raw Fit sleep-stage enum stage_name TEXT NOT NULL, -- awake, light, deep, rem, out_of_bed, sleep data_source_id TEXT NOT NULL DEFAULT '', source TEXT NOT NULL DEFAULT 'ha', ingested_at TEXT NOT NULL, PRIMARY KEY (start_ns, end_ns, data_source_id) ); CREATE INDEX IF NOT EXISTS idx_sleep_start ON sleep_segments (start_ns); -- Incremental-sync cursor per stream. `last_synced_ns` is the high-water mark -- (max end_ns fetched); the next run resumes from there minus a small overlap. CREATE TABLE IF NOT EXISTS sync_state ( stream_key TEXT PRIMARY KEY, -- metric name, 'sessions', or 'sleep' last_synced_ns INTEGER NOT NULL DEFAULT 0, last_run_at TEXT, last_status TEXT, -- ok | error last_error TEXT ); -- Audit log of ingestion runs (observability; Zabbix can later read staleness). CREATE TABLE IF NOT EXISTS ingest_runs ( id INTEGER PRIMARY KEY AUTOINCREMENT, started_at TEXT NOT NULL, finished_at TEXT, status TEXT, -- ok | error points_upserted INTEGER DEFAULT 0, sessions_upserted INTEGER DEFAULT 0, segments_upserted INTEGER DEFAULT 0, error TEXT );