From 30df3030e6386c0d6ea96de124b76732305bb914 Mon Sep 17 00:00:00 2001 From: fy59 Date: Mon, 21 Sep 2026 06:24:05 +0200 Subject: [PATCH] feat(sync): synchronize sleep diary --- .../trainlog/data/SyncGenerationService.kt | 6 + tests/test_sleep_diary_exchange.py | 60 +++++ tests/test_sync_generation_exchange.py | 2 + tools/package_sync_candidate.py | 1 + tools/sleep_diary_exchange.py | 218 ++++++++++++++++++ tools/sync_generation_exchange.py | 14 +- 6 files changed, 298 insertions(+), 3 deletions(-) create mode 100644 tests/test_sleep_diary_exchange.py create mode 100644 tools/sleep_diary_exchange.py diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt b/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt index 52796ed..4e578ed 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt @@ -68,6 +68,7 @@ internal class SyncGenerationService(private val repository: TrainlogRepository) Kind("trainlog-causal-deletions", 1, "causal-deletions-v1.json"), "program-executions" to Kind("trainlog-program-executions", 1, "program-executions-v1.json", false), + "sleep-diary" to Kind("trainlog-sleep-diary", 1, "sleep-diary-v1.json", false), ) private val SUPPORTED = (ANDROID_KINDS.values + @@ -664,6 +665,7 @@ internal class SyncGenerationService(private val repository: TrainlogRepository) is ProgramsImportResult.Applied, is CausalDeleteResult.Applied, is CausalDeleteResult.Unchanged -> null + is TrainlogRepository.SleepDiaryImportResult.Applied -> null else -> result.toString() } @@ -840,6 +842,10 @@ internal class SyncGenerationService(private val repository: TrainlogRepository) apply("execution-drafts") { repository.applyExecutionDraftExportV1Json(checkNotNull(a["execution-drafts"])) } + if ("sleep-diary" in a) + apply("sleep-diary") { + repository.applySleepDiaryV1Json(checkNotNull(a["sleep-diary"])) + } if ("ai-proposals" in a) apply("ai-proposals") { repository.applyAiSessionDraftsJson(checkNotNull(a["ai-proposals"])) diff --git a/tests/test_sleep_diary_exchange.py b/tests/test_sleep_diary_exchange.py new file mode 100644 index 0000000..abdbc9f --- /dev/null +++ b/tests/test_sleep_diary_exchange.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +import json +import sqlite3 +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tools")) +import sleep_diary_exchange as exchange + + +def entry(revision, parent=None, deleted=False): + return {"entry_id": "sl_10000000-0000-4000-8000-000000000001", + "night_start_date": "2026-10-24", "night_end_date": "2026-10-25", + "created_at": "2026-10-24T22:00:00+02:00", "updated_at": "2026-10-25T18:00:00+01:00", + "revision_id": revision, "parent_revision_id": parent, "deleted": deleted, + "sleep_quality": "B", "wake_quality": "Moy", "day_form": "TB", + "treatment_and_notes": "Synthetic fixture", "events": [ + {"event_id": "sle_30000000-0000-4000-8000-000000000003", "type": "sleep", + "start_at": "2026-10-24T23:30:00+02:00", "end_at": "2026-10-25T06:30:00+01:00"}]} + + +class SleepExchangeTest(unittest.TestCase): + def setUp(self): + self.db = sqlite3.connect(":memory:") + self.db.executescript(""" + CREATE TABLE sleep_diary_entries(entry_id TEXT PRIMARY KEY,night_start_date TEXT,night_end_date TEXT,created_at TEXT,updated_at TEXT,current_revision_id TEXT,deleted INTEGER); + CREATE TABLE sleep_diary_revisions(revision_id TEXT PRIMARY KEY,entry_id TEXT,parent_revision_id TEXT,created_at TEXT,sleep_quality TEXT,wake_quality TEXT,day_form TEXT,treatment_and_notes TEXT); + CREATE TABLE sleep_diary_events(revision_id TEXT,event_id TEXT,event_type TEXT,start_at TEXT,end_at TEXT,PRIMARY KEY(revision_id,event_id)); + """) + + def document(self, item): + return {"format": exchange.FORMAT, "version": 1, "generated_at": "2026-10-25T18:00:00+01:00", "entries": [item]} + + def test_roundtrip_replay_conflict_and_non_resurrection(self): + first = "slr_20000000-0000-4000-8000-000000000002" + second = "slr_40000000-0000-4000-8000-000000000004" + self.assertEqual((1, 0), exchange.apply(self.db, self.document(entry(first)))) + self.assertEqual((0, 1), exchange.apply(self.db, self.document(entry(first)))) + self.assertEqual((1, 0), exchange.apply(self.db, self.document(entry(second, first, True)))) + with self.assertRaisesRegex(ValueError, "resurrect"): + exchange.apply(self.db, self.document(entry("slr_50000000-0000-4000-8000-000000000005", second, False))) + exported = exchange.build(self.db) + self.assertTrue(exported["entries"][0]["deleted"]) + + def test_rejects_concurrent_sibling_and_negative_absolute_interval(self): + first = "slr_20000000-0000-4000-8000-000000000002" + self.assertEqual((1, 0), exchange.apply(self.db, self.document(entry(first)))) + with self.assertRaisesRegex(ValueError, "concurrent"): + exchange.apply(self.db, self.document(entry("slr_50000000-0000-4000-8000-000000000005", None))) + invalid = entry("slr_60000000-0000-4000-8000-000000000006", first) + invalid["events"][0]["end_at"] = "2026-10-24T22:30:00+02:00" + with self.assertRaisesRegex(ValueError, "positive"): + exchange.validate(self.document(invalid)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_sync_generation_exchange.py b/tests/test_sync_generation_exchange.py index 9321ee0..5f1c6c1 100644 --- a/tests/test_sync_generation_exchange.py +++ b/tests/test_sync_generation_exchange.py @@ -203,6 +203,8 @@ class GenerationTest(unittest.TestCase): self.assertEqual([operation["payload_sha256"] for operation in operations],[row[0] for row in db.execute("SELECT payload_sha256 FROM sync_causal_operations ORDER BY operation_id")]) def test_real_consumer_rolls_back_earlier_domain_write_on_late_failure(self): + # A pre-Sleep-Diary generation remains consumable after the companion + # is introduced; the late-domain failure still rolls everything back. names=("catalog","history","execution-drafts","exercise-aliases","exercise-profile-state","equipment-definitions","equipment-associations","body-zones","feedback","causal-deletions") directory=self.root/"incoming";directory.mkdir() artifacts=[] diff --git a/tools/package_sync_candidate.py b/tools/package_sync_candidate.py index e4b0f16..b129c63 100644 --- a/tools/package_sync_candidate.py +++ b/tools/package_sync_candidate.py @@ -16,6 +16,7 @@ RUNTIME_TOOLS = [ "sync_peer_worker.py", "sync_drive_transport.py", "sync_generation_exchange.py", + "sleep_diary_exchange.py", "trainlog_sqlite.py", "validate_json.py", "validate_training_knowledge.py", diff --git a/tools/sleep_diary_exchange.py b/tools/sleep_diary_exchange.py new file mode 100644 index 0000000..2c6e745 --- /dev/null +++ b/tools/sleep_diary_exchange.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +"""Export, validate and merge the versioned Sleep Diary V1 companion.""" + +import argparse +import json +import re +import sqlite3 +from datetime import date, datetime +from pathlib import Path + +from trainlog_sqlite import connect_database + + +FORMAT = "trainlog-sleep-diary" +VERSION = 1 +MAX_BYTES = 16 * 1024 * 1024 +MAX_ENTRIES = 3660 +MAX_EVENTS = 64 +UUID4 = r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}" +ENTRY_ID = re.compile(rf"^sl_{UUID4}$") +REVISION_ID = re.compile(rf"^slr_{UUID4}$") +EVENT_ID = re.compile(rf"^sle_{UUID4}$") +POINTS = {"bed_time", "final_get_up", "night_get_up", "daytime_sleepiness"} +INTERVALS = {"sleep", "nap", "long_awake", "half_sleep"} +QUALITIES = {"TB", "B", "Moy", "M", "TM"} + + +def fail(message: str) -> None: + raise ValueError(message) + + +def unique_object(pairs): + result = {} + for key, value in pairs: + if key in result: + fail("duplicate JSON field: " + key) + result[key] = value + return result + + +def timestamp(value: object) -> str: + if not isinstance(value, str) or re.search(r"(?:Z|[+-][0-9]{2}:[0-9]{2})$", value) is None: + fail("invalid sleep diary timestamp") + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as error: + raise ValueError("invalid sleep diary timestamp") from error + if parsed.utcoffset() is None: + fail("sleep diary timestamp lacks an offset") + return value + + +def validate(root: object) -> dict: + if not isinstance(root, dict) or set(root) != {"format", "version", "generated_at", "entries"}: + fail("invalid sleep diary envelope") + if root["format"] != FORMAT or type(root["version"]) is not int or root["version"] != VERSION: + fail("unsupported sleep diary format") + timestamp(root["generated_at"]) + entries = root["entries"] + if not isinstance(entries, list) or len(entries) > MAX_ENTRIES: + fail("sleep diary entry bound exceeded") + seen_entries = set() + for item in entries: + keys = {"entry_id", "night_start_date", "night_end_date", "created_at", "updated_at", + "revision_id", "parent_revision_id", "deleted", "sleep_quality", "wake_quality", + "day_form", "treatment_and_notes", "events"} + if not isinstance(item, dict) or set(item) != keys: + fail("invalid sleep diary entry") + if not ENTRY_ID.fullmatch(item["entry_id"]) or item["entry_id"] in seen_entries: + fail("invalid or duplicate sleep diary entry identity") + seen_entries.add(item["entry_id"]) + if not REVISION_ID.fullmatch(item["revision_id"]): + fail("invalid sleep diary revision identity") + parent = item["parent_revision_id"] + if parent is not None and (not isinstance(parent, str) or not REVISION_ID.fullmatch(parent) or parent == item["revision_id"]): + fail("invalid sleep diary parent revision") + try: + start_date = date.fromisoformat(item["night_start_date"]) + end_date = date.fromisoformat(item["night_end_date"]) + except (TypeError, ValueError) as error: + raise ValueError("invalid sleep diary date") from error + if start_date >= end_date: + fail("sleep diary date range is empty") + created = datetime.fromisoformat(timestamp(item["created_at"]).replace("Z", "+00:00")) + updated = datetime.fromisoformat(timestamp(item["updated_at"]).replace("Z", "+00:00")) + if updated < created or type(item["deleted"]) is not bool: + fail("invalid sleep diary lifecycle") + for name in ("sleep_quality", "wake_quality", "day_form"): + if item[name] is not None and item[name] not in QUALITIES: + fail("invalid sleep diary appreciation") + notes = item["treatment_and_notes"] + if not isinstance(notes, str) or len(notes.encode()) > 16384: + fail("sleep diary notes bound exceeded") + events = item["events"] + if not isinstance(events, list) or len(events) > MAX_EVENTS: + fail("sleep diary event bound exceeded") + seen_events = set() + for event in events: + if not isinstance(event, dict) or set(event) != {"event_id", "type", "start_at", "end_at"}: + fail("invalid sleep diary event") + if not EVENT_ID.fullmatch(event["event_id"]) or event["event_id"] in seen_events: + fail("invalid or duplicate sleep event identity") + seen_events.add(event["event_id"]) + start = datetime.fromisoformat(timestamp(event["start_at"]).replace("Z", "+00:00")) + if event["type"] in POINTS: + if event["end_at"] is not None: + fail("point sleep event has an end") + elif event["type"] in INTERVALS: + end = datetime.fromisoformat(timestamp(event["end_at"]).replace("Z", "+00:00")) + if end <= start: + fail("sleep interval is not positive in absolute time") + else: + fail("unknown sleep diary event type") + return root + + +def load(path: Path) -> dict: + if path.stat().st_size > MAX_BYTES: + fail("sleep diary artifact exceeds 16 MiB") + return validate(json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=unique_object)) + + +def build(db: sqlite3.Connection) -> dict: + entries = [] + rows = db.execute( + "SELECT entry_id,night_start_date,night_end_date,created_at,updated_at,current_revision_id,deleted " + "FROM sleep_diary_entries ORDER BY entry_id" + ).fetchall() + if len(rows) > MAX_ENTRIES: + fail("sleep diary entry bound exceeded") + for entry_id, night_start, night_end, created_at, updated_at, revision_id, deleted in rows: + revision = db.execute( + "SELECT parent_revision_id,sleep_quality,wake_quality,day_form,treatment_and_notes " + "FROM sleep_diary_revisions WHERE revision_id=? AND entry_id=?", (revision_id, entry_id) + ).fetchone() + if revision is None: + fail("sleep diary current revision is missing") + events = [ + {"event_id": row[0], "type": row[1], "start_at": row[2], "end_at": row[3]} + for row in db.execute( + "SELECT event_id,event_type,start_at,end_at FROM sleep_diary_events " + "WHERE revision_id=? ORDER BY event_id", (revision_id,) + ) + ] + entries.append({"entry_id": entry_id, "night_start_date": night_start, + "night_end_date": night_end, "created_at": created_at, "updated_at": updated_at, + "revision_id": revision_id, "parent_revision_id": revision[0], "deleted": bool(deleted), + "sleep_quality": revision[1], "wake_quality": revision[2], "day_form": revision[3], + "treatment_and_notes": revision[4] or "", "events": events}) + return validate({"format": FORMAT, "version": VERSION, + "generated_at": datetime.now().astimezone().isoformat(), "entries": entries}) + + +def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]: + validate(root) + applied = 0 + unchanged = 0 + for item in root["entries"]: + local = db.execute( + "SELECT current_revision_id,deleted FROM sleep_diary_entries WHERE entry_id=?", + (item["entry_id"],), + ).fetchone() + if local is not None and local[0] == item["revision_id"]: + persisted = next( + value for value in build(db)["entries"] if value["entry_id"] == item["entry_id"] + ) + if persisted != item: + fail("sleep diary revision identity reused with different content") + unchanged += 1 + continue + if local is not None and item["parent_revision_id"] != local[0]: + fail("concurrent sleep diary revision") + if local is None and item["parent_revision_id"] is not None: + fail("sleep diary revision has an unknown parent") + if local is not None and local[1] and not item["deleted"]: + fail("sleep diary deletion cannot be resurrected") + if local is None: + db.execute("INSERT INTO sleep_diary_entries VALUES(?,?,?,?,?,?,?)", + (item["entry_id"], item["night_start_date"], item["night_end_date"], + item["created_at"], item["updated_at"], item["revision_id"], int(item["deleted"]))) + else: + db.execute("UPDATE sleep_diary_entries SET night_start_date=?,night_end_date=?,updated_at=?," + "current_revision_id=?,deleted=? WHERE entry_id=? AND current_revision_id=?", + (item["night_start_date"], item["night_end_date"], item["updated_at"], + item["revision_id"], int(item["deleted"]), item["entry_id"], local[0])) + db.execute("INSERT INTO sleep_diary_revisions VALUES(?,?,?,?,?,?,?,?)", + (item["revision_id"], item["entry_id"], item["parent_revision_id"], item["updated_at"], + item["sleep_quality"], item["wake_quality"], item["day_form"], item["treatment_and_notes"])) + db.executemany("INSERT INTO sleep_diary_events VALUES(?,?,?,?,?)", + [(item["revision_id"], event["event_id"], event["type"], event["start_at"], event["end_at"]) + for event in item["events"]]) + applied += 1 + return applied, unchanged + + +def main() -> None: + parser = argparse.ArgumentParser() + sub = parser.add_subparsers(dest="command", required=True) + export = sub.add_parser("export") + export.add_argument("output", type=Path) + export.add_argument("--database", required=True, type=Path) + import_command = sub.add_parser("import") + import_command.add_argument("input", type=Path) + import_command.add_argument("--database", required=True, type=Path) + args = parser.parse_args() + with connect_database(args.database) as db: + if db.execute("PRAGMA user_version").fetchone()[0] != 29: + fail("desktop schema v29 required") + if args.command == "export": + args.output.write_text(json.dumps(build(db), ensure_ascii=False, sort_keys=True, + separators=(",", ":")), encoding="utf-8") + else: + apply(db, load(args.input)) + db.commit() + + +if __name__ == "__main__": + main() diff --git a/tools/sync_generation_exchange.py b/tools/sync_generation_exchange.py index 68f53de..d0ba2e1 100644 --- a/tools/sync_generation_exchange.py +++ b/tools/sync_generation_exchange.py @@ -29,6 +29,7 @@ import import_exercise_profile_state import import_mobile_export import import_training_feedback import program_execution_exchange +import sleep_diary_exchange import export_equipment_definitions import export_exercise_body_zones import export_pc_mobile @@ -75,6 +76,8 @@ ARTIFACTS = ( "session-preparations-v2.json", False, "export_session_preparations.py", ()), ("programs-v1", "trainlog-programs", 1, "programs-v1.json", False, "export_programs.py", ()), + ("sleep-diary", "trainlog-sleep-diary", 1, "sleep-diary-v1.json", False, + "sleep_diary_exchange.py", ("export",)), ) SUPPORTED = {(row[1], row[2]) for row in ARTIFACTS} | { ("trainlog-program-executions", 1), @@ -206,9 +209,9 @@ def validate_manifest_bytes(raw: bytes, expected_consumer: str | None = None): def require_schema(db: sqlite3.Connection) -> None: - supported_versions = (24, 25, 26, 27, 28) + supported_versions = (24, 25, 26, 27, 28, 29) if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise GenerationError("desktop schema v24, v25, v26, v27 or v28 required") + raise GenerationError("desktop schema v24, v25, v26, v27, v28 or v29 required") def peer_identity(db: sqlite3.Connection, kind: str) -> str: @@ -325,7 +328,8 @@ def archive_acknowledged(database: Path, owned_root: Path, consumer: str) -> int def run_export(tool: str, extra: tuple[str, ...], output: Path, snapshot: Path) -> None: command = [sys.executable, str(ROOT / "tools" / tool)] - if tool in ("execution_draft_exchange.py", "causal_delete_exchange.py"): + if tool in ("execution_draft_exchange.py", "causal_delete_exchange.py", + "sleep_diary_exchange.py"): command.extend(extra); command.append(str(output)) else: command.append(str(output)); command.extend(extra) @@ -610,6 +614,8 @@ def consume_desktop(database: Path, directory: Path) -> dict: zones_payload = strict_json(required("body-zones").read_bytes(), MAX_ARTIFACT) parsed_zones = import_exercise_body_zones.parse_payload(zones_payload) feedback = import_training_feedback.load(required("feedback")) + sleep_diary = (sleep_diary_exchange.load(listed["sleep-diary"]) + if "sleep-diary" in listed else None) program_executions = ( program_execution_exchange.load(listed["program-executions"]) if "program-executions" in listed else None @@ -651,6 +657,8 @@ def consume_desktop(database: Path, directory: Path) -> dict: import_exercise_body_zones.apply_body_zones(db, parsed_zones, {}, complete_causal_envelope=True) import_training_feedback.apply_feedback(db, feedback, complete_causal_envelope=True) execution_draft_exchange.import_document(db, drafts, own_transaction=False) + if sleep_diary is not None: + sleep_diary_exchange.apply(db, sleep_diary) if program_executions is not None: program_execution_exchange.apply_executions(db, program_executions) ack = record_consumed(db, manifest, manifest_digest)