From 444508b8b97127f5ad8bd08e4d4446f8f742bc8f Mon Sep 17 00:00:00 2001 From: fy59 Date: Thu, 17 Sep 2026 11:14:53 +0200 Subject: [PATCH] test(sync): prove remaining causal deletion domains --- CHANGELOG.md | 6 + .../data/TrainlogRepositoryDraftTest.kt | 225 ++++++++++++++++++ docs/current_state.md | 9 +- docs/roadmap.md | 3 +- docs/tests.md | 31 ++- tests/support/roundtrip_desktop_lifecycle.py | 141 ++++++++++- 6 files changed, 411 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b67d3c5..75e8feb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased — 0.1.2 +- Completed the residual causal-deletion evidence with real Android and + desktop producers for exercise, body-observation, custom-equipment, feedback + and BODY ZONE relation targets, including production replica exchange, + retained-history/current-reader checks, reopen/replay and stale-companion + refusal. + - Closed the causal-deletion correctness review: deletion predecessors now cover affected session children, measurements, links, notes and immutable feedback revisions; supported corrections preserve an intervening mutation diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt index cb3202e..5f1a007 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt @@ -739,6 +739,166 @@ class TrainlogRepositoryDraftTest { } } + @Test + fun causalFiveDomainDeletionsRoundTripWithAndroidAndDesktopProducers() { + val sourceName = "causal-domains-source-${UUID.randomUUID()}.db" + val source = TrainlogRepository(context, sourceName) + val root = Files.createTempDirectory("trainlog-causal-domains-").toFile() + try { + val retired = createExercise(source, "Retraite échappée 💪", RecordingMode.SETS, TrackingMode.REPS) + val zoned = createExercise(source, "Zones croisées", RecordingMode.SETS, TrackingMode.REPS) + val unrelated = createExercise(source, "Témoin inchangé", RecordingMode.SETS, TrackingMode.REPS) + val aliasSource = createExercise(source, "Ancien nom causal", RecordingMode.SETS, TrackingMode.REPS) + val aliasInput = JSONObject().put("format", "trainlog-exercise-aliases").put("version", 1) + .put("aliases", JSONArray().put(JSONObject() + .put("source_exercise_id", aliasSource.exerciseId) + .put("canonical_exercise_id", retired.exerciseId))) + assertTrue(source.applyExerciseAliasesJson(aliasInput.toString()) is ExerciseAliasImportResult.Applied) + val zoneArtifact = JSONObject(source.buildExerciseBodyZonesJson()) + fun zoneItem(id: String, primary: String, secondary: String) = JSONObject() + .put("exercise_id", id).put("primary_zone_id", primary) + .put("secondary_zone_ids", JSONArray().put(secondary)) + zoneArtifact.put("exercises", JSONArray() + .put(zoneItem(retired.exerciseId, "arms", "shoulders")) + .put(zoneItem(zoned.exerciseId, "chest", "arms")) + .put(zoneItem(unrelated.exerciseId, "thighs", "glutes"))) + assertTrue(source.applyExerciseBodyZonesJson(zoneArtifact.toString()) is ExerciseBodyZoneImportResult.Applied) + + val targetEquipment = (source.createCustomEquipment("Presse privée ç") as CreateEquipmentResult.Created).equipment + val otherEquipment = (source.createCustomEquipment("Rack témoin") as CreateEquipmentResult.Created).equipment + val targetEntry = "sxe_62000000-0000-4000-8000-000000000001" + val otherEntry = "sxe_62000000-0000-4000-8000-000000000002" + val session = source.saveSession(SessionDraft(listOf( + SessionExerciseDraft(entryId = targetEntry, exercise = retired, + equipmentId = targetEquipment.equipmentId, + sets = listOf(SessionSetDraft(reps = 8, weightKg = 47.5))), + SessionExerciseDraft(entryId = otherEntry, exercise = zoned, + equipmentId = "leg_press", sets = listOf(SessionSetDraft(reps = 11))), + ))) as SaveSessionResult.Saved + val targetFeedback = (source.saveExerciseFeedback(session.sessionId, targetEntry, + "Première impression échappée") as SaveFeedbackResult.Saved).stableId + assertTrue(source.reviseExerciseFeedback(targetFeedback, + "Révision durable 💪") is SaveFeedbackResult.Saved) + val otherFeedback = (source.saveExerciseFeedback(session.sessionId, otherEntry, + "Ressenti témoin") as SaveFeedbackResult.Saved).stableId + val targetObservation = (source.saveBodyObservation(BodyObservationDraft( + bodyWeightKg = 79.4, waistCm = 83.2, leftArmCm = 36.1, + )) as SaveBodyObservationResult.Saved).observationId + val otherObservation = (source.saveBodyObservation(BodyObservationDraft( + bodyWeightKg = 78.9, chestCm = 101.3, + )) as SaveBodyObservationResult.Saved).observationId + + val live = File(root, "live-v4.json").apply { writeText(source.buildMobileExportV4Json()) } + val equipment = File(root, "equipment.json").apply { writeText(source.buildEquipmentDefinitionsJson()) } + val zones = File(root, "zones.json").apply { writeText(source.buildExerciseBodyZonesJson()) } + val feedback = File(root, "feedback.json").apply { writeText(source.buildTrainingFeedbackJson()) } + val aliases = File(root, "aliases.json").apply { writeText(source.buildExerciseAliasesJson()) } + val targets = linkedMapOf( + "exercise" to retired.exerciseId, + "body_observation" to targetObservation, + "custom_equipment" to targetEquipment.equipmentId, + "feedback" to targetFeedback, + "body_zone_relation" to "${zoned.exerciseId}|arms|secondary", + ) + val manifest = File(root, "manifest.json").apply { writeText(JSONObject() + .put("history", live.absolutePath).put("equipment", equipment.absolutePath) + .put("zones", zones.absolutePath).put("feedback", feedback.absolutePath) + .put("aliases", aliases.absolutePath) + .put("targets", JSONObject(targets as Map<*, *>)) + .put("other_observation_id", otherObservation) + .put("other_exercise_id", unrelated.exerciseId) + .put("other_equipment_id", otherEquipment.equipmentId) + .put("other_feedback_id", otherFeedback) + .put("zone_exercise_id", zoned.exerciseId).put("other_zone_id", "chest") + .put("session_id", session.sessionId).toString()) } + + val androidReplicaDesktop = File(root, "android-source-desktop.sqlite") + val desktopArtifacts = File(root, "desktop-artifacts") + runLifecycleBridge("domains", manifest, androidReplicaDesktop, desktopArtifacts) + + targets.forEach { (kind, target) -> + assertTrue("Android producer $kind", source.deleteCausally(kind, target, + "peer_android_domains") is CausalDeleteResult.Applied) + assertTrue("Android repeated delete $kind", source.deleteCausally(kind, target, + "peer_android_domains") is CausalDeleteResult.Unchanged) + } + val androidDelete = File(root, "android-domain-delete.json").apply { + writeText(source.buildCausalDeletionExportV1Json()) + } + assertEquals(targets.keys, causalOperations(androidDelete).map { it.getString("target_kind") }.toSet()) + runDesktopCausal("import", androidDelete, androidReplicaDesktop) + runDesktopCausal("import", androidDelete, androidReplicaDesktop) + assertDesktopDomainEffects(manifest, androidReplicaDesktop, File(root, "android-effects.json")) + runLifecycleBridge("refuse-domains", manifest, androidReplicaDesktop, + File(root, "desktop-stale-refusals.json")) + assertDesktopDomainEffects(manifest, androidReplicaDesktop, File(root, "android-effects-after-stale.json")) + val desktopRelay = File(root, "desktop-relay.json") + runDesktopCausal("export", desktopRelay, androidReplicaDesktop) + assertEquals(semanticCausalProjection(JSONObject(androidDelete.readText())), + semanticCausalProjection(JSONObject(desktopRelay.readText()))) + + val desktopProducer = File(root, "desktop-producer.sqlite") + val desktopProducerArtifacts = File(root, "desktop-producer-artifacts") + runLifecycleBridge("domains", manifest, desktopProducer, desktopProducerArtifacts) + targets.forEach { (kind, target) -> + val first = File(root, "desktop-$kind-first.json") + val second = File(root, "desktop-$kind-second.json") + runDesktopCausal("delete", first, desktopProducer, kind, target) + runDesktopCausal("delete", second, desktopProducer, kind, target) + assertEquals(first.readText(), second.readText()) + } + val desktopDelete = File(root, "desktop-domain-delete.json") + runDesktopCausal("export", desktopDelete, desktopProducer) + assertDesktopDomainEffects(manifest, desktopProducer, File(root, "desktop-effects.json")) + + val reverseName = "causal-domains-reverse-${UUID.randomUUID()}.db" + val reverse = TrainlogRepository(context, reverseName) + try { + assertTrue(reverse.applyPcEquipmentDefinitionsJson( + File(desktopProducerArtifacts, "equipment.json").readText()) is EquipmentDefinitionImportResult.Applied) + assertTrue(reverse.applyPcCatalogJson( + File(desktopProducerArtifacts, "catalog.json").readText()) is PcCatalogImportResult.Applied) + assertTrue(reverse.applyPcMobileExportV4Json( + File(desktopProducerArtifacts, "history.json").readText()) is MobileSessionImportResult.Applied) + assertTrue(reverse.applyExerciseBodyZonesJson( + File(desktopProducerArtifacts, "zones.json").readText()) is ExerciseBodyZoneImportResult.Applied) + assertTrue(reverse.applyTrainingFeedbackJson( + File(desktopProducerArtifacts, "feedback.json").readText()) is TrainingFeedbackImportResult.Applied) + assertTrue(reverse.applyExerciseAliasesJson( + File(desktopProducerArtifacts, "aliases.json").readText()) is ExerciseAliasImportResult.Applied) + assertTrue(reverse.applyCausalDeletionExportV1Json(desktopDelete.readText()) is CausalDeleteResult.Applied) + assertTrue(reverse.applyCausalDeletionExportV1Json(desktopDelete.readText()) is CausalDeleteResult.Unchanged) + assertAndroidDomainEffects(reverse, reverseName, targets, otherObservation, + unrelated.exerciseId, otherEquipment.equipmentId, otherFeedback, zoned.exerciseId) + assertTrue(reverse.applyPcMobileExportV4Json( + File(desktopProducerArtifacts, "history.json").readText()) is MobileSessionImportResult.Invalid) + assertTrue(reverse.applyPcEquipmentDefinitionsJson( + File(desktopProducerArtifacts, "equipment.json").readText()) is EquipmentDefinitionImportResult.Invalid) + assertTrue(reverse.applyExerciseBodyZonesJson( + File(desktopProducerArtifacts, "zones.json").readText()) is ExerciseBodyZoneImportResult.Invalid) + assertTrue(reverse.applyTrainingFeedbackJson( + File(desktopProducerArtifacts, "feedback.json").readText()) is TrainingFeedbackImportResult.Invalid) + val androidRelay = File(root, "android-relay.json").apply { + writeText(reverse.buildCausalDeletionExportV1Json()) + } + runDesktopCausal("import", androidRelay, desktopProducer) + assertEquals(semanticCausalProjection(JSONObject(desktopDelete.readText())), + semanticCausalProjection(JSONObject(androidRelay.readText()))) + reverse.close() + val reopened = TrainlogRepository(context, reverseName) + assertTrue(reopened.applyCausalDeletionExportV1Json(desktopDelete.readText()) is CausalDeleteResult.Unchanged) + assertAndroidDomainEffects(reopened, reverseName, targets, otherObservation, + unrelated.exerciseId, otherEquipment.equipmentId, otherFeedback, zoned.exerciseId) + reopened.close() + } finally { + try { reverse.close() } catch (_: Exception) { } + context.deleteDatabase(reverseName) + } + } finally { + source.close(); context.deleteDatabase(sourceName); root.deleteRecursively() + } + } + @Test fun causalDraftDeletionUsesTheSameRevisionForActivePendingAndDesktopReplicas() { val sourceName = "causal-draft-source-${UUID.randomUUID()}.db" @@ -2911,6 +3071,71 @@ class TrainlogRepositoryDraftTest { return canonicalJson(copy).toString() } + private fun causalOperations(file: File): List { + val values = JSONObject(file.readText()).getJSONArray("operations") + return (0 until values.length()).map { values.getJSONObject(it) } + } + + private fun assertDesktopDomainEffects(manifest: File, database: File, output: File) { + runLifecycleBridge("inspect-domains", manifest, database, output) + val value = JSONObject(output.readText()) + assertEquals(5, value.getInt("operation_count")) + val states = value.getJSONObject("states") + listOf("exercise", "body_observation", "custom_equipment", "feedback", + "body_zone_relation").forEach { assertEquals(1, states.getInt(it)) } + listOf("target_observation_rows", "target_exercise_available", + "target_equipment_available", "feedback_current_rows", "target_zone_rows") + .forEach { assertEquals(it, 0, value.getInt(it)) } + listOf("other_observation_rows", "target_exercise_history_rows", + "other_exercise_available", "target_equipment_history_rows", + "other_equipment_available", "builtin_equipment_rows", "other_feedback_current_rows", "other_zone_rows", + "session_rows").forEach { assertEquals(it, 1, value.getInt(it)) } + assertEquals(2, value.getInt("feedback_revision_rows")) + } + + private fun assertAndroidDomainEffects( + repo: TrainlogRepository, + name: String, + targets: Map, + otherObservation: String, + otherExercise: String, + otherEquipment: String, + otherFeedback: String, + zoneExercise: String, + ) { + assertFalse(repo.listExercises().any { it.exerciseId == targets.getValue("exercise") }) + assertTrue(repo.listExercises().any { it.exerciseId == otherExercise }) + assertFalse(repo.listEquipment().any { it.equipmentId == targets.getValue("custom_equipment") }) + assertTrue(repo.listEquipment().any { it.equipmentId == otherEquipment }) + assertTrue(repo.listEquipment().any { it.equipmentId == "leg_press" }) + assertFalse(repo.listBodyObservations().any { it.observationId == targets.getValue("body_observation") }) + assertTrue(repo.listBodyObservations().any { it.observationId == otherObservation }) + val sessionId = repo.listSessions().single().sessionId + assertFalse(repo.listExerciseFeedback(sessionId).any { it.feedbackId == targets.getValue("feedback") }) + assertTrue(repo.listExerciseFeedback(sessionId).any { it.feedbackId == otherFeedback }) + val db = SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null, + SQLiteDatabase.OPEN_READONLY) + db.use { + fun count(sql: String, args: Array = emptyArray()): Int = + it.rawQuery(sql, args).use { cursor -> cursor.moveToFirst(); cursor.getInt(0) } + assertEquals(1, count("SELECT COUNT(*) FROM exercises WHERE exercise_id=?", + arrayOf(targets.getValue("exercise")))) + assertEquals(1, count("SELECT COUNT(*) FROM equipment WHERE equipment_id=?", + arrayOf(targets.getValue("custom_equipment")))) + assertEquals(1, count("SELECT COUNT(*) FROM exercise_feedback WHERE feedback_id=?", + arrayOf(targets.getValue("feedback")))) + assertEquals(2, count("SELECT COUNT(*) FROM exercise_feedback_revisions WHERE feedback_id=?", + arrayOf(targets.getValue("feedback")))) + assertEquals(0, count("SELECT COUNT(*) FROM exercise_body_zones z JOIN exercises e " + + "ON e.id=z.exercise_row_id WHERE e.exercise_id=? AND z.zone_id='arms' AND z.role='secondary'", + arrayOf(zoneExercise))) + assertEquals(1, count("SELECT COUNT(*) FROM exercise_body_zones z JOIN exercises e " + + "ON e.id=z.exercise_row_id WHERE e.exercise_id=? AND z.zone_id='chest' AND z.role='primary'", + arrayOf(zoneExercise))) + assertEquals(5, count("SELECT COUNT(*) FROM sync_causal_state WHERE deleted=1")) + } + } + private fun runDesktopCausal( mode: String, artifact: File, diff --git a/docs/current_state.md b/docs/current_state.md index fb6338a..e9adac1 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -303,8 +303,15 @@ active/pending/desktop draft-revision equivalence, protected companion refusal, current-view retirement and bounded pre-effect admission. It changes neither the v20/v19 schemas nor active V3 transport selection. +The residual causal evidence also proves legitimate Android and desktop +producer directions separately for exercise retirement, populated body +observation deletion, custom-equipment retirement, multi-revision feedback +withdrawal and BODY ZONE relation removal. Production exchanges establish each +replica before deletion; stored effects, current readers, stale-companion +refusal and exact replay are checked after reopening the receiving store. + The isolated closeout passed 76/76 normal Meson tests, 76/76 ASan/UBSan -tests, Android debug assembly, and 210 Android tests (206 passed, four optional +tests, Android debug assembly, and 211 Android tests (207 passed, four optional historical-fixture skips). Strict desktop builds use Clang 22.1.8; the compared pre-existing GCC 16.2.1 Web warning gate remains documented rather than green. diff --git a/docs/roadmap.md b/docs/roadmap.md index ae7f7ab..dafb6a8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -132,7 +132,8 @@ orchestrator/report, Web API, then button/refresh. The environment/test and characterization and data/lifecycle lots are complete. Enriched V4 history and the separate execution-draft codec are implemented behind explicit entry points, while active transport remains V3. Causal deletion is implemented and -validated through explicit staged entry points; generation/consumption +validated through explicit staged entry points, including both real producer +directions for every contracted target kind; generation/consumption acknowledgement, orchestration/reporting and Web sync remain unimplemented. `WEB_DASHBOARD_V1=PASS/FROZEN` remains closed and unchanged. diff --git a/docs/tests.md b/docs/tests.md index 326fece..4c0195a 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -1199,12 +1199,41 @@ safety, exact replay, conflicts, finalization protection, and rollback. Android real Android repository and desktop service in both producer directions with fresh destinations, reopen/replay, and legacy non-resurrection. +`causalFiveDomainDeletionsRoundTripWithAndroidAndDesktopProducers` closes the +remaining producer/consumer matrix. The Android repository creates exercises, +populated observations, custom equipment, multi-revision feedback and BODY +ZONE relations; production V4 and companion tools establish the desktop +replica. Android-local deletion is consumed and replayed by the desktop +service. A second production-provisioned desktop replica creates each deletion +locally, exports it to Android, and receives Android's exact replay. + +| Kind | Producer -> consumer | Stored/read-model assertion | Result | +|---|---|---|---| +| `exercise` | Android -> desktop | Historical row/reference retained; new-work predicate excludes it; unrelated exercise and alias protection remain | PASS | +| `exercise` | desktop -> Android | Repository list excludes it after reopen; history row remains; stale V4/catalog state cannot restore it | PASS | +| `body_observation` | Android -> desktop | Populated target row is absent; session and unrelated populated observation remain | PASS | +| `body_observation` | desktop -> Android | Repository list excludes the target after reopen; unrelated observation and session remain | PASS | +| `custom_equipment` | Android -> desktop | Custom definition/history remains but new-work predicate excludes it; built-in occurrence equipment remains | PASS | +| `custom_equipment` | desktop -> Android | Historical equipment row/reference remains; repository list excludes it; built-in equipment remains | PASS | +| `feedback` | Android -> desktop | Root and two immutable revisions remain; current predicate excludes it; unrelated feedback remains | PASS | +| `feedback` | desktop -> Android | Root/revisions remain after reopen; repository current view excludes it; unrelated feedback remains | PASS | +| `body_zone_relation` | Android -> desktop | Target secondary relation is absent; primary relation and exercise remain | PASS | +| `body_zone_relation` | desktop -> Android | Target secondary relation is absent after reopen; primary relation and exercise remain | PASS | + +Every row uses a legitimate replica established through production exchange +entry points. Repeated local deletion returns the original immutable operation; +the opposite exporter preserves its operation ID, target, predecessor and +payload; replay after a new database connection is unchanged. Protected V4, +equipment, feedback and BODY ZONES legacy imports refuse stale restoration and +leave all accepted rows and causal state unchanged. Generic codec reuse is not +counted as a domain result. + Strict compilation uses Clang 22.1.8. GCC 16.2.1 retains the compared pre-existing `-Werror=misleading-indentation` failure in unchanged Web baseline sources; it is not reported as green and warning policy is unchanged. The causal closeout full harness result is 76/76 normal Meson, 76/76 -ASan/UBSan, and 210 Android tests: 206 passed and four optional historical +ASan/UBSan, and 211 Android tests: 207 passed and four optional historical database fixtures skipped. `assembleDebug`, JSON validation, import-contract validation, both frontend tests, and the isolated characterization all pass. diff --git a/tests/support/roundtrip_desktop_lifecycle.py b/tests/support/roundtrip_desktop_lifecycle.py index 3a5d151..e95cc7f 100644 --- a/tests/support/roundtrip_desktop_lifecycle.py +++ b/tests/support/roundtrip_desktop_lifecycle.py @@ -2,6 +2,7 @@ """Bridge lifecycle artifacts through the real staged desktop entry points.""" import argparse +import json import sqlite3 import subprocess import sys @@ -16,9 +17,13 @@ from test_session_exchange_v3 import V11_SCHEMA # noqa: E402 V19_ADDITIONS = """ ALTER TABLE session_exercises ADD COLUMN tracking_mode TEXT; +DROP TABLE exercise_body_zone_sync; +CREATE TABLE exercise_body_zone_sync(exercise_row_id INTEGER PRIMARY KEY REFERENCES exercises(id) ON DELETE CASCADE,synced_state TEXT NOT NULL); CREATE TABLE exercise_aliases(source_exercise_id TEXT PRIMARY KEY,canonical_exercise_id TEXT NOT NULL REFERENCES exercises(exercise_id) ON DELETE RESTRICT,CHECK(source_exercise_id<>canonical_exercise_id)); CREATE TABLE exercise_feedback(id INTEGER PRIMARY KEY,feedback_id TEXT NOT NULL UNIQUE,session_exercise_row_id INTEGER NOT NULL REFERENCES session_exercises(id) ON DELETE CASCADE,observed_at TEXT NOT NULL,raw_text TEXT NOT NULL); CREATE TABLE exercise_feedback_revisions(revision_id TEXT PRIMARY KEY,feedback_id TEXT NOT NULL REFERENCES exercise_feedback(feedback_id) ON DELETE CASCADE,created_at TEXT NOT NULL,raw_text TEXT NOT NULL); +CREATE TABLE session_followups(id INTEGER PRIMARY KEY,followup_id TEXT NOT NULL UNIQUE,session_row_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,observed_at TEXT NOT NULL,raw_text TEXT NOT NULL); +CREATE TABLE session_followup_revisions(revision_id TEXT PRIMARY KEY,followup_id TEXT NOT NULL REFERENCES session_followups(followup_id) ON DELETE CASCADE,created_at TEXT NOT NULL,raw_text TEXT NOT NULL); CREATE TABLE sync_note_revisions(owner_kind TEXT NOT NULL,owner_id TEXT NOT NULL,revision_id TEXT NOT NULL,parent_revision_id TEXT,note TEXT,CHECK(note IS NULL OR length(CAST(note AS BLOB))<=4096),PRIMARY KEY(owner_kind,owner_id,revision_id)); CREATE TABLE sync_note_state(owner_kind TEXT NOT NULL,owner_id TEXT NOT NULL,revision_id TEXT NOT NULL,PRIMARY KEY(owner_kind,owner_id),FOREIGN KEY(owner_kind,owner_id,revision_id) REFERENCES sync_note_revisions(owner_kind,owner_id,revision_id)); CREATE TABLE execution_drafts(session_id TEXT PRIMARY KEY,session_type TEXT NOT NULL,source_session_id TEXT,started_at TEXT,revision_id TEXT NOT NULL,parent_revision_id TEXT,state TEXT NOT NULL,payload_json TEXT NOT NULL); @@ -44,13 +49,147 @@ def create_v19(path: Path) -> None: connection.executescript(V19_ADDITIONS) +def domain_exchange(manifest_path: Path, database: Path, output: Path) -> None: + """Provision a replica only through the supported production exchanges.""" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + create_v19(database) + run([sys.executable, str(ROOT / "tools/import_equipment_definitions.py"), + manifest["equipment"], "--database", str(database)]) + run([sys.executable, str(ROOT / "tools/import_mobile_export.py"), + manifest["history"], "--database", str(database)]) + run([sys.executable, str(ROOT / "tools/import_exercise_body_zones.py"), + manifest["zones"], "--database", str(database)]) + run([sys.executable, str(ROOT / "tools/import_training_feedback.py"), + manifest["feedback"], "--database", str(database)]) + run([sys.executable, str(ROOT / "tools/import_exercise_aliases.py"), + manifest["aliases"], "--database", str(database)]) + output.mkdir(parents=True) + commands = { + "history": ("export_pc_mobile.py", ["--version", "4"]), + "catalog": ("export_pc_catalog.py", []), + "equipment": ("export_equipment_definitions.py", []), + "zones": ("export_exercise_body_zones.py", []), + "feedback": ("export_training_feedback.py", []), + "aliases": ("export_exercise_aliases.py", []), + } + for name, (tool, extra) in commands.items(): + target = output / f"{name}.json" + run([sys.executable, str(ROOT / "tools" / tool), str(target), + "--database", str(database), *extra]) + + +def inspect_domains(manifest_path: Path, database: Path, output: Path) -> None: + """Report persisted effects; production code owns every mutation above.""" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + with sqlite3.connect(database) as connection: + scalar = lambda sql, args=(): connection.execute(sql, args).fetchone()[0] + states = {kind: scalar( + "SELECT deleted FROM sync_causal_state WHERE target_kind=? AND target_id=?", + (kind, target), + ) for kind, target in manifest["targets"].items()} + result = { + "states": states, + "operation_count": scalar("SELECT COUNT(*) FROM sync_causal_operations"), + "target_observation_rows": scalar( + "SELECT COUNT(*) FROM body_observations WHERE observation_id=?", + (manifest["targets"]["body_observation"],)), + "other_observation_rows": scalar( + "SELECT COUNT(*) FROM body_observations WHERE observation_id=?", + (manifest["other_observation_id"],)), + "target_exercise_history_rows": scalar( + "SELECT COUNT(*) FROM exercises WHERE exercise_id=?", + (manifest["targets"]["exercise"],)), + "target_exercise_available": scalar( + "SELECT COUNT(*) FROM exercises e WHERE e.exercise_id=? AND NOT EXISTS(" + "SELECT 1 FROM sync_causal_state s WHERE s.target_kind='exercise' " + "AND s.target_id=e.exercise_id AND s.deleted=1)", + (manifest["targets"]["exercise"],)), + "other_exercise_available": scalar( + "SELECT COUNT(*) FROM exercises e WHERE e.exercise_id=? AND NOT EXISTS(" + "SELECT 1 FROM sync_causal_state s WHERE s.target_kind='exercise' " + "AND s.target_id=e.exercise_id AND s.deleted=1)", + (manifest["other_exercise_id"],)), + "target_equipment_history_rows": scalar( + "SELECT COUNT(*) FROM custom_equipment WHERE equipment_id=?", + (manifest["targets"]["custom_equipment"],)), + "target_equipment_available": scalar( + "SELECT COUNT(*) FROM custom_equipment e WHERE e.equipment_id=? AND NOT EXISTS(" + "SELECT 1 FROM sync_causal_state s WHERE s.target_kind='custom_equipment' " + "AND s.target_id=e.equipment_id AND s.deleted=1)", + (manifest["targets"]["custom_equipment"],)), + "other_equipment_available": scalar( + "SELECT COUNT(*) FROM custom_equipment e WHERE e.equipment_id=? AND NOT EXISTS(" + "SELECT 1 FROM sync_causal_state s WHERE s.target_kind='custom_equipment' " + "AND s.target_id=e.equipment_id AND s.deleted=1)", + (manifest["other_equipment_id"],)), + "builtin_equipment_rows": scalar( + "SELECT COUNT(*) FROM session_exercises WHERE equipment_id='leg_press'"), + "feedback_root_rows": scalar( + "SELECT COUNT(*) FROM exercise_feedback WHERE feedback_id=?", + (manifest["targets"]["feedback"],)), + "feedback_revision_rows": scalar( + "SELECT COUNT(*) FROM exercise_feedback_revisions WHERE feedback_id=?", + (manifest["targets"]["feedback"],)), + "feedback_current_rows": scalar( + "SELECT COUNT(*) FROM exercise_feedback f WHERE f.feedback_id=? AND NOT EXISTS(" + "SELECT 1 FROM sync_causal_state s WHERE s.target_kind='feedback' " + "AND s.target_id=f.feedback_id AND s.deleted=1)", + (manifest["targets"]["feedback"],)), + "other_feedback_current_rows": scalar( + "SELECT COUNT(*) FROM exercise_feedback f WHERE f.feedback_id=? AND NOT EXISTS(" + "SELECT 1 FROM sync_causal_state s WHERE s.target_kind='feedback' " + "AND s.target_id=f.feedback_id AND s.deleted=1)", + (manifest["other_feedback_id"],)), + "target_zone_rows": scalar( + "SELECT COUNT(*) FROM exercise_body_zones z JOIN exercises e ON e.id=z.exercise_row_id " + "WHERE e.exercise_id=? AND z.zone_id=? AND z.role=?", + tuple(manifest["targets"]["body_zone_relation"].split("|"))), + "other_zone_rows": scalar( + "SELECT COUNT(*) FROM exercise_body_zones z JOIN exercises e ON e.id=z.exercise_row_id " + "WHERE e.exercise_id=? AND z.zone_id=?", + (manifest["zone_exercise_id"], manifest["other_zone_id"])), + "session_rows": scalar("SELECT COUNT(*) FROM sessions WHERE session_id=?", + (manifest["session_id"],)), + } + output.write_text(json.dumps(result, sort_keys=True), encoding="utf-8") + + +def refuse_stale_domains(manifest_path: Path, database: Path, output: Path) -> None: + """Require every protected legacy producer to refuse stale restoration.""" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + commands = { + "history": ("import_mobile_export.py", manifest["history"]), + "equipment": ("import_equipment_definitions.py", manifest["equipment"]), + "zones": ("import_exercise_body_zones.py", manifest["zones"]), + "feedback": ("import_training_feedback.py", manifest["feedback"]), + } + refused = {} + for name, (tool, artifact) in commands.items(): + result = subprocess.run([sys.executable, str(ROOT / "tools" / tool), artifact, + "--database", str(database)], + text=True, capture_output=True, check=False) + refused[name] = result.returncode != 0 + if not all(refused.values()): + raise RuntimeError(f"stale companion unexpectedly accepted: {refused}") + output.write_text(json.dumps(refused, sort_keys=True), encoding="utf-8") + + def main() -> None: parser = argparse.ArgumentParser() - parser.add_argument("kind", choices=("history", "draft")) + parser.add_argument("kind", choices=("history", "draft", "domains", "inspect-domains", "refuse-domains")) parser.add_argument("input", type=Path) parser.add_argument("database", type=Path) parser.add_argument("output", type=Path) args = parser.parse_args() + if args.kind == "domains": + domain_exchange(args.input, args.database, args.output) + return + if args.kind == "inspect-domains": + inspect_domains(args.input, args.database, args.output) + return + if args.kind == "refuse-domains": + refuse_stale_domains(args.input, args.database, args.output) + return create_v19(args.database) if args.kind == "history": run([sys.executable, str(ROOT / "tools/import_mobile_export.py"),