garsync 0.2.2__tar.gz → 0.3.0__tar.gz

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.
Files changed (33) hide show
  1. garsync-0.3.0/LICENSE +21 -0
  2. {garsync-0.2.2 → garsync-0.3.0}/PKG-INFO +8 -7
  3. {garsync-0.2.2 → garsync-0.3.0}/README.md +1 -1
  4. {garsync-0.2.2 → garsync-0.3.0}/pyproject.toml +15 -7
  5. {garsync-0.2.2 → garsync-0.3.0}/src/garsync/__init__.py +1 -1
  6. {garsync-0.2.2 → garsync-0.3.0}/src/garsync/api/auth.py +21 -1
  7. {garsync-0.2.2 → garsync-0.3.0}/src/garsync/api/deps.py +4 -4
  8. {garsync-0.2.2 → garsync-0.3.0}/src/garsync/api/main.py +39 -8
  9. {garsync-0.2.2 → garsync-0.3.0}/src/garsync/api/routes/sync.py +5 -5
  10. {garsync-0.2.2 → garsync-0.3.0}/src/garsync/cli.py +4 -4
  11. {garsync-0.2.2 → garsync-0.3.0}/src/garsync/db/__init__.py +5 -4
  12. garsync-0.3.0/src/garsync/db/backup.py +65 -0
  13. garsync-0.3.0/src/garsync/db/migrations/env.py +44 -0
  14. garsync-0.3.0/src/garsync/db/migrations/script.py.mako +28 -0
  15. garsync-0.3.0/src/garsync/db/migrations/versions/0001_v1_baseline.py +105 -0
  16. garsync-0.3.0/src/garsync/db/migrations/versions/0002_v2.py +406 -0
  17. {garsync-0.2.2 → garsync-0.3.0}/src/garsync/db/repository.py +110 -88
  18. garsync-0.3.0/src/garsync/db/schema.py +206 -0
  19. {garsync-0.2.2 → garsync-0.3.0}/src/garsync/pipeline.py +44 -15
  20. garsync-0.3.0/src/garsync/timeutil.py +56 -0
  21. garsync-0.2.2/src/garsync/db/schema.py +0 -88
  22. {garsync-0.2.2 → garsync-0.3.0}/src/garsync/__main__.py +0 -0
  23. {garsync-0.2.2 → garsync-0.3.0}/src/garsync/api/__init__.py +0 -0
  24. {garsync-0.2.2 → garsync-0.3.0}/src/garsync/api/routes/__init__.py +0 -0
  25. {garsync-0.2.2 → garsync-0.3.0}/src/garsync/api/routes/activities.py +0 -0
  26. {garsync-0.2.2 → garsync-0.3.0}/src/garsync/api/routes/biometrics.py +0 -0
  27. {garsync-0.2.2 → garsync-0.3.0}/src/garsync/api/routes/sleep.py +0 -0
  28. {garsync-0.2.2 → garsync-0.3.0}/src/garsync/api/routes/stats.py +0 -0
  29. {garsync-0.2.2 → garsync-0.3.0}/src/garsync/api/schemas.py +0 -0
  30. {garsync-0.2.2 → garsync-0.3.0}/src/garsync/client.py +0 -0
  31. {garsync-0.2.2 → garsync-0.3.0}/src/garsync/db/connection.py +0 -0
  32. {garsync-0.2.2 → garsync-0.3.0}/src/garsync/exporter.py +0 -0
  33. {garsync-0.2.2 → garsync-0.3.0}/src/garsync/models.py +0 -0
garsync-0.3.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Manu Lorente
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,7 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: garsync
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary:
5
+ License-File: LICENSE
5
6
  Author: Manu
6
7
  Author-email: mlorentedev@gmail.com
7
8
  Requires-Python: >=3.12
@@ -9,16 +10,16 @@ Classifier: Programming Language :: Python :: 3
9
10
  Classifier: Programming Language :: Python :: 3.12
10
11
  Classifier: Programming Language :: Python :: 3.13
11
12
  Classifier: Programming Language :: Python :: 3.14
