test(sync): prove remaining causal deletion domains

This commit is contained in:
fy59 2026-09-17 11:14:53 +02:00
parent 70cb7a116b
commit 444508b8b9
6 changed files with 411 additions and 4 deletions

View file

@ -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

View file

@ -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<JSONObject> {
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<String, String>,
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<String> = 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,

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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"),