feat(sync): synchronize sleep diary
This commit is contained in:
parent
2565d0eafa
commit
30df3030e6
6 changed files with 298 additions and 3 deletions
|
|
@ -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"]))
|
||||
|
|
|
|||
60
tests/test_sleep_diary_exchange.py
Normal file
60
tests/test_sleep_diary_exchange.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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=[]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
218
tools/sleep_diary_exchange.py
Normal file
218
tools/sleep_diary_exchange.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue