feat(sync): add generation acknowledgements
This commit is contained in:
parent
444508b8b9
commit
a6fe9be296
49 changed files with 1954 additions and 405 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<Pair<Any,Int>>();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<String>();val paths=mutableSetOf<String>();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<String,String>)
|
||||
|
||||
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<String,String>();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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <T> 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<String, String> =
|
||||
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<ExerciseProfile> { 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(" +
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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") }
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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_<uuid-v4>",
|
||||
"run_id": "sy_<uuid-v4>",
|
||||
"producer": {"peer_id": "peer_<uuid-v4>", "kind": "android"},
|
||||
"consumer_peer_id": "peer_<uuid-v4>",
|
||||
"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
|
||||
|
||||
|
|
|
|||
|
|
@ -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/<generation_id>/`; 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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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/<generation_id>/`, 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
169
tests/test_sync_generation_exchange.py
Normal file
169
tests/test_sync_generation_exchange.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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"):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
589
tools/sync_generation_exchange.py
Normal file
589
tools/sync_generation_exchange.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
47
tui/tests/sync_generation_fixture.c
Normal file
47
tui/tests/sync_generation_fixture.c
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
/* Test-only producer fixture through the public desktop persistence API. */
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#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;
|
||||
}
|
||||
42
tui/tests/test_schema_v21_migration.c
Normal file
42
tui/tests/test_schema_v21_migration.c
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
#define _POSIX_C_SOURCE 200809L
|
||||
|
||||
#include <sqlite3.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue