test(sync): prove remaining causal deletion domains
This commit is contained in:
parent
70cb7a116b
commit
444508b8b9
6 changed files with 411 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
Loading…
Reference in a new issue