13
+ Classifier: Programming Language :: Python :: 3.15
14
+ Requires-Dist: alembic (>=1.20.0,<2.0.0)
12
15
  Requires-Dist: fastapi (>=0.141.1,<1.0.0)
13
- Requires-Dist: garminconnect (>=0.3.11,<0.4.0)
14
- Requires-Dist: pandas (<4)
15
- Requires-Dist: plotly (>=7.0.0,<8.0.0)
16
+ Requires-Dist: garminconnect (>=0.3.16,<0.4.0)
16
17
  Requires-Dist: pydantic (>=2.13.5,<3.0.0)
17
18
  Requires-Dist: rich (>=15.0.0,<16.0.0)
18
- Requires-Dist: streamlit (>=1.63.0,<2.0.0)
19
+ Requires-Dist: sqlalchemy (>=2.1.0,<3.0.0)
19
20
  Requires-Dist: tenacity (>=9.1.4,<10.0.0)
20
21
  Requires-Dist: typer (>=0.27.2,<0.28.0)
21
- Requires-Dist: uvicorn[standard] (>=0.52.4,<1.0.0)
22
+ Requires-Dist: uvicorn[standard] (>=0.53.0,<1.0.0)
22
23
  Description-Content-Type: text/markdown
23
24
 
24
25
  # GarSync
@@ -46,7 +47,7 @@ Garmin Connect provides great data, but it's locked in a proprietary cloud.
46
47
  | **Data Ownership** | Proprietary Cloud | Local SQLite (Full Control) |
47
48
  | **Customization** | Fixed Dashboards | Extensible Astro + Chart.js |
48
49
  | **Access** | Web/Mobile Only | REST API + SQL + CLI |
49
- | **Automation** | Manual Export | Scheduled Incremental Sync |
50
+ | **Automation** | Manual Export | CLI sync over any date window on demand (in-process scheduler: [ADR-008](docs/adr/adr-008-ingestion-ledger-and-adapters.md)) |
50
51
 
51
52
  ## Quick Start
52
53
 
@@ -23,7 +23,7 @@ Garmin Connect provides great data, but it's locked in a proprietary cloud.
23
23
  | **Data Ownership** | Proprietary Cloud | Local SQLite (Full Control) |
24
24
  | **Customization** | Fixed Dashboards | Extensible Astro + Chart.js |
25
25
  | **Access** | Web/Mobile Only | REST API + SQL + CLI |
26
- | **Automation** | Manual Export | Scheduled Incremental Sync |
26
+ | **Automation** | Manual Export | CLI sync over any date window on demand (in-process scheduler: [ADR-008](docs/adr/adr-008-ingestion-ledger-and-adapters.md)) |
27
27
 
28
28
  ## Quick Start
29
29
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "garsync"
3
- version = "0.2.2"
3
+ version = "0.3.0"
4
4
  description = ""
