garsync 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
garsync/client.py ADDED
@@ -0,0 +1,169 @@
1
+ import datetime
2
+ import logging
3
+ import os
4
+ from typing import List, Optional
5
+
6
+ from garminconnect import Garmin
7
+ from tenacity import retry, stop_after_attempt, wait_exponential
8
+
9
+ from garsync.models import DailyBiometrics, NormalizedActivity, SleepData
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class GarminClient:
15
+ """Wrapper for Garmin Connect API with token caching support."""
16
+
17
+ def __init__(self, email: str, password: str, token_store: Optional[str] = None):
18
+ self.email = email
19
+ self.password = password
20
+ self.token_store = token_store
21
+ self.client = Garmin(self.email, self.password)
22
+
23
+ @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10))
24
+ def login(self) -> None:
25
+ """Authenticate with Garmin Connect using tokens or credentials."""
26
+ try:
27
+ if self.token_store and os.path.exists(self.token_store):
28
+ logger.info(f"Attempting to login using tokens from {self.token_store}...")
29
+ self.client.login(self.token_store)
30
+ logger.info("Token-based authentication successful.")
31
+ else:
32
+ logger.info("Authenticating with Garmin Connect credentials...")
33
+ self.client.login()
34
+ logger.info("Authentication successful.")
35
+ if self.token_store:
36
+ # Save tokens for next time
37
+ os.makedirs(os.path.dirname(self.token_store), exist_ok=True)
38
+ self.client.garth.save(self.token_store)
39
+ logger.info(f"Session tokens saved to {self.token_store}")
40
+ except Exception as e:
41
+ logger.error(f"Failed to authenticate: {e}")
42
+ # If token login fails, try one more time with credentials (if they exist)
43
+ if self.token_store and os.path.exists(self.token_store):
44
+ logger.warning("Token login failed, falling back to credentials...")
45
+ try:
46
+ self.client.login()
47
+ if self.token_store:
48
+ self.client.garth.save(self.token_store)
49
+ return
50
+ except Exception as cred_err:
51
+ logger.error(f"Fallback credential login also failed: {cred_err}")
52
+ raise
53
+
54
+ @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10))
55
+ def fetch_activities(self, start: int = 0, limit: int = 100) -> List[NormalizedActivity]:
56
+ logger.info(f"Fetching activities (start={start}, limit={limit})...")
57
+ raw_activities = self.client.get_activities(start, limit)
58
+ activities = []
59
+ for raw in raw_activities:
60
+ try:
61
+ # Convert timestamp from Garmin format
62
+ start_time_str = raw.get("startTimeLocal", raw.get("startTimeGMT"))
63
+ start_time = (
64
+ datetime.datetime.fromisoformat(start_time_str) if start_time_str else None
65
+ )
66
+
67
+ activities.append(
68
+ NormalizedActivity(
69
+ activity_id=raw.get("activityId"),
70
+ activity_name=raw.get("activityName"),
71
+ activity_type=raw.get("activityType", {}).get("typeKey"),
72
+ start_time=start_time,
73
+ duration_seconds=raw.get("duration", 0.0),
74
+ distance_meters=raw.get("distance"),
75
+ average_heart_rate=raw.get("averageHR"),
76
+ max_heart_rate=raw.get("maxHR"),
77
+ calories=raw.get("calories"),
78
+ raw_data=raw,
79
+ )
80
+ )
81
+ except Exception as e:
82
+ logger.warning(f"Failed to parse activity {raw.get('activityId')}: {e}")
83
+ return activities
84
+
85
+ @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10))
86
+ def fetch_biometrics(self, date: datetime.date) -> DailyBiometrics:
87
+ logger.info(f"Fetching biometrics for {date}...")
88
+ try:
89
+ date_str = date.isoformat()
90
+ hr_data = self.client.get_heart_rates(date_str)
91
+ bb_data = self.client.get_body_battery(date_str)
92
+ stress_data = self.client.get_all_day_stress(date_str)
93
+ hrv_data = self.client.get_hrv_data(date_str)
94
+
95
+ # Helpers to safely extract data whether it's a list or dict
96
+ def get_dict(data):
97
+ if isinstance(data, list):
98
+ return data[0] if data else {}
99
+ return data or {}
100
+
101
+ hr_dict = get_dict(hr_data)
102
+ bb_dict = get_dict(bb_data)
103
+ stress_dict = get_dict(stress_data)
104
+ hrv_dict = get_dict(hrv_data)
105
+
106
+ # Simple aggregations
107
+ rhr = hr_dict.get("restingHeartRate")
108
+
109
+ bb_values = [v for t, v in bb_dict.get("bodyBatteryValues", []) if v] if bb_dict else []
110
+ bb_highest = max(bb_values) if bb_values else None
111
+ bb_lowest = min(bb_values) if bb_values else None
112
+
113
+ stress_avg = stress_dict.get("averageStressLevel")
114
+ hrv_bal = hrv_dict.get("hrvSummary", {}).get("baselineStatus")
115
+
116
+ return DailyBiometrics(
117
+ date=date,
118
+ resting_heart_rate=rhr,
119
+ hrv_balance=hrv_bal,
120
+ body_battery_highest=bb_highest,
121
+ body_battery_lowest=bb_lowest,
122
+ stress_average=stress_avg,
123
+ raw_data={
124
+ "hr": hr_data,
125
+ "bb": bb_data,
126
+ "stress": stress_data,
127
+ "hrv": hrv_data,
128
+ },
129
+ )
130
+ except Exception as e:
131
+ logger.error(f"Failed to fetch biometrics for {date}: {e}")
132
+ raise
133
+
134
+ @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10))
135
+ def fetch_sleep(self, date: datetime.date) -> SleepData:
136
+ logger.info(f"Fetching sleep data for {date}...")
137
+ try:
138
+ sleep_data = self.client.get_sleep_data(date.isoformat())
139
+
140
+ # Check if sleep data is available
141
+ daily_sleep = sleep_data.get("dailySleepDTO", {})
142
+ if not daily_sleep:
143
+ return SleepData(date=date, raw_data=sleep_data)
144
+
145
+ sleep_start = None
146
+ if start_timestamp := daily_sleep.get("sleepStartTimestampGMT"):
147
+ sleep_start = datetime.datetime.fromtimestamp(
148
+ start_timestamp / 1000.0, datetime.UTC
149
+ )
150
+
151
+ sleep_end = None
152
+ if end_timestamp := daily_sleep.get("sleepEndTimestampGMT"):
153
+ sleep_end = datetime.datetime.fromtimestamp(end_timestamp / 1000.0, datetime.UTC)
154
+
155
+ return SleepData(
156
+ date=date,
157
+ sleep_start=sleep_start,
158
+ sleep_end=sleep_end,
159
+ total_sleep_seconds=daily_sleep.get("sleepTimeSeconds"),
160
+ deep_sleep_seconds=daily_sleep.get("deepSleepSeconds"),
161
+ light_sleep_seconds=daily_sleep.get("lightSleepSeconds"),
162
+ rem_sleep_seconds=daily_sleep.get("remSleepSeconds"),
163
+ awake_sleep_seconds=daily_sleep.get("awakeSleepSeconds"),
164
+ sleep_score=sleep_data.get("sleepScore", {}).get("value"),
165
+ raw_data=sleep_data,
166
+ )
167
+ except Exception as e:
168
+ logger.error(f"Failed to fetch sleep for {date}: {e}")
169
+ raise
garsync/db/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ """Database package for garsync — SQLite persistence layer."""
2
+
3
+ from garsync.db.connection import get_connection
4
+ from garsync.db.repository import (
5
+ ActivityRepository,
6
+ BiometricsRepository,
7
+ SleepRepository,
8
+ SyncLogRepository,
9
+ )
10
+ from garsync.db.schema import CURRENT_VERSION, init_db
11
+
12
+ __all__ = [
13
+ "get_connection",
14
+ "init_db",
15
+ "CURRENT_VERSION",
16
+ "ActivityRepository",
17
+ "BiometricsRepository",
18
+ "SleepRepository",
19
+ "SyncLogRepository",
20
+ ]
@@ -0,0 +1,19 @@
1
+ """SQLite connection management with WAL mode and Row factory."""
2
+
3
+ import sqlite3
4
+
5
+
6
+ def get_connection(db_path: str) -> sqlite3.Connection:
7
+ """Create a SQLite connection with WAL mode and Row factory.
8
+
9
+ Args:
10
+ db_path: Path to the database file, or ":memory:" for in-memory.
11
+
12
+ Returns:
13
+ Configured sqlite3.Connection.
14
+ """
15
+ conn = sqlite3.connect(db_path, check_same_thread=False)
16
+ conn.row_factory = sqlite3.Row
17
+ conn.execute("PRAGMA journal_mode=WAL")
18
+ conn.execute("PRAGMA foreign_keys=ON")
19
+ return conn
@@ -0,0 +1,400 @@
1
+ """Repository classes for garsync SQLite tables.
2
+
3
+ Each repository wraps a single table with upsert, query, and count operations.
4
+ Connection is injected — the caller manages lifecycle and transactions.
5
+ """
6
+
7
+ import sqlite3
8
+ from typing import Any, Optional
9
+
10
+
11
+ class ActivityRepository:
12
+ """CRUD operations for the activities table."""
13
+
14
+ def __init__(self, conn: sqlite3.Connection) -> None:
15
+ self._conn = conn
16
+
17
+ def upsert(self, row: dict[str, Any]) -> None:
18
+ """Insert or update an activity by activity_id."""
19
+ self._conn.execute(
20
+ """
21
+ INSERT INTO activities (
22
+ activity_id, activity_name, activity_type, start_time,
23
+ duration_seconds, distance_meters, average_heart_rate,
24
+ max_heart_rate, calories, raw_data
25
+ ) VALUES (
26
+ :activity_id, :activity_name, :activity_type, :start_time,
27
+ :duration_seconds, :distance_meters, :average_heart_rate,
28
+ :max_heart_rate, :calories, :raw_data
29
+ )
30
+ ON CONFLICT(activity_id) DO UPDATE SET
31
+ activity_name = excluded.activity_name,
32
+ activity_type = excluded.activity_type,
33
+ start_time = excluded.start_time,
34
+ duration_seconds = excluded.duration_seconds,
35
+ distance_meters = excluded.distance_meters,
36
+ average_heart_rate = excluded.average_heart_rate,
37
+ max_heart_rate = excluded.max_heart_rate,
38
+ calories = excluded.calories,
39
+ raw_data = excluded.raw_data,
40
+ updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
41
+ """,
42
+ row,
43
+ )
44
+ self._conn.commit()
45
+
46
+ def upsert_batch(self, rows: list[dict[str, Any]]) -> None:
47
+ """Upsert multiple activities in a single transaction."""
48
+ for row in rows:
49
+ self._conn.execute(
50
+ """
51
+ INSERT INTO activities (
52
+ activity_id, activity_name, activity_type, start_time,
53
+ duration_seconds, distance_meters, average_heart_rate,
54
+ max_heart_rate, calories, raw_data
55
+ ) VALUES (
56
+ :activity_id, :activity_name, :activity_type, :start_time,
57
+ :duration_seconds, :distance_meters, :average_heart_rate,
58
+ :max_heart_rate, :calories, :raw_data
59
+ )
60
+ ON CONFLICT(activity_id) DO UPDATE SET
61
+ activity_name = excluded.activity_name,
62
+ activity_type = excluded.activity_type,
63
+ start_time = excluded.start_time,
64
+ duration_seconds = excluded.duration_seconds,
65
+ distance_meters = excluded.distance_meters,
66
+ average_heart_rate = excluded.average_heart_rate,
67
+ max_heart_rate = excluded.max_heart_rate,
68
+ calories = excluded.calories,
69
+ raw_data = excluded.raw_data,
70
+ updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
71
+ """,
72
+ row,
73
+ )
74
+ self._conn.commit()
75
+
76
+ def get_by_id(self, activity_id: int) -> Optional[sqlite3.Row]:
77
+ """Get a single activity by ID, or None."""
78
+ cursor = self._conn.execute(
79
+ "SELECT * FROM activities WHERE activity_id = ?", (activity_id,)
80
+ )
81
+ row: Optional[sqlite3.Row] = cursor.fetchone()
82
+ return row
83
+
84
+ def get_all(self, limit: int = 1000) -> list[sqlite3.Row]:
85
+ """Get all activities, ordered by start_time DESC."""
86
+ cursor = self._conn.execute(
87
+ "SELECT * FROM activities ORDER BY start_time DESC LIMIT ?", (limit,)
88
+ )
89
+ result: list[sqlite3.Row] = cursor.fetchall()
90
+ return result
91
+
92
+ def count(self) -> int:
93
+ """Return total number of stored activities."""
94
+ cursor = self._conn.execute("SELECT COUNT(*) as cnt FROM activities")
95
+ cnt: int = cursor.fetchone()["cnt"]
96
+ return cnt
97
+
98
+ def get_paginated(
99
+ self,
100
+ page: int = 1,
101
+ limit: int = 20,
102
+ start_date: Optional[str] = None,
103
+ end_date: Optional[str] = None,
104
+ activity_type: Optional[str] = None,
105
+ ) -> tuple[list[sqlite3.Row], int]:
106
+ """Get activities with pagination and optional filters.
107
+
108
+ Returns (rows, total_count).
109
+ """
110
+ where_clauses: list[str] = []
111
+ params: list[Any] = []
112
+
113
+ if start_date:
114
+ where_clauses.append("date(start_time) >= ?")
115
+ params.append(start_date)
116
+ if end_date:
117
+ where_clauses.append("date(start_time) <= ?")
118
+ params.append(end_date)
119
+ if activity_type:
120
+ where_clauses.append("activity_type = ?")
121
+ params.append(activity_type)
122
+
123
+ where_sql = ""
124
+ if where_clauses:
125
+ where_sql = "WHERE " + " AND ".join(where_clauses)
126
+
127
+ count_cursor = self._conn.execute(
128
+ f"SELECT COUNT(*) as cnt FROM activities {where_sql}", # noqa: S608
129
+ params,
130
+ )
131
+ total: int = count_cursor.fetchone()["cnt"]
132
+
133
+ offset = (page - 1) * limit
134
+ cursor = self._conn.execute(
135
+ f"SELECT * FROM activities {where_sql} " # noqa: S608
136
+ "ORDER BY start_time DESC LIMIT ? OFFSET ?",
137
+ [*params, limit, offset],
138
+ )
139
+ rows: list[sqlite3.Row] = cursor.fetchall()
140
+ return rows, total
141
+
142
+ def get_heatmap_data(
143
+ self,
144
+ year: int,
145
+ activity_type: Optional[str] = None,
146
+ ) -> list[sqlite3.Row]:
147
+ """Get per-day activity aggregates for a calendar year."""
148
+ params: list[Any] = [str(year)]
149
+ type_filter = ""
150
+ if activity_type:
151
+ type_filter = "AND activity_type = ?"
152
+ params.append(activity_type)
153
+
154
+ cursor = self._conn.execute(
155
+ f"""
156
+ SELECT
157
+ date(start_time) as date,
158
+ COUNT(*) as activity_count,
159
+ SUM(duration_seconds) as total_duration,
160
+ SUM(calories) as total_calories
161
+ FROM activities
162
+ WHERE strftime('%Y', start_time) = ?
163
+ {type_filter}
164
+ GROUP BY date(start_time)
165
+ ORDER BY date(start_time)
166
+ """, # noqa: S608
167
+ params,
168
+ )
169
+ result: list[sqlite3.Row] = cursor.fetchall()
170
+ return result
171
+
172
+ def get_summary_stats(self, start_date: str, end_date: str) -> Optional[sqlite3.Row]:
173
+ """Get aggregate stats for a date range."""
174
+ cursor = self._conn.execute(
175
+ """
176
+ SELECT
177
+ COUNT(*) as total_activities,
178
+ COALESCE(SUM(duration_seconds), 0) as total_duration_seconds,
179
+ COALESCE(SUM(distance_meters), 0) as total_distance_meters,
180
+ COALESCE(SUM(calories), 0) as total_calories,
181
+ AVG(duration_seconds) as avg_duration_seconds,
182
+ AVG(distance_meters) as avg_distance_meters,
183
+ AVG(average_heart_rate) as avg_heart_rate
184
+ FROM activities
185
+ WHERE date(start_time) >= ? AND date(start_time) <= ?
186
+ """,
187
+ (start_date, end_date),
188
+ )
189
+ row: Optional[sqlite3.Row] = cursor.fetchone()
190
+ return row
191
+
192
+
193
+ class BiometricsRepository:
194
+ """CRUD operations for the biometrics table."""
195
+
196
+ def __init__(self, conn: sqlite3.Connection) -> None:
197
+ self._conn = conn
198
+
199
+ def upsert(self, row: dict[str, Any]) -> None:
200
+ """Insert or update biometrics by date."""
201
+ self._conn.execute(
202
+ """
203
+ INSERT INTO biometrics (
204
+ date, resting_heart_rate, hrv_balance,
205
+ body_battery_highest, body_battery_lowest,
206
+ stress_average, raw_data
207
+ ) VALUES (
208
+ :date, :resting_heart_rate, :hrv_balance,
209
+ :body_battery_highest, :body_battery_lowest,
210
+ :stress_average, :raw_data
211
+ )
212
+ ON CONFLICT(date) DO UPDATE SET
213
+ resting_heart_rate = excluded.resting_heart_rate,
214
+ hrv_balance = excluded.hrv_balance,
215
+ body_battery_highest = excluded.body_battery_highest,
216
+ body_battery_lowest = excluded.body_battery_lowest,
217
+ stress_average = excluded.stress_average,
218
+ raw_data = excluded.raw_data,
219
+ updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
220
+ """,
221
+ row,
222
+ )
223
+ self._conn.commit()
224
+
225
+ def get_by_date(self, date: str) -> Optional[sqlite3.Row]:
226
+ """Get biometrics for a specific date (ISO format string)."""
227
+ cursor = self._conn.execute("SELECT * FROM biometrics WHERE date = ?", (date,))
228
+ row: Optional[sqlite3.Row] = cursor.fetchone()
229
+ return row
230
+
231
+ def get_latest_date(self) -> Optional[str]:
232
+ """Return the most recent date with biometrics data, or None."""
233
+ cursor = self._conn.execute("SELECT date FROM biometrics ORDER BY date DESC LIMIT 1")
234
+ row = cursor.fetchone()
235
+ result: Optional[str] = row["date"] if row else None
236
+ return result
237
+
238
+ def count(self) -> int:
239
+ """Return total number of stored biometrics rows."""
240
+ cursor = self._conn.execute("SELECT COUNT(*) as cnt FROM biometrics")
241
+ cnt: int = cursor.fetchone()["cnt"]
242
+ return cnt
243
+
244
+ def get_by_date_range(self, start_date: str, end_date: str) -> list[sqlite3.Row]:
245
+ """Get biometrics rows within a date range (inclusive)."""
246
+ cursor = self._conn.execute(
247
+ "SELECT * FROM biometrics WHERE date >= ? AND date <= ? ORDER BY date",
248
+ (start_date, end_date),
249
+ )
250
+ result: list[sqlite3.Row] = cursor.fetchall()
251
+ return result
252
+
253
+ def get_avg_stats(self, start_date: str, end_date: str) -> Optional[sqlite3.Row]:
254
+ """Get average biometrics for a date range."""
255
+ cursor = self._conn.execute(
256
+ """
257
+ SELECT
258
+ COUNT(*) as record_count,
259
+ AVG(resting_heart_rate) as avg_resting_heart_rate,
260
+ AVG(stress_average) as avg_stress,
261
+ AVG(body_battery_highest) as avg_body_battery_high,
262
+ AVG(body_battery_lowest) as avg_body_battery_low
263
+ FROM biometrics
264
+ WHERE date >= ? AND date <= ?
265
+ """,
266
+ (start_date, end_date),
267
+ )
268
+ row: Optional[sqlite3.Row] = cursor.fetchone()
269
+ return row
270
+
271
+
272
+ class SleepRepository:
273
+ """CRUD operations for the sleep table."""
274
+
275
+ def __init__(self, conn: sqlite3.Connection) -> None:
276
+ self._conn = conn
277
+
278
+ def upsert(self, row: dict[str, Any]) -> None:
279
+ """Insert or update sleep data by date."""
280
+ self._conn.execute(
281
+ """
282
+ INSERT INTO sleep (
283
+ date, sleep_start, sleep_end, total_sleep_seconds,
284
+ deep_sleep_seconds, light_sleep_seconds, rem_sleep_seconds,
285
+ awake_sleep_seconds, sleep_score, raw_data
286
+ ) VALUES (
287
+ :date, :sleep_start, :sleep_end, :total_sleep_seconds,
288
+ :deep_sleep_seconds, :light_sleep_seconds, :rem_sleep_seconds,
289
+ :awake_sleep_seconds, :sleep_score, :raw_data
290
+ )
291
+ ON CONFLICT(date) DO UPDATE SET
292
+ sleep_start = excluded.sleep_start,
293
+ sleep_end = excluded.sleep_end,
294
+ total_sleep_seconds = excluded.total_sleep_seconds,
295
+ deep_sleep_seconds = excluded.deep_sleep_seconds,
296
+ light_sleep_seconds = excluded.light_sleep_seconds,
297
+ rem_sleep_seconds = excluded.rem_sleep_seconds,
298
+ awake_sleep_seconds = excluded.awake_sleep_seconds,
299
+ sleep_score = excluded.sleep_score,
300
+ raw_data = excluded.raw_data,
301
+ updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
302
+ """,
303
+ row,
304
+ )
305
+ self._conn.commit()
306
+
307
+ def get_by_date(self, date: str) -> Optional[sqlite3.Row]:
308
+ """Get sleep data for a specific date (ISO format string)."""
309
+ cursor = self._conn.execute("SELECT * FROM sleep WHERE date = ?", (date,))
310
+ row: Optional[sqlite3.Row] = cursor.fetchone()
311
+ return row
312
+
313
+ def get_latest_date(self) -> Optional[str]:
314
+ """Return the most recent date with sleep data, or None."""
315
+ cursor = self._conn.execute("SELECT date FROM sleep ORDER BY date DESC LIMIT 1")
316
+ row = cursor.fetchone()
317
+ result: Optional[str] = row["date"] if row else None
318
+ return result
319
+
320
+ def count(self) -> int:
321
+ """Return total number of stored sleep rows."""
322
+ cursor = self._conn.execute("SELECT COUNT(*) as cnt FROM sleep")
323
+ cnt: int = cursor.fetchone()["cnt"]
324
+ return cnt
325
+
326
+ def get_by_date_range(self, start_date: str, end_date: str) -> list[sqlite3.Row]:
327
+ """Get sleep rows within a date range (inclusive)."""
328
+ cursor = self._conn.execute(
329
+ "SELECT * FROM sleep WHERE date >= ? AND date <= ? ORDER BY date",
330
+ (start_date, end_date),
331
+ )
332
+ result: list[sqlite3.Row] = cursor.fetchall()
333
+ return result
334
+
335
+ def get_avg_stats(self, start_date: str, end_date: str) -> Optional[sqlite3.Row]:
336
+ """Get average sleep stats for a date range."""
337
+ cursor = self._conn.execute(
338
+ """
339
+ SELECT
340
+ COUNT(*) as record_count,
341
+ AVG(total_sleep_seconds) as avg_sleep_seconds,
342
+ AVG(deep_sleep_seconds) as avg_deep_sleep_seconds,
343
+ AVG(light_sleep_seconds) as avg_light_sleep_seconds,
344
+ AVG(rem_sleep_seconds) as avg_rem_sleep_seconds,
345
+ AVG(sleep_score) as avg_sleep_score
346
+ FROM sleep
347
+ WHERE date >= ? AND date <= ?
348
+ """,
349
+ (start_date, end_date),
350
+ )
351
+ row: Optional[sqlite3.Row] = cursor.fetchone()
352
+ return row
353
+
354
+
355
+ class SyncLogRepository:
356
+ """Append-only audit trail for sync operations."""
357
+
358
+ def __init__(self, conn: sqlite3.Connection) -> None:
359
+ self._conn = conn
360
+
361
+ def log(
362
+ self,
363
+ sync_type: str,
364
+ records_synced: int,
365
+ status: str = "success",
366
+ error_message: Optional[str] = None,
367
+ ) -> None:
368
+ """Append a sync log entry."""
369
+ self._conn.execute(
370
+ """
371
+ INSERT INTO sync_log (sync_type, records_synced, status, error_message)
372
+ VALUES (?, ?, ?, ?)
373
+ """,
374
+ (sync_type, records_synced, status, error_message),
375
+ )
376
+ self._conn.commit()
377
+
378
+ def get_latest(self, sync_type: Optional[str] = None) -> Optional[sqlite3.Row]:
379
+ """Get the most recent sync log entry, optionally filtered by type."""
380
+ if sync_type:
381
+ cursor = self._conn.execute(
382
+ "SELECT * FROM sync_log WHERE sync_type = ? ORDER BY id DESC LIMIT 1",
383
+ (sync_type,),
384
+ )
385
+ else:
386
+ cursor = self._conn.execute("SELECT * FROM sync_log ORDER BY id DESC LIMIT 1")
387
+ row: Optional[sqlite3.Row] = cursor.fetchone()
388
+ return row
389
+
390
+ def get_all(self, limit: int = 100) -> list[sqlite3.Row]:
391
+ """Get recent sync log entries."""
392
+ cursor = self._conn.execute("SELECT * FROM sync_log ORDER BY id DESC LIMIT ?", (limit,))
393
+ result: list[sqlite3.Row] = cursor.fetchall()
394
+ return result
395
+
396
+ def count(self) -> int:
397
+ """Return total number of sync log entries."""
398
+ cursor = self._conn.execute("SELECT COUNT(*) as cnt FROM sync_log")
399
+ cnt: int = cursor.fetchone()["cnt"]
400
+ return cnt