garsync 0.2.3__tar.gz → 0.3.1__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.
- {garsync-0.2.3 → garsync-0.3.1}/PKG-INFO +11 -6
- {garsync-0.2.3 → garsync-0.3.1}/README.md +5 -2
- {garsync-0.2.3 → garsync-0.3.1}/pyproject.toml +8 -6
- {garsync-0.2.3 → garsync-0.3.1}/src/garsync/__init__.py +1 -1
- {garsync-0.2.3 → garsync-0.3.1}/src/garsync/api/deps.py +4 -4
- {garsync-0.2.3 → garsync-0.3.1}/src/garsync/api/main.py +4 -4
- {garsync-0.2.3 → garsync-0.3.1}/src/garsync/api/routes/sync.py +5 -5
- {garsync-0.2.3 → garsync-0.3.1}/src/garsync/cli.py +4 -4
- {garsync-0.2.3 → garsync-0.3.1}/src/garsync/db/__init__.py +5 -4
- garsync-0.3.1/src/garsync/db/backup.py +65 -0
- garsync-0.3.1/src/garsync/db/migrations/env.py +44 -0
- garsync-0.3.1/src/garsync/db/migrations/script.py.mako +28 -0
- garsync-0.3.1/src/garsync/db/migrations/versions/0001_v1_baseline.py +105 -0
- garsync-0.3.1/src/garsync/db/migrations/versions/0002_v2.py +406 -0
- {garsync-0.2.3 → garsync-0.3.1}/src/garsync/db/repository.py +110 -88
- garsync-0.3.1/src/garsync/db/schema.py +206 -0
- {garsync-0.2.3 → garsync-0.3.1}/src/garsync/pipeline.py +44 -15
- garsync-0.3.1/src/garsync/timeutil.py +56 -0
- garsync-0.2.3/src/garsync/db/schema.py +0 -88
- {garsync-0.2.3 → garsync-0.3.1}/LICENSE +0 -0
- {garsync-0.2.3 → garsync-0.3.1}/src/garsync/__main__.py +0 -0
- {garsync-0.2.3 → garsync-0.3.1}/src/garsync/api/__init__.py +0 -0
- {garsync-0.2.3 → garsync-0.3.1}/src/garsync/api/auth.py +0 -0
- {garsync-0.2.3 → garsync-0.3.1}/src/garsync/api/routes/__init__.py +0 -0
- {garsync-0.2.3 → garsync-0.3.1}/src/garsync/api/routes/activities.py +0 -0
- {garsync-0.2.3 → garsync-0.3.1}/src/garsync/api/routes/biometrics.py +0 -0
- {garsync-0.2.3 → garsync-0.3.1}/src/garsync/api/routes/sleep.py +0 -0
- {garsync-0.2.3 → garsync-0.3.1}/src/garsync/api/routes/stats.py +0 -0
- {garsync-0.2.3 → garsync-0.3.1}/src/garsync/api/schemas.py +0 -0
- {garsync-0.2.3 → garsync-0.3.1}/src/garsync/client.py +0 -0
- {garsync-0.2.3 → garsync-0.3.1}/src/garsync/db/connection.py +0 -0
- {garsync-0.2.3 → garsync-0.3.1}/src/garsync/exporter.py +0 -0
- {garsync-0.2.3 → garsync-0.3.1}/src/garsync/models.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: garsync
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary:
|
|
5
5
|
License-File: LICENSE
|
|
6
6
|
Author: Manu
|
|
@@ -11,13 +11,15 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
11
11
|
Classifier: Programming Language :: Python :: 3.13
|
|
12
12
|
Classifier: Programming Language :: Python :: 3.14
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.15
|
|
14
|
-
Requires-Dist:
|
|
15
|
-
Requires-Dist:
|
|
14
|
+
Requires-Dist: alembic (>=1.20.0,<2.0.0)
|
|
15
|
+
Requires-Dist: fastapi (>=0.142.2,<1.0.0)
|
|
16
|
+
Requires-Dist: garminconnect (>=0.3.17,<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)
|
|
19
|
+
Requires-Dist: sqlalchemy (>=2.1.1,<3.0.0)
|
|
18
20
|
Requires-Dist: tenacity (>=9.1.4,<10.0.0)
|
|
19
21
|
Requires-Dist: typer (>=0.27.2,<0.28.0)
|
|
20
|
-
Requires-Dist: uvicorn[standard] (>=0.
|
|
22
|
+
Requires-Dist: uvicorn[standard] (>=0.54.0,<1.0.0)
|
|
21
23
|
Description-Content-Type: text/markdown
|
|
22
24
|
|
|
23
25
|
# GarSync
|
|
@@ -60,9 +62,12 @@ make setup
|
|
|
60
62
|
```
|
|
61
63
|
|
|
62
64
|
### 3. Configure Secrets
|
|
63
|
-
Edit your Garmin credentials using SOPS
|
|
65
|
+
Edit your Garmin credentials using SOPS. The file is encrypted to garsync's dedicated age identity
|
|
66
|
+
(see [`docs/runbooks/onboard-new-machine.md`](docs/runbooks/onboard-new-machine.md)), and sops must
|
|
67
|
+
be told the format, because it cannot infer it from `.enc`:
|
|
64
68
|
```bash
|
|
65
|
-
|
|
69
|
+
SOPS_AGE_KEY_FILE=~/.config/age/garsync.txt \
|
|
70
|
+
sops --input-type dotenv --output-type dotenv secrets.env.enc
|
|
66
71
|
```
|
|
67
72
|
|
|
68
73
|
### 4. Sync & Launch
|
|
@@ -38,9 +38,12 @@ make setup
|
|
|
38
38
|
```
|
|
39
39
|
|
|
40
40
|
### 3. Configure Secrets
|
|
41
|
-
Edit your Garmin credentials using SOPS
|
|
41
|
+
Edit your Garmin credentials using SOPS. The file is encrypted to garsync's dedicated age identity
|
|
42
|
+
(see [`docs/runbooks/onboard-new-machine.md`](docs/runbooks/onboard-new-machine.md)), and sops must
|
|
43
|
+
be told the format, because it cannot infer it from `.enc`:
|
|
42
44
|
```bash
|
|
43
|
-
|
|
45
|
+
SOPS_AGE_KEY_FILE=~/.config/age/garsync.txt \
|
|
46
|
+
sops --input-type dotenv --output-type dotenv secrets.env.enc
|
|
44
47
|
```
|
|
45
48
|
|
|
46
49
|
### 4. Sync & Launch
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "garsync"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.1"
|
|
4
4
|
description = ""
|
|
5
5
|
authors = [
|
|
6
6
|
{name = "Manu",email = "mlorentedev@gmail.com"}
|
|
@@ -8,13 +8,15 @@ authors = [
|
|
|
8
8
|
readme = "README.md"
|
|
9
9
|
requires-python = ">=3.12"
|
|
10
10
|
dependencies = [
|
|
11
|
-
"garminconnect (>=0.3.
|
|
11
|
+
"garminconnect (>=0.3.17,<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
|
-
"fastapi (>=0.
|
|
17
|
-
"uvicorn[standard] (>=0.
|
|
16
|
+
"fastapi (>=0.142.2,<1.0.0)",
|
|
17
|
+
"uvicorn[standard] (>=0.54.0,<1.0.0)",
|
|
18
|
+
"alembic (>=1.20.0,<2.0.0)",
|
|
19
|
+
"sqlalchemy (>=2.1.1,<3.0.0)",
|
|
18
20
|
]
|
|
19
21
|
|
|
20
22
|
|
|
@@ -33,8 +35,8 @@ dev = [
|
|
|
33
35
|
"pytest (>=9.1.1,<10.0.0)",
|
|
34
36
|
"pytest-cov (>=7.1.0,<8.0.0)",
|
|
35
37
|
"mypy (>=2.3.1,<3.0.0)",
|
|
36
|
-
"ruff (>=0.16.
|
|
37
|
-
"pytest-mock (>=3.
|
|
38
|
+
"ruff (>=0.16.9,<0.17.0)",
|
|
39
|
+
"pytest-mock (>=3.16.0,<4.0.0)",
|
|
38
40
|
"httpx (>=0.28.1,<1.0.0)"
|
|
39
41
|
]
|
|
40
42
|
|
|
@@ -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
|
|
37
|
-
"""Factory for
|
|
38
|
-
return
|
|
36
|
+
def get_ingest_run_repo(request: Request) -> IngestRunRepository:
|
|
37
|
+
"""Factory for IngestRunRepository."""
|
|
38
|
+
return IngestRunRepository(get_db(request))
|
|
@@ -23,8 +23,7 @@ from garsync.api.auth import (
|
|
|
23
23
|
verify_session_token,
|
|
24
24
|
)
|
|
25
25
|
from garsync.api.routes import activities, biometrics, sleep, stats, sync
|
|
26
|
-
from garsync.db.
|
|
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
|
|
|
@@ -38,8 +37,9 @@ MAX_LOGIN_BODY_BYTES = 4096
|
|
|
38
37
|
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|
39
38
|
"""Manage database connection lifecycle."""
|
|
40
39
|
db_path = os.environ.get("GARSYNC_DB_PATH", "data/garsync.db")
|
|
41
|
-
|
|
42
|
-
|
|
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)
|
|
43
43
|
app.state.db = conn
|
|
44
44
|
yield
|
|
45
45
|
conn.close()
|
|
@@ -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
|
-
|
|
27
|
+
ingest_run_repo: IngestRunRepository = Depends(get_ingest_run_repo),
|
|
28
28
|
) -> SyncStatus:
|
|
29
29
|
"""Get current sync status and record counts."""
|
|
30
|
-
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=
|
|
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.
|
|
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
|
-
|
|
65
|
-
|
|
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
|
|
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")
|