5
5
  authors = [
6
6
  {name = "Manu",email = "mlorentedev@gmail.com"}
@@ -8,16 +8,15 @@ authors = [
8
8
  readme = "README.md"
9
9
  requires-python = ">=3.12"
10
10
  dependencies = [
11
- "garminconnect (>=0.3.11,<0.4.0)",
11
+ "garminconnect (>=0.3.16,<0.4.0)",
12
12
  "pydantic (>=2.13.5,<3.0.0)",
13
13
  "tenacity (>=9.1.4,<10.0.0)",
14
14
  "typer (>=0.27.2,<0.28.0)",
15
15
  "rich (>=15.0.0,<16.0.0)",
16
- "pandas (<4)",
17
- "streamlit (>=1.63.0,<2.0.0)",
18
- "plotly (>=7.0.0,<8.0.0)",
19
16
  "fastapi (>=0.141.1,<1.0.0)",
20
- "uvicorn[standard] (>=0.52.4,<1.0.0)",
17
+ "uvicorn[standard] (>=0.53.0,<1.0.0)",
18
+ "alembic (>=1.20.0,<2.0.0)",
19
+ "sqlalchemy (>=2.1.0,<3.0.0)",
21
20
  ]
22
21
 
23
22
 
@@ -36,7 +35,7 @@ dev = [
36
35
  "pytest (>=9.1.1,<10.0.0)",
37
36
  "pytest-cov (>=7.1.0,<8.0.0)",
38
37
  "mypy (>=2.3.1,<3.0.0)",
39
- "ruff (>=0.16.5,<0.17.0)",
38
+ "ruff (>=0.16.8,<0.17.0)",
40
39
  "pytest-mock (>=3.15.1,<4.0.0)",
41
40
  "httpx (>=0.28.1,<1.0.0)"
42
41
  ]
@@ -56,6 +55,15 @@ disallow_untyped_defs = false
56
55
  [tool.pytest.ini_options]
57
56
  testpaths = ["tests"]
58
57
  addopts = "-q"
58
+ # Markers exist so a slower, end-to-end layer can be excluded from the default
59
+ # run (`-m "not e2e"`) instead of being made faster by deleting it. `make smoke`
60
+ # is today's e2e path and lives outside pytest; the marker is declared here so
61
+ # a future pytest-driven e2e test has somewhere to land.
62
+ markers = [
63
+ "unit: one layer — repository, schema, parsing, pure helpers",
64
+ "integration: more than one layer — CLI + database, or API + database over HTTP",
65
+ "e2e: the deployed shape end to end; excluded from the default run",
66
+ ]
59
67
 
60
68
  [tool.ruff]
61
69
  target-version = "py312"
@@ -1,3 +1,3 @@
1
1
  """Garmin Sync Microservice"""
2
2
 
3
- __version__ = "0.2.2"
3
+ __version__ = "0.3.0"
@@ -120,6 +120,26 @@ def make_session_token(access_password: str, now: float | None = None) -> str:
120
120
  return f"{expiry}.{sig}"
121
121
 
122
122
 
123
+ def constant_time_equals(candidate: str, expected: str) -> bool:
124
+ """Constant-time string comparison that survives any input a client can send.
125
+
126
+ `hmac.compare_digest` refuses `str` arguments that contain non-ASCII characters and
127
+ raises `TypeError: comparing strings with non-ASCII characters is not supported`.
128
+ On an auth check that turns attacker-controlled input into an unhandled 500: sending
129
+ `password=ñ` to `POST /login` (percent-encoded UTF-8, which is what any browser
130
+ sends), a non-ASCII `X-API-KEY` header (latin-1 on the wire) or a non-ASCII session
131
+ cookie crashed the request **before** the failed attempt was recorded, so it was an
132
+ unauthenticated denial of service that also bypassed the login rate limiter.
133
+ Independent adversarial review of specs/SEC-001, 2026-09-25 (`agy/gemini-3.1-pro-high`),
134
+ filed it as a Blocker and the spec was refused until it was fixed.
135
+
136
+ Encoding both sides to UTF-8 keeps the constant-time property (the comparison is over
137
+ the encoded bytes) and removes the crash. Length is still observable, as it is for
138
+ `compare_digest` on equal-length inputs; that is not the property this protects.
139
+ """
140
+ return hmac.compare_digest(candidate.encode("utf-8"), expected.encode("utf-8"))
141
+
142
+
123
143
  def verify_session_token(token: str | None, access_password: str, now: float | None = None) -> bool:
124
144
  """Constant-time token verification; False on tamper, bad format or expiry."""
125
145
  if not token or not access_password:
@@ -133,7 +153,7 @@ def verify_session_token(token: str | None, access_password: str, now: float | N
133
153
  f"garsync-session|{expiry_str}".encode(),
134
154
  hashlib.sha256,
135
155
  ).hexdigest()
136
- if not hmac.compare_digest(provided_sig, expected):
156
+ if not constant_time_equals(provided_sig, expected):
137
157
  return False
138
158
  current = time.time() if now is None else now
139
159
  return int(expiry_str) > current
@@ -7,8 +7,8 @@ from fastapi import Request
7
7
  from garsync.db.repository import (
8
8
  ActivityRepository,
9
9
  BiometricsRepository,
10
+ IngestRunRepository,
10
11
  SleepRepository,
11
- SyncLogRepository,
12
12
  )
13
13
 
14
14
 
@@ -33,6 +33,6 @@ def get_sleep_repo(request: Request) -> SleepRepository:
33
33
  return SleepRepository(get_db(request))
34
34
 
35
35
 
36
- def get_sync_log_repo(request: Request) -> SyncLogRepository:
37
- """Factory for SyncLogRepository."""
38
- return SyncLogRepository(get_db(request))
36
+ def get_ingest_run_repo(request: Request) -> IngestRunRepository:
37
+ """Factory for IngestRunRepository."""
38
+ return IngestRunRepository(get_db(request))
@@ -1,6 +1,5 @@
1
1
  """FastAPI application factory for garsync."""
2
2
 
3
- import hmac
4
3
  import logging
5
4
  import os
6
5
  import sqlite3
@@ -18,23 +17,29 @@ from garsync.api.auth import (
18
17
  SESSION_COOKIE_NAME,
19
18
  SESSION_TTL_SECONDS,
20
19
  LoginRateLimiter,
20
+ constant_time_equals,
21
21
  make_session_token,
22
22
  render_login_page,
23
23
  verify_session_token,
24
24
  )
25
25
  from garsync.api.routes import activities, biometrics, sleep, stats, sync
26
- from garsync.db.connection import get_connection
27
- from garsync.db.schema import init_db
26
+ from garsync.db.schema import open_database
28
27
 
29
28
  logger = logging.getLogger("garsync.auth")
30
29
 
30
+ # Largest login body the app will buffer before treating the attempt as failed. The
31
+ # URL-encoded form is a handful of bytes; anything past this is a client trying to spend
32
+ # the server's memory, and the endpoint needs no credentials to reach it.
33
+ MAX_LOGIN_BODY_BYTES = 4096
34
+
31
35
 
32
36
  @asynccontextmanager
33
37
  async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
34
38
  """Manage database connection lifecycle."""
35
39
  db_path = os.environ.get("GARSYNC_DB_PATH", "data/garsync.db")
36
- conn = get_connection(db_path)
37
- init_db(conn)
40
+ # Snapshot-then-migrate: the file is the only copy of this history, and this is the function that
41
+ # knows a migration is about to rewrite it.
42
+ conn = open_database(db_path)
38
43
  app.state.db = conn
39
44
  yield
40
45
  conn.close()
@@ -91,7 +96,7 @@ def create_app(conn: sqlite3.Connection | None = None) -> FastAPI:
91
96
  if api_key:
92
97
  provided = request.headers.get("X-API-KEY")
93
98
  if provided is not None:
94
- if hmac.compare_digest(provided, api_key):
99
+ if constant_time_equals(provided, api_key):
95
100
  return await call_next(request)
96
101
  return JSONResponse(
97
102
  status_code=status.HTTP_403_FORBIDDEN,
@@ -127,10 +132,36 @@ def create_app(conn: sqlite3.Connection | None = None) -> FastAPI:
127
132
  status_code=status.HTTP_429_TOO_MANY_REQUESTS,
128
133
  )
129
134
 
130
- form = parse_qs((await request.body()).decode())
135
+ # Cap what an unauthenticated endpoint will buffer: `await request.body()` read
136
+ # whatever the client sent, so `POST /login` was a free memory lever for anyone
137
+ # who could reach it. The proxy's body limit is a second line of defence, not the
138
+ # first one on the app's only unauthenticated write path. Independent review of
139
+ # specs/SEC-001 (2026-09-25) raised it as speculative; it is cheap to close, and an
140
+ # oversized body counts as a failed attempt rather than a distinct status, so the
141
+ # response is not an oracle for how the body was rejected.
142
+ #
143
+ # This cap holds only while nothing upstream has consumed the body: FastAPI does not
144
+ # parse one for a handler that declares no body parameter, and the auth middleware
145
+ # never reads it, so the request arrives unconsumed (verified: `_stream_consumed` is
146
+ # False at entry and the loop receives chunks). If a future middleware starts reading
147
+ # the body, the proxy's size limit is the remaining backstop.
148
+ body = b""
149
+ async for chunk in request.stream():
150
+ body += chunk
151
+ if len(body) > MAX_LOGIN_BODY_BYTES:
152
+ limiter.record_failure(client_ip)
153
+ return HTMLResponse(
154
+ render_login_page("Invalid password. Try again."),
155
+ status_code=status.HTTP_401_UNAUTHORIZED,
156
+ )
157
+
158
+ # errors="replace": a body that is not valid UTF-8 is a wrong password, not a
159
+ # 500. A bare `.decode()` raised UnicodeDecodeError on `password=\xff` —
160
+ # reachable unauthenticated, and the same crash class the compare fix addresses.
161
+ form = parse_qs(body.decode("utf-8", errors="replace"))
131
162
  supplied = form.get("password", [""])[0]
132
163
 
133
- if access_password and hmac.compare_digest(supplied, access_password):
164
+ if access_password and constant_time_equals(supplied, access_password):
134
165
  limiter.reset(client_ip)
135
166
  response = RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
136
167
  response.set_cookie(
@@ -5,15 +5,15 @@ from fastapi import APIRouter, Depends
5
5
  from garsync.api.deps import (
6
6
  get_activity_repo,
7
7
  get_biometrics_repo,
8
+ get_ingest_run_repo,
8
9
  get_sleep_repo,
9
- get_sync_log_repo,
10
10
  )
11
11
  from garsync.api.schemas import SyncStatus
12
12
  from garsync.db.repository import (
13
13
  ActivityRepository,
14
14
  BiometricsRepository,
15
+ IngestRunRepository,
15
16
  SleepRepository,
16
- SyncLogRepository,
17
17
  )
18
18
 
19
19
  router = APIRouter(prefix="/api", tags=["sync"])
@@ -24,15 +24,15 @@ def sync_status(
24
24
  activity_repo: ActivityRepository = Depends(get_activity_repo),
25
25
  biometrics_repo: BiometricsRepository = Depends(get_biometrics_repo),
26
26
  sleep_repo: SleepRepository = Depends(get_sleep_repo),
27
- sync_log_repo: SyncLogRepository = Depends(get_sync_log_repo),
27
+ ingest_run_repo: IngestRunRepository = Depends(get_ingest_run_repo),
28
28
  ) -> SyncStatus:
29
29
  """Get current sync status and record counts."""
30
- latest = sync_log_repo.get_latest()
30
+ latest = ingest_run_repo.get_latest()
31
31
  return SyncStatus(
32
32
  last_sync_time=latest["created_at"] if latest else None,
33
33
  last_sync_status=latest["status"] if latest else None,
34
34
  total_activities=activity_repo.count(),
35
35
  total_biometrics=biometrics_repo.count(),
36
36
  total_sleep=sleep_repo.count(),
37
- total_sync_logs=sync_log_repo.count(),
37
+ total_sync_logs=ingest_run_repo.count(),
38
38
  )
@@ -9,8 +9,7 @@ import typer
9
9
  from rich.console import Console
10
10
 
11
11
  from garsync.client import GarminClient
12
- from garsync.db.connection import get_connection
13
- from garsync.db.schema import init_db
12
+ from garsync.db.schema import open_database
14
13
  from garsync.pipeline import SyncService
15
14
 
16
15
  app = typer.Typer(help="GarSync: Garmin Connect data extraction pipeline")
@@ -61,8 +60,9 @@ def sync(
61
60
 
62
61
  conn = None
63
62
  if db:
64
- conn = get_connection(db)
65
- init_db(conn)
63
+ # `open_database` snapshots the file first when a migration is pending: this is the only
64
+ # place that knows it is a file rather than a fixture, which is what a backup needs.
65
+ conn = open_database(db)
66
66
  service = SyncService(client, conn)
67
67
 
68
68
  latest_date = service.get_latest_synced_date()
@@ -4,17 +4,18 @@ from garsync.db.connection import get_connection
4
4
  from garsync.db.repository import (
5
5
  ActivityRepository,
6
6
  BiometricsRepository,
7
+ IngestRunRepository,
7
8
  SleepRepository,
8
- SyncLogRepository,
9
9
  )
10
- from garsync.db.schema import CURRENT_VERSION, init_db
10
+ from garsync.db.schema import init_db, migrate, open_database
11
11
 
12
12
  __all__ = [
13
- "CURRENT_VERSION",
14
13
  "ActivityRepository",
15
14
  "BiometricsRepository",
15
+ "IngestRunRepository",
16
16
  "SleepRepository",
17
- "SyncLogRepository",
18
17
  "get_connection",
19
18
  "init_db",
19
+ "migrate",
20
+ "open_database",
20
21
  ]
@@ -0,0 +1,65 @@
1
+ """Pre-migration snapshots of the database file.
2
+
3
+ ADR-009 §4.1 requires a backup before the migration runs. Written as an instruction, that is a rule
4
+ nobody executes at the moment it matters — so it is a function the upgrade path calls, and a target
5
+ an operator can run by hand (`make db-backup`).
6
+
7
+ `VACUUM INTO` rather than a file copy: it writes a consistent snapshot of a live database, including
8
+ anything still in the write-ahead log, and it cannot produce the torn file that `cp` produces when a
9
+ writer is mid-commit. A checkpoint is deliberately *not* issued first — `VACUUM INTO` already reads
10
+ through the connection, so a checkpoint would only add a write to a database this code is otherwise
11
+ only reading.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import sqlite3
17
+ import sys
18
+ from datetime import UTC, datetime
19
+ from pathlib import Path
20
+
21
+ #: Snapshots live beside the database, inside the directory that is already gitignored.
22
+ BACKUP_DIRNAME = "backups"
23
+
24
+
25
+ def snapshot_path(db_path: Path, taken_at: datetime | None = None) -> Path:
26
+ """Where a snapshot taken now would go. UTC, so the names sort as they were written."""
27
+ stamp = (taken_at or datetime.now(UTC)).strftime("%Y%m%dT%H%M%SZ")
28
+ return db_path.parent / BACKUP_DIRNAME / f"{db_path.stem}-{stamp}.db"
29
+
30
+
31
+ def snapshot(conn: sqlite3.Connection, db_path: Path, taken_at: datetime | None = None) -> Path:
32
+ """Write a snapshot of `conn`'s database next to `db_path` and return where it landed.
33
+
34
+ Refuses to overwrite: a second snapshot in the same second is a name collision, and silently
35
+ replacing the earlier one is exactly the outcome a backup exists to prevent.
36
+ """
37
+ target = snapshot_path(db_path, taken_at)
38
+ if target.exists():
39
+ raise FileExistsError(f"refusing to overwrite an existing snapshot: {target}")
40
+ target.parent.mkdir(parents=True, exist_ok=True)
41
+ conn.commit() # `VACUUM INTO` cannot run inside a transaction
42
+ conn.execute("VACUUM INTO ?", (str(target),))
43
+ return target
44
+
45
+
46
+ def main(argv: list[str] | None = None) -> int:
47
+ """`python -m garsync.db.backup [db_path]` — the same snapshot, on demand."""
48
+ from garsync.db.connection import get_connection
49
+
50
+ args = sys.argv[1:] if argv is None else argv
51
+ db_path = Path(args[0] if args else "data/garsync.db")
52
+ if not db_path.exists():
53
+ print(f"db-backup: no database at {db_path}", file=sys.stderr)
54
+ return 1
55
+ conn = get_connection(str(db_path))
56
+ try:
57
+ written = snapshot(conn, db_path)
58
+ finally:
59
+ conn.close()
60
+ print(written)
61
+ return 0
62
+
63
+
64
+ if __name__ == "__main__":
65
+ raise SystemExit(main())
@@ -0,0 +1,44 @@
1
+ """Alembic environment — driven by the caller's connection, never by a URL.
2
+
3
+ The connection is required. Alembic opening one from a URL would be a *second* connection, and for
4
+ SQLite `:memory:` a second connection is a second, empty database: the fixtures in this suite would
5
+ then assert against nothing while the whole suite stayed green. A run with no connection is a
6
+ configuration error and says so.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from alembic import context
12
+
13
+
14
+ def run_migrations_offline() -> None:
15
+ """Offline mode is not supported, and refusing is the point.
16
+
17
+ `--sql` output cannot be reviewed the way this chain is reviewed: it issues its own SQL and is
18
+ only ever run against a database somebody has already backed up (see `make db-backup`).
19
+ """
20
+ raise RuntimeError(
21
+ "Offline mode is not supported. The garsync chain is run by "
22
+ "`garsync.db.schema.init_db` on a live connection, so that the schema lands on the same "
23
+ "database the application is about to use."
24
+ )
25
+
26
+
27
+ def run_migrations_online() -> None:
28
+ connection = context.config.attributes.get("connection")
29
+ if connection is None:
30
+ raise RuntimeError(
31
+ "No connection was supplied to the migration chain. Pass the connection the "
32
+ "application already holds via `config.attributes['connection']` — that is what "
33
+ "`garsync.db.schema.init_db` does. Alembic is deliberately not given a URL here: a "
34
+ "connection it opened itself would be a different database from the caller's."
35
+ )
36
+ context.configure(connection=connection, target_metadata=None)
37
+ with context.begin_transaction():
38
+ context.run_migrations()
39
+
40
+
41
+ if context.is_offline_mode():
42
+ run_migrations_offline()
43
+ else:
44
+ run_migrations_online()
@@ -0,0 +1,28 @@
1
+ """${message}
2
+
3
+ Revision ID: ${up_revision}
4
+ Revises: ${down_revision | comma,n}
5
+ Create Date: ${create_date}
6
+
7
+ Migrations in this project do not import application code: a revision has to keep working after the
8
+ module it would import has changed shape, so anything it needs is written out here.
9
+ """
10
+
11
+ from typing import Sequence, Union
12
+
13
+ import sqlalchemy as sa
14
+ from alembic import op
15
+ ${imports if imports else ""}
16
+
17
+ revision: str = ${repr(up_revision)}
18
+ down_revision: Union[str, None] = ${repr(down_revision)}
19
+ branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
20
+ depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
21
+
22
+
23
+ def upgrade() -> None:
24
+ ${upgrades if upgrades else "pass"}
25
+
26
+
27
+ def downgrade() -> None:
28
+ ${downgrades if downgrades else "pass"}
@@ -0,0 +1,105 @@
1
+ """v1 baseline — the schema exactly as it existed before Alembic.
2
+
3
+ This revision is not a migration; it is the *starting point*, and it exists so that an existing v1
4
+ database can be stamped rather than recreated. Everything depends on it being faithful: if it
5
+ disagrees with what is on disk, `alembic stamp 0001_v1` records a lie and the next revision
6
+ transforms a schema nobody has. `TestAlembicSeam::test_the_baseline_reproduces_the_v1_schema` holds
7
+ it against the frozen DDL in `tests/fixtures/schema_v1.sql`.
8
+
9
+ Two details are load-bearing and easy to get wrong:
10
+
11
+ * `activity_id`/`date`/`id` are primary keys with ``nullable=True``. SQLAlchemy otherwise renders
12
+ ``INTEGER NOT NULL`` where v1 wrote ``INTEGER PRIMARY KEY`` — a different table, since a rowid
13
+ alias accepts an omitted value and a NOT NULL column does not.
14
+ * ``server_default=sa.text(...)`` is the expression form. Passed as a plain string it is quoted and
15
+ becomes a *literal*, so `created_at` would default to the text of a function call.
16
+
17
+ Revision ID: 0001_v1
18
+ Revises:
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from collections.abc import Sequence
24
+ from typing import Any
25
+
26
+ import sqlalchemy as sa
27
+ from alembic import op
28
+
29
+ revision: str = "0001_v1"
30
+ down_revision: str | None = None
31
+ branch_labels: Sequence[str] | None = None
32
+ depends_on: Sequence[str] | None = None
33
+
34
+ _TIMESTAMP = sa.text("(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))")
35
+
36
+
37
+ def _timestamps() -> list[sa.Column[Any]]:
38
+ return [
39
+ sa.Column("created_at", sa.Text(), nullable=False, server_default=_TIMESTAMP),
40
+ sa.Column("updated_at", sa.Text(), nullable=False, server_default=_TIMESTAMP),
41
+ ]
42
+
43
+
44
+ def upgrade() -> None:
45
+ op.create_table(
46
+ "activities",
47
+ sa.Column("activity_id", sa.Integer(), primary_key=True, nullable=True),
48
+ sa.Column("activity_name", sa.Text(), nullable=True),
49
+ sa.Column("activity_type", sa.Text(), nullable=True),
50
+ sa.Column("start_time", sa.Text(), nullable=True),
51
+ sa.Column("duration_seconds", sa.REAL(), nullable=True),
52
+ sa.Column("distance_meters", sa.REAL(), nullable=True),
53
+ sa.Column("average_heart_rate", sa.Integer(), nullable=True),
54
+ sa.Column("max_heart_rate", sa.Integer(), nullable=True),
55
+ sa.Column("calories", sa.REAL(), nullable=True),
56
+ sa.Column("raw_data", sa.Text(), nullable=True),
57
+ *_timestamps(),
58
+ )
59
+ op.create_table(
60
+ "biometrics",
61
+ sa.Column("date", sa.Text(), primary_key=True, nullable=True),
62
+ sa.Column("resting_heart_rate", sa.Integer(), nullable=True),
63
+ sa.Column("hrv_balance", sa.Text(), nullable=True),
64
+ sa.Column("body_battery_highest", sa.Integer(), nullable=True),
65
+ sa.Column("body_battery_lowest", sa.Integer(), nullable=True),
66
+ sa.Column("stress_average", sa.Integer(), nullable=True),
67
+ sa.Column("raw_data", sa.Text(), nullable=True),
68
+ *_timestamps(),
69
+ )
70
+ op.create_table(
71
+ "sleep",
72
+ sa.Column("date", sa.Text(), primary_key=True, nullable=True),
73
+ sa.Column("sleep_start", sa.Text(), nullable=True),
74
+ sa.Column("sleep_end", sa.Text(), nullable=True),
75
+ sa.Column("total_sleep_seconds", sa.Integer(), nullable=True),
76
+ sa.Column("deep_sleep_seconds", sa.Integer(), nullable=True),
77
+ sa.Column("light_sleep_seconds", sa.Integer(), nullable=True),
78
+ sa.Column("rem_sleep_seconds", sa.Integer(), nullable=True),
79
+ sa.Column("awake_sleep_seconds", sa.Integer(), nullable=True),
80
+ sa.Column("sleep_score", sa.Integer(), nullable=True),
81
+ sa.Column("raw_data", sa.Text(), nullable=True),
82
+ *_timestamps(),
83
+ )
84
+ op.create_table(
85
+ "sync_log",
86
+ sa.Column("id", sa.Integer(), primary_key=True, nullable=True),
87
+ sa.Column("sync_type", sa.Text(), nullable=False),
88
+ sa.Column("records_synced", sa.Integer(), nullable=False, server_default=sa.text("0")),
89
+ sa.Column("status", sa.Text(), nullable=False, server_default="success"),
90
+ sa.Column("error_message", sa.Text(), nullable=True),
91
+ sa.Column("created_at", sa.Text(), nullable=False, server_default=_TIMESTAMP),
92
+ sqlite_autoincrement=True,
93
+ )
94
+ op.create_table(
95
+ "schema_version",
96
+ sa.Column("version", sa.Integer(), primary_key=True, nullable=True),
97
+ )
98
+
99
+
100
+ def downgrade() -> None:
101
+ op.drop_table("schema_version")
102
+ op.drop_table("sync_log")
103
+ op.drop_table("sleep")
104
+ op.drop_table("biometrics")
105
+ op.drop_table("activities")