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"),
|
Kind("trainlog-causal-deletions", 1, "causal-deletions-v1.json"),
|
||||||
"program-executions" to
|
"program-executions" to
|
||||||
Kind("trainlog-program-executions", 1, "program-executions-v1.json", false),
|
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 =
|
private val SUPPORTED =
|
||||||
(ANDROID_KINDS.values +
|
(ANDROID_KINDS.values +
|
||||||
|
|
@ -664,6 +665,7 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
||||||
is ProgramsImportResult.Applied,
|
is ProgramsImportResult.Applied,
|
||||||
is CausalDeleteResult.Applied,
|
is CausalDeleteResult.Applied,
|
||||||
is CausalDeleteResult.Unchanged -> null
|
is CausalDeleteResult.Unchanged -> null
|
||||||
|
is TrainlogRepository.SleepDiaryImportResult.Applied -> null
|
||||||
else -> result.toString()
|
else -> result.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -840,6 +842,10 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
||||||
apply("execution-drafts") {
|
apply("execution-drafts") {
|
||||||
repository.applyExecutionDraftExportV1Json(checkNotNull(a["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)
|
if ("ai-proposals" in a)
|
||||||
apply("ai-proposals") {
|
apply("ai-proposals") {
|
||||||
repository.applyAiSessionDraftsJson(checkNotNull(a["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")])
|
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):
|
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")
|
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()
|
directory=self.root/"incoming";directory.mkdir()
|
||||||
artifacts=[]
|
artifacts=[]
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ RUNTIME_TOOLS = [
|
||||||
"sync_peer_worker.py",
|
"sync_peer_worker.py",
|
||||||
"sync_drive_transport.py",
|
"sync_drive_transport.py",
|
||||||
"sync_generation_exchange.py",
|
"sync_generation_exchange.py",
|
||||||
|
"sleep_diary_exchange.py",
|
||||||
"trainlog_sqlite.py",
|
"trainlog_sqlite.py",
|
||||||
"validate_json.py",
|
"validate_json.py",
|
||||||
"validate_training_knowledge.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_mobile_export
|
||||||
import import_training_feedback
|
import import_training_feedback
|
||||||
import program_execution_exchange
|
import program_execution_exchange
|
||||||
|
import sleep_diary_exchange
|
||||||
import export_equipment_definitions
|
import export_equipment_definitions
|
||||||
import export_exercise_body_zones
|
import export_exercise_body_zones
|
||||||
import export_pc_mobile
|
import export_pc_mobile
|
||||||
|
|
@ -75,6 +76,8 @@ ARTIFACTS = (
|
||||||
"session-preparations-v2.json", False, "export_session_preparations.py", ()),
|
"session-preparations-v2.json", False, "export_session_preparations.py", ()),
|
||||||
("programs-v1", "trainlog-programs", 1, "programs-v1.json", False,
|
("programs-v1", "trainlog-programs", 1, "programs-v1.json", False,
|
||||||
"export_programs.py", ()),
|
"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} | {
|
SUPPORTED = {(row[1], row[2]) for row in ARTIFACTS} | {
|
||||||
("trainlog-program-executions", 1),
|
("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:
|
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:
|
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:
|
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:
|
def run_export(tool: str, extra: tuple[str, ...], output: Path, snapshot: Path) -> None:
|
||||||
command = [sys.executable, str(ROOT / "tools" / tool)]
|
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))
|
command.extend(extra); command.append(str(output))
|
||||||
else:
|
else:
|
||||||
command.append(str(output)); command.extend(extra)
|
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)
|
zones_payload = strict_json(required("body-zones").read_bytes(), MAX_ARTIFACT)
|
||||||
parsed_zones = import_exercise_body_zones.parse_payload(zones_payload)
|
parsed_zones = import_exercise_body_zones.parse_payload(zones_payload)
|
||||||
feedback = import_training_feedback.load(required("feedback"))
|
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_executions = (
|
||||||
program_execution_exchange.load(listed["program-executions"])
|
program_execution_exchange.load(listed["program-executions"])
|
||||||
if "program-executions" in listed else None
|
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_exercise_body_zones.apply_body_zones(db, parsed_zones, {}, complete_causal_envelope=True)
|
||||||
import_training_feedback.apply_feedback(db, feedback, 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)
|
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:
|
if program_executions is not None:
|
||||||
program_execution_exchange.apply_executions(db, program_executions)
|
program_execution_exchange.apply_executions(db, program_executions)
|
||||||
ack = record_consumed(db, manifest, manifest_digest)
|
ack = record_consumed(db, manifest, manifest_digest)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue