diff --git a/AGENTS.md b/AGENTS.md index 6e8a0be..4a24275 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -166,7 +166,7 @@ It is not the canonical analytics store. The Android UI is driven by exercise metadata, never by exercise-name heuristics. -Android local SQLite schema v18 retains the exactly-one durable active-session +Android local SQLite schema v20 retains the exactly-one durable active-session draft introduced in v4. Every meaningful draft/form mutation is persisted by the repository. Back, backgrounding and process death never delete the draft. Home offers explicit @@ -180,7 +180,7 @@ from actual occurrence data after the additive v10 -> v11 migration. Existing rows retain `load_mode=none`, zero rest and NULL targets. Android v12 adds the durable flattened exercise-alias mapping used to resolve retired creator IDs. Desktop schema v12 owns the same alias compatibility boundary. Later additive -desktop migrations reach v19 and Android migrations reach v18 without changing +desktop migrations reach v21 and Android migrations reach v20 without changing that identity contract. The active completed-session exchange is the separate strict `trainlog-mobile-export` V3; V1/V2 remain readable and `TRAINLOG_FORMAT_V1` @@ -237,11 +237,12 @@ Desktop SQLite schema is versioned with: PRAGMA user_version; ``` -The current desktop schema is v19. Schema v9 added an occurrence-owned explicit +The current desktop schema is v21. Schema v9 added an occurrence-owned explicit maximum result; v10/v11 added body-zone and planning metadata; v12 added durable flattened exercise aliases; v13-v18 add machine metadata, feedback and revisions, occurrence tracking snapshots, causal profile state, and AI proposal -storage/publication state. None changes `TRAINLOG_FORMAT_V1`. +storage/publication state; v19-v21 add lifecycle, causal deletion, and staged +generation/ACK ledgers. None changes `TRAINLOG_FORMAT_V1`. Every incompatible schema evolution requires an explicit migration and regression coverage. diff --git a/CHANGELOG.md b/CHANGELOG.md index 75e8feb..b89d9c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## Unreleased — 0.1.2 +- Completed `TRAINLOG_SYNC_GENERATION_ACK_V1` with desktop schema v21 and + Android schema v20. Added coherent per-platform capture, strict bounded + manifest V1, immutable manifest-last publication, whole-generation SQLite + consumption, pair-scoped lineage, durable consumed/rejected ACK V1 replay, + and a separate causal-operation publication ledger. Real isolated Android → + desktop and desktop → Android chains use production entry points and retain + operation identity across generations. Automatic V3/MTP selection and all + operational services remain unchanged. + - 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, diff --git a/README.md b/README.md index 53f68f3..1871a37 100644 --- a/README.md +++ b/README.md @@ -308,7 +308,7 @@ not contain cloud credentials and does not run `rclone`. As of 2026-09-17: - `TRAINLOG_FORMAT_V1=PASS/FROZEN`; -- desktop SQLite schema v20 and Android SQLite schema v19; +- desktop SQLite schema v21 and Android SQLite schema v20; - Notcurses is the only active desktop terminal backend; - direct storage is `/storage/emulated/0/Documents/Trainlog` under Android's all-files access setting; @@ -322,9 +322,9 @@ As of 2026-09-17: - `WEB_FRONTEND_SHELL_V1=PASS/FROZEN` and `WEB_DASHBOARD_V1=PASS/FROZEN`; the other four Web routes remain placeholders; - `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED`; -- `TRAINLOG_SYNC_GAP_CONTRACT_V1=CONTRACT_FROZEN / - IMPLEMENTATION_NOT_STARTED` and the next cursor remains - `TRAINLOG_SYNC_CHARACTERIZATION_V1`. +- `TRAINLOG_SYNC_GENERATION_ACK_V1=PASS/FROZEN` through explicit staged + services; automatic synchronization still selects V3. The next cursor is + `TRAINLOG_SYNC_ORCHESTRATOR_REPORT_V1`. The latest executable result belongs in [current state](docs/current_state.md), not in multiple README narratives. diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt b/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt new file mode 100644 index 0000000..82dbd17 --- /dev/null +++ b/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt @@ -0,0 +1,357 @@ +package com.labfytools.trainlog.data + +import android.content.ContentValues +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.time.OffsetDateTime +import java.util.UUID + +internal class SyncGenerationException(message: String) : IllegalArgumentException(message) + +internal data class CapturedSyncGeneration( + val generationId: String, + val runId: String, + val manifestSha256: String, + val stagingDirectory: File, +) + +/** + * Staged generation service. It is deliberately not wired into the active V3 + * transport. WHY: manifest publication and peer consumption need durable, + * independently retryable identities. CONTRACT: artifacts are immutable, + * bounded and published before the manifest marker. INVARIANT: causal + * operation payloads are never rewritten to attach generation identity. + */ +internal class SyncGenerationService(private val repository: TrainlogRepository) { + companion object { + const val MAX_MANIFEST_BYTES = 64 * 1024 + const val MAX_ARTIFACTS = 32 + const val MAX_ARTIFACT_BYTES = 64L * 1024L * 1024L + const val MAX_GENERATION_BYTES = 256L * 1024L * 1024L + const val MAX_LOGICAL_NAME_BYTES = 64 + const val MAX_PATH_BYTES = 240 + const val MAX_DIAGNOSTIC_BYTES = 1024 + const val MAX_RETAINED_PER_PEER = 8 + private val ID = Regex("^(gen|peer|sy)_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$") + private val DIGEST = Regex("^[0-9a-f]{64}$") + private data class Kind(val format: String, val version: Int, val filename: String, val required: Boolean = true) + private val ANDROID_KINDS = linkedMapOf( + "catalog" to Kind("trainlog-pc-catalog", 1, "catalog-v1.json"), + "history" to Kind("trainlog-mobile-export", 4, "history-v4.json"), + "execution-drafts" to Kind("trainlog-execution-drafts", 1, "execution-drafts-v1.json"), + "exercise-aliases" to Kind("trainlog-exercise-aliases", 1, "exercise-aliases-v1.json"), + "exercise-profile-state" to Kind("trainlog-exercise-profile-state", 1, "exercise-profile-state-v1.json"), + "equipment-definitions" to Kind("trainlog-equipment-definitions", 1, "equipment-definitions-v1.json"), + "equipment-associations" to Kind("trainlog-equipment-associations", 2, "equipment-associations-v2.json"), + "body-zones" to Kind("trainlog-exercise-body-zones", 1, "body-zones-v1.json"), + "feedback" to Kind("trainlog-training-feedback", 2, "feedback-v2.json"), + "causal-deletions" to Kind("trainlog-causal-deletions", 1, "causal-deletions-v1.json"), + ) + private val SUPPORTED = (ANDROID_KINDS.values + Kind("trainlog-ai-session-drafts",1,"ai-proposals-v1.json",false)) + .map { it.format to it.version }.toSet() + + private fun sha256(bytes: ByteArray): String = MessageDigest.getInstance("SHA-256") + .digest(bytes).joinToString("") { "%02x".format(it) } + + private fun canonical(value: Any?): String = when (value) { + null, JSONObject.NULL -> "null" + is JSONObject -> value.keys().asSequence().toList().sorted().joinToString(",", "{", "}") { + JSONObject.quote(it) + ":" + canonical(value.get(it)) + } + is JSONArray -> (0 until value.length()).joinToString(",", "[", "]") { canonical(value.get(it)) } + is String -> JSONObject.quote(value) + is Boolean, is Int, is Long -> value.toString() + else -> throw SyncGenerationException("unsupported manifest JSON value") + } + + private fun requireId(value: String, prefix: String) { + if (!value.startsWith("${prefix}_") || !ID.matches(value)) + throw SyncGenerationException("invalid $prefix identity") + } + + private fun safePath(value: String, generationId: String) { + if (value.toByteArray(StandardCharsets.UTF_8).size > MAX_PATH_BYTES || '\u0000' in value || '\\' in value || value.startsWith('/')) + throw SyncGenerationException("unsafe artifact path") + val parts = value.split('/') + if (parts.size != 3 || parts.any { it.isEmpty() || it == "." || it == ".." } || parts[0] != "generations" || parts[1] != generationId) + throw SyncGenerationException("artifact outside generation namespace") + } + + private fun boundedJson(root: Any) { + val stack=java.util.ArrayDeque>();stack.add(root to 1);var nodes=0 + while(stack.isNotEmpty()){ + val (value,depth)=stack.removeLast();nodes++ + if(depth>8 || nodes>8192)throw SyncGenerationException("JSON nesting/node bound exceeded") + when(value){ + is JSONObject -> value.keys().forEach{stack.add(value.get(it) to depth+1)} + is JSONArray -> for(index in 0 until value.length())stack.add(value.get(index) to depth+1) + } + } + } + } + + private fun stablePeerId(kind: String): String = repository.inSyncGenerationTransaction { db -> + db.rawQuery("SELECT peer_id,kind FROM sync_peer_identity WHERE singleton=1", null).use { cursor -> + if (cursor.moveToFirst()) { + if (cursor.getString(1) != kind) throw SyncGenerationException("peer kind conflict") + return@inSyncGenerationTransaction cursor.getString(0) + } + } + val value = "peer_${UUID.randomUUID()}" + db.execSQL("INSERT INTO sync_peer_identity(singleton,peer_id,kind) VALUES(1,?,?)", arrayOf(value, kind)) + value + } + + fun capture( + ownedRoot: File, + consumerPeerId: String, + runId: String = "sy_${UUID.randomUUID()}", + generationId: String = "gen_${UUID.randomUUID()}", + ): CapturedSyncGeneration { + requireId(consumerPeerId, "peer"); requireId(runId, "sy"); requireId(generationId, "gen") + repository.inSyncGenerationTransaction { db -> + val retained=db.rawQuery("SELECT COUNT(*) FROM sync_generations WHERE consumer_peer_id=? AND status IN('captured','published','waiting_acknowledgement','acknowledged')",arrayOf(consumerPeerId)).use{it.moveToFirst();it.getInt(0)} + if(retained>=MAX_RETAINED_PER_PEER)throw SyncGenerationException("generation retention capacity exhausted") + } + val producer = stablePeerId("android") + val stage = File(File(ownedRoot, "staging"), generationId) + if (stage.exists() || !stage.mkdirs()) throw SyncGenerationException("generation staging already exists") + try { + val artifacts = repository.captureSyncGenerationArtifacts() + if (artifacts.keys != ANDROID_KINDS.keys) throw SyncGenerationException("incomplete Android domain capture") + val descriptors = JSONArray(); var total = 0L + artifacts.forEach { (logical, json) -> + val kind = checkNotNull(ANDROID_KINDS[logical]); val bytes = json.toByteArray(StandardCharsets.UTF_8) + if (bytes.size.toLong() > MAX_ARTIFACT_BYTES) throw SyncGenerationException("artifact exceeds 64 MiB") + total = Math.addExact(total, bytes.size.toLong()) + if (total > MAX_GENERATION_BYTES) throw SyncGenerationException("generation exceeds 256 MiB") + val output = File(stage, kind.filename) + output.outputStream().use { it.write(bytes) }; output.setReadable(false, false); output.setReadable(true, true); output.setWritable(false, false); output.setWritable(true, true) + descriptors.put(JSONObject().put("logical_name", logical).put("format", kind.format) + .put("version", kind.version).put("filename", "generations/$generationId/${kind.filename}") + .put("size", bytes.size).put("sha256", sha256(bytes)).put("required", kind.required)) + } + val generatedAt = OffsetDateTime.now().toString() + val parent = repository.inSyncGenerationTransaction { db -> + db.rawQuery("SELECT g.generation_id FROM sync_generations g WHERE g.producer_peer_id=? AND g.consumer_peer_id=? AND g.status='acknowledged' AND NOT EXISTS(SELECT 1 FROM sync_generations c WHERE c.parent_generation_id=g.generation_id AND c.status='acknowledged') LIMIT 2", arrayOf(producer,consumerPeerId)).use { + if(!it.moveToFirst())null else {val value=it.getString(0);if(it.moveToNext())throw SyncGenerationException("outgoing generation lineage has multiple tips");value} + } + } + val manifest = JSONObject().put("format", "trainlog-sync-manifest").put("version", 1) + .put("generation_id", generationId).put("run_id", runId) + .put("producer", JSONObject().put("peer_id", producer).put("kind", "android")) + .put("consumer_peer_id", consumerPeerId).put("generated_at", generatedAt) + .put("parent_generation_id", parent ?: JSONObject.NULL).put("artifacts", descriptors) + val raw = canonical(manifest).toByteArray(StandardCharsets.UTF_8) + validateManifest(raw, consumerPeerId) + val digest = sha256(raw) + File(stage, "manifest.json").outputStream().use { it.write(raw) } + repository.inSyncGenerationTransaction { db -> + db.execSQL("INSERT INTO sync_generations VALUES(?,?,?,?,?,?,?,?,?,?,?,NULL)", arrayOf(generationId,runId,producer,consumerPeerId,"android",generatedAt,parent,digest,"captured",raw.toString(StandardCharsets.UTF_8),stage.absolutePath)) + for (index in 0 until descriptors.length()) { + val item=descriptors.getJSONObject(index) + db.execSQL("INSERT INTO sync_generation_artifacts VALUES(?,?,?,?,?,?,?,?)", arrayOf(generationId,item.getString("logical_name"),item.getString("format"),item.getInt("version"),item.getString("filename"),item.getLong("size"),item.getString("sha256"),if(item.getBoolean("required"))1 else 0)) + } + val causal = JSONObject(checkNotNull(artifacts["causal-deletions"])) + val operations = causal.getJSONArray("operations") + for (index in 0 until operations.length()) { + val operationId=operations.getJSONObject(index).getString("operation_id") + val existed=db.rawQuery("SELECT 1 FROM sync_causal_publications WHERE operation_id=? LIMIT 1",arrayOf(operationId)).use{it.moveToFirst()} + db.execSQL("INSERT INTO sync_causal_publications VALUES(?,?,?)",arrayOf(operationId,generationId,if(existed)0 else 1)) + } + } + return CapturedSyncGeneration(generationId, runId, digest, stage) + } catch (error: Exception) { + stage.deleteRecursively() + throw error + } + } + + fun publish(captured: CapturedSyncGeneration, objectRoot: File): File { + val destination = File(File(objectRoot, "generations"), captured.generationId) + val sourceManifest = File(captured.stagingDirectory, "manifest.json").readBytes() + val manifest = validateManifest(sourceManifest, null) + val marker=File(destination,"manifest.json") + if (marker.isFile) { + if (sha256(marker.readBytes()) != captured.manifestSha256) throw SyncGenerationException("same generation has different published bytes") + validatePublished(destination,manifest.getString("consumer_peer_id")) + } else { + if(destination.exists() && (!destination.isDirectory || java.nio.file.Files.isSymbolicLink(destination.toPath())))throw SyncGenerationException("unsafe generation namespace") + if (!destination.exists() && !destination.mkdirs()) throw SyncGenerationException("cannot create generation namespace") + val values=manifest.getJSONArray("artifacts") + for(index in 0 until values.length()) { + val name=values.getJSONObject(index).getString("filename").substringAfterLast('/') + val item=values.getJSONObject(index);val source=File(captured.stagingDirectory,name) + if(!source.isFile || java.nio.file.Files.isSymbolicLink(source.toPath()) || source.canonicalFile.parentFile!=captured.stagingDirectory.canonicalFile || source.length()!=item.getLong("size") || sha256(source.readBytes())!=item.getString("sha256"))throw SyncGenerationException("staging artifact changed after capture") + val target=File(destination,name) + if(target.exists()){ + if(java.nio.file.Files.isSymbolicLink(target.toPath()) || target.length()!=item.getLong("size") || sha256(target.readBytes())!=item.getString("sha256"))throw SyncGenerationException("conflicting partial publication") + }else source.inputStream().use { input -> target.outputStream().use { output -> input.copyTo(output, 1024*1024) } } + } + // The manifest is the visibility/commit marker and is always last. + marker.outputStream().use { it.write(sourceManifest) } + } + repository.inSyncGenerationTransaction { db -> db.execSQL("UPDATE sync_generations SET status='waiting_acknowledgement' WHERE generation_id=? AND status IN('captured','published','waiting_acknowledgement')", arrayOf(captured.generationId)) } + return destination + } + + fun validateManifest(raw: ByteArray, expectedConsumer: String?): JSONObject { + if (raw.size > MAX_MANIFEST_BYTES || !repository.syncGenerationJsonHasUniqueKeys(raw.toString(StandardCharsets.UTF_8))) + throw SyncGenerationException("invalid or oversized manifest JSON") + val root=try { JSONObject(raw.toString(StandardCharsets.UTF_8)) } catch (_: Exception) { throw SyncGenerationException("invalid manifest JSON") } + boundedJson(root) + val keys=setOf("format","version","generation_id","run_id","producer","consumer_peer_id","generated_at","parent_generation_id","artifacts") + if(root.keys().asSequence().toSet()!=keys || root.optString("format")!="trainlog-sync-manifest" || root.opt("version") !is Int || root.getInt("version")!=1) throw SyncGenerationException("unsupported manifest shape/version") + val generation=root.getString("generation_id");requireId(generation,"gen");requireId(root.getString("run_id"),"sy") + val producer=root.getJSONObject("producer");if(producer.keys().asSequence().toSet()!=setOf("peer_id","kind") || producer.getString("kind") !in setOf("android","desktop")) throw SyncGenerationException("invalid producer") + requireId(producer.getString("peer_id"),"peer"); val consumer=root.getString("consumer_peer_id");requireId(consumer,"peer") + if(expectedConsumer!=null && consumer!=expectedConsumer)throw SyncGenerationException("wrong consumer") + if(consumer==producer.getString("peer_id"))throw SyncGenerationException("producer and consumer must differ") + if(!root.isNull("parent_generation_id")){requireId(root.getString("parent_generation_id"),"gen");if(root.getString("parent_generation_id")==generation)throw SyncGenerationException("generation cannot parent itself")} + try { OffsetDateTime.parse(root.getString("generated_at")) } catch (_:Exception){throw SyncGenerationException("invalid generated_at")} + val values=root.getJSONArray("artifacts");if(values.length() !in 1..MAX_ARTIFACTS)throw SyncGenerationException("artifact descriptor bound") + val names=mutableSetOf();val paths=mutableSetOf();var total=0L + for(index in 0 until values.length()){ + val item=values.getJSONObject(index);if(item.keys().asSequence().toSet()!=setOf("logical_name","format","version","filename","size","sha256","required"))throw SyncGenerationException("invalid artifact descriptor") + val name=item.getString("logical_name");if(!name.all{it.code in 0..127} || name.toByteArray().size !in 1..MAX_LOGICAL_NAME_BYTES || !names.add(name))throw SyncGenerationException("invalid logical name") + val path=item.getString("filename");safePath(path,generation);if(!paths.add(path))throw SyncGenerationException("duplicate artifact path") + if(item.opt("version") !is Int || item.getInt("version")<1 || item.opt("size") !is Number || item.getLong("size") !in 0..MAX_ARTIFACT_BYTES || item.opt("required") !is Boolean || !DIGEST.matches(item.getString("sha256")))throw SyncGenerationException("invalid artifact metadata") + if(item.getBoolean("required") && (item.getString("format") to item.getInt("version")) !in SUPPORTED)throw SyncGenerationException("unsupported required capability") + total=Math.addExact(total,item.getLong("size"));if(total>MAX_GENERATION_BYTES)throw SyncGenerationException("generation exceeds bound") + } + if(!ANDROID_KINDS.keys.all{it in names})throw SyncGenerationException("missing required domain") + return root + } + + private data class ValidatedGeneration(val manifest: JSONObject, val digest: String, val artifacts: Map) + + private fun validatePublished(directory: File, expectedConsumer: String): ValidatedGeneration { + if (directory.isDirectory.not() || java.nio.file.Files.isSymbolicLink(directory.toPath())) + throw SyncGenerationException("generation directory missing or unsafe") + val marker=File(directory,"manifest.json") + if(!marker.isFile || java.nio.file.Files.isSymbolicLink(marker.toPath()) || marker.length()>MAX_MANIFEST_BYTES) + throw SyncGenerationException("complete manifest marker missing") + val raw=marker.readBytes();val manifest=validateManifest(raw,expectedConsumer) + val artifacts=linkedMapOf();var total=0L + val values=manifest.getJSONArray("artifacts") + for(index in 0 until values.length()){ + val item=values.getJSONObject(index);val filename=item.getString("filename").substringAfterLast('/') + val file=File(directory,filename) + if(!file.isFile || java.nio.file.Files.isSymbolicLink(file.toPath()) || file.canonicalFile.parentFile!=directory.canonicalFile) + throw SyncGenerationException("missing or unsafe listed artifact") + val declared=item.getLong("size") + if(file.length()!=declared || declared>MAX_ARTIFACT_BYTES)throw SyncGenerationException("artifact size mismatch") + total=Math.addExact(total,declared);if(total>MAX_GENERATION_BYTES)throw SyncGenerationException("generation exceeds bound") + val digest=MessageDigest.getInstance("SHA-256") + val bytes=file.inputStream().use { input -> + val output=java.io.ByteArrayOutputStream(declared.toInt()) + val buffer=ByteArray(1024*1024);var count:Int;var seen=0L + while(input.read(buffer).also{count=it}>=0){if(count==0)continue;seen+=count;if(seen>declared)throw SyncGenerationException("artifact grew during staging");digest.update(buffer,0,count);output.write(buffer,0,count)} + output.toByteArray() + } + if(digest.digest().joinToString(""){"%02x".format(it)}!=item.getString("sha256"))throw SyncGenerationException("artifact digest mismatch") + artifacts[item.getString("logical_name")]=bytes.toString(StandardCharsets.UTF_8) + } + return ValidatedGeneration(manifest,sha256(raw),artifacts) + } + + private fun failure(result: Any): String? = when(result){ + is PcCatalogImportResult.Applied,is ExerciseProfileStateImportResult.Applied, + is EquipmentDefinitionImportResult.Applied,is MobileSessionImportResult.Applied, + is EquipmentAssociationImportResult.Applied,is ExerciseBodyZoneImportResult.Applied, + is TrainingFeedbackImportResult.Applied,is ExerciseAliasImportResult.Applied, + is ExecutionDraftImportResult.Applied,is AiSessionDraftImportResult.Applied, + is CausalDeleteResult.Applied,is CausalDeleteResult.Unchanged -> null + else -> result.toString() + } + + private fun recordRejected(manifest:JSONObject,digest:String,localPeer:String,error:SyncGenerationException):String = repository.inSyncGenerationTransaction { db -> + val generation=manifest.getString("generation_id") + db.rawQuery("SELECT manifest_sha256,ack_json FROM sync_consumed_generations WHERE generation_id=?",arrayOf(generation)).use{known->if(known.moveToFirst()){if(known.getString(0)!=digest)throw SyncGenerationException("generation identity reused with different manifest");return@inSyncGenerationTransaction known.getString(1)}} + var diagnostic=error.message ?: "generation rejected" + while(diagnostic.toByteArray(StandardCharsets.UTF_8).size>MAX_DIAGNOSTIC_BYTES)diagnostic=diagnostic.dropLast(1) + val at=OffsetDateTime.now().toString();val producer=manifest.getJSONObject("producer").getString("peer_id") + val ack=JSONObject().put("format","trainlog-sync-ack").put("version",1).put("ack_id","ack_${generation.removePrefix("gen_")}") + .put("run_id",manifest.getString("run_id")).put("generation_id",generation).put("producer_peer_id",producer).put("consumer_peer_id",localPeer) + .put("manifest_sha256",digest).put("result","rejected").put("durability","sqlite-commit-rejection").put("consumed_at",at).put("diagnostic",diagnostic) + ack.put("payload_sha256",sha256(canonical(ack).toByteArray(StandardCharsets.UTF_8)));val json=canonical(ack) + db.execSQL("INSERT INTO sync_consumed_generations VALUES(?,?,?,?,?,?,?,?,?,?,?)",arrayOf(generation,manifest.getString("run_id"),producer,localPeer,if(manifest.isNull("parent_generation_id"))null else manifest.getString("parent_generation_id"),digest,at,"rejected","sqlite-commit-rejection",diagnostic,json)) + db.execSQL("INSERT INTO sync_acknowledgements VALUES(?,?,?,?,?,?,?,?,?,?,?)",arrayOf(ack.getString("ack_id"),generation,manifest.getString("run_id"),producer,localPeer,digest,"rejected","sqlite-commit-rejection",at,diagnostic,ack.getString("payload_sha256"))) + json + } + + /** + * Validates all bytes before mutation, then applies every listed domain and + * the durable consumption/ACK record in one outer SQLite transaction. + */ + fun consume(directory: File): String { + val localPeer=stablePeerId("android") + val frozen=validatePublished(directory,localPeer) + val manifest=frozen.manifest;val generation=manifest.getString("generation_id") + return try { repository.inSyncGenerationTransaction { db -> + db.rawQuery("SELECT manifest_sha256,ack_json FROM sync_consumed_generations WHERE generation_id=?",arrayOf(generation)).use{known-> + if(known.moveToFirst()){ + if(known.getString(0)!=frozen.digest)throw SyncGenerationException("generation identity reused with different manifest") + return@inSyncGenerationTransaction known.getString(1) + } + } + val producerPeer=manifest.getJSONObject("producer").getString("peer_id") + val expectedParent=db.rawQuery("SELECT g.generation_id FROM sync_consumed_generations g WHERE g.producer_peer_id=? AND g.result='consumed' AND NOT EXISTS(SELECT 1 FROM sync_consumed_generations c WHERE c.parent_generation_id=g.generation_id AND c.result='consumed') LIMIT 2",arrayOf(producerPeer)).use{if(!it.moveToFirst())null else{val value=it.getString(0);if(it.moveToNext())throw SyncGenerationException("consumed generation lineage has multiple tips");value}} + val suppliedParent=if(manifest.isNull("parent_generation_id"))null else manifest.getString("parent_generation_id") + if(suppliedParent!=expectedParent)throw SyncGenerationException("generation lineage is stale or unrelated") + val a=frozen.artifacts + fun apply(name:String,result:()->Any){val json=a[name]?:throw SyncGenerationException("missing required domain: $name");failure(result())?.let{throw SyncGenerationException("$name rejected: $it")}} + // Tombstones are visible before live snapshots; typed generation + // imports know the complete causal envelope is present. + apply("causal-deletions"){repository.applyCausalDeletionExportV1Json(checkNotNull(a["causal-deletions"]))} + apply("catalog"){repository.applyPcCatalogJson(checkNotNull(a["catalog"]))} + apply("exercise-profile-state"){repository.applyExerciseProfileStateJson(checkNotNull(a["exercise-profile-state"]),allowPending=true)} + apply("exercise-aliases"){repository.applyExerciseAliasesJson(checkNotNull(a["exercise-aliases"]))} + apply("equipment-definitions"){repository.applyPcEquipmentDefinitionsJson(checkNotNull(a["equipment-definitions"]),true)} + apply("history"){repository.applyPcMobileExportV4Json(checkNotNull(a["history"]))} + apply("equipment-associations"){repository.applyPcEquipmentAssociationsJson(checkNotNull(a["equipment-associations"]))} + apply("body-zones"){repository.applyExerciseBodyZonesJson(checkNotNull(a["body-zones"]),true)} + apply("feedback"){repository.applyTrainingFeedbackJson(checkNotNull(a["feedback"]),true)} + apply("execution-drafts"){repository.applyExecutionDraftExportV1Json(checkNotNull(a["execution-drafts"]))} + if("ai-proposals" in a)apply("ai-proposals"){repository.applyAiSessionDraftsJson(checkNotNull(a["ai-proposals"]))} + val consumedAt=OffsetDateTime.now().toString() + val ack=JSONObject().put("format","trainlog-sync-ack").put("version",1) + .put("ack_id","ack_${generation.removePrefix("gen_")}").put("run_id",manifest.getString("run_id")) + .put("generation_id",generation).put("producer_peer_id",manifest.getJSONObject("producer").getString("peer_id")) + .put("consumer_peer_id",localPeer).put("manifest_sha256",frozen.digest).put("result","consumed") + .put("durability","sqlite-commit-full").put("consumed_at",consumedAt).put("diagnostic","") + val payload=canonical(ack);ack.put("payload_sha256",sha256(payload.toByteArray(StandardCharsets.UTF_8))) + val ackJson=canonical(ack) + db.execSQL("INSERT INTO sync_consumed_generations VALUES(?,?,?,?,?,?,?,?,?,?,?)",arrayOf(generation,manifest.getString("run_id"),manifest.getJSONObject("producer").getString("peer_id"),localPeer,suppliedParent,frozen.digest,consumedAt,"consumed","sqlite-commit-full","",ackJson)) + db.execSQL("INSERT INTO sync_acknowledgements VALUES(?,?,?,?,?,?,?,?,?,?,?)",arrayOf(ack.getString("ack_id"),generation,manifest.getString("run_id"),manifest.getJSONObject("producer").getString("peer_id"),localPeer,frozen.digest,"consumed","sqlite-commit-full",consumedAt,"",ack.getString("payload_sha256"))) + ackJson + }} catch(error:SyncGenerationException){recordRejected(manifest,frozen.digest,localPeer,error)} + } + + fun acceptAcknowledgement(raw: ByteArray): String { + if(raw.size>MAX_MANIFEST_BYTES || !repository.syncGenerationJsonHasUniqueKeys(raw.toString(StandardCharsets.UTF_8)))throw SyncGenerationException("invalid ACK JSON") + val ack=try{JSONObject(raw.toString(StandardCharsets.UTF_8))}catch(_:Exception){throw SyncGenerationException("invalid ACK JSON")} + boundedJson(ack) + val keys=setOf("format","version","ack_id","run_id","generation_id","producer_peer_id","consumer_peer_id","manifest_sha256","result","durability","consumed_at","diagnostic","payload_sha256") + if(ack.keys().asSequence().toSet()!=keys || ack.optString("format")!="trainlog-sync-ack" || ack.opt("version") !is Int || ack.getInt("version")!=1 || ack.getString("result") !in setOf("consumed","rejected") || ack.getString("durability")!=(if(ack.getString("result")=="consumed")"sqlite-commit-full" else "sqlite-commit-rejection") || ack.getString("diagnostic").toByteArray().size>MAX_DIAGNOSTIC_BYTES)throw SyncGenerationException("unsupported ACK") + requireId(ack.getString("run_id"),"sy");requireId(ack.getString("generation_id"),"gen");requireId(ack.getString("producer_peer_id"),"peer");requireId(ack.getString("consumer_peer_id"),"peer") + if(ack.getString("ack_id")!="ack_${ack.getString("generation_id").removePrefix("gen_")}" || !DIGEST.matches(ack.getString("manifest_sha256")))throw SyncGenerationException("invalid ACK identity") + try{OffsetDateTime.parse(ack.getString("consumed_at"))}catch(_:Exception){throw SyncGenerationException("invalid ACK timestamp")} + val payload=JSONObject(ack.toString());val claimed=payload.remove("payload_sha256") as? String ?: throw SyncGenerationException("ACK digest missing") + if(!DIGEST.matches(claimed) || sha256(canonical(payload).toByteArray(StandardCharsets.UTF_8))!=claimed)throw SyncGenerationException("ACK digest mismatch") + return repository.inSyncGenerationTransaction { db -> + db.rawQuery("SELECT run_id,producer_peer_id,consumer_peer_id,manifest_sha256,status FROM sync_generations WHERE generation_id=?",arrayOf(ack.getString("generation_id"))).use{row-> + if(!row.moveToFirst())throw SyncGenerationException("ACK has no pending generation") + if(ack.getString("run_id")!=row.getString(0)||ack.getString("producer_peer_id")!=row.getString(1)||ack.getString("consumer_peer_id")!=row.getString(2)||ack.getString("manifest_sha256")!=row.getString(3))throw SyncGenerationException("ACK does not match pending generation") + val target=if(ack.getString("result")=="consumed")"acknowledged" else "rejected" + if(row.getString(4) in setOf("acknowledged","rejected")){if(row.getString(4)!=target)throw SyncGenerationException("conflicting ACK replay");return@inSyncGenerationTransaction "unchanged"} + if(row.getString(4)!="waiting_acknowledgement")throw SyncGenerationException("generation is not awaiting ACK") + db.execSQL("UPDATE sync_generations SET status=?,acknowledged_at=? WHERE generation_id=?",arrayOf(target,ack.getString("consumed_at"),ack.getString("generation_id"))) + target + } + } + } +} diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt index 66d2f92..704e2fd 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt @@ -443,6 +443,52 @@ class TrainlogRepository( database.close() } + /** + * Runs generation work in one SQLite scope. WHY: independently opened + * exporter/importer connections cannot prove a coherent generation. + * CONTRACT: the caller owns the outer transaction and decides success. + * INVARIANT: nested repository transactions cannot commit independently + * of this boundary. + */ + internal fun inSyncGenerationTransaction(block: (SQLiteDatabase) -> T): T { + val db = database.writableDatabase + db.beginTransaction() + return try { + val result = block(db) + db.setTransactionSuccessful() + result + } finally { + db.endTransaction() + } + } + + internal fun syncGenerationJsonHasUniqueKeys(json: String): Boolean = + jsonHasUniqueObjectKeys(json) + + /** Captures Android-owned generation artifacts from one read snapshot. */ + internal fun captureSyncGenerationArtifacts(afterFirstArtifact: (() -> Unit)? = null): Map = + inSyncGenerationTransaction { + val artifacts=linkedMapOf("catalog" to buildPcCatalogJson()) + afterFirstArtifact?.invoke() + artifacts["history"]=buildMobileExport(4, true);artifacts["execution-drafts"]=buildExecutionDraftExportV1Json() + artifacts["exercise-aliases"]=buildExerciseAliasesJson();artifacts["exercise-profile-state"]=buildExerciseProfileStateJson() + artifacts["equipment-definitions"]=buildEquipmentDefinitionsJson(true);artifacts["equipment-associations"]=buildEquipmentAssociationsJson() + artifacts["body-zones"]=buildExerciseBodyZonesJson(true);artifacts["feedback"]=buildTrainingFeedbackJson(true) + artifacts["causal-deletions"]=buildCausalDeletionExportV1Json();artifacts + } + + /** Direction-neutral stable-identity catalogue used before generation history. */ + internal fun buildPcCatalogJson(): String { + val items=JSONArray() + listExercises().sortedWith(compareBy { it.name.lowercase(Locale.ROOT) }.thenBy { it.exerciseId }).forEach { exercise -> + items.put(JSONObject().put("exercise_id",exercise.exerciseId).put("name",exercise.name) + .put("recording_mode",exercise.recordingMode.wireValue).put("tracking_mode",exercise.trackingMode.wireValue) + .put("data_fields",exercise.dataFields)) + } + return JSONObject().put("format","trainlog-pc-catalog").put("version",1) + .put("generated_at",OffsetDateTime.now().toString()).put("exercises",items).toString() + } + /** * WHY: dashboard comparisons must remain conservative. CONTRACT: rows are * actual observable-history facts only; external working loads are grouped @@ -1222,8 +1268,10 @@ class TrainlogRepository( * CONTRACT: this is the sole body-zone exchange source in both directions. * Session V2 and frozen TRAINLOG_FORMAT_V1 retain their exact shapes. */ - fun buildExerciseBodyZonesJson(): String { - check(!hasCausalProtection("body_zone_relation")) { "Causal BODY ZONES protection requires the staged artifact." } + fun buildExerciseBodyZonesJson(): String = buildExerciseBodyZonesJson(false) + + private fun buildExerciseBodyZonesJson(completeCausalEnvelope: Boolean): String { + check(completeCausalEnvelope || !hasCausalProtection("body_zone_relation")) { "Causal BODY ZONES protection requires the staged artifact." } val exercises = JSONArray() listExercises().sortedBy { it.exerciseId }.forEach { exercise -> exercises.put(JSONObject() @@ -1422,8 +1470,11 @@ class TrainlogRepository( } catch(_:Exception){ExerciseProfileStateImportResult.DatabaseError} finally {if(db.inTransaction())db.endTransaction()} } - fun applyExerciseBodyZonesJson(json: String): ExerciseBodyZoneImportResult { - if (hasCausalProtection("body_zone_relation")) + fun applyExerciseBodyZonesJson(json: String): ExerciseBodyZoneImportResult = + applyExerciseBodyZonesJson(json, false) + + internal fun applyExerciseBodyZonesJson(json: String, completeCausalEnvelope: Boolean): ExerciseBodyZoneImportResult { + if (!completeCausalEnvelope && hasCausalProtection("body_zone_relation")) return ExerciseBodyZoneImportResult.Invalid("Causal BODY ZONES protection requires the staged artifact.") val root = try { JSONObject(json) } catch (_: Exception) { return ExerciseBodyZoneImportResult.Invalid("Relations de zones JSON invalides.") @@ -3740,9 +3791,9 @@ class TrainlogRepository( if (note.isNull("value")) null else note.getString("value")) } - private fun buildMobileExport(version: Int): String { + private fun buildMobileExport(version: Int, completeCausalEnvelope: Boolean = false): String { require(version in 1..4) - if (database.readableDatabase.rawQuery( + if (!completeCausalEnvelope && database.readableDatabase.rawQuery( "SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1", null, ).use { it.moveToFirst() }) { error("Causal protection refuses a mobile snapshot that omits tombstones.") @@ -4926,8 +4977,10 @@ class TrainlogRepository( /** Snapshot only user-created definitions; bundled manifest rows are never * exported as mutable data and absence never requests deletion. */ - fun buildEquipmentDefinitionsJson(): String { - check(!hasCausalProtection("custom_equipment")) { "Causal equipment protection requires the staged artifact." } + fun buildEquipmentDefinitionsJson(): String = buildEquipmentDefinitionsJson(false) + + private fun buildEquipmentDefinitionsJson(completeCausalEnvelope: Boolean): String { + check(completeCausalEnvelope || !hasCausalProtection("custom_equipment")) { "Causal equipment protection requires the staged artifact." } val supplied = EquipmentCatalog.load(applicationContext).map { it.equipmentId }.toSet() val items = JSONArray() listEquipment().filter { it.equipmentId !in supplied }.forEach { entry -> @@ -4957,8 +5010,11 @@ class TrainlogRepository( .put("equipment", items).toString() } - fun applyPcEquipmentDefinitionsJson(json: String): EquipmentDefinitionImportResult { - if (hasCausalProtection("custom_equipment")) + fun applyPcEquipmentDefinitionsJson(json: String): EquipmentDefinitionImportResult = + applyPcEquipmentDefinitionsJson(json, false) + + internal fun applyPcEquipmentDefinitionsJson(json: String, completeCausalEnvelope: Boolean): EquipmentDefinitionImportResult { + if (!completeCausalEnvelope && hasCausalProtection("custom_equipment")) return EquipmentDefinitionImportResult.Invalid("Causal equipment protection requires the staged artifact.") val root = try { JSONObject(json) } catch (_: Exception) { return EquipmentDefinitionImportResult.Invalid("Définitions équipement JSON invalides.") @@ -5760,8 +5816,10 @@ class TrainlogRepository( text.toByteArray(Charsets.UTF_8).size <= MAX_FEEDBACK_UTF8_BYTES /** Direction-neutral V2 companion: roots and every immutable revision. */ - fun buildTrainingFeedbackJson(): String { - check(!hasCausalProtection("feedback")) { "Causal feedback protection requires the staged artifact." } + fun buildTrainingFeedbackJson(): String = buildTrainingFeedbackJson(false) + + private fun buildTrainingFeedbackJson(completeCausalEnvelope: Boolean): String { + check(completeCausalEnvelope || !hasCausalProtection("feedback")) { "Causal feedback protection requires the staged artifact." } val root = JSONObject().put("format", "trainlog-training-feedback").put("version", 2) .put("generated_at", OffsetDateTime.now().toString()) val exercise = JSONArray() @@ -5798,8 +5856,11 @@ class TrainlogRepository( return root.put("exercise_feedback",exercise).put("session_followups",followups).toString() } - fun applyTrainingFeedbackJson(json: String): TrainingFeedbackImportResult { - if (hasCausalProtection("feedback")) + fun applyTrainingFeedbackJson(json: String): TrainingFeedbackImportResult = + applyTrainingFeedbackJson(json, false) + + internal fun applyTrainingFeedbackJson(json: String, completeCausalEnvelope: Boolean): TrainingFeedbackImportResult { + if (!completeCausalEnvelope && hasCausalProtection("feedback")) return TrainingFeedbackImportResult.Invalid("Causal feedback protection requires the staged artifact.") if (json.toByteArray(Charsets.UTF_8).size > 40 * 1024 * 1024) return TrainingFeedbackImportResult.Invalid("Compagnon de ressentis trop volumineux.") @@ -7836,7 +7897,7 @@ private class TrainlogDatabaseHelper( appContext, databaseName, null, - 19, + 20, ) { override fun onConfigure( db: SQLiteDatabase, @@ -7880,6 +7941,7 @@ private class TrainlogDatabaseHelper( createAiSessionDraftTables(db) createSyncDataLifecycleTables(db) createCausalDeleteTables(db) + createSyncGenerationTables(db) seedEquipment(db) } @@ -8039,6 +8101,14 @@ private class TrainlogDatabaseHelper( createCausalDeleteTables(db) version = 19 } + if (version < 20 && newVersion >= 20) { + /* WHY: publication is not peer consumption. CONTRACT: v20 stores + * staged generation/manifest/ACK identity separately from frozen + * domain artifacts. INVARIANT: migration invents no generation, + * acknowledgement, ancestry, or causal operation. */ + createSyncGenerationTables(db) + version = 20 + } if (version != newVersion) { error( @@ -8128,6 +8198,15 @@ private class TrainlogDatabaseHelper( ) } + private fun createSyncGenerationTables(db: SQLiteDatabase) { + db.execSQL("CREATE TABLE IF NOT EXISTS sync_peer_identity(singleton INTEGER PRIMARY KEY CHECK(singleton=1),peer_id TEXT NOT NULL UNIQUE,kind TEXT NOT NULL CHECK(kind IN('desktop','android')));") + db.execSQL("CREATE TABLE IF NOT EXISTS sync_generations(generation_id TEXT PRIMARY KEY,run_id TEXT NOT NULL,producer_peer_id TEXT NOT NULL,consumer_peer_id TEXT NOT NULL,producer_kind TEXT NOT NULL,generated_at TEXT NOT NULL,parent_generation_id TEXT,manifest_sha256 TEXT NOT NULL,status TEXT NOT NULL CHECK(status IN('captured','published','waiting_acknowledgement','acknowledged','rejected')),manifest_json TEXT NOT NULL,staging_path TEXT NOT NULL,acknowledged_at TEXT);") + db.execSQL("CREATE TABLE IF NOT EXISTS sync_generation_artifacts(generation_id TEXT NOT NULL REFERENCES sync_generations(generation_id) ON DELETE CASCADE,logical_name TEXT NOT NULL,format TEXT NOT NULL,version INTEGER NOT NULL,filename TEXT NOT NULL,size_bytes INTEGER NOT NULL,sha256 TEXT NOT NULL,required INTEGER NOT NULL CHECK(required IN(0,1)),PRIMARY KEY(generation_id,logical_name),UNIQUE(generation_id,filename));") + db.execSQL("CREATE TABLE IF NOT EXISTS sync_consumed_generations(generation_id TEXT PRIMARY KEY,run_id TEXT NOT NULL,producer_peer_id TEXT NOT NULL,consumer_peer_id TEXT NOT NULL,parent_generation_id TEXT,manifest_sha256 TEXT NOT NULL,consumed_at TEXT NOT NULL,result TEXT NOT NULL CHECK(result IN('consumed','rejected')),durability TEXT NOT NULL,diagnostic TEXT NOT NULL,ack_json TEXT NOT NULL,UNIQUE(producer_peer_id,generation_id));") + db.execSQL("CREATE TABLE IF NOT EXISTS sync_acknowledgements(ack_id TEXT PRIMARY KEY,generation_id TEXT NOT NULL,run_id TEXT NOT NULL,producer_peer_id TEXT NOT NULL,consumer_peer_id TEXT NOT NULL,manifest_sha256 TEXT NOT NULL,result TEXT NOT NULL CHECK(result IN('consumed','rejected')),durability TEXT NOT NULL,created_at TEXT NOT NULL,diagnostic TEXT NOT NULL,payload_sha256 TEXT NOT NULL);") + db.execSQL("CREATE TABLE IF NOT EXISTS sync_causal_publications(operation_id TEXT NOT NULL REFERENCES sync_causal_operations(operation_id),generation_id TEXT NOT NULL,first_emission INTEGER NOT NULL CHECK(first_emission IN(0,1)),PRIMARY KEY(operation_id,generation_id));") + } + private fun createAiSessionDraftTables(db: SQLiteDatabase) { db.execSQL( "CREATE TABLE IF NOT EXISTS ai_session_drafts(" + diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt index a5199af..0761881 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt @@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest { TrainlogRepository(context, name).useForTest { it.listSessions() } SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db -> - assertEquals(19, scalar(db, "PRAGMA user_version")) + assertEquals(20, scalar(db, "PRAGMA user_version")) for (table in listOf("session_exercises", "draft_session_exercises")) { assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')")) assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL")) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt index 85622bb..39adcfd 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt @@ -69,7 +69,7 @@ class AndroidV11ExerciseAliasMigrationTest { migrated.close() } SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> - assertEquals(19, scalar(db, "PRAGMA user_version")) + assertEquals(20, scalar(db, "PRAGMA user_version")) /* v13 intentionally adds the frozen machine identities and * exercise-owned metadata; all non-exercise facts stay exact. */ val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") } diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt index 970830d..549c504 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt @@ -78,7 +78,7 @@ class AndroidV16AiSessionDraftMigrationTest { migrated.close() } SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> - assertEquals(19, scalar(db, "PRAGMA user_version")) + assertEquals(20, scalar(db, "PRAGMA user_version")) assertEquals(before, listOf( scalar(db, "SELECT COUNT(*) FROM sessions"), scalar(db, "SELECT COUNT(*) FROM performed_sets"), diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt index b34e65c..c710d14 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt @@ -328,7 +328,7 @@ class BodyZonesTest { SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null, SQLiteDatabase.OPEN_READONLY).use { db -> db.rawQuery("PRAGMA user_version", null).use { cursor -> - assertTrue(cursor.moveToFirst()); assertEquals(19, cursor.getInt(0)) + assertTrue(cursor.moveToFirst()); assertEquals(20, cursor.getInt(0)) } db.rawQuery("SELECT id FROM exercises", null).use { cursor -> assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0)) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt new file mode 100644 index 0000000..edbc3f7 --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt @@ -0,0 +1,184 @@ +package com.labfytools.trainlog.data + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import androidx.test.core.app.ApplicationProvider +import com.labfytools.trainlog.model.NewExerciseProfile +import com.labfytools.trainlog.model.RecordingMode +import com.labfytools.trainlog.model.SessionDraft +import com.labfytools.trainlog.model.SessionExerciseDraft +import com.labfytools.trainlog.model.SessionSetDraft +import com.labfytools.trainlog.model.TrackingMode +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.nio.file.Files +import java.util.UUID +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class SyncGenerationServiceTest { + private val context: Context = ApplicationProvider.getApplicationContext() + + @Test + fun v19MigrationPreservesDataAndInventsNoGenerationState() { + val name="generation-migration-${UUID.randomUUID()}.db" + TrainlogRepository(context,name).also{ + assertTrue(it.createExercise(NewExerciseProfile("Migration sentinel",RecordingMode.SETS,TrackingMode.REPS,0)) is CreateExerciseResult.Created) + it.close() + } + val path=context.getDatabasePath(name) + SQLiteDatabase.openDatabase(path.absolutePath,null,SQLiteDatabase.OPEN_READWRITE).use{db-> + listOf("sync_causal_publications","sync_acknowledgements","sync_consumed_generations","sync_generation_artifacts","sync_generations","sync_peer_identity").forEach{db.execSQL("DROP TABLE $it")} + db.execSQL("PRAGMA user_version=19") + } + val migrated=TrainlogRepository(context,name).also{it.listExercises()} + try{ + SQLiteDatabase.openDatabase(path.absolutePath,null,SQLiteDatabase.OPEN_READONLY).use{db-> + db.rawQuery("PRAGMA user_version",null).use{assertTrue(it.moveToFirst());assertEquals(20,it.getInt(0))} + db.rawQuery("SELECT COUNT(*) FROM exercises",null).use{assertTrue(it.moveToFirst());assertTrue(it.getInt(0)>0)} + listOf("sync_generations","sync_consumed_generations","sync_acknowledgements","sync_causal_publications").forEach{table->db.rawQuery("SELECT COUNT(*) FROM $table",null).use{assertTrue(it.moveToFirst());assertEquals(0,it.getInt(0))}} + } + }finally{migrated.close();context.deleteDatabase(name)} + } + + @Test + fun lateSemanticFailureRollsBackWholeAndroidGeneration() { + val sourceName="generation-rollback-source-${UUID.randomUUID()}.db";val destinationName="generation-rollback-destination-${UUID.randomUUID()}.db" + val root=Files.createTempDirectory("trainlog-generation-rollback-").toFile();val source=TrainlogRepository(context,sourceName);val destination=TrainlogRepository(context,destinationName) + try{ + val exercise=(source.createExercise(NewExerciseProfile("Rollback exercise",RecordingMode.SETS,TrackingMode.REPS,0)) as CreateExerciseResult.Created).exercise + assertTrue(source.saveSession(SessionDraft(listOf(SessionExerciseDraft(exercise=exercise,sets=listOf(SessionSetDraft(reps=5)))))) is SaveSessionResult.Saved) + val destinationService=SyncGenerationService(destination);val identity=destinationService.capture(root,"peer_11111111-1111-4111-8111-111111111111") + val peer=JSONObject(java.io.File(identity.stagingDirectory,"manifest.json").readText()).getJSONObject("producer").getString("peer_id") + val sourceService=SyncGenerationService(source);val captured=sourceService.capture(root,peer);val published=sourceService.publish(captured,java.io.File(root,"objects")) + val bad="{}".toByteArray();java.io.File(published,"feedback-v2.json").writeBytes(bad) + val manifest=JSONObject(java.io.File(published,"manifest.json").readText());val artifacts=manifest.getJSONArray("artifacts") + for(index in 0 until artifacts.length())if(artifacts.getJSONObject(index).getString("logical_name")=="feedback")artifacts.getJSONObject(index).put("size",bad.size).put("sha256",java.security.MessageDigest.getInstance("SHA-256").digest(bad).joinToString(""){"%02x".format(it)}) + java.io.File(published,"manifest.json").writeText(manifest.toString()) + assertEquals("rejected",JSONObject(destinationService.consume(published)).getString("result")) + assertTrue(destination.listSessions().isEmpty()) + SQLiteDatabase.openDatabase(context.getDatabasePath(destinationName).absolutePath,null,SQLiteDatabase.OPEN_READONLY).use{db->db.rawQuery("SELECT COUNT(*) FROM sync_consumed_generations WHERE result='rejected'",null).use{assertTrue(it.moveToFirst());assertEquals(1,it.getInt(0))}} + }finally{source.close();destination.close();context.deleteDatabase(sourceName);context.deleteDatabase(destinationName);root.deleteRecursively()} + } + + @Test + fun causalOperationBytesRemainImmutableAcrossAndroidGenerations() { + val name="generation-causal-${UUID.randomUUID()}.db";val root=Files.createTempDirectory("trainlog-generation-causal-").toFile();val repo=TrainlogRepository(context,name) + try{ + val exercise=(repo.createExercise(NewExerciseProfile("Causal generation",RecordingMode.SETS,TrackingMode.REPS,0)) as CreateExerciseResult.Created).exercise + assertTrue(repo.saveSession(SessionDraft(listOf(SessionExerciseDraft(exercise=exercise,sets=listOf(SessionSetDraft(reps=4)))))) is SaveSessionResult.Saved) + val sessionId=repo.listSessions().single().sessionId + assertTrue(repo.deleteCausally("session",sessionId,"peer_android_generation") is CausalDeleteResult.Applied) + val service=SyncGenerationService(repo);val peer="peer_33333333-3333-4333-8333-333333333333" + val first=service.capture(root,peer);val second=service.capture(root,peer) + fun operation(captured:CapturedSyncGeneration)=JSONObject(java.io.File(captured.stagingDirectory,"causal-deletions-v1.json").readText()).getJSONArray("operations").getJSONObject(0) + assertEquals(operation(first).toString(),operation(second).toString());assertTrue(operation(first).isNull("publication_context")) + SQLiteDatabase.openDatabase(context.getDatabasePath(name).absolutePath,null,SQLiteDatabase.OPEN_READONLY).use{db-> + db.rawQuery("SELECT SUM(first_emission),COUNT(*)-SUM(first_emission) FROM sync_causal_publications",null).use{assertTrue(it.moveToFirst());assertEquals(1,it.getInt(0));assertEquals(1,it.getInt(1))} + } + }finally{repo.close();context.deleteDatabase(name);root.deleteRecursively()} + } + + @Test + fun androidCaptureUsesOneSnapshotAgainstControlledWriter() { + val name="generation-snapshot-${UUID.randomUUID()}.db";val repo=TrainlogRepository(context,name) + try{ + val first=(repo.createExercise(NewExerciseProfile("Snapshot A",RecordingMode.SETS,TrackingMode.REPS,0)) as CreateExerciseResult.Created).exercise + assertTrue(repo.saveSession(SessionDraft(listOf(SessionExerciseDraft(exercise=first,sets=listOf(SessionSetDraft(reps=3)))))) is SaveSessionResult.Saved) + val attempted=CountDownLatch(1);var writer:Thread?=null + val captured=repo.captureSyncGenerationArtifacts{ + writer=Thread{ + attempted.countDown() + repo.createExercise(NewExerciseProfile("Snapshot B",RecordingMode.SETS,TrackingMode.REPS,0)) + }.also{it.start()} + assertTrue(attempted.await(5,TimeUnit.SECONDS)) + } + val completedWriter=checkNotNull(writer) + completedWriter.join(5000);assertTrue(!completedWriter.isAlive) + assertEquals(1,JSONObject(captured.getValue("catalog")).getJSONArray("exercises").length()) + assertEquals(1,JSONObject(captured.getValue("exercise-profile-state")).getJSONArray("exercises").length()) + assertEquals(2,repo.listExercises().size) + }finally{repo.close();context.deleteDatabase(name)} + } + + @Test + fun androidGenerationCapturePublishConsumeAndAckSurviveReopen() { + val sourceName="generation-source-${UUID.randomUUID()}.db" + val destinationName="generation-destination-${UUID.randomUUID()}.db" + val desktopDestinationName="generation-desktop-destination-${UUID.randomUUID()}.db" + val root=Files.createTempDirectory("trainlog-generation-android-").toFile() + var source=TrainlogRepository(context,sourceName);var destination=TrainlogRepository(context,destinationName) + var desktopDestination=TrainlogRepository(context,desktopDestinationName) + try { + val created=source.createExercise(NewExerciseProfile("Generation exercise",RecordingMode.SETS,TrackingMode.REPS,0)) as CreateExerciseResult.Created + val saved = source.saveSession( + SessionDraft(listOf(SessionExerciseDraft( + exercise=created.exercise, + sets=listOf(SessionSetDraft(reps=7,weightKg=21.5)), + ))), + ) + assertTrue(saved is SaveSessionResult.Saved) + val destinationService=SyncGenerationService(destination) + val identityCapture=destinationService.capture(root,"peer_11111111-1111-4111-8111-111111111111") + val destinationPeer=JSONObject(java.io.File(identityCapture.stagingDirectory,"manifest.json").readText()).getJSONObject("producer").getString("peer_id") + val sourceService=SyncGenerationService(source) + val captured=sourceService.capture(root,destinationPeer) + val published=sourceService.publish(captured,java.io.File(root,"objects")) + val before=published.listFiles()!!.associate{it.name to it.readBytes().contentHashCode()} + assertEquals(published,sourceService.publish(captured,java.io.File(root,"objects"))) + assertEquals(before,published.listFiles()!!.associate{it.name to it.readBytes().contentHashCode()}) + val ack=destinationService.consume(published) + assertEquals("acknowledged",sourceService.acceptAcknowledgement(ack.toByteArray())) + source.close();destination.close() + source=TrainlogRepository(context,sourceName);destination=TrainlogRepository(context,destinationName) + assertEquals(1,destination.listSessions().size) + assertEquals("unchanged",SyncGenerationService(source).acceptAcknowledgement(ack.toByteArray())) + assertEquals(ack,SyncGenerationService(destination).consume(published)) + + // A desktop-created mutation uses the public C persistence API, + // then the production desktop generation entry point. This is not + // an Android generation re-exported through desktop. + val repositoryRoot=generateSequence(java.io.File(checkNotNull(System.getProperty("user.dir")))){it.parentFile} + .first{java.io.File(it,"tools/sync_generation_exchange.py").isFile} + val desktopDb=java.io.File(root,"desktop-source.sqlite") + fun run(vararg command:String):String{ + val process=ProcessBuilder(*command).directory(repositoryRoot).redirectErrorStream(true).start() + val text=process.inputStream.bufferedReader().readText() + assertEquals("command failed: ${command.joinToString(" ")}\n$text",0,process.waitFor()) + return text + } + val desktopConsumerDb=java.io.File(root,"desktop-consumer.sqlite") + run(java.io.File(repositoryRoot,"build/tui/sync-generation-fixture").absolutePath,desktopConsumerDb.absolutePath) + val desktopPeer=Regex("PEER_ID=(peer_[^\\s]+)").find(run("python3",java.io.File(repositoryRoot,"tools/sync_generation_exchange.py").absolutePath,"peer-id","--database",desktopConsumerDb.absolutePath))!!.groupValues[1] + val androidToDesktop=sourceService.capture(root,desktopPeer) + val androidPublished=sourceService.publish(androidToDesktop,java.io.File(root,"android-desktop-objects")) + val androidAckFile=java.io.File(root,"android-desktop-ack.json") + run("python3",java.io.File(repositoryRoot,"tools/sync_generation_exchange.py").absolutePath,"consume-desktop",androidPublished.absolutePath,"--database",desktopConsumerDb.absolutePath,"--ack-output",androidAckFile.absolutePath) + assertEquals("acknowledged",sourceService.acceptAcknowledgement(androidAckFile.readBytes())) + val androidSessionId=source.listSessions().single().sessionId + assertEquals("1",run("sqlite3",desktopConsumerDb.absolutePath,"SELECT count(*) FROM sessions WHERE session_id='$androidSessionId';").trim()) + + run(java.io.File(repositoryRoot,"build/tui/sync-generation-fixture").absolutePath,desktopDb.absolutePath) + val androidPeerCapture=SyncGenerationService(desktopDestination).capture(root,"peer_22222222-2222-4222-8222-222222222222") + val androidPeer=JSONObject(java.io.File(androidPeerCapture.stagingDirectory,"manifest.json").readText()).getJSONObject("producer").getString("peer_id") + val desktopOwned=java.io.File(root,"desktop-owned");val desktopObjects=java.io.File(root,"desktop-objects") + val captureText=run("python3",java.io.File(repositoryRoot,"tools/sync_generation_exchange.py").absolutePath,"capture-desktop","--database",desktopDb.absolutePath,"--owned-root",desktopOwned.absolutePath,"--consumer-peer",androidPeer) + val desktopGeneration=Regex("generation_id=(gen_[^ ]+)").find(captureText)!!.groupValues[1] + run("python3",java.io.File(repositoryRoot,"tools/sync_generation_exchange.py").absolutePath,"publish","--database",desktopDb.absolutePath,"--generation-id",desktopGeneration,"--object-root",desktopObjects.absolutePath) + val desktopPublished=java.io.File(desktopObjects,"generations/$desktopGeneration") + val desktopAck=SyncGenerationService(desktopDestination).consume(desktopPublished) + val desktopAckFile=java.io.File(root,"desktop-ack.json").apply{writeText(desktopAck)} + assertTrue(run("python3",java.io.File(repositoryRoot,"tools/sync_generation_exchange.py").absolutePath,"accept-ack",desktopAckFile.absolutePath,"--database",desktopDb.absolutePath).contains("ACK_ACCEPT=acknowledged")) + assertTrue(desktopDestination.listSessions().any{it.sessionId=="se_99999999-9999-4999-8999-999999999999"}) + } finally { + source.close();destination.close();desktopDestination.close();context.deleteDatabase(sourceName);context.deleteDatabase(destinationName);context.deleteDatabase(desktopDestinationName);root.deleteRecursively() + } + } +} 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 5f1a007..3ebeb2d 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 @@ -2260,7 +2260,7 @@ class TrainlogRepositoryDraftTest { ).use { db -> db.rawQuery("PRAGMA user_version;", null).use { cursor -> assertTrue(cursor.moveToFirst()) - assertEquals(19, cursor.getInt(0)) + assertEquals(20, cursor.getInt(0)) } db.rawQuery( "SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " + @@ -2387,7 +2387,7 @@ class TrainlogRepositoryDraftTest { ).use { db -> db.rawQuery("PRAGMA user_version;", null).use { cursor -> assertTrue(cursor.moveToFirst()) - assertEquals(19, cursor.getInt(0)) + assertEquals(20, cursor.getInt(0)) } db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor -> assertTrue(cursor.moveToFirst()) diff --git a/docs/android.md b/docs/android.md index f9f3160..3c88519 100644 --- a/docs/android.md +++ b/docs/android.md @@ -921,3 +921,18 @@ admission covers ordered occurrences, targets, confirmed sets/continuous/MAX, notes, feedback revisions and linked observations. Draft admission uses the stable draft revision for active and pending storage; unfinished form strings remain local and never affect cross-platform identity. + +Android schema v20 additively stores installation peer identity, outgoing +generation/artifact state, consumed-generation state, durable ACKs, and the +separate causal-operation publication ledger. Migration from v19 preserves +business, draft, finalization, revision, and causal rows and creates none of +those new records retroactively. + +`SyncGenerationService` is an explicit staged entry point. It captures catalog, +V4 history, drafts, aliases, profiles, custom equipment, associations, BODY +ZONES, feedback, and causal deletions in one repository transaction; writes an +immutable private generation; publishes its manifest last; consumes all listed +domains plus the durable generation record in one outer transaction; and +replays the stored logical ACK after restart. The complete-envelope context is +internal and typed: standalone artifact methods retain their causal guards. +This service is not selected by the automatic V3 storage/request path. diff --git a/docs/architecture.md b/docs/architecture.md index f182d56..fab92aa 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -468,7 +468,7 @@ silently rendered as unclassified. ### Desktop -Desktop SQLite schema v19 is canonical long-term history. Its additive v18 -> +Desktop SQLite schema v21 is canonical long-term history. Its additive v18 -> v19 migration stores causal notes and execution-draft lifecycle state without activating a new transport. Its v15 -> v16 transactional rebuild adds the occurrence-owned `tracking_mode` snapshot; the @@ -816,15 +816,20 @@ schema change. ## 13. Audited evolution constraints -Each mutating importer has strict validation and a SQLite transaction. The V2 +Each standalone mutating importer has strict validation and a SQLite +transaction. The V2 association companion is a strict corroboration of the equipment already carried by the mobile snapshot and does not rewrite divergent state. However, one definitions/mobile/body-zones/associations publication has no common generation -manifest and is not one cross-artifact database transaction. A late association +manifest in the active V3 path and is not one cross-artifact database +transaction. A late association conflict can therefore follow a successfully committed definitions or mobile import; replay remains idempotent and existing conflicting content is not -overwritten. A future batch protocol must be separately versioned rather than -retrofitted into frozen formats. +overwritten. The explicit staged generation service now supplies that +separately versioned batch boundary: one source snapshot, immutable artifacts, +manifest-last publication, complete validation, and one destination +transaction including consumption/ACK state. It is intentionally not selected +by the active V3 engine. Body-zone concurrency uses an internal canonical-state baseline. Equal states are idempotent; the sole changed side wins; if both local and incoming states diff --git a/docs/current_state.md b/docs/current_state.md index e9adac1..1feee93 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -25,8 +25,8 @@ parallel implementation of its rules. | Boundary | Current state | |---|---| | Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` | -| Desktop SQLite | schema v20 | -| Android SQLite | schema v19 | +| Desktop SQLite | schema v21 | +| Android SQLite | schema v20 | | Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport | | Desktop terminal backend | Notcurses only | | Trainlog product version | `0.1.2` development, synchronized across Android and desktop; latest stable release: `v0.1.1` | @@ -52,6 +52,8 @@ parallel implementation of its rules. | Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` | | Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` | | Synchronization data/lifecycle slice | `TRAINLOG_SYNC_DATA_LIFECYCLE_V1=PASS/FROZEN` | +| Synchronization causal deletion slice | `TRAINLOG_SYNC_CAUSAL_DELETE_V1=PASS/FROZEN` | +| Synchronization generation/ACK slice | `TRAINLOG_SYNC_GENERATION_ACK_V1=PASS/FROZEN` (explicit staged entry points; active transport remains V3) | Desktop and Android schema numbers are independent. Neither changes the frozen Trainlog JSON V1 contract. @@ -94,18 +96,22 @@ Android inbox/outbox still select V3 and do not move the draft artifact. - The desktop imports Android snapshots, publishes catalog/profile/feedback/AI companions, and returns a receipt. Stable IDs, artifact idempotence and the few domain-specific explicit states make defined replay paths idempotent. - Current snapshots do not provide general tombstones, a common generation or - proof that Android durably consumed the desktop publication. + Explicit staged generation services now provide coherent V4/companion + capture, immutable manifests, whole-generation SQLite consumption and + durable peer ACKs. The active automatic path remains V3 and does not select + these services. - SQLite database files are never synchronized. - The desktop AI flow uses external `rclone` for Drive inbox/archive and read-only history export. Android owns no Drive credentials. The frozen [complete synchronization gap contract](design/sync_gap_contract_v1.md) -documents the future target without implementing it. Mobile V3 still omits +remains the authority for the later orchestrator. Mobile V3 still omits `ended_at`, session/occurrence/body-observation notes, the body-observation to -session link and the active Android draft. Imports are transactional per -artifact, not globally across a logical publication. A receipt records desktop -processing; publication alone does not prove durable peer consumption. +session link and the active Android draft. Legacy imports remain transactional +per artifact. Explicit generation consumption instead validates every listed +artifact, applies all domains and its consumption record in one transaction, +and emits `consumed` only after commit. A legacy receipt remains distinct from +the correlated generation ACK. Characterization now also freezes the observed preservation of local-only session/body values on an identical replay, loss of an occurrence-local note when V3 correction reconstructs that occurrence, delete-before-send exposure, @@ -293,15 +299,16 @@ smoke test are not automated. The latter is why desktop producers/consumers for `trainlog-causal-deletions` V1. All contracted domains are durably protected; exact replay is idempotent and unprovable or concurrent ancestry conflicts before mutation. Protected legacy snapshots are -refused before resurrection. Active synchronization still selects V3: no -generation acknowledgement, service activation, deployment, or user-data -migration occurred. +refused before resurrection. Staged generation acknowledgement is implemented, +but active synchronization still selects V3: no staged-service activation, +deployment, or user-data migration occurred. The focused causal closeout additionally proves child-data delete/update conflicts including change-and-revert, imported built-in and alias protection, 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. +current-view retirement and bounded pre-effect admission. The later generation +slice additively advances schemas to desktop v21/Android v20 without changing +active V3 transport selection. The residual causal evidence also proves legitimate Android and desktop producer directions separately for exercise retirement, populated body diff --git a/docs/database.md b/docs/database.md index 48e64ea..95f30b1 100644 --- a/docs/database.md +++ b/docs/database.md @@ -16,8 +16,8 @@ is a later, separately versioned migration. ## 1. Status ```text -TRAINLOG_DATABASE_SCHEMA_VERSION=20 -DATABASE_SCHEMA_V20=IMPLEMENTED +TRAINLOG_DATABASE_SCHEMA_VERSION=21 +DATABASE_SCHEMA_V21=IMPLEMENTED TRAINLOG_FORMAT_V1=FROZEN ``` @@ -36,7 +36,7 @@ PRAGMA user_version; Current value: ```text -20 +21 ``` The independent actual-set loads documented in the current desktop, Android @@ -63,6 +63,15 @@ They do not fabricate migration ancestry: they are written only by a real post-migration mutation. Session exercise replacement writes this revision in the same transaction and refuses to modify a deleted target. +Schema v21 additively introduces stable installation peer identity, outgoing +generation/artifact state, consumed-generation state, durable ACK records, and +the separate immutable-operation publication ledger. The v20→v21 migration is +one SQLite transaction, preserves all business and causal rows, and creates no +historical generation, ancestry, consumption, or ACK. A complete generation +consumer owns one outer transaction covering every domain mutation, its +consumption record, and the data required to replay the same ACK after restart. +Transport publication and ACK-file writes remain outside that transaction. + Version 19 additively stores causal note revisions/current state and staged execution drafts, their immutable revisions, pending/active state and durable finalization ledger. It changes no published V1 format and performs no user diff --git a/docs/design/sync_gap_contract_v1.md b/docs/design/sync_gap_contract_v1.md index d764854..7468a5a 100644 --- a/docs/design/sync_gap_contract_v1.md +++ b/docs/design/sync_gap_contract_v1.md @@ -262,17 +262,20 @@ aucun écran de résolution ne sont définis dans ce lot. ## 8. Génération et manifest -**[CONTRAT FUTUR — NON IMPLÉMENTÉ]** Toute publication multi-artifact possède un -`generation_id` opaque unique (UUIDv4 préfixé `gen_` recommandé), jamais un -timestamp seul. Le manifest indépendant est conceptuellement : +**[IMPLEMENTED — PASS/FROZEN]** Every staged multi-artifact publication has a +unique opaque `generation_id` (a `gen_`-prefixed UUIDv4), never a timestamp. +The independent manifest is conceptually: ```json { "format": "trainlog-sync-manifest", "version": 1, "generation_id": "gen_", + "run_id": "sy_", "producer": {"peer_id": "peer_", "kind": "android"}, + "consumer_peer_id": "peer_", "generated_at": "offset-date-time", + "parent_generation_id": null, "artifacts": [{ "logical_name": "mobile-history", "format": "trainlog-mobile-export", @@ -421,14 +424,14 @@ de fichiers Android anciens. toutes plateformes ; 3. `TRAINLOG_SYNC_DATA_LIFECYCLE_V1`: mobile V4, drafts and previously lost fields — `PASS/FROZEN`; 4. `TRAINLOG_SYNC_CAUSAL_DELETE_V1` : opérations causales et tombstones ; -5. `TRAINLOG_SYNC_GENERATION_ACK_V1` : manifest, staging, publication, - consommation, reprise et ack ; +5. `TRAINLOG_SYNC_GENERATION_ACK_V1`: manifest, staging, publication, + transactional consumption, restart, and ACK — `PASS/FROZEN`; 6. `TRAINLOG_SYNC_ORCHESTRATOR_REPORT_V1` : états et rapport complet ; 7. `TRAINLOG_WEB_SYNC_API_V1` : façade et endpoints ; 8. `TRAINLOG_WEB_SYNC_BUTTON_V1` : UX et rafraîchissement. -Chaque lot dépend des précédents et reste borné. Aucun n'est commencé par ce -contrat. +Each tranche remains bounded by its predecessors. The next implementation +cursor is `TRAINLOG_SYNC_ORCHESTRATOR_REPORT_V1`. ## 15. Statut des preuves diff --git a/docs/exchange_format.md b/docs/exchange_format.md index 364a21e..f497066 100644 --- a/docs/exchange_format.md +++ b/docs/exchange_format.md @@ -658,7 +658,7 @@ atomically. Timestamps are evidence, never last-writer-wins authority. The artifact is available only through explicit repository/tool entry points. It is not selected by V3 transport. Null publication context is the typed -handoff to the later generation/acknowledgement tranche, not consumption proof. +handoff to the separate generation association ledger, not consumption proof. `predecessor_revision_id` identifies either the canonical complete business projection or a durable mutation revision created by a supported correction. @@ -669,3 +669,35 @@ Readers consume at most the 4 MiB limit plus one sentinel byte, exporters enumerate at most 4096 operations plus one sentinel row, and a local deletion is refused before its effect if the complete protection artifact would exceed either bound. + +## Staged generation manifest and acknowledgement V1 + +`trainlog-sync-manifest` V1 is a strict independent envelope. It contains +`generation_id`, `run_id`, producer `{peer_id, kind}`, `consumer_peer_id`, an +offset-aware `generated_at`, nullable `parent_generation_id`, and the complete +artifact descriptor array. A descriptor contains `logical_name`, `format`, +integer `version`, generation-relative `filename`, exact byte `size`, lowercase +SHA-256, and boolean `required`. Unknown root/descriptor fields, duplicate JSON +keys, duplicate names/paths, unknown required capabilities, unsafe paths, wrong +peer context, and missing required domains are rejected. + +The inclusive outer limits are 64 KiB per manifest, 32 descriptors, 64 MiB per +artifact, 256 MiB per generation, 64 ASCII bytes per logical name, 240 UTF-8 +bytes per relative path, path depth three, JSON depth eight, and 8192 JSON +nodes. Tighter domain bounds still apply. Artifacts live under +`generations//`; the manifest is the final visibility marker. +SHA-256 proves exact byte integrity under that manifest, not authentication, +freshness, or snapshot coherence. + +`trainlog-sync-ack` V1 contains `ack_id`, run/generation and both peer +identities, the exact manifest digest, `consumed|rejected`, durability, +offset-aware completion time, a diagnostic limited to 1024 UTF-8 bytes, and a +digest of the ACK payload without `payload_sha256`. `consumed` uses +`sqlite-commit-full`; `rejected` uses `sqlite-commit-rejection`. Exact replay +returns the stored ACK. A wrong peer, run, generation, manifest digest, ACK +digest, timestamp, result/durability pair, or conflicting replay is rejected. + +Generation membership never changes a causal V1 operation. The immutable +operation retains null `publication_context`, its original ID and digest; +`sync_causal_publications` records first emission and retransmission by +operation/generation identity. diff --git a/docs/roadmap.md b/docs/roadmap.md index dafb6a8..b2e642e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -19,7 +19,7 @@ distinction between measured values and estimates. ## Current baseline -Desktop schema v20, Android schema v19, Notcurses, direct +Desktop schema v21, Android schema v20, Notcurses, direct `Documents/Trainlog` storage, mobile export V3, Training Knowledge V1, Body Zones V1, Training Feedback V1/V2, STATS V1, and Session Generator V1 are implemented. Session Generator V1 is hidden pending V2. AI session-draft @@ -33,25 +33,28 @@ visualizations are implemented; the other four Web routes remain placeholders. ## Current cursor ```text -CURRENT_OPERATIONAL_CURSOR=TRAINLOG_SYNC_GENERATION_ACK_V1 +CURRENT_OPERATIONAL_CURSOR=TRAINLOG_SYNC_ORCHESTRATOR_REPORT_V1 ``` The focused `TRAINLOG_SYNC_CAUSAL_DELETE_V1_CLOSEOUT` is complete before this cursor: complete predecessor state, imported built-in authorization, cross-platform draft revision identity and resource-bound admission are closed. -Generation manifests, publication context assignment and acknowledgement have -not started. +`TRAINLOG_SYNC_GENERATION_ACK_V1=PASS/FROZEN`: both platforms expose explicit +coherent capture, immutable manifest publication, whole-generation +transactional consumption, durable correlated ACK and restart/replay entry +points. Automatic V3 selection remains unchanged. The next cursor is only +`TRAINLOG_SYNC_ORCHESTRATOR_REPORT_V1`; no orchestrator work starts here. -`TRAINLOG_SYNC_GAP_CONTRACT_V1=CONTRACT_FROZEN / IMPLEMENTATION_NOT_STARTED` +`TRAINLOG_SYNC_GAP_CONTRACT_V1=CONTRACT_FROZEN / IMPLEMENTATION_IN_PROGRESS` defines the complete-sync target and its bounded dependency order. `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` supplies the reproducible isolated desktop/Android validation entry point, JDK 17 gate and private JVM/XDG paths. `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` reuses and completes the current-behavior evidence for cross-implementation V3 exchange, identities, ordering, replay, partial publication, request/receipt processing and the real -lock. The next cursor is only `TRAINLOG_SYNC_DATA_LIFECYCLE_V1`; it is not -started and does not yet authorize a protocol, schema, endpoint or button -change. +lock. Data lifecycle, causal deletion, and generation/ACK slices are complete; +the orchestrator/report slice remains next and does not authorize an endpoint, +button, deployment, or transport activation here. [Contract details](design/sync_gap_contract_v1.md). `WEB_FRONTEND_SHELL_V1=PASS/FROZEN`. `trainlog -w` serves the embedded React, diff --git a/docs/sync_exchange.md b/docs/sync_exchange.md index 1ba5da2..e638452 100644 --- a/docs/sync_exchange.md +++ b/docs/sync_exchange.md @@ -18,7 +18,7 @@ available because availability is independently guarded by causal state. ## Machine-exercise Phase 1 compatibility -Desktop schema v20 and Android schema v19 retain mobile export V3, readable V1/V2 imports, +Desktop schema v21 and Android schema v20 retain mobile export V3, readable V1/V2 imports, equipment definitions V1, equipment associations V2, exercise aliases V1, and the BODY ZONES companion without wire-format changes. Machine metadata is not silently added to a frozen artifact: stable exercise IDs and canonical names @@ -742,12 +742,44 @@ equal to the first pass, and both peers retained the same 32 relations for 20 of 23 stable exercise IDs. The final companion states were semantically equal between passes and the three explicit unclassified states remained intact. -## 18. Audited protocol limitations +## 18. Staged coherent generations and durable acknowledgement -The future complete-sync semantics are frozen separately in -[`TRAINLOG_SYNC_GAP_CONTRACT_V1`](design/sync_gap_contract_v1.md). That document -is a non-implemented specification and does not extend any format described -here. +`tools/sync_generation_exchange.py` and Android `SyncGenerationService` are +the production staged owners for manifest V1 and ACK V1. Neither is wired into +the automatic V3 MTP selection. Both allocate stable installation peer IDs and +opaque UUIDv4 generation/run IDs only in the explicitly opened staged store. + +Desktop capture uses one private SQLite backup and runs every established +exporter against that immutable snapshot. Android holds one repository read +transaction while calling the existing domain exporters. Both validate domain +and outer limits before recording a captured generation. Publication copies +verified immutable bytes into `generations//`, resumes only +byte-identical partial publication, and writes `manifest.json` last. Capacity +admits at most eight retained outgoing generations per consumer; exhaustion is +recoverable and never evicts an unacknowledged generation or causal proof. + +The required artifact order is causal deletions, catalog/profile identity, +custom-equipment definitions, V4 history, aliases and occurrence equipment, +BODY ZONES, feedback, execution drafts, then optional desktop AI proposals. +All files are validated and frozen before mutation. Transaction-neutral desktop +helpers and the Android repository's outer transaction apply every business +row, causal state and the consumption/ACK record together. A late semantic +failure rolls back earlier writes and commits a separate bounded `rejected` +ACK; missing/incomplete transport input remains retryable and produces no ACK. + +Lineage is scoped to the producer/consumer pair. Exact replay returns the +stored result; an accepted successor must name the last consumed generation as +parent. UUIDs, clocks, mtimes, and directory scans never choose a successor. +An ACK timeout leaves `waiting_acknowledgement`; a late exact ACK resolves it +idempotently. Causal V1 operation bytes remain unchanged across first emission +and retransmission because generation membership lives only in +`sync_causal_publications`. + +## 19. Legacy active-protocol limitations + +The complete-sync semantics remain frozen separately in +[`TRAINLOG_SYNC_GAP_CONTRACT_V1`](design/sync_gap_contract_v1.md). The staged +generation slice implements its batch boundary but does not activate it. Each mutating importer validates strictly and owns a SQLite transaction. The V2 association companion only corroborates equipment already imported in the @@ -756,9 +788,9 @@ definitions/mobile/body-zones/associations batch nevertheless has no common gene or cross-file transaction. Independent “newest artifact” selection can therefore observe a partially published generation; validation stops on a mismatch, but an earlier artifact may already have committed. Replay is -idempotent and no conflicting local value is overwritten. A future atomic-batch -design requires a new versioned manifest rather than a semantic change to any -published format. +idempotent and no conflicting local value is overwritten. That active legacy +design remains non-atomic. Explicit manifest V1 generation consumption +provides the separate atomic path without changing any published companion. Snapshots carry no exercise, session, body-observation, body-zone-relation, or equipment-definition tombstones. Omission therefore never deletes one of those objects. The only diff --git a/docs/tests.md b/docs/tests.md index 4c0195a..8e65c64 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -713,7 +713,7 @@ passing tests: | Inter-process lock | `sync_body_zone_wiring` against the real `sync.lock` acquisition with private `XDG_DATA_HOME` | A pipe-coordinated child holds the lock; the second trigger returns conflict before any MTP probe; every child is joined | Added | | Enriched V4 completed history | `TrainlogRepositoryDraftTest.v4HistoryRoundTripsAcrossRealAndroidAndDesktopImplementations` through Android V4 repository methods and the real desktop V4 importer/exporter | Android → desktop → fresh Android and desktop → Android → fresh desktop preserve stable identities, order, heterogeneous sets, plans, equipment, causal null/empty/escaped UTF-8 notes and idempotent replay | Added cross-implementation proof | | Execution-draft V1 | `TrainlogRepositoryDraftTest.executionDraftRoundTripsAcrossRealAndroidAndDesktopImplementations` through Android repository methods and `execution_draft_exchange.py` | Both implementation directions preserve stable session/entry identity and confirmed facts; raw unfinished form text remains local; replay is unchanged and pre-finalization replay is stale after reopen/finalization | Added cross-implementation proof | -| General tombstones, coherent generations, causal deletion, and consumption acknowledgement | Frozen gap contract only | No active production implementation exists | Future criterion, not tested green | +| General tombstones, coherent generations, causal deletion, and consumption acknowledgement | Causal-deletion suites plus `sync_generation_exchange` and `SyncGenerationServiceTest` | Explicit staged production entry points pass cross-implementation, rollback, replay, bounds and restart assertions; automatic V3 remains unchanged | PASS/FROZEN staged evidence | The cross-implementation helpers only provision minimal production-compatible desktop schemas and launch production Python tools; they do not duplicate @@ -1190,6 +1190,34 @@ Documents and Download roots while failing on any legacy access. Android unit tests and any instrumented tests run on an emulator; the primary personal phone is reserved for manual, non-destructive install-and-sync smoke validation and must not run `connectedDebugAndroidTest`. + +## Generation, transaction, and acknowledgement V1 + +The normal Meson inventory includes `sync_generation_exchange` and +`schema_v21_migration`. The service suite exercises deterministic desktop +snapshot capture against a controlled writer, immutable publication/replay, +partial and substituted input, strict paths/capabilities/bounds, wrong ACK +context, durable ACK replay after reopen, late-domain rollback, rejected ACKs, +and unchanged causal-operation IDs/digests across first emission and +retransmission for all seven causal target kinds. The v20 fixture migration +preserves populated business state and creates no historical generation/ACK. + +Android `SyncGenerationServiceTest` uses production repository codecs and the +filesystem object adapter. It proves a transaction-held capture against a +deterministically blocked writer, v19→v20 preservation, manifest-last immutable +publication, whole-generation rollback on a late semantic failure, committed +ACK replay after close/reopen, and causal-operation byte stability. Its real +cross-implementation test runs both chains: an Android-created session is +captured and transactionally consumed by the desktop Python service, and a +separate desktop-created session written through the public C17 database API is +captured by the desktop service and consumed by Android. Both ACKs return to +the original producer. The bridge moves bytes and invokes real entry points; it +does not author expected business JSON or repair artifacts. + +After these additions the direct normal suites report 78/78 Meson tests and +216 Android tests: 212 passed, four historical-fixture skips, zero failures. +The publication adapter is an isolated directory/object-store double. No +physical MTP, phone, Drive, active service, or user database participates. ## Causal deletion V1 The normal Meson inventory includes `causal_delete_exchange`, which invokes diff --git a/tests/test_sync_generation_exchange.py b/tests/test_sync_generation_exchange.py new file mode 100644 index 0000000..509910d --- /dev/null +++ b/tests/test_sync_generation_exchange.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""Production generation/manifest/ACK service regressions.""" +import json, sqlite3, sys, tempfile, unittest +from contextlib import closing +from pathlib import Path +from unittest import mock +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tools")) +import sync_generation_exchange as generation + +PEER_A="peer_11111111-1111-4111-8111-111111111111" +PEER_B="peer_22222222-2222-4222-8222-222222222222" +RUN="sy_33333333-3333-4333-8333-333333333333" +GEN="gen_44444444-4444-4444-8444-444444444444" +SCHEMA=""" +PRAGMA foreign_keys=ON; +CREATE TABLE facts(id TEXT PRIMARY KEY,value TEXT NOT NULL); +CREATE TABLE sync_causal_operations(operation_id TEXT PRIMARY KEY,target_kind TEXT,target_id TEXT,creator_id TEXT,predecessor_revision_id TEXT,created_at TEXT,payload_sha256 TEXT,publication_context TEXT); +CREATE TABLE sync_peer_identity(singleton INTEGER PRIMARY KEY CHECK(singleton=1),peer_id TEXT NOT NULL UNIQUE,kind TEXT NOT NULL); +CREATE TABLE sync_generations(generation_id TEXT PRIMARY KEY,run_id TEXT,producer_peer_id TEXT,consumer_peer_id TEXT,producer_kind TEXT,generated_at TEXT,parent_generation_id TEXT,manifest_sha256 TEXT,status TEXT,manifest_json TEXT,staging_path TEXT,acknowledged_at TEXT); +CREATE TABLE sync_generation_artifacts(generation_id TEXT,logical_name TEXT,format TEXT,version INTEGER,filename TEXT,size_bytes INTEGER,sha256 TEXT,required INTEGER,PRIMARY KEY(generation_id,logical_name),UNIQUE(generation_id,filename)); +CREATE TABLE sync_consumed_generations(generation_id TEXT PRIMARY KEY,run_id TEXT,producer_peer_id TEXT,consumer_peer_id TEXT,parent_generation_id TEXT,manifest_sha256 TEXT,consumed_at TEXT,result TEXT,durability TEXT,diagnostic TEXT,ack_json TEXT,UNIQUE(producer_peer_id,generation_id)); +CREATE TABLE sync_acknowledgements(ack_id TEXT PRIMARY KEY,generation_id TEXT,run_id TEXT,producer_peer_id TEXT,consumer_peer_id TEXT,manifest_sha256 TEXT,result TEXT,durability TEXT,created_at TEXT,diagnostic TEXT,payload_sha256 TEXT); +CREATE TABLE sync_causal_publications(operation_id TEXT,generation_id TEXT,first_emission INTEGER,PRIMARY KEY(operation_id,generation_id)); +PRAGMA user_version=21; +""" + +class GenerationTest(unittest.TestCase): + def setUp(self): + self.temp=tempfile.TemporaryDirectory(); self.root=Path(self.temp.name) + self.database=self.root/"source.sqlite" + with closing(sqlite3.connect(self.database)) as db: + db.executescript(SCHEMA); db.execute("INSERT INTO facts VALUES('state','A')"); db.commit() + db.execute("INSERT INTO sync_peer_identity VALUES(1,?,'desktop')",(PEER_A,)) + def tearDown(self): self.temp.cleanup() + @staticmethod + def artifacts(): + return (("history","trainlog-mobile-export",4,"history.json",True,"controlled.py",()),) + def capture(self): + def exporter(_tool,_extra,output,snapshot): + with closing(sqlite3.connect(self.database)) as live: live.execute("UPDATE facts SET value='B'"); live.commit() + with closing(sqlite3.connect(snapshot)) as frozen: value=frozen.execute("SELECT value FROM facts").fetchone()[0] + output.write_text(json.dumps({"format":"trainlog-mobile-export","version":4,"state":value})) + with mock.patch.object(generation,"ARTIFACTS",self.artifacts()),mock.patch.object(generation,"SUPPORTED",{("trainlog-mobile-export",4)}),mock.patch.object(generation,"run_export",exporter): + return generation.capture_desktop(self.database,self.root/"owned",PEER_B,RUN,GEN) + def validate(self,directory): + with mock.patch.object(generation,"ARTIFACTS",self.artifacts()),mock.patch.object(generation,"SUPPORTED",{("trainlog-mobile-export",4)}): + return generation.validate_published(directory,PEER_B) + def publish(self): + with mock.patch.object(generation,"ARTIFACTS",self.artifacts()),mock.patch.object(generation,"SUPPORTED",{("trainlog-mobile-export",4)}): + return generation.publish(self.database,GEN,self.root/"objects") + def test_coherent_capture_publish_replay_and_ack_restart(self): + stage,manifest,checksum=self.capture() + self.assertEqual("A",json.loads((stage/"history.json").read_text())["state"]) + with closing(sqlite3.connect(self.database)) as db:self.assertEqual("B",db.execute("SELECT value FROM facts").fetchone()[0]) + published=self.publish() + before={p.name:p.read_bytes() for p in published.iterdir()} + self.assertEqual(published,self.publish()) + self.assertEqual(before,{p.name:p.read_bytes() for p in published.iterdir()}) + checked,actual=self.validate(published);self.assertEqual(manifest["generation_id"],checked["generation_id"]);self.assertEqual(checksum,actual) + destination=self.root/"destination.sqlite" + with closing(sqlite3.connect(destination)) as db: + db.executescript(SCHEMA);db.execute("INSERT INTO sync_peer_identity VALUES(1,?,'desktop')",(PEER_B,));db.commit();db.execute("BEGIN IMMEDIATE") + db.execute("INSERT INTO facts VALUES('imported','yes')");ack=generation.record_consumed(db,manifest,checksum);db.commit() + ack_path=self.root/"ack.json";ack_path.write_bytes(generation.canonical(ack)) + self.assertEqual("acknowledged",generation.accept_ack(self.database,ack_path));self.assertEqual("unchanged",generation.accept_ack(self.database,ack_path)) + with closing(sqlite3.connect(destination)) as reopened: + self.assertEqual(ack,generation.record_consumed(reopened,manifest,checksum));self.assertEqual(1,reopened.execute("SELECT count(*) FROM facts WHERE id='imported'").fetchone()[0]) + def test_substitution_and_transaction_failure_leave_no_consumption(self): + _,manifest,checksum=self.capture();published=self.publish() + (published/"history.json").write_text("{}") + with self.assertRaisesRegex(generation.GenerationError,"size/digest"):self.validate(published) + destination=self.root/"rollback.sqlite" + with closing(sqlite3.connect(destination)) as db: + db.executescript(SCHEMA);db.execute("INSERT INTO sync_peer_identity VALUES(1,?,'desktop')",(PEER_B,));db.commit();db.execute("BEGIN IMMEDIATE") + db.execute("INSERT INTO facts VALUES('partial','rollback')");generation.record_consumed(db,manifest,checksum);db.rollback() + with closing(sqlite3.connect(destination)) as db: + self.assertEqual(0,db.execute("SELECT count(*) FROM facts WHERE id='partial'").fetchone()[0]);self.assertEqual(0,db.execute("SELECT count(*) FROM sync_consumed_generations").fetchone()[0]) + def test_paths_duplicates_limits_and_wrong_ack_are_rejected(self): + _,manifest,checksum=self.capture() + with mock.patch.object(generation,"ARTIFACTS",self.artifacts()),mock.patch.object(generation,"SUPPORTED",{("trainlog-mobile-export",4)}): + for filename in ("/absolute",f"generations/{GEN}/../escape",f"generations/{GEN}/deep/too-deep",f"generations\\{GEN}\\x"): + bad=json.loads(json.dumps(manifest));bad["artifacts"][0]["filename"]=filename + with self.assertRaises(generation.GenerationError):generation.validate_manifest_bytes(generation.canonical(bad),PEER_B) + duplicate=json.loads(json.dumps(manifest));duplicate["artifacts"].append(dict(duplicate["artifacts"][0])) + with self.assertRaises(generation.GenerationError):generation.validate_manifest_bytes(generation.canonical(duplicate),PEER_B) + exact=b" "*generation.MAX_MANIFEST + with self.assertRaisesRegex(generation.GenerationError,"invalid"):generation.validate_manifest_bytes(exact,PEER_B) + with self.assertRaisesRegex(generation.GenerationError,"byte bound"):generation.validate_manifest_bytes(exact+b" ",PEER_B) + self.publish() + published=self.root/"objects"/"generations"/GEN + original=(published/"manifest.json").read_bytes();(published/"manifest.json").write_bytes(original+b" ") + with self.assertRaisesRegex(generation.GenerationError,"different published bytes"):self.publish() + (published/"manifest.json").write_bytes(original) + wrong=generation.ack_document(RUN,GEN,PEER_A,PEER_B,checksum,"consumed",generation.now());wrong["consumer_peer_id"]="peer_55555555-5555-4555-8555-555555555555" + payload=dict(wrong);payload.pop("payload_sha256");wrong["payload_sha256"]=generation.digest_bytes(generation.canonical(payload)) + path=self.root/"wrong.json";path.write_bytes(generation.canonical(wrong)) + with self.assertRaisesRegex(generation.GenerationError,"does not match"):generation.accept_ack(self.database,path) + with closing(sqlite3.connect(self.database)) as db:self.assertEqual("waiting_acknowledgement",db.execute("SELECT status FROM sync_generations").fetchone()[0]) + + def test_missing_marker_unsupported_required_capability_and_aggregate_bounds(self): + empty=self.root/"partial";empty.mkdir() + with self.assertRaisesRegex(generation.GenerationError,"marker"):generation.validate_published(empty,PEER_B) + _,manifest,_=self.capture() + with mock.patch.object(generation,"ARTIFACTS",self.artifacts()),mock.patch.object(generation,"SUPPORTED",set()): + with self.assertRaisesRegex(generation.GenerationError,"unsupported required"): + generation.validate_manifest_bytes(generation.canonical(manifest),PEER_B) + descriptors=[] + for index in range(4): + descriptors.append({"logical_name":f"part-{index}","format":"test","version":1,"filename":f"generations/{GEN}/p{index}","size":generation.MAX_ARTIFACT,"sha256":"0"*64,"required":False}) + bounded=dict(manifest);bounded["artifacts"]=descriptors + with mock.patch.object(generation,"ARTIFACTS",()),mock.patch.object(generation,"SUPPORTED",set()): + generation.validate_manifest_bytes(generation.canonical(bounded),PEER_B) + bounded["artifacts"].append({"logical_name":"overflow","format":"test","version":1,"filename":f"generations/{GEN}/overflow","size":1,"sha256":"0"*64,"required":False}) + with self.assertRaisesRegex(generation.GenerationError,"generation exceeds"):generation.validate_manifest_bytes(generation.canonical(bounded),PEER_B) + + def test_causal_operation_bytes_survive_multiple_generation_associations(self): + kinds=("session","execution_draft","exercise","body_observation","custom_equipment","feedback","body_zone_relation") + operations=[] + for index,kind in enumerate(kinds,1): + operations.append({"operation_id":f"op_{index:08x}-aaaa-4aaa-8aaa-aaaaaaaaaaaa","target_kind":kind,"target_id":f"target-{index}","creator_id":"peer_cccccccc-cccc-4ccc-8ccc-cccccccccccc","predecessor_revision_id":f"lv_deadbeef{index}","created_at":"2026-09-17T10:00:00+02:00","payload_sha256":f"{index:x}"*64,"publication_context":None}) + with closing(sqlite3.connect(self.database)) as db: + db.executemany("INSERT INTO sync_causal_operations VALUES(?,?,?,?,?,?,?,?)",[tuple(operation.values()) for operation in operations]);db.commit() + artifacts=(("causal-deletions","trainlog-causal-deletions",1,"causal-deletions-v1.json",True,"controlled.py",()),) + def exporter(_tool,_extra,output,_snapshot):output.write_text(json.dumps({"format":"trainlog-causal-deletions","version":1,"generated_at":"2026-09-17T10:00:00+02:00","operations":operations},sort_keys=True)) + with mock.patch.object(generation,"ARTIFACTS",artifacts),mock.patch.object(generation,"SUPPORTED",{("trainlog-causal-deletions",1)}),mock.patch.object(generation,"run_export",exporter): + first=generation.capture_desktop(self.database,self.root/"causal",PEER_B,RUN,GEN)[0] + second_id="gen_55555555-5555-4555-8555-555555555555" + second=generation.capture_desktop(self.database,self.root/"causal",PEER_B,"sy_66666666-6666-4666-8666-666666666666",second_id)[0] + self.assertEqual(json.loads((first/"causal-deletions-v1.json").read_text())["operations"],json.loads((second/"causal-deletions-v1.json").read_text())["operations"]) + with closing(sqlite3.connect(self.database)) as db: + self.assertEqual((7,7),tuple(db.execute("SELECT SUM(first_emission),COUNT(*)-SUM(first_emission) FROM sync_causal_publications").fetchone())) + 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): + names=("catalog","history","execution-drafts","exercise-aliases","exercise-profile-state","equipment-definitions","equipment-associations","body-zones","feedback","causal-deletions") + directory=self.root/"incoming";directory.mkdir() + artifacts=[] + for name in names: + filename=name+".json";(directory/filename).write_text("{}") + artifacts.append({"logical_name":name,"format":"test","version":1,"filename":f"generations/{GEN}/{filename}","size":2,"sha256":"0"*64,"required":True}) + manifest={"format":generation.MANIFEST,"version":1,"generation_id":GEN,"run_id":RUN,"producer":{"peer_id":PEER_A,"kind":"android"},"consumer_peer_id":PEER_B,"generated_at":"2026-09-17T10:00:00+02:00","parent_generation_id":None,"artifacts":artifacts} + def write_fact(db,_payload,_trace=False):db.execute("INSERT INTO facts VALUES('partial','must roll back')");return {} + with mock.patch.object(generation,"validate_published",return_value=(manifest,"a"*64)), \ + mock.patch.object(generation.causal_delete_exchange,"load",return_value={"operations":[]}), \ + mock.patch.object(generation.execution_draft_exchange,"load",return_value={}), \ + mock.patch.object(generation.import_exercise_aliases,"load",return_value=[]), \ + mock.patch.object(generation,"strict_json",return_value={}), \ + mock.patch.object(generation.import_equipment_definitions,"supplied_ids",return_value=set()), \ + mock.patch.object(generation.import_equipment_definitions,"validate",return_value=[]), \ + mock.patch.object(generation.import_mobile_export,"load_payload",return_value={}), \ + mock.patch.object(generation.import_mobile_export,"validate_payload"), \ + mock.patch.object(generation.import_equipment_associations,"load_mobile_occurrences",return_value={}), \ + mock.patch.object(generation.import_exercise_body_zones,"parse_payload",return_value=[]), \ + mock.patch.object(generation.import_training_feedback,"load",return_value={}), \ + mock.patch.object(generation.import_exercise_profile_state,"apply_profile_state"), \ + mock.patch.object(generation.import_equipment_definitions,"apply_definitions"), \ + mock.patch.object(generation.import_mobile_export,"apply_payload",side_effect=write_fact), \ + mock.patch.object(generation.import_exercise_aliases,"apply_aliases"), \ + mock.patch.object(generation.import_equipment_associations,"apply_associations"), \ + mock.patch.object(generation.import_exercise_body_zones,"apply_body_zones"), \ + mock.patch.object(generation.import_training_feedback,"apply_feedback",side_effect=ValueError("late semantic failure")): + rejected=generation.consume_desktop(self.database,directory) + self.assertEqual("rejected",rejected["result"]);self.assertIn("late semantic",rejected["diagnostic"]) + with closing(sqlite3.connect(self.database)) as db: + self.assertEqual(0,db.execute("SELECT COUNT(*) FROM facts WHERE id='partial'").fetchone()[0]) + self.assertEqual(1,db.execute("SELECT COUNT(*) FROM sync_consumed_generations WHERE result='rejected'").fetchone()[0]) + +if __name__=="__main__":unittest.main() diff --git a/tools/causal_delete_exchange.py b/tools/causal_delete_exchange.py index 109e406..3769026 100644 --- a/tools/causal_delete_exchange.py +++ b/tools/causal_delete_exchange.py @@ -38,8 +38,8 @@ def digest(value) -> str: def require_schema(db: sqlite3.Connection) -> None: version = db.execute("PRAGMA user_version").fetchone()[0] - if version != 20: - raise CausalError(f"desktop schema v20 required, found v{version}") + if version not in (20, 21): + raise CausalError(f"desktop schema v20/v21 required, found v{version}") def target_snapshot(db: sqlite3.Connection, kind: str, target: str): diff --git a/tools/execution_draft_exchange.py b/tools/execution_draft_exchange.py index 0a05bda..5e36afb 100644 --- a/tools/execution_draft_exchange.py +++ b/tools/execution_draft_exchange.py @@ -169,14 +169,15 @@ def canonical(value): def require_schema(connection): version = connection.execute("PRAGMA user_version").fetchone()[0] - if version not in (19, 20): - raise LifecycleError(f"desktop schema v19/v20 required, found v{version}") + if version not in (19, 20, 21): + raise LifecycleError(f"desktop schema v19/v20/v21 required, found v{version}") -def import_document(connection, document): +def import_document(connection, document, own_transaction=True): require_schema(connection) outcomes = [] - connection.execute("BEGIN IMMEDIATE") + if own_transaction: + connection.execute("BEGIN IMMEDIATE") try: for draft in document["drafts"]: session_id, revision = draft["session_id"], draft["revision_id"] @@ -215,9 +216,12 @@ def import_document(connection, document): connection.execute("INSERT INTO execution_draft_revisions VALUES(?,?,?,?)", (session_id, revision, draft["parent_revision_id"], payload)) outcomes.append((session_id, outcome)) - connection.commit() + if own_transaction: + connection.commit() except Exception: - connection.rollback(); raise + if own_transaction: + connection.rollback() + raise return outcomes diff --git a/tools/export_ai_history.py b/tools/export_ai_history.py index 54c2625..a241735 100644 --- a/tools/export_ai_history.py +++ b/tools/export_ai_history.py @@ -12,7 +12,7 @@ from pathlib import Path from trainlog_sqlite import configure_connection -SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20) +SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21) MEASUREMENT_FIELDS = ( "body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm", "hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm", diff --git a/tools/export_ai_session_drafts.py b/tools/export_ai_session_drafts.py index e49d8b3..267b5fa 100644 --- a/tools/export_ai_session_drafts.py +++ b/tools/export_ai_session_drafts.py @@ -20,8 +20,8 @@ ID_PREFIX = "aid_" def ensure_publication_state(connection): """Install the additive v18 cursor for direct syncd/tool invocations.""" owned_transaction = not connection.in_transaction - if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19, 20): - raise ValueError("schema desktop v18-v20 requis") + if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19, 20, 21): + raise ValueError("schema desktop v18-v21 requis") columns = {row[1] for row in connection.execute( "PRAGMA table_info(ai_session_drafts)")} if "published_at" not in columns: diff --git a/tools/export_equipment_associations.py b/tools/export_equipment_associations.py index 7432856..f559deb 100644 --- a/tools/export_equipment_associations.py +++ b/tools/export_equipment_associations.py @@ -28,7 +28,7 @@ def main(): args = parser.parse_args() connection = connect_database(args.database) try: - if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20): + if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21): raise ValueError("schema desktop v8 à v16 requis") known_equipment = load_supplied_equipment_ids(args.catalog) known_equipment.update(row[0] for row in connection.execute( diff --git a/tools/export_equipment_definitions.py b/tools/export_equipment_definitions.py index 8f1873a..bf04596 100644 --- a/tools/export_equipment_definitions.py +++ b/tools/export_equipment_definitions.py @@ -16,7 +16,7 @@ def default_database(): return Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share"))) / "trainlog/trainlog.db" -def main(): +def main(complete_causal_envelope=False): parser = argparse.ArgumentParser() parser.add_argument("output", type=Path) parser.add_argument("--database", type=Path, default=default_database()) @@ -24,9 +24,9 @@ def main(): connection = connect_database(args.database) connection.row_factory = sqlite3.Row try: - if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20): + if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21): raise ValueError("schema desktop v8 à v16 requis") - if connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone(): + if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal equipment protection requires the staged artifact") equipment = [dict(row) for row in connection.execute( "SELECT equipment_id,display_name,label_name,equipment_type,load_semantics " diff --git a/tools/export_exercise_aliases.py b/tools/export_exercise_aliases.py index 48c5ff8..b631201 100644 --- a/tools/export_exercise_aliases.py +++ b/tools/export_exercise_aliases.py @@ -16,7 +16,7 @@ def main(): args = parser.parse_args() con = connect_database(args.database) try: - if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17, 18, 19, 20): + if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17, 18, 19, 20, 21): raise ValueError("schema desktop v12-v16 requis") rows = con.execute( "SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases " diff --git a/tools/export_exercise_body_zones.py b/tools/export_exercise_body_zones.py index 6724881..a2358d6 100644 --- a/tools/export_exercise_body_zones.py +++ b/tools/export_exercise_body_zones.py @@ -32,7 +32,7 @@ def default_database(): return Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share"))) / "trainlog/trainlog.db" -def main(): +def main(complete_causal_envelope=False): parser = argparse.ArgumentParser() parser.add_argument("output", type=Path) parser.add_argument("--database", type=Path, default=default_database()) @@ -41,9 +41,9 @@ def main(): connection = connect_database(args.database) connection.row_factory = sqlite3.Row try: - if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17, 18, 19, 20): + if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21): raise ValueError("schema desktop v11-v16 requis") - if connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): + if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal BODY ZONES protection requires the staged artifact") exercises = [] for exercise in connection.execute("SELECT id,exercise_id FROM exercises ORDER BY exercise_id"): diff --git a/tools/export_exercise_profile_state.py b/tools/export_exercise_profile_state.py index c8badfc..a7573a4 100644 --- a/tools/export_exercise_profile_state.py +++ b/tools/export_exercise_profile_state.py @@ -25,7 +25,7 @@ def main(): con.row_factory = sqlite3.Row try: version = con.execute("PRAGMA user_version").fetchone()[0] - if version not in (17, 18, 19, 20): + if version not in (17, 18, 19, 20, 21): raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}") rows = con.execute(""" SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields, diff --git a/tools/export_pc_catalog.py b/tools/export_pc_catalog.py index 72428ab..d834e13 100755 --- a/tools/export_pc_catalog.py +++ b/tools/export_pc_catalog.py @@ -71,7 +71,7 @@ def main(): # CONTRACT: v8 adds only desktop-local custom equipment. The PC # catalogue artifact is unchanged, but it must read the current # canonical desktop schema rather than accept a stale pre-v8 database. - if version not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20): + if version not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21): raise SystemExit( "PC_CATALOG_EXPORT=FAIL " f"schema={version}" diff --git a/tools/export_pc_mobile.py b/tools/export_pc_mobile.py index 939ea72..90c1ac7 100644 --- a/tools/export_pc_mobile.py +++ b/tools/export_pc_mobile.py @@ -73,7 +73,7 @@ def note_payload(connection, owner_kind, owner_id, value): return {"value": row[2], "revision_id": row[0], "parent_revision_id": row[1]} -def main(): +def main(complete_causal_envelope=False): parser = argparse.ArgumentParser() parser.add_argument("output", type=Path) parser.add_argument("--database", type=Path, default=default_database()) @@ -83,11 +83,11 @@ def main(): con.row_factory = sqlite3.Row try: schema_version = con.execute("PRAGMA user_version").fetchone()[0] - if args.version == 4 and schema_version not in (19, 20): + if args.version == 4 and schema_version not in (19, 20, 21): raise ValueError("mobile V4 exige le schéma desktop v19 ou v20") - if schema_version not in (11, 12, 13, 14, 15, 16, 17, 18, 19, 20) and not (args.version == 2 and schema_version == 10): + if schema_version not in (11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21) and not (args.version == 2 and schema_version == 10): raise ValueError("schema desktop v11-v16 requis (v10 accepté pour export V2 explicite)") - if schema_version >= 20 and con.execute( + if not complete_causal_envelope and schema_version >= 20 and con.execute( "SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1").fetchone(): raise ValueError("causal protection refuses a mobile snapshot that omits tombstones") known_equipment = supplied_equipment_ids() diff --git a/tools/export_training_feedback.py b/tools/export_training_feedback.py index 0db9ad1..5f0ed4e 100644 --- a/tools/export_training_feedback.py +++ b/tools/export_training_feedback.py @@ -6,12 +6,12 @@ from pathlib import Path def instant(value): return datetime.datetime.fromisoformat(value.replace("Z", "+00:00")) -def main(): +def main(complete_causal_envelope=False): parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args() db=connect_database(args.database) try: - if db.execute("PRAGMA user_version").fetchone()[0] not in (15, 16, 17, 18, 19, 20): raise ValueError("schema desktop v15-v20 requis") - if db.execute("PRAGMA user_version").fetchone()[0] >= 20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal feedback protection requires the staged artifact") + if db.execute("PRAGMA user_version").fetchone()[0] not in (15, 16, 17, 18, 19, 20, 21): raise ValueError("schema desktop v15-v21 requis") + if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0] >= 20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal feedback protection requires the staged artifact") roots=db.execute("SELECT f.feedback_id,s.session_id,se.entry_id,e.exercise_id,f.observed_at FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id").fetchall() followups=db.execute("SELECT f.followup_id,s.session_id,f.observed_at FROM session_followups f JOIN sessions s ON s.id=f.session_row_id").fetchall() if len(roots)>4096 or len(followups)>4096: raise ValueError("trop d'observations") diff --git a/tools/import_ai_session_draft.py b/tools/import_ai_session_draft.py index 97b93a8..4b7df37 100644 --- a/tools/import_ai_session_draft.py +++ b/tools/import_ai_session_draft.py @@ -193,8 +193,8 @@ def import_payload(connection, payload, imported_at=None): INVARIANT: validation and every child insert share one IMMEDIATE transaction; any failure leaves no draft, child, or import-identity row behind. """ - if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19, 20): - raise ImportFailure("schema desktop v18-v20 requis") + if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19, 20, 21): + raise ImportFailure("schema desktop v18-v21 requis") draft, entries = validate(payload, connection) digest = canonical_digest(payload) imported_at = imported_at or dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") diff --git a/tools/import_equipment_associations.py b/tools/import_equipment_associations.py index 4311fc3..1beb773 100644 --- a/tools/import_equipment_associations.py +++ b/tools/import_equipment_associations.py @@ -85,7 +85,7 @@ def load_mobile_occurrences(path): payload = json.loads(path.read_text(encoding="utf-8")) if ( payload.get("format") != "trainlog-mobile-export" - or payload.get("version") not in (2, 3) + or payload.get("version") not in (2, 3, 4) or not isinstance(payload.get("sessions"), list) ): fail("snapshot mobile V2 de preuve invalide") @@ -117,6 +117,24 @@ def load_mobile_occurrences(path): return occurrences +def apply_associations(connection, payload, known, mobile_occurrences): + if payload.get("format") != FORMAT or payload.get("version") != VERSION or set(payload) != {"format", "version", "generated_at", "associations"}: + fail("extension équipement non supportée") + known = set(known); known.update(row[0] for row in connection.execute("SELECT equipment_id FROM custom_equipment")) + associations = validate_associations(payload["associations"], known) + for session_id, entry_id, exercise_id, equipment_id in associations: + exists = connection.execute("SELECT e.exercise_id,se.equipment_id FROM session_exercises se JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id WHERE s.session_id=? AND se.entry_id=?", (session_id, entry_id)).fetchone() + if exists is None: fail(f"entrée séance inconnue: {session_id}/{entry_id}") + stored_canonical = canonical_exercise_id(connection, exists[0]); incoming_canonical = canonical_exercise_id(connection, exercise_id) + if stored_canonical != incoming_canonical: + proof = mobile_occurrences.get((session_id, entry_id)) + incoming_still_exists = connection.execute("SELECT 1 FROM exercises WHERE exercise_id=?", (exercise_id,)).fetchone() is not None + if not (proof == exercise_id and not incoming_still_exists) and not approved_machine_split(session_id, entry_id, incoming_canonical, stored_canonical, mobile_occurrences): + fail(f"conflit exercice association: {session_id}/{entry_id}") + if exists[1] != equipment_id: fail(f"conflit association équipement: {session_id}/{entry_id}") + return len(associations) + + def approved_machine_split(session_id, entry_id, incoming_id, local_id, mobile_occurrences): """Corroborate one frozen completed-history split with the current snapshot.""" @@ -167,18 +185,10 @@ def main(): ) args = parser.parse_args() payload = json.loads(args.artifact.read_text(encoding="utf-8")) - if payload.get("format") != FORMAT or payload.get("version") != VERSION: - fail("extension équipement non supportée") - if set(payload) != {"format", "version", "generated_at", "associations"}: - fail("clés extension équipement invalides") connection = connect_database(args.database) try: - if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20): - fail("schema desktop v8 à v20 requis") - known = load_catalog(args.catalog) - known.update(row[0] for row in connection.execute( - "SELECT equipment_id FROM custom_equipment")) - associations = validate_associations(payload["associations"], known) + if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21): + fail("schema desktop v8 à v21 requis") proof_path = args.mobile_export if proof_path is None: candidate = args.artifact.with_name("trainlog-mobile-export-v2.json") @@ -189,41 +199,9 @@ def main(): if proof_path is not None else {} ) - for session_id, entry_id, exercise_id, equipment_id in associations: - exists = connection.execute( - "SELECT e.exercise_id,se.equipment_id FROM session_exercises se " - "JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id " - "WHERE s.session_id=? AND se.entry_id=?", - (session_id, entry_id)).fetchone() - if exists is None: - fail(f"entrée séance inconnue: {session_id}/{entry_id}") - # WHY: the association companion can outlive the creator ID used - # by its source occurrence. The durable flattened alias is the - # synchronization identity evidence and is stronger than raw text. - stored_canonical = canonical_exercise_id(connection, exists[0]) - incoming_canonical = canonical_exercise_id(connection, exercise_id) - if stored_canonical != incoming_canonical: - proof = mobile_occurrences.get((session_id, entry_id)) - incoming_still_exists = connection.execute( - "SELECT 1 FROM exercises WHERE exercise_id=?;", - (exercise_id,), - ).fetchone() is not None - alias_reconciliation = proof == exercise_id and not incoming_still_exists - split_reconciliation = approved_machine_split( - session_id, entry_id, incoming_canonical, - stored_canonical, mobile_occurrences) - if not alias_reconciliation and not split_reconciliation: - fail(f"conflit exercice association: {session_id}/{entry_id}") - # CONTRACT: the first fallback is only for schemas/runs without - # a persistent alias. The second is the closed v13 split table - # above; no source ID becomes a global one-to-many alias. - # INVARIANT: identity reconciliation never changes session_id, - # entry_id, equipment_id, or any persisted occurrence. A distinct - # live canonical exercise therefore remains a hard conflict. - if exists[1] != equipment_id: - fail(f"conflit association équipement: {session_id}/{entry_id}") + association_count = apply_associations(connection, payload, load_catalog(args.catalog), mobile_occurrences) print("EQUIPMENT_ASSOCIATIONS_IMPORT=PASS") - print(f"associations={len(associations)}") + print(f"associations={association_count}") finally: connection.close() diff --git a/tools/import_equipment_definitions.py b/tools/import_equipment_definitions.py index faf8cd2..b203abe 100644 --- a/tools/import_equipment_definitions.py +++ b/tools/import_equipment_definitions.py @@ -51,6 +51,23 @@ def validate(payload, reserved): return output +def apply_definitions(connection, definitions, complete_causal_envelope=False): + """Apply validated definitions without owning the surrounding transaction.""" + if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21): + fail("schema desktop v8 à v21 requis") + if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone(): + fail("causal equipment protection requires the staged artifact") + imported = skipped = 0 + for definition in definitions: + row = connection.execute("SELECT equipment_id,display_name,label_name,equipment_type,load_semantics FROM custom_equipment WHERE equipment_id=?", (definition[0],)).fetchone() + if row is not None and tuple(row) != definition: fail(f"conflit définition équipement: {definition[0]}") + for definition in definitions: + cursor = connection.execute("INSERT OR IGNORE INTO custom_equipment(equipment_id,display_name,label_name,equipment_type,load_semantics) VALUES(?,?,?,?,?)", definition) + if cursor.rowcount == 1: imported += 1 + else: skipped += 1 + return imported, skipped + + def main(): parser = argparse.ArgumentParser() parser.add_argument("artifact", type=Path) @@ -61,28 +78,8 @@ def main(): definitions = validate(json.loads(args.artifact.read_text(encoding="utf-8")), supplied_ids(args.catalog)) connection = connect_database(args.database) try: - if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20): - fail("schema desktop v8 à v16 requis") - if connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone(): - fail("causal equipment protection requires the staged artifact") - imported = skipped = 0 - # Validate every same-ID row before inserting any definition. - for definition in definitions: - row = connection.execute( - "SELECT equipment_id,display_name,label_name,equipment_type,load_semantics " - "FROM custom_equipment WHERE equipment_id=?", (definition[0],)).fetchone() - if row is not None and tuple(row) != definition: - fail(f"conflit définition équipement: {definition[0]}") with connection: - for definition in definitions: - cursor = connection.execute( - "INSERT OR IGNORE INTO custom_equipment" - "(equipment_id,display_name,label_name,equipment_type,load_semantics) VALUES(?,?,?,?,?)", - definition) - if cursor.rowcount == 1: - imported += 1 - else: - skipped += 1 + imported, skipped = apply_definitions(connection, definitions) print("EQUIPMENT_DEFINITIONS_IMPORT=PASS") print(f"definitions_imported={imported}") print(f"definitions_skipped={skipped}") diff --git a/tools/import_exercise_aliases.py b/tools/import_exercise_aliases.py index 703abbe..e989168 100644 --- a/tools/import_exercise_aliases.py +++ b/tools/import_exercise_aliases.py @@ -54,6 +54,35 @@ def load(path): return result +def apply_aliases(con, aliases): + """Apply parsed aliases inside the caller-owned transaction.""" + if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17, 18, 19, 20, 21): + fail("schema desktop v12-v21 requis") + for source, canonical in aliases: + target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone() + if target is None: fail(f"cible canonique absente: {canonical}") + current = con.execute("SELECT canonical_exercise_id FROM exercise_aliases WHERE source_exercise_id=?", (source,)).fetchone() + if current is not None: + if current[0] != canonical: fail(f"conflit alias: {source}") + continue + retired = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (source,)).fetchone() + if retired is not None: + if retired[1:] != target[1:]: fail(f"profil incompatible: {source}") + primary = lambda row_id: con.execute("SELECT zone_id FROM exercise_body_zones WHERE exercise_row_id=? AND role='primary'", (row_id,)).fetchone() + sp, tp = primary(retired[0]), primary(target[0]) + if sp and tp and sp[0] != tp[0]: fail(f"zone primaire incompatible: {source}") + chosen = tp[0] if tp else (sp[0] if sp else None) + if chosen: + con.execute("DELETE FROM exercise_body_zones WHERE exercise_row_id=? AND zone_id=?", (target[0], chosen)) + con.execute("INSERT INTO exercise_body_zones VALUES(?,?,'primary')", (target[0], chosen)) + con.execute("INSERT OR IGNORE INTO exercise_body_zones SELECT ?,zone_id,'secondary' FROM exercise_body_zones WHERE exercise_row_id=? AND role='secondary' AND zone_id<>COALESCE(?, '')", (target[0], retired[0], chosen)) + con.execute("UPDATE session_exercises SET exercise_row_id=? WHERE exercise_row_id=?", (target[0], retired[0])) + con.execute("DELETE FROM exercise_body_zone_sync WHERE exercise_row_id IN(?,?)", (retired[0], target[0])) + con.execute("UPDATE exercise_aliases SET canonical_exercise_id=? WHERE canonical_exercise_id=?", (canonical, source)) + con.execute("DELETE FROM exercises WHERE id=?", (retired[0],)) + con.execute("INSERT INTO exercise_aliases VALUES(?,?)", (source, canonical)) + + def main(): parser = argparse.ArgumentParser() parser.add_argument("artifact", type=Path) @@ -63,35 +92,8 @@ def main(): con = connect_database(args.database) try: con.execute("PRAGMA foreign_keys=ON") - if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17, 18, 19, 20): - fail("schema desktop v12-v16 requis") con.execute("BEGIN IMMEDIATE") - for source, canonical in aliases: - target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone() - if target is None: - fail(f"cible canonique absente: {canonical}") - current = con.execute("SELECT canonical_exercise_id FROM exercise_aliases WHERE source_exercise_id=?", (source,)).fetchone() - if current is not None: - if current[0] != canonical: fail(f"conflit alias: {source}") - continue - retired = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (source,)).fetchone() - if retired is not None: - if retired[1:] != target[1:]: fail(f"profil incompatible: {source}") - primary = lambda row_id: con.execute("SELECT zone_id FROM exercise_body_zones WHERE exercise_row_id=? AND role='primary'", (row_id,)).fetchone() - sp, tp = primary(retired[0]), primary(target[0]) - if sp and tp and sp[0] != tp[0]: fail(f"zone primaire incompatible: {source}") - chosen = tp[0] if tp else (sp[0] if sp else None) - if chosen: - con.execute("DELETE FROM exercise_body_zones WHERE exercise_row_id=? AND zone_id=?", (target[0], chosen)) - con.execute("INSERT INTO exercise_body_zones VALUES(?,?,'primary')", (target[0], chosen)) - con.execute("INSERT OR IGNORE INTO exercise_body_zones SELECT ?,zone_id,'secondary' FROM exercise_body_zones WHERE exercise_row_id=? AND role='secondary' AND zone_id<>COALESCE(?, '')", (target[0], retired[0], chosen)) - con.execute("UPDATE session_exercises SET exercise_row_id=? WHERE exercise_row_id=?", (target[0], retired[0])) - con.execute("DELETE FROM exercise_body_zone_sync WHERE exercise_row_id IN(?,?)", (retired[0], target[0])) - # INVARIANT: earlier sources stay one hop from a live target; - # deleting an intermediate canonical can never create a chain. - con.execute("UPDATE exercise_aliases SET canonical_exercise_id=? WHERE canonical_exercise_id=?", (canonical, source)) - con.execute("DELETE FROM exercises WHERE id=?", (retired[0],)) - con.execute("INSERT INTO exercise_aliases VALUES(?,?)", (source, canonical)) + apply_aliases(con, aliases) con.commit() except Exception: con.rollback(); raise diff --git a/tools/import_exercise_body_zones.py b/tools/import_exercise_body_zones.py index 905ddc6..da98d45 100644 --- a/tools/import_exercise_body_zones.py +++ b/tools/import_exercise_body_zones.py @@ -149,6 +149,57 @@ def replace(connection, row_id, primary, secondary): ) +def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False): + """Apply validated zone rows without owning transaction boundaries.""" + if connection.execute("PRAGMA user_version").fetchone()[0] not in (11,12,13,14,15,16,17,18,19,20,21): raise ImportFailure("schema desktop v11-v21 requis") + if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0]>=20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): raise ImportFailure("causal BODY ZONES protection requires the staged artifact") + connection.execute("PRAGMA foreign_keys=ON");grouped={} + # CONTRACT: resolve complete alias groups before the first write; direct + # primary/secondary claims may be equal but are never unioned. + for exercise_id,primary,secondary in parsed: + row_id=resolve_exercise_row(connection,exercise_id,mobile_proof);incoming=(primary,secondary);group=grouped.get(row_id) + if group is None:grouped[row_id]=(exercise_id,incoming) + elif group[1]!=incoming:raise ImportFailure(f"données de zones incompatibles après résolution d'alias: {group[0]} / {exercise_id}") + planned=[] + for row_id,(exercise_id,incoming) in grouped.items(): + primary,secondary=incoming;local_primary,local_secondary=current(connection,row_id);local_state=state(local_primary,local_secondary);incoming_state=state(primary,secondary) + baseline_row=connection.execute("SELECT synced_state FROM exercise_body_zone_sync WHERE exercise_row_id=?",(row_id,)).fetchone();baseline=None if baseline_row is None else baseline_row[0] + if local_state==incoming_state:action="skip" + elif baseline is not None and local_state==baseline:action="update" + elif baseline is not None and incoming_state==baseline:action="keep" + elif baseline is None and local_state=="|":action="update" + else:raise ImportFailure(f"conflit zones simultané: {exercise_id}") + planned.append((action,row_id,primary,secondary,incoming_state)) + updated=skipped=kept_local=0 + # WHY: aliases are compatibility identities, not extra exercises. Apply + # once per canonical row so input order cannot change the chosen payload. + for action,row_id,primary,secondary,incoming_state in planned: + if action=="keep":kept_local+=1;continue + if action=="update":replace(connection,row_id,primary,secondary);updated+=1 + else:skipped+=1 + connection.execute("INSERT OR REPLACE INTO exercise_body_zone_sync VALUES(?,?)",(row_id,incoming_state)) + return updated,skipped,kept_local + + +def parse_payload(payload): + zones=load_catalog();exact(payload,{"format","version","generated_at","exercises"},"racine") + if payload["format"]!="trainlog-exercise-body-zones" or payload["version"]!=1 or isinstance(payload["version"],bool) or not isinstance(payload["generated_at"],str) or not payload["generated_at"] or not isinstance(payload["exercises"],list):raise ImportFailure("companion zones v1 invalide") + try: generated_at=datetime.fromisoformat(payload["generated_at"].replace("Z","+00:00")) + except ValueError as error:raise ImportFailure("generated_at invalide") from error + if generated_at.utcoffset() is None:raise ImportFailure("generated_at sans offset") + parsed=[];seen=set() + for index,item in enumerate(payload["exercises"]): + exact(item,{"exercise_id","primary_zone_id","secondary_zone_ids"},f"exercises[{index}]");exercise_id=item["exercise_id"];primary=item["primary_zone_id"];secondary=item["secondary_zone_ids"] + if not isinstance(exercise_id,str) or EXERCISE_ID_PATTERN.fullmatch(exercise_id) is None or exercise_id in seen:raise ImportFailure(f"exercises[{index}].exercise_id invalide") + if primary is not None and (not isinstance(primary,str) or primary not in zones):raise ImportFailure(f"exercises[{index}].primary_zone_id invalide") + if not isinstance(secondary,list) or any(not isinstance(zone,str) or zone not in zones for zone in secondary):raise ImportFailure(f"exercises[{index}].secondary_zone_ids invalide") + if len(secondary)!=len(set(secondary)) or primary in secondary:raise ImportFailure(f"exercises[{index}]: zones dupliquées") + if primary is None and secondary:raise ImportFailure(f"exercises[{index}]: secondaires sans zone principale") + if any(zones[zone]["kind"]=="group" for zone in ([primary] if primary else [])+secondary):raise ImportFailure(f"exercises[{index}]: relation parent dérivable interdite") + seen.add(exercise_id);parsed.append((exercise_id,primary,sorted(secondary))) + return parsed + + def main(): parser = argparse.ArgumentParser() parser.add_argument("input", type=Path) @@ -159,42 +210,8 @@ def main(): help="snapshot V2 importé juste avant, utilisé seulement pour prouver un ID réconcilié", ) args = parser.parse_args() - zones = load_catalog() payload = json.loads(args.input.read_text(encoding="utf-8")) - exact(payload, {"format", "version", "generated_at", "exercises"}, "racine") - if payload["format"] != "trainlog-exercise-body-zones" or payload["version"] != 1 or \ - isinstance(payload["version"], bool) or \ - not isinstance(payload["generated_at"], str) or not payload["generated_at"] or \ - not isinstance(payload["exercises"], list): - raise ImportFailure("companion zones v1 invalide") - try: - generated_at = datetime.fromisoformat(payload["generated_at"].replace("Z", "+00:00")) - except ValueError as error: - raise ImportFailure("generated_at invalide") from error - if generated_at.utcoffset() is None: - raise ImportFailure("generated_at sans offset") - parsed = [] - seen = set() - for index, item in enumerate(payload["exercises"]): - exact(item, {"exercise_id", "primary_zone_id", "secondary_zone_ids"}, f"exercises[{index}]") - exercise_id = item["exercise_id"] - primary = item["primary_zone_id"] - secondary = item["secondary_zone_ids"] - if not isinstance(exercise_id, str) or EXERCISE_ID_PATTERN.fullmatch(exercise_id) is None or \ - exercise_id in seen: - raise ImportFailure(f"exercises[{index}].exercise_id invalide") - if primary is not None and (not isinstance(primary, str) or primary not in zones): - raise ImportFailure(f"exercises[{index}].primary_zone_id invalide") - if not isinstance(secondary, list) or any(not isinstance(zone, str) or zone not in zones for zone in secondary): - raise ImportFailure(f"exercises[{index}].secondary_zone_ids invalide") - if len(secondary) != len(set(secondary)) or primary in secondary: - raise ImportFailure(f"exercises[{index}]: zones dupliquées") - if primary is None and secondary: - raise ImportFailure(f"exercises[{index}]: secondaires sans zone principale") - if any(zones[zone]["kind"] == "group" for zone in ([primary] if primary else []) + secondary): - raise ImportFailure(f"exercises[{index}]: relation parent dérivable interdite") - seen.add(exercise_id) - parsed.append((exercise_id, primary, sorted(secondary))) + parsed = parse_payload(payload) # A V2 snapshot is reconciliation evidence only when the caller selected # and supplied that exact snapshot. Never discover a sibling implicitly: @@ -203,70 +220,9 @@ def main(): if args.mobile_export is not None else {}) connection = connect_database(args.database) - updated = skipped = kept_local = 0 try: - if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17, 18, 19, 20): - raise ImportFailure("schema desktop v11-v20 requis") - if connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): - raise ImportFailure("causal BODY ZONES protection requires the staged artifact") - connection.execute("PRAGMA foreign_keys=ON") connection.execute("BEGIN IMMEDIATE") - grouped = {} - for exercise_id, primary, secondary in parsed: - row_id = resolve_exercise_row(connection, exercise_id, mobile_proof) - incoming = (primary, secondary) - group = grouped.get(row_id) - if group is None: - grouped[row_id] = (exercise_id, incoming) - elif group[1] != incoming: - # CONTRACT: secondary zones are direct ordered-set metadata, so - # alias coalescing may accept equality but must never union two - # companion claims or choose between primary/secondary roles. - raise ImportFailure( - f"données de zones incompatibles après résolution d'alias: " - f"{group[0]} / {exercise_id}" - ) - - # INVARIANT: resolve and compare the complete alias groups before the - # first write. This makes one reconciliation decision per canonical - # exercise and prevents input order from changing the chosen payload. - planned = [] - for row_id, (exercise_id, incoming) in grouped.items(): - primary, secondary = incoming - local_primary, local_secondary = current(connection, row_id) - local_state = state(local_primary, local_secondary) - incoming_state = state(primary, secondary) - baseline_row = connection.execute( - "SELECT synced_state FROM exercise_body_zone_sync WHERE exercise_row_id=?", (row_id,), - ).fetchone() - baseline = None if baseline_row is None else baseline_row[0] - if local_state == incoming_state: - planned.append(("skip", row_id, primary, secondary, incoming_state)) - elif baseline is not None and local_state == baseline: - planned.append(("update", row_id, primary, secondary, incoming_state)) - elif baseline is not None and incoming_state == baseline: - planned.append(("keep", row_id, primary, secondary, incoming_state)) - elif baseline is None and local_state == "|": - planned.append(("update", row_id, primary, secondary, incoming_state)) - else: - raise ImportFailure(f"conflit zones simultané: {exercise_id}") - - # WHY: aliases are compatibility identities, not additional exercises. - # Applying the plan once per resolved row avoids duplicate mutations and - # cannot recreate a retired source ID. - for action, row_id, primary, secondary, incoming_state in planned: - if action == "keep": - kept_local += 1 - continue - if action == "update": - replace(connection, row_id, primary, secondary) - updated += 1 - else: - skipped += 1 - connection.execute( - "INSERT OR REPLACE INTO exercise_body_zone_sync VALUES(?,?)", - (row_id, incoming_state), - ) + updated,skipped,kept_local=apply_body_zones(connection,parsed,mobile_proof) connection.commit() print("EXERCISE_BODY_ZONES_IMPORT=PASS") print(f"zones_updated={updated}") diff --git a/tools/import_exercise_profile_state.py b/tools/import_exercise_profile_state.py index 6836770..d2c809e 100644 --- a/tools/import_exercise_profile_state.py +++ b/tools/import_exercise_profile_state.py @@ -46,22 +46,18 @@ def validate(item): revision_ids.add(rid);previous=rid if any(history[-1][key]!=item[key] for key in keys): fail("pointe historique incohérente") -def main(): - p=argparse.ArgumentParser();p.add_argument("artifact",type=Path);p.add_argument("--database",required=True,type=Path);p.add_argument("--allow-pending",action="store_true");p.add_argument("--mobile-export",type=Path,help="sync pre-pass identity proof; permits identities introduced by the following catalog import");a=p.parse_args() - try: - root=json.loads(a.artifact.read_text(encoding="utf-8")) +def apply_profile_state(con, root, allow_pending=False): if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide") - parse_timestamp(root["generated_at"], "generated_at") - con=connect_database(a.database);con.row_factory=sqlite3.Row - if con.execute("PRAGMA user_version").fetchone()[0] not in (17,18,19,20): fail("schema desktop v17-v20 requis") - applied=equal=ancestor=pending=0;seen=set();con.execute("BEGIN IMMEDIATE") + parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row + if con.execute("PRAGMA user_version").fetchone()[0] not in (17,18,19,20,21): fail("schema desktop v17-v21 requis") + applied=equal=ancestor=pending=0;seen=set() for item in root["exercises"]: validate(item);eid=item["exercise_id"] if eid in seen: fail("exercise_id profile-state dupliqué") seen.add(eid) row=con.execute("""SELECT e.id,e.recording_mode,e.tracking_mode,e.data_fields,e.load_semantics,e.machine_variant,e.machine_provenance,e.scientific_profile_id,e.science_state,e.legacy_equipment_id,s.revision_id,s.parent_revision_id,s.legacy_seed FROM exercises e JOIN exercise_profile_state s ON s.exercise_row_id=e.id WHERE e.exercise_id=?""",(eid,)).fetchone() if row is None: - if a.allow_pending or a.mobile_export is not None: pending+=1;continue + if allow_pending: pending+=1;continue fail(f"identité exercice inconnue: {eid}") if any(row[k]!=item[k] for k in ADJ): fail(f"conflit identité/machine immutable: {eid}") local_profile=(row["recording_mode"],row["tracking_mode"],row["data_fields"]);incoming=(item["recording_mode"],item["tracking_mode"],item["data_fields"]) @@ -90,6 +86,14 @@ def main(): con.execute("UPDATE exercise_profile_state SET revision_id=?,parent_revision_id=?,legacy_seed=? WHERE exercise_row_id=?",(item["revision_id"],item["parent_revision_id"],int(item["legacy_seed"]),row["id"])) con.execute("UPDATE exercises SET recording_mode=?,tracking_mode=?,data_fields=? WHERE id=?",(*incoming,row["id"])) applied+=1 + return applied,equal,ancestor,pending + +def main(): + p=argparse.ArgumentParser();p.add_argument("artifact",type=Path);p.add_argument("--database",required=True,type=Path);p.add_argument("--allow-pending",action="store_true");p.add_argument("--mobile-export",type=Path,help="sync pre-pass identity proof; permits identities introduced by the following catalog import");a=p.parse_args() + try: + root=json.loads(a.artifact.read_text(encoding="utf-8"));con=connect_database(a.database) + con.execute("BEGIN IMMEDIATE") + applied,equal,ancestor,pending=apply_profile_state(con,root,a.allow_pending or a.mobile_export is not None) con.commit();print(f"EXERCISE_PROFILE_STATE_IMPORT=PASS applied={applied} equal={equal} ancestor={ancestor} pending={pending}") except (OSError,json.JSONDecodeError,sqlite3.Error,ValueError) as e: try: diff --git a/tools/import_mobile_export.py b/tools/import_mobile_export.py index 03533ac..b2e8e11 100755 --- a/tools/import_mobile_export.py +++ b/tools/import_mobile_export.py @@ -846,9 +846,9 @@ def require_supported_schema(connection): # CONTRACT: v9 owns explicit max_results; earlier supported schemas remain # readable for legacy artifacts and are never made to fake that table. - if version not in (5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20): + if version not in (5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21): raise ImportFailure( - f"base desktop schema v5 à v20 attendue, version trouvée: {version}" + f"base desktop schema v5 à v21 attendue, version trouvée: {version}" ) @@ -1959,23 +1959,9 @@ def import_body( report["body_imported"] += 1 -def run_import( - payload, - database_path, - dry_run, - trace_exercises=False, -): - if not database_path.exists(): - raise ImportFailure( - f"base desktop introuvable: {database_path}" - ) - - connection = connect_database( - database_path - ) - +def apply_payload(connection, payload, trace_exercises=False): + """Apply a validated artifact without owning commit or rollback.""" connection.row_factory = sqlite3.Row - report = { "exercises_imported": 0, "exercises_reconciled": 0, @@ -1987,70 +1973,43 @@ def run_import( "body_skipped": 0, } + connection.execute("PRAGMA foreign_keys = ON;") + + require_supported_schema(connection) + + schema_version = connection.execute("PRAGMA user_version;").fetchone()[0] + if payload["version"] == 4 and schema_version not in (19, 20, 21): + raise ImportFailure("mobile V4 exige le schéma desktop v19, v20 ou v21") + if schema_version >= 20: + protected = {(row[0], row[1]) for row in connection.execute( + "SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")} + incoming = ({("session", value["session_id"]) for value in payload["sessions"]} | + {("body_observation", value["observation_id"]) for value in payload["body_observations"]} | + {("exercise", value["exercise_id"]) for value in payload["exercises"]}) + collision = protected & incoming + if collision: + kind, identity = sorted(collision)[0] + raise ImportFailure(f"causal protection refuses legacy/live replay: {kind}/{identity}") + has_explicit_max = any("max_weight_kg" in entry for session in payload["sessions"] for entry in session["exercises"]) + if has_explicit_max and schema_version < 9: + raise ImportFailure("max_weight_kg exige le schéma desktop v9") + has_explicit_zero_set_weight = any(set_item.get("weight_kg") == 0 for session in payload["sessions"] for entry in session["exercises"] for set_item in entry.get("sets", []) if "weight_kg" in set_item) + if has_explicit_zero_set_weight and schema_version < 10: + raise ImportFailure("weight_kg=0 exige le schéma desktop v10; import annulé") + + mapping = import_exercises(connection, payload, report, trace_exercises) + import_sessions(connection, payload, mapping, report) + import_body(connection, payload, report) + return report + + +def run_import(payload, database_path, dry_run, trace_exercises=False): + if not database_path.exists(): + raise ImportFailure(f"base desktop introuvable: {database_path}") + connection = connect_database(database_path) try: - connection.execute( - "PRAGMA foreign_keys = ON;" - ) - - require_supported_schema( - connection - ) - - schema_version = connection.execute("PRAGMA user_version;").fetchone()[0] - if payload["version"] == 4 and schema_version not in (19, 20): - raise ImportFailure("mobile V4 exige le schéma desktop v19 ou v20") - if schema_version >= 20: - protected = {(row[0], row[1]) for row in connection.execute( - "SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")} - incoming = ({("session", value["session_id"]) for value in payload["sessions"]} | - {("body_observation", value["observation_id"]) for value in payload["body_observations"]} | - {("exercise", value["exercise_id"]) for value in payload["exercises"]}) - collision = protected & incoming - if collision: - kind, identity = sorted(collision)[0] - raise ImportFailure(f"causal protection refuses legacy/live replay: {kind}/{identity}") - has_explicit_max = any( - "max_weight_kg" in entry - for session in payload["sessions"] - for entry in session["exercises"] - ) - if has_explicit_max and schema_version < 9: - raise ImportFailure("max_weight_kg exige le schéma desktop v9") - has_explicit_zero_set_weight = any( - set_item.get("weight_kg") == 0 - for session in payload["sessions"] - for entry in session["exercises"] - for set_item in entry.get("sets", []) - if "weight_kg" in set_item - ) - if has_explicit_zero_set_weight and schema_version < 10: - raise ImportFailure( - "weight_kg=0 exige le schéma desktop v10; import annulé" - ) - - connection.execute( - "BEGIN IMMEDIATE;" - ) - - mapping = import_exercises( - connection, - payload, - report, - trace_exercises, - ) - - import_sessions( - connection, - payload, - mapping, - report, - ) - - import_body( - connection, - payload, - report, - ) + connection.execute("BEGIN IMMEDIATE;") + report = apply_payload(connection, payload, trace_exercises) if dry_run: connection.rollback() diff --git a/tools/import_training_feedback.py b/tools/import_training_feedback.py index 00ef080..1e4194d 100644 --- a/tools/import_training_feedback.py +++ b/tools/import_training_feedback.py @@ -46,15 +46,13 @@ def merge_revisions(db,table,parent_column,parent_id,revisions): else:fail("HARD CONFLICT "+revision[0]) current=max(db.execute(f"SELECT revision_id,created_at,raw_text FROM {table} WHERE {parent_column}=?",(parent_id,)).fetchall(),key=lambda r:(instant(r[1]),r[0].encode())) return added,skipped,current[2] -def main(): - parser=argparse.ArgumentParser();parser.add_argument("artifact",type=Path);parser.add_argument("--database",type=Path,required=True);args=parser.parse_args();root=load(args.artifact);db=connect_database(args.database) +def apply_feedback(db,root,complete_causal_envelope=False): roots_added=roots_skipped=revisions_added=revisions_skipped=0 - try: - db.execute("PRAGMA foreign_keys=ON") - if db.execute("PRAGMA user_version").fetchone()[0] not in (15,16,17,18,19,20):fail("schema desktop v15-v20 requis") - if db.execute("PRAGMA user_version").fetchone()[0]>=20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone():fail("causal feedback protection requires the staged artifact") - db.execute("BEGIN IMMEDIATE");seen=set() - for index,item in enumerate(root["exercise_feedback"]): + db.execute("PRAGMA foreign_keys=ON") + if db.execute("PRAGMA user_version").fetchone()[0] not in (15,16,17,18,19,20,21):fail("schema desktop v15-v21 requis") + if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0]>=20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone():fail("causal feedback protection requires the staged artifact") + seen=set() + for index,item in enumerate(root["exercise_feedback"]): keys={"feedback_id","session_id","entry_id","exercise_id","observed_at","raw_text"} if root["version"]==1 else {"feedback_id","session_id","entry_id","exercise_id","observed_at","revisions"} if not isinstance(item,dict) or set(item)!=keys:fail(f"exercise_feedback[{index}] invalide") fid=item["feedback_id"] @@ -68,8 +66,8 @@ def main(): elif old==expected:roots_skipped+=1 else:fail("HARD CONFLICT "+fid) added,skipped,current=merge_revisions(db,"exercise_feedback_revisions","feedback_id",fid,revisions);revisions_added+=added;revisions_skipped+=skipped;db.execute("UPDATE exercise_feedback SET raw_text=? WHERE feedback_id=?",(current,fid)) - seen=set() - for index,item in enumerate(root["session_followups"]): + seen=set() + for index,item in enumerate(root["session_followups"]): keys={"followup_id","session_id","observed_at","raw_text"} if root["version"]==1 else {"followup_id","session_id","observed_at","revisions"} if not isinstance(item,dict) or set(item)!=keys:fail(f"session_followups[{index}] invalide") fid=item["followup_id"] @@ -81,6 +79,13 @@ def main(): elif old==expected:roots_skipped+=1 else:fail("HARD CONFLICT "+fid) added,skipped,current=merge_revisions(db,"session_followup_revisions","followup_id",fid,revisions);revisions_added+=added;revisions_skipped+=skipped;db.execute("UPDATE session_followups SET raw_text=? WHERE followup_id=?",(current,fid)) + return roots_added,roots_skipped,revisions_added,revisions_skipped + +def main(): + parser=argparse.ArgumentParser();parser.add_argument("artifact",type=Path);parser.add_argument("--database",type=Path,required=True);args=parser.parse_args();root=load(args.artifact);db=connect_database(args.database) + try: + db.execute("BEGIN IMMEDIATE") + roots_added,roots_skipped,revisions_added,revisions_skipped=apply_feedback(db,root) db.commit() except Exception:db.rollback();raise finally:db.close() diff --git a/tools/sync_generation_exchange.py b/tools/sync_generation_exchange.py new file mode 100644 index 0000000..98cab0e --- /dev/null +++ b/tools/sync_generation_exchange.py @@ -0,0 +1,589 @@ +#!/usr/bin/env python3 +"""Staged coherent-generation, manifest, consumption and ACK service. + +The active V3 transport does not call this module. Filesystem publication is a +bounded object-store adapter used by explicit staged callers and tests. +""" +import argparse +import datetime as dt +import hashlib +import json +import os +import re +import shutil +import sqlite3 +import subprocess +import sys +import uuid +from contextlib import closing +from pathlib import Path, PurePosixPath + +from trainlog_sqlite import connect_database +import causal_delete_exchange +import execution_draft_exchange +import import_equipment_associations +import import_equipment_definitions +import import_exercise_aliases +import import_exercise_body_zones +import import_exercise_profile_state +import import_mobile_export +import import_training_feedback +import export_equipment_definitions +import export_exercise_body_zones +import export_pc_mobile +import export_training_feedback + +ROOT = Path(__file__).resolve().parents[1] +MANIFEST = "trainlog-sync-manifest" +ACK = "trainlog-sync-ack" +MAX_MANIFEST = 64 * 1024 +MAX_ARTIFACTS = 32 +MAX_ARTIFACT = 64 * 1024 * 1024 +MAX_GENERATION = 256 * 1024 * 1024 +MAX_NAME = 64 +MAX_PATH = 240 +MAX_DIAGNOSTIC = 1024 +MAX_RETAINED_PER_PEER = 8 +ID = re.compile(r"^(?:gen|peer|sy)_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$") +HEX = re.compile(r"^[0-9a-f]{64}$") + +ARTIFACTS = ( + ("catalog", "trainlog-pc-catalog", 1, "catalog-v1.json", True, + "export_pc_catalog.py", ()), + ("history", "trainlog-mobile-export", 4, "history-v4.json", True, + "export_pc_mobile.py", ("--version", "4")), + ("execution-drafts", "trainlog-execution-drafts", 1, "execution-drafts-v1.json", True, + "execution_draft_exchange.py", ("export",)), + ("exercise-aliases", "trainlog-exercise-aliases", 1, "exercise-aliases-v1.json", True, + "export_exercise_aliases.py", ()), + ("exercise-profile-state", "trainlog-exercise-profile-state", 1, "exercise-profile-state-v1.json", True, + "export_exercise_profile_state.py", ()), + ("equipment-definitions", "trainlog-equipment-definitions", 1, "equipment-definitions-v1.json", True, + "export_equipment_definitions.py", ()), + ("equipment-associations", "trainlog-equipment-associations", 2, "equipment-associations-v2.json", True, + "export_equipment_associations.py", ()), + ("body-zones", "trainlog-exercise-body-zones", 1, "body-zones-v1.json", True, + "export_exercise_body_zones.py", ()), + ("feedback", "trainlog-training-feedback", 2, "feedback-v2.json", True, + "export_training_feedback.py", ()), + ("causal-deletions", "trainlog-causal-deletions", 1, "causal-deletions-v1.json", True, + "causal_delete_exchange.py", ("export",)), + ("ai-proposals", "trainlog-ai-session-drafts", 1, "ai-proposals-v1.json", False, + "export_ai_session_drafts.py", ()), +) +SUPPORTED = {(row[1], row[2]) for row in ARTIFACTS} + + +class GenerationError(RuntimeError): + pass + + +def now() -> str: + return dt.datetime.now().astimezone().isoformat() + + +def canonical(value) -> bytes: + return json.dumps(value, ensure_ascii=False, sort_keys=True, + separators=(",", ":")).encode("utf-8") + + +def digest_bytes(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def digest_file(path: Path, declared: int | None = None) -> tuple[int, str]: + total = 0 + checksum = hashlib.sha256() + with path.open("rb") as stream: + while True: + block = stream.read(1024 * 1024) + if not block: + break + total += len(block) + if total > MAX_ARTIFACT or declared is not None and total > declared: + raise GenerationError("artifact exceeds declared or 64 MiB bound") + checksum.update(block) + return total, checksum.hexdigest() + + +def strict_json(raw: bytes, maximum: int): + if len(raw) > maximum: + raise GenerationError("JSON exceeds byte bound") + def unique(pairs): + result = {} + for key, value in pairs: + if key in result: + raise GenerationError("duplicate JSON key: " + key) + result[key] = value + return result + try: + root = json.loads(raw.decode("utf-8"), object_pairs_hook=unique) + stack = [(root, 1)]; nodes = 0 + while stack: + value, depth = stack.pop(); nodes += 1 + if depth > 8 or nodes > 8192: + raise GenerationError("JSON nesting/node bound exceeded") + if isinstance(value, dict): stack.extend((child, depth + 1) for child in value.values()) + elif isinstance(value, list): stack.extend((child, depth + 1) for child in value) + return root + except (UnicodeDecodeError, json.JSONDecodeError, RecursionError) as error: + raise GenerationError("invalid UTF-8/JSON") from error + + +def valid_id(value: object, prefix: str) -> bool: + return isinstance(value, str) and value.startswith(prefix + "_") and ID.fullmatch(value) is not None + + +def validate_relative(value: object, generation_id: str) -> str: + if not isinstance(value, str) or not value or len(value.encode()) > MAX_PATH or "\\" in value or "\x00" in value: + raise GenerationError("invalid artifact path") + path = PurePosixPath(value) + parts = path.parts + if path.is_absolute() or len(parts) > 3 or any(part in ("", ".", "..") for part in parts): + raise GenerationError("unsafe artifact path") + if len(parts) != 3 or parts[:2] != ("generations", generation_id): + raise GenerationError("artifact outside generation namespace") + return value + + +def validate_manifest_bytes(raw: bytes, expected_consumer: str | None = None): + root = strict_json(raw, MAX_MANIFEST) + keys = {"format", "version", "generation_id", "run_id", "producer", + "consumer_peer_id", "generated_at", "parent_generation_id", "artifacts"} + if not isinstance(root, dict) or set(root) != keys or root["format"] != MANIFEST or type(root["version"]) is not int or root["version"] != 1: + raise GenerationError("unsupported manifest shape/version") + generation = root["generation_id"] + if not valid_id(generation, "gen") or not valid_id(root["run_id"], "sy"): + raise GenerationError("invalid generation/run identity") + producer = root["producer"] + if not isinstance(producer, dict) or set(producer) != {"peer_id", "kind"} or not valid_id(producer["peer_id"], "peer") or producer["kind"] not in ("android", "desktop"): + raise GenerationError("invalid producer") + if not valid_id(root["consumer_peer_id"], "peer") or expected_consumer and root["consumer_peer_id"] != expected_consumer: + raise GenerationError("wrong consumer") + if root["consumer_peer_id"] == producer["peer_id"]: + raise GenerationError("producer and consumer must differ") + if root["parent_generation_id"] is not None and (not valid_id(root["parent_generation_id"], "gen") or root["parent_generation_id"] == generation): + raise GenerationError("invalid parent generation") + try: + parsed = dt.datetime.fromisoformat(root["generated_at"].replace("Z", "+00:00")) + if parsed.utcoffset() is None: + raise ValueError + except Exception as error: + raise GenerationError("invalid generated_at") from error + values = root["artifacts"] + if not isinstance(values, list) or not 1 <= len(values) <= MAX_ARTIFACTS: + raise GenerationError("artifact descriptor bound") + names, paths, total = set(), set(), 0 + for item in values: + if not isinstance(item, dict) or set(item) != {"logical_name", "format", "version", "filename", "size", "sha256", "required"}: + raise GenerationError("invalid artifact descriptor") + name = item["logical_name"] + if not isinstance(name, str) or not name.isascii() or not 0 < len(name.encode()) <= MAX_NAME or name in names: + raise GenerationError("invalid/duplicate logical name") + names.add(name) + filename = validate_relative(item["filename"], generation) + if filename in paths: + raise GenerationError("duplicate artifact path") + paths.add(filename) + if type(item["version"]) is not int or item["version"] < 1 or type(item["size"]) is not int or not 0 <= item["size"] <= MAX_ARTIFACT or type(item["required"]) is not bool or not isinstance(item["format"], str) or not HEX.fullmatch(item["sha256"] or ""): + raise GenerationError("invalid artifact metadata") + if item["required"] and (item["format"], item["version"]) not in SUPPORTED: + raise GenerationError("unsupported required capability") + total += item["size"] + if total > MAX_GENERATION: + raise GenerationError("generation exceeds 256 MiB") + required = {row[0] for row in ARTIFACTS if row[4]} + if not required.issubset(names): + raise GenerationError("missing required domain") + return root + + +def require_schema(db: sqlite3.Connection) -> None: + if db.execute("PRAGMA user_version").fetchone()[0] != 21: + raise GenerationError("desktop schema v21 required") + + +def peer_identity(db: sqlite3.Connection, kind: str) -> str: + require_schema(db) + row = db.execute("SELECT peer_id,kind FROM sync_peer_identity WHERE singleton=1").fetchone() + if row: + if row[1] != kind: + raise GenerationError("peer kind conflict") + return row[0] + identity = "peer_" + str(uuid.uuid4()) + db.execute("INSERT INTO sync_peer_identity VALUES(1,?,?)", (identity, kind)) + return identity + + +def run_export(tool: str, extra: tuple[str, ...], output: Path, snapshot: Path) -> None: + command = [sys.executable, str(ROOT / "tools" / tool)] + if tool in ("execution_draft_exchange.py", "causal_delete_exchange.py"): + command.extend(extra); command.append(str(output)) + else: + command.append(str(output)); command.extend(extra) + command.extend(("--database", str(snapshot))) + generation_exporters = { + "export_pc_mobile.py": export_pc_mobile, + "export_equipment_definitions.py": export_equipment_definitions, + "export_exercise_body_zones.py": export_exercise_body_zones, + "export_training_feedback.py": export_training_feedback, + } + if tool in generation_exporters: + previous = sys.argv + try: + sys.argv = command[1:] + generation_exporters[tool].main(complete_causal_envelope=True) + return + finally: + sys.argv = previous + result = subprocess.run(command, text=True, capture_output=True, check=False) + if result.returncode != 0: + raise GenerationError(f"export {tool} failed: {result.stdout}{result.stderr}") + + +def capture_desktop(database: Path, owned_root: Path, consumer: str, + run_id: str | None = None, generation_id: str | None = None): + if not valid_id(consumer, "peer"): + raise GenerationError("invalid expected consumer") + generation = generation_id or "gen_" + str(uuid.uuid4()) + run = run_id or "sy_" + str(uuid.uuid4()) + if not valid_id(generation, "gen") or not valid_id(run, "sy"): + raise GenerationError("invalid generation/run") + with closing(connect_database(database)) as admission: + require_schema(admission) + retained = admission.execute("SELECT COUNT(*) FROM sync_generations WHERE consumer_peer_id=? AND status IN('captured','published','waiting_acknowledgement','acknowledged')", (consumer,)).fetchone()[0] + if retained >= MAX_RETAINED_PER_PEER: + raise GenerationError("generation retention capacity exhausted") + stage = owned_root / "staging" / generation + if stage.exists(): + raise GenerationError("generation staging already exists") + stage.mkdir(parents=True, mode=0o700) + snapshot = stage / ".snapshot.sqlite" + try: + with closing(connect_database(database)) as source, closing(sqlite3.connect(snapshot)) as target: + require_schema(source) + producer = peer_identity(source, "desktop") + source.commit() + source.backup(target) + descriptors = [] + total = 0 + for logical, fmt, version, filename, required, tool, extra in ARTIFACTS: + output = stage / filename + run_export(tool, extra, output, snapshot) + size, checksum = digest_file(output) + total += size + if total > MAX_GENERATION: + raise GenerationError("generation exceeds 256 MiB") + descriptors.append({"logical_name": logical, "format": fmt, + "version": version, "filename": f"generations/{generation}/{filename}", + "size": size, "sha256": checksum, "required": required}) + parent = None + with closing(connect_database(database)) as db: + parent_rows = db.execute("SELECT g.generation_id FROM sync_generations g WHERE g.producer_peer_id=? AND g.consumer_peer_id=? AND g.status='acknowledged' AND NOT EXISTS(SELECT 1 FROM sync_generations c WHERE c.parent_generation_id=g.generation_id AND c.status='acknowledged') LIMIT 2", (producer, consumer)).fetchall() + if len(parent_rows) > 1: raise GenerationError("outgoing generation lineage has multiple tips") + parent = parent_rows[0][0] if parent_rows else None + manifest = {"format": MANIFEST, "version": 1, "generation_id": generation, + "run_id": run, "producer": {"peer_id": producer, "kind": "desktop"}, + "consumer_peer_id": consumer, "generated_at": now(), + "parent_generation_id": parent, "artifacts": descriptors} + raw = canonical(manifest) + validate_manifest_bytes(raw, consumer) + checksum = digest_bytes(raw) + (stage / "manifest.json").write_bytes(raw) + os.chmod(stage / "manifest.json", 0o600) + snapshot.unlink() + with closing(connect_database(database)) as db: + db.execute("BEGIN IMMEDIATE") + peer_identity(db, "desktop") + db.execute("INSERT INTO sync_generations VALUES(?,?,?,?,?,?,?,?,?,?,?,NULL)", + (generation, run, producer, consumer, "desktop", manifest["generated_at"], + parent, checksum, "captured", raw.decode(), str(stage))) + db.executemany("INSERT INTO sync_generation_artifacts VALUES(?,?,?,?,?,?,?,?)", + [(generation, value["logical_name"], value["format"], value["version"], + value["filename"], value["size"], value["sha256"], int(value["required"])) + for value in descriptors]) + causal_path = stage / "causal-deletions-v1.json" + if causal_path.is_file(): + for operation in json.loads(causal_path.read_text())["operations"]: + existed = db.execute("SELECT 1 FROM sync_causal_publications WHERE operation_id=?", (operation["operation_id"],)).fetchone() + db.execute("INSERT INTO sync_causal_publications VALUES(?,?,?)", + (operation["operation_id"], generation, 0 if existed else 1)) + db.commit() + return stage, manifest, checksum + except Exception: + if stage.exists(): + shutil.rmtree(stage) + raise + + +def publish(database: Path, generation: str, object_root: Path) -> Path: + with closing(connect_database(database)) as db: + require_schema(db) + row = db.execute("SELECT manifest_json,manifest_sha256,staging_path,status FROM sync_generations WHERE generation_id=?", (generation,)).fetchone() + if not row: + raise GenerationError("unknown generation") + if row[3] not in ("captured", "published", "waiting_acknowledgement"): + raise GenerationError("generation is not publishable") + manifest = validate_manifest_bytes(row[0].encode()) + destination = object_root / "generations" / generation + source = Path(row[2]) + marker = destination / "manifest.json" + if marker.is_file(): + existing = marker.read_bytes() + if digest_bytes(existing) != row[1]: raise GenerationError("same generation has different published bytes") + validate_published(destination, manifest["consumer_peer_id"]) + else: + if destination.exists() and (not destination.is_dir() or destination.is_symlink()): + raise GenerationError("unsafe generation namespace") + destination.mkdir(parents=True, mode=0o700) + for item in manifest["artifacts"]: + source_file = source / PurePosixPath(item["filename"]).name + target = destination / source_file.name + if not source_file.is_file() or source_file.is_symlink() or source_file.resolve().parent != source.resolve(): + raise GenerationError("unsafe staging substitution") + size, checksum = digest_file(source_file, item["size"]) + if size != item["size"] or checksum != item["sha256"]: + raise GenerationError("staging artifact changed after capture") + if target.exists(): + if target.is_symlink() or digest_file(target, item["size"]) != (item["size"], item["sha256"]): + raise GenerationError("conflicting partial publication") + else: + with source_file.open("rb") as incoming, target.open("xb") as outgoing: + shutil.copyfileobj(incoming, outgoing, 1024 * 1024) + os.chmod(target, 0o600) + # Commit marker is deliberately last. + with marker.open("xb") as stream: + stream.write(row[0].encode()) + os.chmod(marker, 0o600) + db.execute("UPDATE sync_generations SET status='waiting_acknowledgement' WHERE generation_id=?", (generation,)) + db.commit() + return destination + + +def validate_published(directory: Path, consumer: str): + marker = directory / "manifest.json" + if not marker.is_file() or marker.is_symlink(): + raise GenerationError("complete manifest marker missing") + raw = marker.read_bytes() if marker.stat().st_size <= MAX_MANIFEST else b"x" * (MAX_MANIFEST + 1) + manifest = validate_manifest_bytes(raw, consumer) + total = 0 + for item in manifest["artifacts"]: + path = directory / PurePosixPath(item["filename"]).name + if not path.is_file() or path.is_symlink() or path.resolve().parent != directory.resolve(): + raise GenerationError("missing or unsafe listed artifact") + size, checksum = digest_file(path, item["size"]) + if size != item["size"] or checksum != item["sha256"]: + raise GenerationError("artifact size/digest mismatch") + total += size + if total > MAX_GENERATION: + raise GenerationError("generation exceeds bound") + return manifest, digest_bytes(raw) + + +def ack_document(run: str, generation: str, producer: str, consumer: str, + manifest_digest: str, result: str, consumed_at: str, + diagnostic: str = ""): + if len(diagnostic.encode()) > MAX_DIAGNOSTIC: + raise GenerationError("diagnostic exceeds bound") + value = {"format": ACK, "version": 1, "ack_id": "ack_" + generation[4:], + "run_id": run, "generation_id": generation, "producer_peer_id": producer, + "consumer_peer_id": consumer, "manifest_sha256": manifest_digest, + "result": result, "durability": "sqlite-commit-full" if result == "consumed" else "sqlite-commit-rejection", "consumed_at": consumed_at, + "diagnostic": diagnostic} + value["payload_sha256"] = digest_bytes(canonical(value)) + return value + + +def record_consumed(db: sqlite3.Connection, manifest: dict, manifest_digest: str, + diagnostic: str = "") -> dict: + """Record consumption inside the caller-owned business transaction.""" + require_schema(db) + generation = manifest["generation_id"] + known = db.execute("SELECT manifest_sha256,ack_json FROM sync_consumed_generations WHERE generation_id=?", (generation,)).fetchone() + if known: + if known[0] != manifest_digest: + raise GenerationError("generation identity reused with different manifest") + return json.loads(known[1]) + consumer = peer_identity(db, "desktop") + if consumer != manifest["consumer_peer_id"]: + raise GenerationError("wrong local consumer") + consumed_at = now() + ack = ack_document(manifest["run_id"], generation, manifest["producer"]["peer_id"], + consumer, manifest_digest, "consumed", consumed_at, diagnostic) + db.execute("INSERT INTO sync_consumed_generations VALUES(?,?,?,?,?,?,?,?,?,?,?)", + (generation, manifest["run_id"], manifest["producer"]["peer_id"], consumer, + manifest["parent_generation_id"], manifest_digest, consumed_at, "consumed", "sqlite-commit-full", diagnostic, + canonical(ack).decode())) + db.execute("INSERT INTO sync_acknowledgements VALUES(?,?,?,?,?,?,?,?,?,?,?)", + (ack["ack_id"], generation, ack["run_id"], ack["producer_peer_id"], consumer, + manifest_digest, "consumed", ack["durability"], consumed_at, diagnostic, + ack["payload_sha256"])) + return ack + + +def record_rejected(database: Path, manifest: dict, manifest_digest: str, diagnostic: str) -> dict: + diagnostic = diagnostic.encode("utf-8")[:MAX_DIAGNOSTIC].decode("utf-8", "ignore") + with closing(connect_database(database)) as db: + require_schema(db); db.execute("BEGIN IMMEDIATE") + consumer = peer_identity(db, "desktop"); rejected_at = now() + ack = ack_document(manifest["run_id"], manifest["generation_id"], manifest["producer"]["peer_id"], consumer, manifest_digest, "rejected", rejected_at, diagnostic) + db.execute("INSERT OR IGNORE INTO sync_consumed_generations VALUES(?,?,?,?,?,?,?,?,?,?,?)", (manifest["generation_id"],manifest["run_id"],manifest["producer"]["peer_id"],consumer,manifest["parent_generation_id"],manifest_digest,rejected_at,"rejected",ack["durability"],diagnostic,canonical(ack).decode())) + db.execute("INSERT OR IGNORE INTO sync_acknowledgements VALUES(?,?,?,?,?,?,?,?,?,?,?)", (ack["ack_id"],manifest["generation_id"],manifest["run_id"],manifest["producer"]["peer_id"],consumer,manifest_digest,"rejected",ack["durability"],rejected_at,diagnostic,ack["payload_sha256"])) + db.commit(); return ack + + +def consume_desktop(database: Path, directory: Path) -> dict: + """Consume one complete Android generation in one desktop transaction.""" + with closing(connect_database(database)) as identity_db: + consumer = peer_identity(identity_db, "desktop") + identity_db.commit() + manifest, manifest_digest = validate_published(directory, consumer) + listed = {item["logical_name"]: directory / PurePosixPath(item["filename"]).name + for item in manifest["artifacts"]} + def required(name): + try: + return listed[name] + except KeyError as error: + raise GenerationError("missing required domain: " + name) from error + + # Parse and domain-validate bounded bytes before opening the write scope. + causal = causal_delete_exchange.load(required("causal-deletions")) + drafts = execution_draft_exchange.load(required("execution-drafts")) + aliases = import_exercise_aliases.load(required("exercise-aliases")) + profile = strict_json(required("exercise-profile-state").read_bytes(), MAX_ARTIFACT) + equipment_payload = strict_json(required("equipment-definitions").read_bytes(), MAX_ARTIFACT) + reserved = import_equipment_definitions.supplied_ids(ROOT / "catalog/equipment-v1.json") + definitions = import_equipment_definitions.validate(equipment_payload, reserved) + history = import_mobile_export.load_payload(required("history")) + known_equipment = set(reserved) | {item[0] for item in definitions} + import_mobile_export.validate_payload(history, known_equipment) + association_payload = strict_json(required("equipment-associations").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) + feedback = import_training_feedback.load(required("feedback")) + mobile_occurrences = import_equipment_associations.load_mobile_occurrences(required("history")) + + with closing(connect_database(database)) as db: + require_schema(db) + db.execute("PRAGMA foreign_keys=ON") + db.execute("BEGIN IMMEDIATE") + try: + known = db.execute("SELECT manifest_sha256,ack_json FROM sync_consumed_generations WHERE generation_id=?", (manifest["generation_id"],)).fetchone() + if known: + if known[0] != manifest_digest: + raise GenerationError("generation identity reused with different manifest") + db.rollback() + return json.loads(known[1]) + predecessors = db.execute("SELECT g.generation_id FROM sync_consumed_generations g WHERE g.producer_peer_id=? AND g.result='consumed' AND NOT EXISTS(SELECT 1 FROM sync_consumed_generations c WHERE c.parent_generation_id=g.generation_id AND c.result='consumed') LIMIT 2", (manifest["producer"]["peer_id"],)).fetchall() + if len(predecessors) > 1: raise GenerationError("consumed generation lineage has multiple tips") + expected_parent = predecessors[0][0] if predecessors else None + if manifest["parent_generation_id"] != expected_parent: + raise GenerationError("generation lineage is stale or unrelated") + # Causal state is applied first. Complete-envelope helpers retain + # item-level conflict rules while avoiding the legacy global guard. + for operation in causal["operations"]: + causal_delete_exchange.apply_operation(db, operation) + import_exercise_profile_state.apply_profile_state(db, profile, allow_pending=True) + import_equipment_definitions.apply_definitions(db, definitions, complete_causal_envelope=True) + import_mobile_export.apply_payload(db, history) + import_exercise_profile_state.apply_profile_state(db, profile) + import_exercise_aliases.apply_aliases(db, aliases) + import_equipment_associations.apply_associations(db, association_payload, reserved, mobile_occurrences) + import_exercise_body_zones.apply_body_zones(db, parsed_zones, {}, complete_causal_envelope=True) + import_training_feedback.apply_feedback(db, feedback, complete_causal_envelope=True) + execution_draft_exchange.import_document(db, drafts, own_transaction=False) + ack = record_consumed(db, manifest, manifest_digest) + db.commit() + return ack + except Exception as error: + db.rollback() + if isinstance(error, sqlite3.Error): + raise + return record_rejected(database, manifest, manifest_digest, str(error)) + + +def validate_ack(raw: bytes, pending: sqlite3.Row | tuple) -> dict: + value = strict_json(raw, MAX_MANIFEST) + keys = {"format", "version", "ack_id", "run_id", "generation_id", "producer_peer_id", + "consumer_peer_id", "manifest_sha256", "result", "durability", "consumed_at", + "diagnostic", "payload_sha256"} + if not isinstance(value, dict) or set(value) != keys or value["format"] != ACK or type(value["version"]) is not int or value["version"] != 1: + raise GenerationError("invalid ACK") + if not valid_id(value["generation_id"], "gen") or value["ack_id"] != "ack_" + value["generation_id"][4:] or not valid_id(value["run_id"], "sy") or not valid_id(value["producer_peer_id"], "peer") or not valid_id(value["consumer_peer_id"], "peer") or not isinstance(value["manifest_sha256"], str) or not HEX.fullmatch(value["manifest_sha256"]) or not isinstance(value["diagnostic"], str): + raise GenerationError("invalid ACK identity/field") + try: + parsed = dt.datetime.fromisoformat(value["consumed_at"].replace("Z", "+00:00")) + if parsed.utcoffset() is None: raise ValueError + except Exception as error: + raise GenerationError("invalid ACK timestamp") from error + checksum = value["payload_sha256"] + payload = dict(value); del payload["payload_sha256"] + if not HEX.fullmatch(checksum or "") or digest_bytes(canonical(payload)) != checksum: + raise GenerationError("ACK digest mismatch") + expected = (pending[1], pending[0], pending[2], pending[3], pending[4]) + actual = (value["run_id"], value["generation_id"], value["producer_peer_id"], + value["consumer_peer_id"], value["manifest_sha256"]) + expected_durability = "sqlite-commit-full" if value["result"] == "consumed" else "sqlite-commit-rejection" + if actual != expected or value["result"] not in ("consumed", "rejected") or value["durability"] != expected_durability or len(value["diagnostic"].encode()) > MAX_DIAGNOSTIC: + raise GenerationError("ACK does not match pending generation") + return value + + +def accept_ack(database: Path, path: Path) -> str: + raw = path.read_bytes() + with closing(connect_database(database)) as db: + require_schema(db); db.execute("BEGIN IMMEDIATE") + value = strict_json(raw, MAX_MANIFEST) + row = db.execute("SELECT generation_id,run_id,producer_peer_id,consumer_peer_id,manifest_sha256,status FROM sync_generations WHERE generation_id=?", (value.get("generation_id"),)).fetchone() + if not row: + raise GenerationError("ACK has no pending generation") + ack = validate_ack(raw, row) + status = "acknowledged" if ack["result"] == "consumed" else "rejected" + if row[5] in ("acknowledged", "rejected"): + if row[5] != status: + raise GenerationError("conflicting ACK replay") + db.rollback(); return "unchanged" + if row[5] != "waiting_acknowledgement": + raise GenerationError("generation is not awaiting ACK") + db.execute("UPDATE sync_generations SET status=?,acknowledged_at=? WHERE generation_id=?", + (status, ack["consumed_at"], ack["generation_id"])) + db.commit(); return status + + +def main() -> None: + parser = argparse.ArgumentParser() + sub = parser.add_subparsers(dest="mode", required=True) + capture = sub.add_parser("capture-desktop"); capture.add_argument("--database", type=Path, required=True); capture.add_argument("--owned-root", type=Path, required=True); capture.add_argument("--consumer-peer", required=True); capture.add_argument("--run-id"); capture.add_argument("--generation-id") + publication = sub.add_parser("publish"); publication.add_argument("--database", type=Path, required=True); publication.add_argument("--generation-id", required=True); publication.add_argument("--object-root", type=Path, required=True) + validate = sub.add_parser("validate"); validate.add_argument("directory", type=Path); validate.add_argument("--consumer-peer", required=True) + consume = sub.add_parser("consume-desktop"); consume.add_argument("directory", type=Path); consume.add_argument("--database", type=Path, required=True); consume.add_argument("--ack-output", type=Path, required=True) + peer = sub.add_parser("peer-id"); peer.add_argument("--database", type=Path, required=True); peer.add_argument("--kind", choices=("desktop",), default="desktop") + accept = sub.add_parser("accept-ack"); accept.add_argument("ack", type=Path); accept.add_argument("--database", type=Path, required=True) + args = parser.parse_args() + if args.mode == "capture-desktop": + stage, manifest, checksum = capture_desktop(args.database, args.owned_root, + args.consumer_peer, args.run_id, args.generation_id) + print(f"GENERATION_CAPTURE=PASS generation_id={manifest['generation_id']} manifest_sha256={checksum} path={stage}") + elif args.mode == "publish": + print("GENERATION_PUBLISH=PASS path=" + str(publish(args.database, args.generation_id, args.object_root))) + elif args.mode == "validate": + manifest, checksum = validate_published(args.directory, args.consumer_peer) + print(f"GENERATION_VALIDATE=PASS generation_id={manifest['generation_id']} manifest_sha256={checksum}") + elif args.mode == "consume-desktop": + ack = consume_desktop(args.database, args.directory) + args.ack_output.write_bytes(canonical(ack)) + print("GENERATION_CONSUME=PASS generation_id=" + ack["generation_id"]) + elif args.mode == "peer-id": + with closing(connect_database(args.database)) as db: + value = peer_identity(db, args.kind); db.commit() + print("PEER_ID=" + value) + else: + print("ACK_ACCEPT=" + accept_ack(args.database, args.ack)) + + +if __name__ == "__main__": + try: + main() + except Exception as error: + print("SYNC_GENERATION=FAIL " + str(error), file=sys.stderr) + raise SystemExit(1) diff --git a/tui/include/trainlog/database.h b/tui/include/trainlog/database.h index 5b0d6bf..94dc3aa 100644 --- a/tui/include/trainlog/database.h +++ b/tui/include/trainlog/database.h @@ -16,7 +16,7 @@ #include "trainlog/model.h" #include "trainlog/status.h" -#define TRAINLOG_DATABASE_SCHEMA_VERSION 20 +#define TRAINLOG_DATABASE_SCHEMA_VERSION 21 typedef struct TrainlogDatabase TrainlogDatabase; diff --git a/tui/meson.build b/tui/meson.build index 0dbff07..69b548e 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -236,6 +236,12 @@ trainlog_core_dep = declare_dependency( ], ) +executable( + 'sync-generation-fixture', + 'tests/sync_generation_fixture.c', + dependencies: trainlog_core_dep, +) + trainlog_tui_sources = files( 'src/cli.c', 'src/main.c', @@ -718,6 +724,16 @@ test( find_program('python3'), args: [meson.project_source_root() / 'tests/test_causal_delete_exchange.py'], ) +test( + 'sync_generation_exchange', + find_program('python3'), + args: [meson.project_source_root() / 'tests/test_sync_generation_exchange.py'], + timeout: 120, +) +test( + 'schema_v21_migration', + executable('test_schema_v21_migration', 'tests/test_schema_v21_migration.c', dependencies: trainlog_core_dep), +) test( 'equipment_associations_exchange', diff --git a/tui/src/database.c b/tui/src/database.c index ab93454..58bd2cf 100644 --- a/tui/src/database.c +++ b/tui/src/database.c @@ -856,6 +856,20 @@ static const char *const MIGRATE_V19_TO_V20_SQL = "CREATE INDEX sync_causal_operations_target ON sync_causal_operations(target_kind,target_id);" "PRAGMA user_version=20;COMMIT;"; +/* WHY: publication and durable consumption are distinct from immutable domain + * operations. CONTRACT: v21 stores peer/generation/manifest/ACK identity and a + * separate operation-to-generation ledger. INVARIANT: migration invents no + * generation, acknowledgement, consumption, ancestry, or causal operation. */ +static const char *const MIGRATE_V20_TO_V21_SQL = + "BEGIN IMMEDIATE;" + "CREATE TABLE sync_peer_identity(singleton INTEGER PRIMARY KEY CHECK(singleton=1),peer_id TEXT NOT NULL UNIQUE,kind TEXT NOT NULL CHECK(kind IN('desktop','android')));" + "CREATE TABLE sync_generations(generation_id TEXT PRIMARY KEY,run_id TEXT NOT NULL,producer_peer_id TEXT NOT NULL,consumer_peer_id TEXT NOT NULL,producer_kind TEXT NOT NULL,generated_at TEXT NOT NULL,parent_generation_id TEXT,manifest_sha256 TEXT NOT NULL,status TEXT NOT NULL CHECK(status IN('captured','published','waiting_acknowledgement','acknowledged','rejected')),manifest_json TEXT NOT NULL,staging_path TEXT NOT NULL,acknowledged_at TEXT);" + "CREATE TABLE sync_generation_artifacts(generation_id TEXT NOT NULL REFERENCES sync_generations(generation_id) ON DELETE CASCADE,logical_name TEXT NOT NULL,format TEXT NOT NULL,version INTEGER NOT NULL,filename TEXT NOT NULL,size_bytes INTEGER NOT NULL,sha256 TEXT NOT NULL,required INTEGER NOT NULL CHECK(required IN(0,1)),PRIMARY KEY(generation_id,logical_name),UNIQUE(generation_id,filename));" + "CREATE TABLE sync_consumed_generations(generation_id TEXT PRIMARY KEY,run_id TEXT NOT NULL,producer_peer_id TEXT NOT NULL,consumer_peer_id TEXT NOT NULL,parent_generation_id TEXT,manifest_sha256 TEXT NOT NULL,consumed_at TEXT NOT NULL,result TEXT NOT NULL CHECK(result IN('consumed','rejected')),durability TEXT NOT NULL,diagnostic TEXT NOT NULL,ack_json TEXT NOT NULL,UNIQUE(producer_peer_id,generation_id));" + "CREATE TABLE sync_acknowledgements(ack_id TEXT PRIMARY KEY,generation_id TEXT NOT NULL,run_id TEXT NOT NULL,producer_peer_id TEXT NOT NULL,consumer_peer_id TEXT NOT NULL,manifest_sha256 TEXT NOT NULL,result TEXT NOT NULL CHECK(result IN('consumed','rejected')),durability TEXT NOT NULL,created_at TEXT NOT NULL,diagnostic TEXT NOT NULL,payload_sha256 TEXT NOT NULL);" + "CREATE TABLE sync_causal_publications(operation_id TEXT NOT NULL REFERENCES sync_causal_operations(operation_id),generation_id TEXT NOT NULL,first_emission INTEGER NOT NULL CHECK(first_emission IN(0,1)),PRIMARY KEY(operation_id,generation_id));" + "PRAGMA user_version=21;COMMIT;"; + /* CONTRACT: v18 was already deployed before bounded draft publication was * added. Keep its version number and add only the nullable lifecycle cursor; * NULL deliberately means retryable/not yet published. */ @@ -1551,7 +1565,7 @@ static TrainlogStatus initialize_or_validate_schema( status = TRAINLOG_STATUS_OK; } else if (version == 11 || version == 12 || version == 13 || version == 14 || version == 15 || version == 16 || version == 17 || version == 18 || - version == 19 || version == 20) { + version == 19 || version == 20 || version == 21) { status = TRAINLOG_STATUS_OK; } else { if (version == 1) { @@ -1721,6 +1735,9 @@ static TrainlogStatus initialize_or_validate_schema( if (status == TRAINLOG_STATUS_OK && version < 20) { status = execute_sql(database, MIGRATE_V19_TO_V20_SQL); } + if (status == TRAINLOG_STATUS_OK && version < 21) { + status = execute_sql(database, MIGRATE_V20_TO_V21_SQL); + } if (status == TRAINLOG_STATUS_OK) { status = ensure_v18_ai_draft_publication_state(database); } @@ -1736,7 +1753,7 @@ static TrainlogStatus initialize_or_validate_schema( set_open_diagnostic( output_diagnostic, output_diagnostic_capacity, - version == 0 ? "create schema v20" : "migrate database to schema v20", + version == 0 ? "create schema v21" : "migrate database to schema v21", database->connection, SQLITE_ERROR ); diff --git a/tui/tests/sync_generation_fixture.c b/tui/tests/sync_generation_fixture.c new file mode 100644 index 0000000..6855c9f --- /dev/null +++ b/tui/tests/sync_generation_fixture.c @@ -0,0 +1,47 @@ +/* Test-only producer fixture through the public desktop persistence API. */ +#include +#include + +#include "trainlog/database.h" + +int main(int argc, char **argv) +{ + TrainlogDatabase *database = NULL; + TrainlogSetInput set = { .reps = 9, .has_weight = true, .weight_kg = 32.5 }; + TrainlogSessionExerciseInput exercise; + TrainlogSessionInput session; + + if (argc != 2 || trainlog_database_open(argv[1], &database) != TRAINLOG_STATUS_OK) { + return 2; + } + if (trainlog_database_insert_exercise_profiled( + database, "ex_77777777-7777-4777-8777-777777777777", + "Desktop generation fixture", "desktop generation fixture", + TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS, 0U) != TRAINLOG_STATUS_OK) { + trainlog_database_close(database); return 3; + } + (void)memset(&exercise, 0, sizeof(exercise)); + (void)snprintf(exercise.entry_id, sizeof(exercise.entry_id), "%s", + "sxe_88888888-8888-4888-8888-888888888888"); + (void)snprintf(exercise.exercise_id, sizeof(exercise.exercise_id), "%s", + "ex_77777777-7777-4777-8777-777777777777"); + exercise.recording_mode = TRAINLOG_RECORDING_SETS; + exercise.tracking_mode = TRAINLOG_TRACKING_REPS; + exercise.sets = &set; + exercise.set_count = 1U; + (void)memset(&session, 0, sizeof(session)); + (void)snprintf(session.session_id, sizeof(session.session_id), "%s", + "se_99999999-9999-4999-8999-999999999999"); + (void)snprintf(session.started_at, sizeof(session.started_at), "%s", + "2026-09-17T10:00:00+02:00"); + (void)snprintf(session.ended_at, sizeof(session.ended_at), "%s", + "2026-09-17T10:30:00+02:00"); + session.exercises = &exercise; + session.exercise_count = 1U; + if (trainlog_database_insert_session(database, &session) != TRAINLOG_STATUS_OK) { + trainlog_database_close(database); return 4; + } + trainlog_database_close(database); + (void)puts("SYNC_GENERATION_FIXTURE=PASS"); + return 0; +} diff --git a/tui/tests/test_schema_v21_migration.c b/tui/tests/test_schema_v21_migration.c new file mode 100644 index 0000000..e4c8193 --- /dev/null +++ b/tui/tests/test_schema_v21_migration.c @@ -0,0 +1,42 @@ +#define _POSIX_C_SOURCE 200809L + +#include +#include +#include +#include + +#include "trainlog/database.h" + +#define CHECK(x) do { if (!(x)) { fprintf(stderr,"CHECK failed at %s:%d: %s\n",__FILE__,__LINE__,#x); return 1; } } while (0) + +static int scalar(sqlite3 *database, const char *sql) +{ + sqlite3_stmt *statement=NULL;int value=-1; + if(sqlite3_prepare_v2(database,sql,-1,&statement,NULL)==SQLITE_OK && sqlite3_step(statement)==SQLITE_ROW)value=sqlite3_column_int(statement,0); + (void)sqlite3_finalize(statement);return value; +} + +int main(void) +{ + char path[]="/tmp/trainlog-schema-v21-XXXXXX"; + int fd=mkstemp(path);TrainlogDatabase *database=NULL;sqlite3 *raw=NULL;size_t exercises=0U; + CHECK(fd>=0);CHECK(close(fd)==0); + CHECK(trainlog_database_open(path,&database)==TRAINLOG_STATUS_OK); + CHECK(trainlog_database_insert_exercise_profiled(database, + "ex_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","Migration sentinel","migration sentinel", + TRAINLOG_TRACKING_REPS,TRAINLOG_RECORDING_SETS,0U)==TRAINLOG_STATUS_OK); + trainlog_database_close(database);database=NULL; + CHECK(sqlite3_open(path,&raw)==SQLITE_OK); + CHECK(sqlite3_exec(raw,"PRAGMA foreign_keys=OFF;DROP TABLE sync_causal_publications;DROP TABLE sync_acknowledgements;DROP TABLE sync_consumed_generations;DROP TABLE sync_generation_artifacts;DROP TABLE sync_generations;DROP TABLE sync_peer_identity;PRAGMA user_version=20;",NULL,NULL,NULL)==SQLITE_OK); + CHECK(sqlite3_close(raw)==SQLITE_OK); + CHECK(trainlog_database_open(path,&database)==TRAINLOG_STATUS_OK); + CHECK(trainlog_database_exercise_count(database,&exercises)==TRAINLOG_STATUS_OK);CHECK(exercises>0U); + trainlog_database_close(database);database=NULL; + CHECK(sqlite3_open(path,&raw)==SQLITE_OK); + CHECK(scalar(raw,"PRAGMA user_version")==21); + CHECK(scalar(raw,"SELECT COUNT(*) FROM sync_generations")==0); + CHECK(scalar(raw,"SELECT COUNT(*) FROM sync_consumed_generations")==0); + CHECK(scalar(raw,"SELECT COUNT(*) FROM sync_acknowledgements")==0); + CHECK(sqlite3_close(raw)==SQLITE_OK);CHECK(unlink(path)==0); + return 0; +} diff --git a/tui/tests/test_schema_v7_migration.c b/tui/tests/test_schema_v7_migration.c index bd29f0f..f3420fd 100644 --- a/tui/tests/test_schema_v7_migration.c +++ b/tui/tests/test_schema_v7_migration.c @@ -134,8 +134,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void) CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR); CHECK(database == NULL); - CHECK(strncmp(diagnostic, "migrate database to schema v20: SQLite rc=", - strlen("migrate database to schema v20: SQLite rc=")) == 0); + CHECK(strncmp(diagnostic, "migrate database to schema v21: SQLite rc=", + strlen("migrate database to schema v21: SQLite rc=")) == 0); CHECK(strstr(diagnostic, "extended_rc=") != NULL); CHECK(strstr(diagnostic, "custom_equipment") != NULL); CHECK(strstr(diagnostic, "already exists") != NULL); @@ -154,12 +154,12 @@ static bool test_newer_schema_has_application_diagnostic(void) CHECK(fd >= 0); CHECK(close(fd) == 0); CHECK(sqlite3_open(path, &raw) == SQLITE_OK); - CHECK(sqlite3_exec(raw, "PRAGMA user_version=21;", NULL, NULL, NULL) == SQLITE_OK); + CHECK(sqlite3_exec(raw, "PRAGMA user_version=22;", NULL, NULL, NULL) == SQLITE_OK); CHECK(sqlite3_close(raw) == SQLITE_OK); CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) == TRAINLOG_STATUS_SCHEMA_UNSUPPORTED); CHECK(database == NULL); - CHECK(strstr(diagnostic, "schema version 21 is newer") != NULL); + CHECK(strstr(diagnostic, "schema version 22 is newer") != NULL); CHECK(strstr(diagnostic, "SQLite") == NULL); CHECK(unlink(path) == 0); return true; diff --git a/tui/tests/test_schema_v9_migration.c b/tui/tests/test_schema_v9_migration.c index 74e92d3..226d2bc 100644 --- a/tui/tests/test_schema_v9_migration.c +++ b/tui/tests/test_schema_v9_migration.c @@ -151,7 +151,7 @@ static bool test_v9_to_v10_failure_rolls_back(void) CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR); CHECK(database == NULL); - CHECK(strstr(diagnostic, "migrate database to schema v20") != NULL); + CHECK(strstr(diagnostic, "migrate database to schema v21") != NULL); CHECK(strstr(diagnostic, "performed_sets_v9") != NULL); CHECK(sqlite3_open(path, &raw) == SQLITE_OK);