fix(sync): close causal deletion guarantees
This commit is contained in:
parent
98cb40749f
commit
70cb7a116b
22 changed files with 501 additions and 42 deletions
|
|
@ -2,6 +2,15 @@
|
|||
|
||||
## Unreleased — 0.1.2
|
||||
|
||||
- Closed the causal-deletion correctness review: deletion predecessors now
|
||||
cover affected session children, measurements, links, notes and immutable
|
||||
feedback revisions; supported corrections preserve an intervening mutation
|
||||
marker even after a content revert. Imported operations share built-in
|
||||
authorization with local deletion, execution drafts use their stable
|
||||
cross-platform revision identity, protected legacy companions refuse unsafe
|
||||
replay, and causal artifact reads/exports enforce count and byte bounds
|
||||
before destructive effects.
|
||||
|
||||
- Completed `TRAINLOG_SYNC_CAUSAL_DELETE_V1` with desktop schema v20 and
|
||||
Android schema v19. Added a strict bounded staged causal-deletion artifact,
|
||||
durable operation/state records, domain-specific deletion effects, causal
|
||||
|
|
|
|||
|
|
@ -1223,6 +1223,7 @@ class TrainlogRepository(
|
|||
* 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." }
|
||||
val exercises = JSONArray()
|
||||
listExercises().sortedBy { it.exerciseId }.forEach { exercise ->
|
||||
exercises.put(JSONObject()
|
||||
|
|
@ -1422,6 +1423,8 @@ class TrainlogRepository(
|
|||
}
|
||||
|
||||
fun applyExerciseBodyZonesJson(json: String): ExerciseBodyZoneImportResult {
|
||||
if (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.")
|
||||
}
|
||||
|
|
@ -2404,6 +2407,8 @@ class TrainlogRepository(
|
|||
return try {
|
||||
db.beginTransaction()
|
||||
insertCompletedSession(db, draft, sessionId, generalCorrection = true)
|
||||
if (!markCausalMutation(db, "session", sessionId))
|
||||
return CorrectCompletedSessionResult.DatabaseError("The session is causally deleted.")
|
||||
db.setTransactionSuccessful()
|
||||
CorrectCompletedSessionResult.Saved
|
||||
} catch (error: Exception) {
|
||||
|
|
@ -3342,11 +3347,11 @@ class TrainlogRepository(
|
|||
.joinToString("") { "%02x".format(it) }
|
||||
|
||||
private fun causalSnapshot(db: SQLiteDatabase, kind: String, targetId: String): JSONArray? {
|
||||
if (kind == "session") return causalSessionSnapshot(db, targetId)
|
||||
val query = when (kind) {
|
||||
"session" -> Pair("SELECT session_id,started_at,ended_at,session_type,notes FROM sessions WHERE session_id=?", arrayOf(targetId))
|
||||
"execution_draft" -> Pair("SELECT session_id,revision_id,parent_revision_id,payload_json FROM execution_draft_candidates WHERE session_id=? UNION ALL SELECT session_id,revision_id,parent_revision_id,'' FROM active_session_draft WHERE session_id=?", arrayOf(targetId, targetId))
|
||||
"execution_draft" -> Pair("SELECT session_id,revision_id FROM execution_draft_candidates WHERE session_id=? UNION ALL SELECT session_id,revision_id FROM active_session_draft WHERE session_id=?", arrayOf(targetId, targetId))
|
||||
"exercise" -> Pair("SELECT exercise_id,name,recording_mode,tracking_mode,data_fields FROM exercises WHERE exercise_id=?", arrayOf(targetId))
|
||||
"body_observation" -> Pair("SELECT observation_id,observed_at,notes FROM body_observations WHERE observation_id=?", arrayOf(targetId))
|
||||
"body_observation" -> Pair("SELECT bo.observation_id,bo.observed_at,bo.body_weight_kg,bo.neck_cm,bo.shoulders_cm,bo.chest_cm,bo.waist_cm,bo.hips_cm,bo.left_arm_cm,bo.right_arm_cm,bo.left_forearm_cm,bo.right_forearm_cm,bo.left_thigh_cm,bo.right_thigh_cm,bo.left_calf_cm,bo.right_calf_cm,bo.notes,s.session_id,(SELECT revision_id FROM sync_note_state WHERE owner_kind='observation' AND owner_id=bo.observation_id) FROM body_observations bo LEFT JOIN sessions s ON s.id=bo.session_row_id WHERE bo.observation_id=?", arrayOf(targetId))
|
||||
"custom_equipment" -> Pair("SELECT equipment_id,display_name,label_name,equipment_type,load_semantics FROM equipment WHERE equipment_id=? AND equipment_type='custom_machine'", arrayOf(targetId))
|
||||
"feedback" -> Pair("SELECT feedback_id,observed_at,raw_text FROM exercise_feedback WHERE feedback_id=?", arrayOf(targetId))
|
||||
"body_zone_relation" -> {
|
||||
|
|
@ -3355,7 +3360,7 @@ class TrainlogRepository(
|
|||
}
|
||||
else -> return null
|
||||
}
|
||||
return db.rawQuery(query.first, query.second).use { cursor ->
|
||||
val root = db.rawQuery(query.first, query.second).use { cursor ->
|
||||
if (!cursor.moveToFirst()) null else JSONArray().also { result ->
|
||||
repeat(cursor.columnCount) { index ->
|
||||
result.put(if (cursor.isNull(index)) JSONObject.NULL else when (cursor.getType(index)) {
|
||||
|
|
@ -3366,11 +3371,83 @@ class TrainlogRepository(
|
|||
}
|
||||
}
|
||||
}
|
||||
if (kind == "feedback" && root != null) {
|
||||
val revisions = JSONArray()
|
||||
db.rawQuery("SELECT revision_id,created_at,raw_text FROM exercise_feedback_revisions WHERE feedback_id=? ORDER BY created_at,revision_id", arrayOf(targetId)).use { cursor ->
|
||||
while (cursor.moveToNext()) revisions.put(causalCursorRow(cursor))
|
||||
}
|
||||
root.put(revisions)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
private fun causalCursorRow(cursor: Cursor): JSONArray = JSONArray().also { row ->
|
||||
repeat(cursor.columnCount) { index -> row.put(if (cursor.isNull(index)) JSONObject.NULL else when (cursor.getType(index)) {
|
||||
Cursor.FIELD_TYPE_INTEGER -> cursor.getLong(index)
|
||||
Cursor.FIELD_TYPE_FLOAT -> cursor.getDouble(index)
|
||||
else -> cursor.getString(index)
|
||||
}) }
|
||||
}
|
||||
|
||||
private fun causalSessionSnapshot(db: SQLiteDatabase, sessionId: String): JSONArray? {
|
||||
val header = db.rawQuery("SELECT id,session_id,started_at,ended_at,session_type,notes FROM sessions WHERE session_id=?", arrayOf(sessionId)).use { cursor ->
|
||||
if (!cursor.moveToFirst()) null else Pair(cursor.getLong(0), causalCursorRow(cursor).also { it.remove(0) })
|
||||
} ?: return null
|
||||
val occurrences = JSONArray()
|
||||
db.rawQuery("SELECT se.id,se.entry_id,e.exercise_id,se.position,se.recording_mode,se.tracking_mode,se.data_fields,eq.equipment_id,se.load_mode,se.rest_seconds,se.target_sets,se.target_reps,se.target_duration_seconds,se.target_weight_kg,se.notes FROM session_exercises se JOIN exercises e ON e.id=se.exercise_row_id LEFT JOIN equipment eq ON eq.id=se.equipment_row_id WHERE se.session_row_id=? ORDER BY se.position,se.entry_id", arrayOf(header.first.toString())).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val rowId = cursor.getLong(0); val occurrence = causalCursorRow(cursor).also { it.remove(0) }
|
||||
val sets = JSONArray()
|
||||
db.rawQuery("SELECT position,reps,duration_seconds,weight_kg FROM performed_sets WHERE session_exercise_row_id=? ORDER BY position", arrayOf(rowId.toString())).use { values -> while (values.moveToNext()) sets.put(causalCursorRow(values)) }
|
||||
val continuous = db.rawQuery("SELECT duration_seconds,speed_kmh,distance_km FROM continuous_activity WHERE session_exercise_row_id=?", arrayOf(rowId.toString())).use { if (it.moveToFirst()) causalCursorRow(it) else JSONObject.NULL }
|
||||
val maximum = db.rawQuery("SELECT max_weight_kg FROM max_results WHERE session_exercise_row_id=?", arrayOf(rowId.toString())).use { if (it.moveToFirst()) it.getDouble(0) else JSONObject.NULL }
|
||||
val feedback = JSONArray()
|
||||
db.rawQuery("SELECT feedback_id,observed_at,raw_text FROM exercise_feedback WHERE session_exercise_row_id=? ORDER BY observed_at,feedback_id", arrayOf(rowId.toString())).use { roots -> while (roots.moveToNext()) {
|
||||
val item = causalCursorRow(roots); val revisions = JSONArray()
|
||||
db.rawQuery("SELECT revision_id,created_at,raw_text FROM exercise_feedback_revisions WHERE feedback_id=? ORDER BY created_at,revision_id", arrayOf(roots.getString(0))).use { values -> while (values.moveToNext()) revisions.put(causalCursorRow(values)) }
|
||||
item.put(revisions); feedback.put(item)
|
||||
} }
|
||||
val note = db.rawQuery("SELECT revision_id FROM sync_note_state WHERE owner_kind='occurrence' AND owner_id=?", arrayOf(cursor.getString(1))).use { if (it.moveToFirst()) it.getString(0) else JSONObject.NULL }
|
||||
occurrence.put(sets).put(continuous).put(maximum).put(feedback).put(note); occurrences.put(occurrence)
|
||||
}
|
||||
}
|
||||
val sessionNote = db.rawQuery("SELECT revision_id FROM sync_note_state WHERE owner_kind='session' AND owner_id=?", arrayOf(sessionId)).use { if (it.moveToFirst()) it.getString(0) else JSONObject.NULL }
|
||||
val links = JSONArray()
|
||||
db.rawQuery("SELECT observation_id FROM body_observations WHERE session_row_id=? ORDER BY observation_id", arrayOf(header.first.toString())).use { while (it.moveToNext()) links.put(it.getString(0)) }
|
||||
return header.second.put(occurrences).put(sessionNote).put(links)
|
||||
}
|
||||
|
||||
private fun causalLiveRevision(db: SQLiteDatabase, kind: String, targetId: String): String? =
|
||||
causalSnapshot(db, kind, targetId)?.let { "lv_${causalDigest(it)}" }
|
||||
|
||||
private fun hasCausalProtection(kind: String): Boolean = database.readableDatabase.rawQuery(
|
||||
"SELECT 1 FROM sync_causal_state WHERE target_kind=? AND deleted=1 LIMIT 1", arrayOf(kind),
|
||||
).use { it.moveToFirst() }
|
||||
|
||||
/* WHY: a supported edit followed by a byte-identical revert is still an
|
||||
* intervening causal mutation. CONTRACT: writers advance this revision in
|
||||
* their business transaction. INVARIANT: deleted state is never revived. */
|
||||
private fun markCausalMutation(db: SQLiteDatabase, kind: String, targetId: String): Boolean {
|
||||
val deleted = db.rawQuery("SELECT deleted FROM sync_causal_state WHERE target_kind=? AND target_id=?", arrayOf(kind, targetId)).use { it.moveToFirst() && it.getInt(0) != 0 }
|
||||
if (deleted) return false
|
||||
db.insertWithOnConflict("sync_causal_state", null, ContentValues().apply {
|
||||
put("target_kind", kind); put("target_id", targetId)
|
||||
put("current_revision_id", "mu_${UUID.randomUUID()}"); put("deleted", 0); putNull("operation_id")
|
||||
}, SQLiteDatabase.CONFLICT_REPLACE)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun causalTargetAuthorized(db: SQLiteDatabase, kind: String, targetId: String): Boolean {
|
||||
if (kind == "exercise") {
|
||||
val canonical = resolveExerciseId(db, targetId)
|
||||
if (trainingKnowledge.getExerciseKnowledge(targetId) != null ||
|
||||
trainingKnowledge.getExerciseKnowledge(canonical) != null) return false
|
||||
}
|
||||
if (kind == "custom_equipment" && trainingKnowledge.getEquipmentKnowledge(targetId) != null)
|
||||
return false
|
||||
return true
|
||||
}
|
||||
|
||||
private fun applyCausalEffect(db: SQLiteDatabase, kind: String, targetId: String, operationId: String) {
|
||||
when (kind) {
|
||||
"session" -> {
|
||||
|
|
@ -3400,7 +3477,8 @@ class TrainlogRepository(
|
|||
private fun validateCausalOperation(value: JSONObject): Boolean {
|
||||
val keys = setOf("operation_id", "target_kind", "target_id", "creator_id",
|
||||
"predecessor_revision_id", "created_at", "payload_sha256", "publication_context")
|
||||
if (!value.hasExactKeys(keys) || value.getString("target_kind") !in causalKinds ||
|
||||
if (!value.hasExactKeys(keys) || keys.filter { it != "publication_context" }.any { value.value(it) !is String } ||
|
||||
value.getString("target_kind") !in causalKinds ||
|
||||
!value.isNull("publication_context") || TrainlogTimestamp.parse(value.getString("created_at")) == null) return false
|
||||
for ((key, maximum) in listOf("operation_id" to 128, "target_id" to 512,
|
||||
"creator_id" to 128, "predecessor_revision_id" to 128)) {
|
||||
|
|
@ -3421,17 +3499,22 @@ class TrainlogRepository(
|
|||
val expected = listOf("target_kind", "target_id", "creator_id", "predecessor_revision_id", "created_at", "payload_sha256").map(operation::getString)
|
||||
if (known != null) return if (known == expected) CausalDeleteResult.Unchanged(operationId) else CausalDeleteResult.Conflict(operation.getString("target_id"))
|
||||
val kind = operation.getString("target_kind"); val target = operation.getString("target_id")
|
||||
if (!causalTargetAuthorized(db, kind, target)) return CausalDeleteResult.Invalid
|
||||
val state = db.rawQuery("SELECT current_revision_id,deleted FROM sync_causal_state WHERE target_kind=? AND target_id=?", arrayOf(kind, target)).use {
|
||||
if (!it.moveToFirst()) null else Pair(it.getString(0), it.getInt(1) != 0)
|
||||
}
|
||||
if (state?.second == true) return CausalDeleteResult.Conflict(target)
|
||||
val current = state?.first ?: causalLiveRevision(db, kind, target) ?: return CausalDeleteResult.NotFound
|
||||
if (current != operation.getString("predecessor_revision_id")) return CausalDeleteResult.Conflict(target)
|
||||
val count = db.rawQuery("SELECT COUNT(*) FROM sync_causal_operations", null).use { it.moveToFirst(); it.getInt(0) }
|
||||
if (count >= MAX_CAUSAL_OPERATIONS) return CausalDeleteResult.Invalid
|
||||
db.insertOrThrow("sync_causal_operations", null, ContentValues().apply {
|
||||
put("operation_id", operationId); put("target_kind", kind); put("target_id", target)
|
||||
put("creator_id", operation.getString("creator_id")); put("predecessor_revision_id", current)
|
||||
put("created_at", operation.getString("created_at")); put("payload_sha256", operation.getString("payload_sha256"))
|
||||
})
|
||||
if (buildCausalDeletionExportV1Json(db).toByteArray(StandardCharsets.UTF_8).size > MAX_CAUSAL_ARTIFACT_BYTES)
|
||||
return CausalDeleteResult.Invalid
|
||||
applyCausalEffect(db, kind, target, operationId)
|
||||
db.insertWithOnConflict("sync_causal_state", null, ContentValues().apply {
|
||||
put("target_kind", kind); put("target_id", target); put("current_revision_id", operationId)
|
||||
|
|
@ -3443,12 +3526,15 @@ class TrainlogRepository(
|
|||
/** Create one durable local deletion without selecting any transport. */
|
||||
fun deleteCausally(kind: String, targetId: String, creatorId: String): CausalDeleteResult {
|
||||
if (kind !in causalKinds || targetId.isBlank() || creatorId.isBlank()) return CausalDeleteResult.Invalid
|
||||
if (kind == "exercise" && trainingKnowledge.getExerciseKnowledge(targetId) != null)
|
||||
return CausalDeleteResult.Invalid
|
||||
val db = database.writableDatabase
|
||||
return try {
|
||||
db.beginTransaction()
|
||||
val predecessor = causalLiveRevision(db, kind, targetId)
|
||||
if (!causalTargetAuthorized(db, kind, targetId)) return CausalDeleteResult.Invalid
|
||||
val state = db.rawQuery("SELECT current_revision_id,deleted FROM sync_causal_state WHERE target_kind=? AND target_id=?", arrayOf(kind, targetId)).use {
|
||||
if (it.moveToFirst()) Pair(it.getString(0), it.getInt(1) != 0) else null
|
||||
}
|
||||
if (state?.second == true) return CausalDeleteResult.Unchanged(state.first)
|
||||
val predecessor = if (state != null && !state.second) state.first else causalLiveRevision(db, kind, targetId)
|
||||
if (predecessor == null) {
|
||||
val existing = db.rawQuery("SELECT operation_id FROM sync_causal_state WHERE target_kind=? AND target_id=? AND deleted=1", arrayOf(kind, targetId)).use { if (it.moveToFirst()) it.getString(0) else null }
|
||||
if (existing != null) return CausalDeleteResult.Unchanged(existing)
|
||||
|
|
@ -3467,8 +3553,14 @@ class TrainlogRepository(
|
|||
} finally { db.endTransaction() }
|
||||
}
|
||||
|
||||
fun buildCausalDeletionExportV1Json(): String {
|
||||
val db = database.readableDatabase
|
||||
fun buildCausalDeletionExportV1Json(): String =
|
||||
buildCausalDeletionExportV1Json(database.readableDatabase).also {
|
||||
check(it.toByteArray(StandardCharsets.UTF_8).size <= MAX_CAUSAL_ARTIFACT_BYTES) {
|
||||
"Causal protection set exceeds artifact byte bound."
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildCausalDeletionExportV1Json(db: SQLiteDatabase): String {
|
||||
val operations = JSONArray()
|
||||
db.rawQuery("SELECT operation_id,target_kind,target_id,creator_id,predecessor_revision_id,created_at,payload_sha256 FROM sync_causal_operations ORDER BY operation_id", null).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
|
|
@ -3484,7 +3576,7 @@ class TrainlogRepository(
|
|||
}
|
||||
|
||||
fun applyCausalDeletionExportV1Json(json: String): CausalDeleteResult {
|
||||
if (json.toByteArray().size > 4 * 1024 * 1024 || !hasStrictJsonShape(json)) return CausalDeleteResult.Invalid
|
||||
if (json.toByteArray().size > MAX_CAUSAL_ARTIFACT_BYTES || !hasStrictJsonShape(json)) return CausalDeleteResult.Invalid
|
||||
val root = try { JSONObject(json) } catch (_: Exception) { return CausalDeleteResult.Invalid }
|
||||
if (!root.hasExactKeys(setOf("format", "version", "generated_at", "operations")) ||
|
||||
root.optString("format") != "trainlog-causal-deletions" || !root.value("version").isJsonInt(1, 1) ||
|
||||
|
|
@ -3538,11 +3630,17 @@ class TrainlogRepository(
|
|||
|
||||
fun linkBodyObservationToSession(observationId: String, sessionId: String?): Boolean {
|
||||
val db = database.writableDatabase
|
||||
val parent = if (sessionId == null) null else db.rawQuery("SELECT id FROM sessions WHERE session_id=?",
|
||||
arrayOf(sessionId)).use { if (it.moveToFirst()) it.getLong(0) else return false }
|
||||
return db.update("body_observations", ContentValues().apply {
|
||||
if (parent == null) putNull("session_row_id") else put("session_row_id", parent)
|
||||
}, "observation_id=?", arrayOf(observationId)) == 1
|
||||
return try {
|
||||
db.beginTransaction()
|
||||
val parent = if (sessionId == null) null else db.rawQuery("SELECT id FROM sessions WHERE session_id=?",
|
||||
arrayOf(sessionId)).use { if (it.moveToFirst()) it.getLong(0) else return false }
|
||||
val changed = db.update("body_observations", ContentValues().apply {
|
||||
if (parent == null) putNull("session_row_id") else put("session_row_id", parent)
|
||||
}, "observation_id=?", arrayOf(observationId)) == 1
|
||||
if (!changed || !markCausalMutation(db, "body_observation", observationId)) return false
|
||||
db.setTransactionSuccessful(); true
|
||||
} catch (_: Exception) { false
|
||||
} finally { if (db.inTransaction()) db.endTransaction() }
|
||||
}
|
||||
|
||||
private fun syncNotePayload(
|
||||
|
|
@ -4829,6 +4927,7 @@ 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." }
|
||||
val supplied = EquipmentCatalog.load(applicationContext).map { it.equipmentId }.toSet()
|
||||
val items = JSONArray()
|
||||
listEquipment().filter { it.equipmentId !in supplied }.forEach { entry ->
|
||||
|
|
@ -4859,6 +4958,8 @@ class TrainlogRepository(
|
|||
}
|
||||
|
||||
fun applyPcEquipmentDefinitionsJson(json: String): EquipmentDefinitionImportResult {
|
||||
if (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.")
|
||||
}
|
||||
|
|
@ -5640,7 +5741,8 @@ class TrainlogRepository(
|
|||
"(SELECT COUNT(*) FROM exercise_feedback_revisions r WHERE r.feedback_id=f.feedback_id) " +
|
||||
"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 " +
|
||||
"WHERE s.session_id=?;", arrayOf(sessionId),
|
||||
"WHERE s.session_id=? AND NOT EXISTS(SELECT 1 FROM sync_causal_state cs " +
|
||||
"WHERE cs.target_kind='feedback' AND cs.target_id=f.feedback_id AND cs.deleted=1);", arrayOf(sessionId),
|
||||
).use { c -> while (c.moveToNext()) add(ExerciseFeedback(c.getString(0),c.getString(1),c.getString(2),c.getString(3),c.getString(4),c.getString(5),c.getInt(6)>1)) }
|
||||
}.sortedWith { a,b -> TrainlogTimestamp.parse(a.observedAt)!!.compareTo(TrainlogTimestamp.parse(b.observedAt)!!)
|
||||
.takeIf { it != 0 } ?: TrainlogTimestamp.compareIds(a.feedbackId,b.feedbackId) }
|
||||
|
|
@ -5659,6 +5761,7 @@ class TrainlogRepository(
|
|||
|
||||
/** Direction-neutral V2 companion: roots and every immutable revision. */
|
||||
fun buildTrainingFeedbackJson(): String {
|
||||
check(!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()
|
||||
|
|
@ -5696,6 +5799,8 @@ class TrainlogRepository(
|
|||
}
|
||||
|
||||
fun applyTrainingFeedbackJson(json: String): TrainingFeedbackImportResult {
|
||||
if (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.")
|
||||
val root = try { JSONObject(json) } catch (_: Exception) {
|
||||
|
|
@ -7689,6 +7794,7 @@ private const val MAX_SYNC_NOTE_UTF8_BYTES = 4096
|
|||
/* The wire document is bounded to sixteen drafts total, including the singleton active draft. */
|
||||
private const val MAX_PENDING_EXECUTION_DRAFTS = 15
|
||||
private const val MAX_CAUSAL_OPERATIONS = 4096
|
||||
private const val MAX_CAUSAL_ARTIFACT_BYTES = 4 * 1024 * 1024
|
||||
private const val MAX_PLAN_SETS = 64
|
||||
private const val MAX_PLAN_REPS = 10000
|
||||
private const val MAX_PLAN_DURATION_SECONDS = 86400
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import org.robolectric.RobolectricTestRunner
|
|||
import org.robolectric.annotation.Config
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.security.MessageDigest
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.util.UUID
|
||||
|
|
@ -682,6 +683,20 @@ class TrainlogRepositoryDraftTest {
|
|||
|
||||
val catalog = JSONObject().put("format", "trainlog-pc-catalog").put("version", 1)
|
||||
.put("exercises", JSONObject(live.readText()).getJSONArray("exercises"))
|
||||
val conflictName = "delete-conflict-${UUID.randomUUID()}.db"
|
||||
val conflict = TrainlogRepository(context, conflictName)
|
||||
assertTrue(conflict.applyPcCatalogJson(catalog.toString()) is PcCatalogImportResult.Applied)
|
||||
assertTrue(conflict.applyPcMobileExportV4Json(live.readText()) is MobileSessionImportResult.Applied)
|
||||
val conflictExercise = conflict.listExercises().first { it.exerciseId == exercise.exerciseId }
|
||||
fun corrected(reps: Int) = SessionDraft(listOf(SessionExerciseDraft(
|
||||
entryId = "sxe_60000000-0000-4000-8000-000000000001",
|
||||
exercise = conflictExercise, sets = listOf(SessionSetDraft(reps = reps)),
|
||||
)))
|
||||
assertTrue(conflict.correctCompletedSession(sessionId, corrected(10)) is CorrectCompletedSessionResult.Saved)
|
||||
assertTrue(conflict.correctCompletedSession(sessionId, corrected(9)) is CorrectCompletedSessionResult.Saved)
|
||||
assertTrue(conflict.applyCausalDeletionExportV1Json(androidCausal.readText()) is CausalDeleteResult.Conflict)
|
||||
assertEquals(1, conflict.listSessions().size)
|
||||
conflict.close(); context.deleteDatabase(conflictName)
|
||||
assertTrue(destination.applyPcCatalogJson(catalog.toString()) is PcCatalogImportResult.Applied)
|
||||
assertTrue(destination.applyPcMobileExportV4Json(live.readText()) is MobileSessionImportResult.Applied)
|
||||
assertTrue(destination.applyCausalDeletionExportV1Json(desktopCausal.readText()) is CausalDeleteResult.Applied)
|
||||
|
|
@ -724,6 +739,80 @@ class TrainlogRepositoryDraftTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun causalDraftDeletionUsesTheSameRevisionForActivePendingAndDesktopReplicas() {
|
||||
val sourceName = "causal-draft-source-${UUID.randomUUID()}.db"
|
||||
val destinationName = "causal-draft-destination-${UUID.randomUUID()}.db"
|
||||
val source = TrainlogRepository(context, sourceName)
|
||||
val destination = TrainlogRepository(context, destinationName)
|
||||
val root = Files.createTempDirectory("trainlog-causal-draft-").toFile()
|
||||
try {
|
||||
val exercise = createExercise(source, "Causal draft bridge", RecordingMode.SETS, TrackingMode.REPS)
|
||||
assertEquals(ActiveDraftMutationResult.Saved, source.saveActiveSessionDraft(
|
||||
ActiveSessionDraft(exercises = listOf(SessionExerciseDraft(
|
||||
entryId = "sxe_61000000-0000-4000-8000-000000000001",
|
||||
exercise = exercise, plan = SessionExercisePlan(2, reps = 6),
|
||||
sets = listOf(SessionSetDraft(reps = 6)),
|
||||
)), form = SessionDraftForm(weightText = "local unfinished input")),
|
||||
))
|
||||
val live = File(root, "draft-live.json").apply { writeText(source.buildExecutionDraftExportV1Json()) }
|
||||
val sessionId = JSONObject(live.readText()).getJSONArray("drafts").getJSONObject(0).getString("session_id")
|
||||
val catalog = JSONObject().put("format", "trainlog-pc-catalog").put("version", 1)
|
||||
.put("exercises", JSONObject(source.buildMobileExportV4Json()).getJSONArray("exercises"))
|
||||
val desktop = File(root, "desktop.sqlite")
|
||||
runLifecycleBridge("draft", live, desktop, File(root, "desktop-live.json"))
|
||||
assertTrue(source.deleteCausally("execution_draft", sessionId, "peer_android_test") is CausalDeleteResult.Applied)
|
||||
val androidDelete = File(root, "android-delete.json").apply { writeText(source.buildCausalDeletionExportV1Json()) }
|
||||
runDesktopCausal("import", androidDelete, desktop)
|
||||
|
||||
assertTrue(destination.applyPcCatalogJson(catalog.toString()) is PcCatalogImportResult.Applied)
|
||||
assertTrue(destination.applyExecutionDraftExportV1Json(live.readText()) is ExecutionDraftImportResult.Applied)
|
||||
val desktopProducer = File(root, "desktop-producer.sqlite")
|
||||
runLifecycleBridge("draft", live, desktopProducer, File(root, "desktop-producer-live.json"))
|
||||
runDesktopCausal("delete", File(root, "desktop-operation.json"), desktopProducer,
|
||||
"execution_draft", sessionId)
|
||||
val desktopDelete = File(root, "desktop-delete.json")
|
||||
runDesktopCausal("export", desktopDelete, desktopProducer)
|
||||
assertTrue(destination.applyCausalDeletionExportV1Json(desktopDelete.readText()) is CausalDeleteResult.Applied)
|
||||
assertTrue(destination.applyExecutionDraftExportV1Json(live.readText()) is ExecutionDraftImportResult.Applied)
|
||||
destination.close()
|
||||
val reopened = TrainlogRepository(context, destinationName)
|
||||
assertTrue(reopened.applyCausalDeletionExportV1Json(desktopDelete.readText()) is CausalDeleteResult.Unchanged)
|
||||
assertTrue(reopened.applyExecutionDraftExportV1Json(live.readText()) is ExecutionDraftImportResult.Applied)
|
||||
reopened.close()
|
||||
} finally {
|
||||
try { source.close() } catch (_: Exception) { }
|
||||
try { destination.close() } catch (_: Exception) { }
|
||||
context.deleteDatabase(sourceName); context.deleteDatabase(destinationName)
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun importedCausalOperationsCannotRetireBuiltInIdentities() {
|
||||
val repo = openRepository()
|
||||
val builtInId = "ex_b432623f-bfe9-4daf-a653-60ec7fdffbde"
|
||||
assertNotNull(repo.getExerciseKnowledge(builtInId))
|
||||
fun hostile(kind: String, target: String, predecessor: String): String {
|
||||
val operation = JSONObject().put("operation_id", "del_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
|
||||
.put("target_kind", kind).put("target_id", target).put("creator_id", "hostile_fixture")
|
||||
.put("predecessor_revision_id", predecessor).put("created_at", "2030-01-01T12:00:00Z")
|
||||
.put("publication_context", JSONObject.NULL)
|
||||
val encoded = canonicalJson(operation).toString()
|
||||
operation.put("payload_sha256", MessageDigest.getInstance("SHA-256")
|
||||
.digest(encoded.toByteArray()).joinToString("") { "%02x".format(it) })
|
||||
return JSONObject().put("format", "trainlog-causal-deletions").put("version", 1)
|
||||
.put("generated_at", "2030-01-01T12:00:00Z").put("operations", JSONArray().put(operation)).toString()
|
||||
}
|
||||
assertTrue(repo.deleteCausally("exercise", builtInId, "peer") is CausalDeleteResult.Invalid)
|
||||
assertTrue(repo.applyCausalDeletionExportV1Json(hostile("exercise", builtInId, "lv_unknown")) is CausalDeleteResult.Invalid)
|
||||
assertTrue(repo.applyCausalDeletionExportV1Json(hostile("custom_equipment", "rear_delt_pec_fly", "lv_unknown")) is CausalDeleteResult.Invalid)
|
||||
val malformed = JSONObject(hostile("exercise", builtInId, "lv_unknown"))
|
||||
malformed.getJSONArray("operations").getJSONObject(0).put("creator_id", 7)
|
||||
assertTrue(repo.applyCausalDeletionExportV1Json(malformed.toString()) is CausalDeleteResult.Invalid)
|
||||
assertEquals(0, JSONObject(repo.buildCausalDeletionExportV1Json()).getJSONArray("operations").length())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun repeatedContinuousExercisePersistsDistinctOccurrencesAcrossReopenAndFinalize() {
|
||||
val repo = openRepository()
|
||||
|
|
|
|||
|
|
@ -914,3 +914,10 @@ Android schema v19 additively stores the same immutable causal-deletion
|
|||
operations and current deleted-target state as desktop v20. Existing data is
|
||||
preserved and no migration-time ancestry is invented. Repository entry points
|
||||
create, validate, apply, export, and import the staged causal artifact.
|
||||
|
||||
Completed-session correction and body-observation relinking advance durable
|
||||
non-deleted causal state in their mutation transaction. Session deletion
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -297,8 +297,14 @@ refused before resurrection. Active synchronization still selects V3: no
|
|||
generation acknowledgement, 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.
|
||||
|
||||
The isolated closeout passed 76/76 normal Meson tests, 76/76 ASan/UBSan
|
||||
tests, Android debug assembly, and 208 Android tests (204 passed, four optional
|
||||
tests, Android debug assembly, and 210 Android tests (206 passed, four optional
|
||||
historical-fixture skips). Strict desktop builds use Clang 22.1.8; the compared
|
||||
pre-existing GCC 16.2.1 Web warning gate remains documented rather than green.
|
||||
|
||||
|
|
|
|||
|
|
@ -57,6 +57,12 @@ v19→v20 migration creates empty protection tables and does not fabricate
|
|||
ancestry for existing rows. Applying an operation, recording its protection,
|
||||
and performing dependent changes share one transaction.
|
||||
|
||||
Non-deleted rows in `sync_causal_state` are also the durable revision boundary
|
||||
for supported corrections whose content can later return to an earlier value.
|
||||
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.
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,24 @@
|
|||
# TRAINLOG_SYNC_GAP_CONTRACT_V1
|
||||
|
||||
## Causal deletion closeout implementation note
|
||||
|
||||
The staged causal-deletion implementation derives deletion admission from the
|
||||
complete affected business projection, not only the parent row. Session
|
||||
projection includes ordered occurrences, plans, confirmed facts, MAX,
|
||||
occurrence/session note revisions, immutable feedback revisions and linked
|
||||
observation identities. Observation projection includes every measurement,
|
||||
its session identity and note revision. Feedback projection includes its full
|
||||
immutable revision history. Supported corrections also persist a non-deleted
|
||||
mutation revision, so a change followed by a content revert does not erase
|
||||
evidence of the intervening edit. Unknown or incomparable ancestry conflicts;
|
||||
timestamps never arbitrate it.
|
||||
|
||||
Execution-draft deletion uses `(session_id, revision_id)` on Android active and
|
||||
pending storage and on desktop storage. Serialized JSON order, whitespace,
|
||||
local row IDs and unfinished Android form strings are not causal identity.
|
||||
Built-in exercise/equipment authorization is identical for local and imported
|
||||
operations, including aliases that resolve to a built-in exercise.
|
||||
|
||||
Statut : `CONTRACT_FROZEN / IMPLEMENTATION_NOT_STARTED`
|
||||
|
||||
Ce document définit la cible produit d'une synchronisation complète sans
|
||||
|
|
|
|||
|
|
@ -659,3 +659,13 @@ 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.
|
||||
|
||||
`predecessor_revision_id` identifies either the canonical complete business
|
||||
projection or a durable mutation revision created by a supported correction.
|
||||
For execution drafts the projection is exactly the stable session and draft
|
||||
revision identities; raw JSON serialization is not identity. Imported
|
||||
operations undergo the same built-in/alias authorization as local operations.
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -36,6 +36,12 @@ visualizations are implemented; the other four Web routes remain placeholders.
|
|||
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_SYNC_GENERATION_ACK_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_GAP_CONTRACT_V1=CONTRACT_FROZEN / IMPLEMENTATION_NOT_STARTED`
|
||||
defines the complete-sync target and its bounded dependency order.
|
||||
`TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` supplies the reproducible isolated
|
||||
|
|
|
|||
|
|
@ -9,6 +9,13 @@ rejects protected session, observation, or exercise identities before mutation.
|
|||
Deleted execution-draft replay is stale. Tombstone-free V1/V2/V3 behavior is
|
||||
unchanged.
|
||||
|
||||
Legacy feedback, BODY ZONES and custom-equipment companions are also refused
|
||||
when their domain has deleted causal state. Retired exercises and custom
|
||||
equipment remain resolvable for history but are excluded from new-work lists;
|
||||
withdrawn feedback keeps its immutable revisions while disappearing from the
|
||||
current feedback view. Profile and alias state cannot make a retired exercise
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -1204,6 +1204,16 @@ pre-existing `-Werror=misleading-indentation` failure in unchanged Web baseline
|
|||
sources; it is not reported as green and warning policy is unchanged.
|
||||
|
||||
The causal closeout full harness result is 76/76 normal Meson, 76/76
|
||||
ASan/UBSan, and 208 Android tests: 204 passed and four optional historical
|
||||
ASan/UBSan, and 210 Android tests: 206 passed and four optional historical
|
||||
database fixtures skipped. `assembleDebug`, JSON validation, import-contract
|
||||
validation, both frontend tests, and the isolated characterization all pass.
|
||||
|
||||
Focused closeout assertions are `test_child_mutations_invalidate_session_observation_and_feedback_deletes`,
|
||||
`test_imported_builtin_and_alias_retirement_are_refused_without_writes`,
|
||||
`test_draft_identity_is_semantic_revision_not_json_serialization`, and
|
||||
`test_operation_count_and_input_byte_bounds_precede_effects` in the desktop
|
||||
production-service suite. Android adds
|
||||
`causalDraftDeletionUsesTheSameRevisionForActivePendingAndDesktopReplicas` and
|
||||
`importedCausalOperationsCannotRetireBuiltInIdentities`; the existing session
|
||||
round trip now also changes and reverts a confirmed set before proving the
|
||||
remote deletion conflicts without losing business or causal state.
|
||||
|
|
|
|||
|
|
@ -18,17 +18,21 @@ SCHEMA = """
|
|||
PRAGMA foreign_keys=ON;
|
||||
CREATE TABLE exercises(id INTEGER PRIMARY KEY,exercise_id TEXT UNIQUE,name TEXT,recording_mode TEXT,tracking_mode TEXT,data_fields INTEGER);
|
||||
CREATE TABLE sessions(id INTEGER PRIMARY KEY,session_id TEXT UNIQUE,started_at TEXT,ended_at TEXT,session_type TEXT,notes TEXT);
|
||||
CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,session_row_id INTEGER REFERENCES sessions(id) ON DELETE CASCADE,exercise_row_id INTEGER REFERENCES exercises(id),entry_id TEXT UNIQUE);
|
||||
CREATE TABLE performed_sets(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER REFERENCES session_exercises(id) ON DELETE CASCADE);
|
||||
CREATE TABLE continuous_activity(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER REFERENCES session_exercises(id) ON DELETE CASCADE);
|
||||
CREATE TABLE max_results(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER REFERENCES session_exercises(id) ON DELETE CASCADE);
|
||||
CREATE TABLE body_observations(id INTEGER PRIMARY KEY,observation_id TEXT UNIQUE,observed_at TEXT,session_row_id INTEGER REFERENCES sessions(id) ON DELETE SET NULL,notes TEXT);
|
||||
CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,session_row_id INTEGER REFERENCES sessions(id) ON DELETE CASCADE,exercise_row_id INTEGER REFERENCES exercises(id),entry_id TEXT UNIQUE,position INTEGER,recording_mode TEXT,tracking_mode TEXT,data_fields INTEGER,equipment_id TEXT,load_mode TEXT,rest_seconds INTEGER,target_sets INTEGER,target_reps INTEGER,target_duration_seconds INTEGER,target_weight_kg REAL,notes TEXT);
|
||||
CREATE TABLE performed_sets(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER REFERENCES session_exercises(id) ON DELETE CASCADE,position INTEGER,reps INTEGER,duration_seconds INTEGER,weight_kg REAL);
|
||||
CREATE TABLE continuous_activity(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER REFERENCES session_exercises(id) ON DELETE CASCADE,duration_seconds INTEGER,speed_kmh REAL,distance_km REAL);
|
||||
CREATE TABLE max_results(session_exercise_row_id INTEGER PRIMARY KEY REFERENCES session_exercises(id) ON DELETE CASCADE,max_weight_kg REAL);
|
||||
CREATE TABLE body_observations(id INTEGER PRIMARY KEY,observation_id TEXT UNIQUE,observed_at TEXT,body_weight_kg REAL,neck_cm REAL,shoulders_cm REAL,chest_cm REAL,waist_cm REAL,hips_cm REAL,left_arm_cm REAL,right_arm_cm REAL,left_forearm_cm REAL,right_forearm_cm REAL,left_thigh_cm REAL,right_thigh_cm REAL,left_calf_cm REAL,right_calf_cm REAL,notes TEXT,session_row_id INTEGER REFERENCES sessions(id) ON DELETE SET NULL);
|
||||
CREATE TABLE custom_equipment(equipment_id TEXT PRIMARY KEY,display_name TEXT,label_name TEXT,equipment_type TEXT,load_semantics TEXT);
|
||||
CREATE TABLE equipment(equipment_id TEXT PRIMARY KEY,equipment_type TEXT);
|
||||
CREATE TABLE exercise_aliases(source_exercise_id TEXT PRIMARY KEY,canonical_exercise_id TEXT);
|
||||
CREATE TABLE exercise_feedback(id INTEGER PRIMARY KEY,feedback_id TEXT UNIQUE,session_exercise_row_id INTEGER REFERENCES session_exercises(id) ON DELETE CASCADE,observed_at TEXT,raw_text TEXT);
|
||||
CREATE TABLE exercise_feedback_revisions(revision_id TEXT PRIMARY KEY,feedback_id TEXT REFERENCES exercise_feedback(feedback_id) ON DELETE CASCADE,created_at TEXT,raw_text TEXT);
|
||||
CREATE TABLE exercise_body_zones(exercise_row_id INTEGER REFERENCES exercises(id),zone_id TEXT,role TEXT,PRIMARY KEY(exercise_row_id,zone_id));
|
||||
CREATE TABLE execution_drafts(session_id TEXT PRIMARY KEY,revision_id TEXT,parent_revision_id TEXT,payload_json TEXT);
|
||||
CREATE TABLE execution_draft_finalizations(session_id TEXT PRIMARY KEY,final_revision_id TEXT,finalized_at TEXT);
|
||||
CREATE TABLE sync_note_revisions(owner_kind TEXT,owner_id TEXT,revision_id TEXT,parent_revision_id TEXT,note TEXT,PRIMARY KEY(owner_kind,owner_id,revision_id));
|
||||
CREATE TABLE sync_note_state(owner_kind TEXT,owner_id TEXT,revision_id TEXT,PRIMARY KEY(owner_kind,owner_id));
|
||||
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_causal_state(target_kind TEXT,target_id TEXT,current_revision_id TEXT,deleted INTEGER,operation_id TEXT,PRIMARY KEY(target_kind,target_id));
|
||||
PRAGMA user_version=20;
|
||||
|
|
@ -41,8 +45,9 @@ class CausalDeleteTest(unittest.TestCase):
|
|||
self.db = sqlite3.connect(self.path); self.db.executescript(SCHEMA)
|
||||
self.db.execute("INSERT INTO exercises VALUES(1,'ex_user','User','sets','reps',0)")
|
||||
self.db.execute("INSERT INTO sessions VALUES(1,'se_one','2030-01-01T10:00:00Z',NULL,'training','note')")
|
||||
self.db.execute("INSERT INTO session_exercises VALUES(1,1,1,'sxe_one')")
|
||||
self.db.execute("INSERT INTO body_observations VALUES(1,'bo_one','2030-01-01T11:00:00Z',1,'body')")
|
||||
self.db.execute("INSERT INTO session_exercises VALUES(1,1,1,'sxe_one',0,'sets','reps',0,NULL,'external',60,1,8,NULL,32.5,'occurrence')")
|
||||
self.db.execute("INSERT INTO performed_sets VALUES(1,1,0,8,NULL,32.5)")
|
||||
self.db.execute("INSERT INTO body_observations VALUES(1,'bo_one','2030-01-01T11:00:00Z',80.0,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'body',1)")
|
||||
self.db.execute("INSERT INTO custom_equipment VALUES('eq_one','Custom','','custom','external')")
|
||||
self.db.execute("INSERT INTO exercise_feedback VALUES(1,'fb_one',1,'2030-01-01T10:30:00Z','root')")
|
||||
self.db.execute("INSERT INTO exercise_feedback_revisions VALUES('fr_one','fb_one','2030-01-01T10:30:00Z','root')")
|
||||
|
|
@ -101,5 +106,67 @@ class CausalDeleteTest(unittest.TestCase):
|
|||
with self.assertRaisesRegex(causal.CausalError, "built-in"):
|
||||
causal.local_delete(self.db, "exercise", built_in, "peer")
|
||||
|
||||
def hostile_operation(self, kind, target):
|
||||
operation = {"operation_id": "del_" + "a" * 36, "target_kind": kind,
|
||||
"target_id": target, "creator_id": "hostile_fixture",
|
||||
"predecessor_revision_id": causal.live_revision(self.db, kind, target) or "lv_unknown",
|
||||
"created_at": "2030-01-01T12:00:00Z", "publication_context": None}
|
||||
operation["payload_sha256"] = causal.digest(operation)
|
||||
return operation
|
||||
|
||||
def test_imported_builtin_and_alias_retirement_are_refused_without_writes(self):
|
||||
built_in = next(iter(causal.load_exercise_names()))
|
||||
self.db.execute("INSERT INTO exercises VALUES(2,?,'Built in','sets','reps',0)", (built_in,))
|
||||
self.db.execute("INSERT INTO equipment VALUES('barbell','barbell')")
|
||||
alias = "ex_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
||||
self.db.execute("INSERT INTO exercises VALUES(3,?,'Alias row','sets','reps',0)", (alias,))
|
||||
self.db.execute("INSERT INTO exercise_aliases VALUES(?,?)", (alias, built_in))
|
||||
self.db.commit()
|
||||
for operation in (self.hostile_operation("exercise", built_in),
|
||||
self.hostile_operation("exercise", alias),
|
||||
self.hostile_operation("custom_equipment", "barbell")):
|
||||
with self.assertRaisesRegex(causal.CausalError, "built-in"):
|
||||
causal.apply_operation(self.db, operation)
|
||||
self.assertEqual(0, self.db.execute("SELECT COUNT(*) FROM sync_causal_operations").fetchone()[0])
|
||||
self.assertEqual(0, self.db.execute("SELECT COUNT(*) FROM sync_causal_state").fetchone()[0])
|
||||
|
||||
def test_child_mutations_invalidate_session_observation_and_feedback_deletes(self):
|
||||
session = self.hostile_operation("session", "se_one")
|
||||
self.db.execute("UPDATE performed_sets SET weight_kg=35.0 WHERE id=1")
|
||||
with self.assertRaisesRegex(causal.CausalError, "predecessor"):
|
||||
causal.apply_operation(self.db, session)
|
||||
self.assertEqual(35.0, self.db.execute("SELECT weight_kg FROM performed_sets WHERE id=1").fetchone()[0])
|
||||
observation = self.hostile_operation("body_observation", "bo_one")
|
||||
self.db.execute("UPDATE body_observations SET body_weight_kg=79.5,session_row_id=NULL WHERE observation_id='bo_one'")
|
||||
with self.assertRaisesRegex(causal.CausalError, "predecessor"):
|
||||
causal.apply_operation(self.db, observation)
|
||||
feedback = self.hostile_operation("feedback", "fb_one")
|
||||
self.db.execute("INSERT INTO exercise_feedback_revisions VALUES('fr_two','fb_one','2030-01-02T10:30:00Z','revised')")
|
||||
with self.assertRaisesRegex(causal.CausalError, "predecessor"):
|
||||
causal.apply_operation(self.db, feedback)
|
||||
self.assertEqual(0, self.db.execute("SELECT COUNT(*) FROM sync_causal_operations").fetchone()[0])
|
||||
|
||||
def test_draft_identity_is_semantic_revision_not_json_serialization(self):
|
||||
first = causal.live_revision(self.db, "execution_draft", "se_draft")
|
||||
self.db.execute("UPDATE execution_drafts SET payload_json=? WHERE session_id='se_draft'", ('{ "b": 2, "a": 1 }',))
|
||||
self.assertEqual(first, causal.live_revision(self.db, "execution_draft", "se_draft"))
|
||||
self.db.execute("UPDATE execution_drafts SET revision_id='dr_two' WHERE session_id='se_draft'")
|
||||
self.assertNotEqual(first, causal.live_revision(self.db, "execution_draft", "se_draft"))
|
||||
|
||||
def test_operation_count_and_input_byte_bounds_precede_effects(self):
|
||||
row = ("exercise", "x", "peer", "lv_x", "2030-01-01T12:00:00Z", "0" * 64, None)
|
||||
self.db.executemany("INSERT INTO sync_causal_operations VALUES(?,?,?,?,?,?,?,?)",
|
||||
[(f"op_{index:04d}", *row) for index in range(causal.MAX_OPERATIONS)])
|
||||
self.db.commit()
|
||||
document = causal.export_document(self.db)
|
||||
self.assertEqual(causal.MAX_OPERATIONS, len(document["operations"]))
|
||||
self.db.execute("INSERT INTO sync_causal_operations VALUES(?,?,?,?,?,?,?,?)", ("op_over", *row)); self.db.commit()
|
||||
with self.assertRaisesRegex(causal.CausalError, "artifact bound"):
|
||||
causal.export_document(self.db)
|
||||
oversized = Path(self.tmp.name) / "oversized.json"
|
||||
oversized.write_bytes(b" " * (causal.MAX_BYTES + 1))
|
||||
with self.assertRaisesRegex(causal.CausalError, "4 MiB"):
|
||||
causal.load(oversized)
|
||||
|
||||
|
||||
if __name__ == "__main__": unittest.main()
|
||||
|
|
|
|||
|
|
@ -44,17 +44,55 @@ def require_schema(db: sqlite3.Connection) -> None:
|
|||
|
||||
def target_snapshot(db: sqlite3.Connection, kind: str, target: str):
|
||||
if kind == "session":
|
||||
row = db.execute("SELECT session_id,started_at,ended_at,session_type,notes FROM sessions WHERE session_id=?", (target,)).fetchone()
|
||||
row = db.execute("SELECT id,session_id,started_at,ended_at,session_type,notes FROM sessions WHERE session_id=?", (target,)).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
session_row = row[0]
|
||||
occurrences = []
|
||||
for occurrence in db.execute(
|
||||
"SELECT se.id,se.entry_id,e.exercise_id,se.position,se.recording_mode,se.tracking_mode,se.data_fields,se.equipment_id,se.load_mode,se.rest_seconds,se.target_sets,se.target_reps,se.target_duration_seconds,se.target_weight_kg,se.notes "
|
||||
"FROM session_exercises se JOIN exercises e ON e.id=se.exercise_row_id WHERE se.session_row_id=? ORDER BY se.position,se.entry_id", (session_row,)
|
||||
):
|
||||
occurrence_row = occurrence[0]
|
||||
sets = [list(value) for value in db.execute(
|
||||
"SELECT position,reps,duration_seconds,weight_kg FROM performed_sets WHERE session_exercise_row_id=? ORDER BY position", (occurrence_row,))]
|
||||
continuous = db.execute(
|
||||
"SELECT duration_seconds,speed_kmh,distance_km FROM continuous_activity WHERE session_exercise_row_id=?", (occurrence_row,)).fetchone()
|
||||
maximum = db.execute(
|
||||
"SELECT max_weight_kg FROM max_results WHERE session_exercise_row_id=?", (occurrence_row,)).fetchone()
|
||||
feedback = []
|
||||
for root in db.execute(
|
||||
"SELECT feedback_id,observed_at,raw_text FROM exercise_feedback WHERE session_exercise_row_id=? ORDER BY observed_at,feedback_id", (occurrence_row,)):
|
||||
revisions = [list(value) for value in db.execute(
|
||||
"SELECT revision_id,created_at,raw_text FROM exercise_feedback_revisions WHERE feedback_id=? ORDER BY created_at,revision_id", (root[0],))]
|
||||
feedback.append([*root, revisions])
|
||||
occurrence_note = db.execute(
|
||||
"SELECT revision_id FROM sync_note_state WHERE owner_kind='occurrence' AND owner_id=?", (occurrence[1],)).fetchone()
|
||||
occurrences.append([*occurrence[1:], sets,
|
||||
None if continuous is None else list(continuous),
|
||||
None if maximum is None else maximum[0], feedback,
|
||||
None if occurrence_note is None else occurrence_note[0]])
|
||||
session_note = db.execute(
|
||||
"SELECT revision_id FROM sync_note_state WHERE owner_kind='session' AND owner_id=?", (target,)).fetchone()
|
||||
links = [value[0] for value in db.execute(
|
||||
"SELECT observation_id FROM body_observations WHERE session_row_id=? ORDER BY observation_id", (session_row,))]
|
||||
return [*row[1:], occurrences, None if session_note is None else session_note[0], links]
|
||||
elif kind == "execution_draft":
|
||||
row = db.execute("SELECT session_id,revision_id,parent_revision_id,payload_json FROM execution_drafts WHERE session_id=?", (target,)).fetchone()
|
||||
row = db.execute("SELECT session_id,revision_id FROM execution_drafts WHERE session_id=?", (target,)).fetchone()
|
||||
elif kind == "exercise":
|
||||
row = db.execute("SELECT exercise_id,name,recording_mode,tracking_mode,data_fields FROM exercises WHERE exercise_id=?", (target,)).fetchone()
|
||||
elif kind == "body_observation":
|
||||
row = db.execute("SELECT observation_id,observed_at,notes FROM body_observations WHERE observation_id=?", (target,)).fetchone()
|
||||
row = db.execute("SELECT bo.observation_id,bo.observed_at,bo.body_weight_kg,bo.neck_cm,bo.shoulders_cm,bo.chest_cm,bo.waist_cm,bo.hips_cm,bo.left_arm_cm,bo.right_arm_cm,bo.left_forearm_cm,bo.right_forearm_cm,bo.left_thigh_cm,bo.right_thigh_cm,bo.left_calf_cm,bo.right_calf_cm,bo.notes,s.session_id FROM body_observations bo LEFT JOIN sessions s ON s.id=bo.session_row_id WHERE bo.observation_id=?", (target,)).fetchone()
|
||||
if row is not None:
|
||||
note = db.execute("SELECT revision_id FROM sync_note_state WHERE owner_kind='observation' AND owner_id=?", (target,)).fetchone()
|
||||
return [*row, None if note is None else note[0]]
|
||||
elif kind == "custom_equipment":
|
||||
row = db.execute("SELECT equipment_id,display_name,label_name,equipment_type,load_semantics FROM custom_equipment WHERE equipment_id=?", (target,)).fetchone()
|
||||
elif kind == "feedback":
|
||||
row = db.execute("SELECT feedback_id,observed_at,raw_text FROM exercise_feedback WHERE feedback_id=?", (target,)).fetchone()
|
||||
if row is not None:
|
||||
revisions = [list(value) for value in db.execute("SELECT revision_id,created_at,raw_text FROM exercise_feedback_revisions WHERE feedback_id=? ORDER BY created_at,revision_id", (target,))]
|
||||
return [*row, revisions]
|
||||
else:
|
||||
parts = target.split("|")
|
||||
if len(parts) != 3:
|
||||
|
|
@ -110,13 +148,13 @@ def validate_operation(value: object) -> dict:
|
|||
def apply_operation(db: sqlite3.Connection, operation: dict) -> str:
|
||||
op = validate_operation(operation)
|
||||
identity = op["operation_id"]
|
||||
encoded = canonical(op)
|
||||
known = db.execute("SELECT target_kind,target_id,creator_id,predecessor_revision_id,created_at,payload_sha256,publication_context FROM sync_causal_operations WHERE operation_id=?", (identity,)).fetchone()
|
||||
expected = (op["target_kind"], op["target_id"], op["creator_id"], op["predecessor_revision_id"], op["created_at"], op["payload_sha256"], None)
|
||||
if known:
|
||||
if tuple(known) != expected:
|
||||
raise CausalError("operation identity reused with different content")
|
||||
return "unchanged"
|
||||
authorize_target(db, op["target_kind"], op["target_id"])
|
||||
state = db.execute("SELECT current_revision_id,deleted FROM sync_causal_state WHERE target_kind=? AND target_id=?", (op["target_kind"], op["target_id"])).fetchone()
|
||||
current = state[0] if state else live_revision(db, op["target_kind"], op["target_id"])
|
||||
if state and state[1]:
|
||||
|
|
@ -125,19 +163,42 @@ def apply_operation(db: sqlite3.Connection, operation: dict) -> str:
|
|||
raise CausalError("unknown target or ancestry")
|
||||
if current != op["predecessor_revision_id"]:
|
||||
raise CausalError("causal predecessor conflict")
|
||||
count = db.execute("SELECT COUNT(*) FROM sync_causal_operations").fetchone()[0]
|
||||
if count >= MAX_OPERATIONS:
|
||||
raise CausalError("protection set exceeds operation bound")
|
||||
db.execute("INSERT INTO sync_causal_operations VALUES(?,?,?,?,?,?,?,NULL)",
|
||||
(identity, op["target_kind"], op["target_id"], op["creator_id"],
|
||||
op["predecessor_revision_id"], op["created_at"], op["payload_sha256"]))
|
||||
if len(canonical(export_document(db)).encode("utf-8")) > MAX_BYTES:
|
||||
raise CausalError("protection set exceeds artifact byte bound")
|
||||
apply_effect(db, op["target_kind"], op["target_id"], identity)
|
||||
db.execute("INSERT OR REPLACE INTO sync_causal_state VALUES(?,?,?,?,?)",
|
||||
(op["target_kind"], op["target_id"], identity, 1, identity))
|
||||
return "applied"
|
||||
|
||||
|
||||
def authorize_target(db: sqlite3.Connection, kind: str, target: str) -> None:
|
||||
if kind == "exercise":
|
||||
canonical = db.execute("SELECT canonical_exercise_id FROM exercise_aliases WHERE source_exercise_id=?", (target,)).fetchone() if _table_exists(db, "exercise_aliases") else None
|
||||
if target in load_exercise_names() or canonical is not None and canonical[0] in load_exercise_names():
|
||||
raise CausalError("built-in exercise identity cannot be retired")
|
||||
if kind == "custom_equipment" and _table_exists(db, "equipment") and db.execute(
|
||||
"SELECT 1 FROM equipment WHERE equipment_id=? AND equipment_type<>'custom_machine'", (target,)).fetchone():
|
||||
raise CausalError("built-in equipment identity cannot be retired")
|
||||
|
||||
|
||||
def _table_exists(db: sqlite3.Connection, table: str) -> bool:
|
||||
return db.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)).fetchone() is not None
|
||||
|
||||
|
||||
def local_delete(db: sqlite3.Connection, kind: str, target: str, creator: str) -> tuple[dict, str]:
|
||||
if kind == "exercise" and target in load_exercise_names():
|
||||
raise CausalError("built-in exercise identity cannot be retired")
|
||||
current = live_revision(db, kind, target)
|
||||
authorize_target(db, kind, target)
|
||||
state = db.execute("SELECT current_revision_id,deleted FROM sync_causal_state WHERE target_kind=? AND target_id=?", (kind, target)).fetchone()
|
||||
if state and state[1]:
|
||||
row = db.execute("SELECT operation_id,target_kind,target_id,creator_id,predecessor_revision_id,created_at,payload_sha256,publication_context FROM sync_causal_operations WHERE operation_id=?", (state[0],)).fetchone()
|
||||
keys = ("operation_id", "target_kind", "target_id", "creator_id", "predecessor_revision_id", "created_at", "payload_sha256", "publication_context")
|
||||
return dict(zip(keys, row)), "unchanged"
|
||||
current = state[0] if state and not state[1] else live_revision(db, kind, target)
|
||||
if current is None:
|
||||
state = db.execute("SELECT operation_id FROM sync_causal_state WHERE target_kind=? AND target_id=? AND deleted=1", (kind, target)).fetchone()
|
||||
if state:
|
||||
|
|
@ -155,7 +216,8 @@ def local_delete(db: sqlite3.Connection, kind: str, target: str, creator: str) -
|
|||
|
||||
|
||||
def load(path: Path) -> dict:
|
||||
raw = path.read_bytes()
|
||||
with path.open("rb") as source:
|
||||
raw = source.read(MAX_BYTES + 1)
|
||||
if len(raw) > MAX_BYTES:
|
||||
raise CausalError("artifact exceeds 4 MiB")
|
||||
def unique(pairs):
|
||||
|
|
@ -181,11 +243,14 @@ def load(path: Path) -> dict:
|
|||
|
||||
def export_document(db: sqlite3.Connection) -> dict:
|
||||
keys = ("operation_id", "target_kind", "target_id", "creator_id", "predecessor_revision_id", "created_at", "payload_sha256", "publication_context")
|
||||
rows = db.execute("SELECT " + ",".join(keys) + " FROM sync_causal_operations ORDER BY operation_id").fetchall()
|
||||
rows = db.execute("SELECT " + ",".join(keys) + " FROM sync_causal_operations ORDER BY operation_id LIMIT ?", (MAX_OPERATIONS + 1,)).fetchall()
|
||||
if len(rows) > MAX_OPERATIONS: raise CausalError("protection set exceeds artifact bound")
|
||||
return {"format": FORMAT, "version": VERSION,
|
||||
document = {"format": FORMAT, "version": VERSION,
|
||||
"generated_at": dt.datetime.now().astimezone().isoformat(),
|
||||
"operations": [dict(zip(keys, row)) for row in rows]}
|
||||
if len(canonical(document).encode("utf-8")) > MAX_BYTES:
|
||||
raise CausalError("protection set exceeds artifact byte bound")
|
||||
return document
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ def main():
|
|||
try:
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20):
|
||||
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():
|
||||
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 "
|
||||
"FROM custom_equipment ORDER BY equipment_id")]
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ def main():
|
|||
try:
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17, 18, 19, 20):
|
||||
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():
|
||||
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"):
|
||||
if EXERCISE_ID_PATTERN.fullmatch(exercise["exercise_id"]) is None:
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ def main():
|
|||
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")
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -63,6 +63,8 @@ def main():
|
|||
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:
|
||||
|
|
|
|||
|
|
@ -207,6 +207,8 @@ def main():
|
|||
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 = {}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import os
|
|||
import sqlite3
|
||||
import sys
|
||||
import unicodedata
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from validate_json import TrainlogSemanticError, parse_timestamp
|
||||
|
|
@ -1648,6 +1649,15 @@ def import_sessions(
|
|||
if connection.execute("PRAGMA user_version;").fetchone()[0] >= 9:
|
||||
connection.execute("DELETE FROM max_results WHERE session_exercise_row_id IN (SELECT id FROM session_exercises WHERE session_row_id=?);", (session_row_id,))
|
||||
connection.execute("DELETE FROM session_exercises WHERE session_row_id=?;", (session_row_id,))
|
||||
if connection.execute("PRAGMA user_version;").fetchone()[0] >= 20:
|
||||
existing_causal = connection.execute(
|
||||
"SELECT deleted FROM sync_causal_state WHERE target_kind='session' AND target_id=?",
|
||||
(session["session_id"],),).fetchone()
|
||||
if existing_causal is not None and existing_causal[0]:
|
||||
raise ImportFailure("session protégée causalement: " + session["session_id"])
|
||||
connection.execute(
|
||||
"INSERT OR REPLACE INTO sync_causal_state(target_kind,target_id,current_revision_id,deleted,operation_id) VALUES('session',?,?,0,NULL)",
|
||||
(session["session_id"], "mu_" + str(uuid.uuid4())),)
|
||||
report["sessions_reconciled"] += 1
|
||||
else:
|
||||
cursor = connection.execute(
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ def main():
|
|||
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"]):
|
||||
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"}
|
||||
|
|
|
|||
|
|
@ -1444,7 +1444,9 @@ TrainlogStatus trainlog_database_list_training_feedback(
|
|||
TrainlogStatus status = list_feedback_query(database,
|
||||
"SELECT f.feedback_id,se.entry_id,f.observed_at,f.raw_text 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 "
|
||||
"WHERE s.session_id=?1 ORDER BY f.observed_at COLLATE BINARY,f.feedback_id COLLATE BINARY",
|
||||
"WHERE s.session_id=?1 AND NOT EXISTS(SELECT 1 FROM sync_causal_state cs "
|
||||
"WHERE cs.target_kind='feedback' AND cs.target_id=f.feedback_id AND cs.deleted=1) "
|
||||
"ORDER BY f.observed_at COLLATE BINARY,f.feedback_id COLLATE BINARY",
|
||||
session_id, exercise_feedback, exercise_capacity, exercise_count, true);
|
||||
if (status != TRAINLOG_STATUS_OK) return status;
|
||||
qsort(exercise_feedback, *exercise_count, sizeof(*exercise_feedback), compare_feedback_view);
|
||||
|
|
@ -1965,7 +1967,9 @@ TrainlogStatus trainlog_database_list_custom_equipment(
|
|||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
*output_count = 0U;
|
||||
rc = sqlite3_prepare_v2(database->connection,
|
||||
"SELECT equipment_id,display_name,label_name,equipment_type,load_semantics FROM custom_equipment ORDER BY display_name COLLATE NOCASE,equipment_id;",
|
||||
"SELECT equipment_id,display_name,label_name,equipment_type,load_semantics FROM custom_equipment ce "
|
||||
"WHERE NOT EXISTS(SELECT 1 FROM sync_causal_state s WHERE s.target_kind='custom_equipment' AND s.target_id=ce.equipment_id AND s.deleted=1) "
|
||||
"ORDER BY display_name COLLATE NOCASE,equipment_id;",
|
||||
-1, &statement, NULL);
|
||||
if (rc != SQLITE_OK) return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
while ((rc = sqlite3_step(statement)) == SQLITE_ROW) {
|
||||
|
|
@ -2011,7 +2015,7 @@ TrainlogStatus trainlog_database_list_custom_equipment_page(
|
|||
{
|
||||
static const char *const SQL =
|
||||
"SELECT equipment_id,display_name,label_name,equipment_type,load_semantics "
|
||||
"FROM custom_equipment ORDER BY display_name COLLATE NOCASE,equipment_id "
|
||||
"FROM custom_equipment ce WHERE NOT EXISTS(SELECT 1 FROM sync_causal_state s WHERE s.target_kind='custom_equipment' AND s.target_id=ce.equipment_id AND s.deleted=1) ORDER BY display_name COLLATE NOCASE,equipment_id "
|
||||
"LIMIT ?1 OFFSET ?2;";
|
||||
sqlite3_stmt *statement = NULL;
|
||||
size_t count = 0U;
|
||||
|
|
@ -3154,7 +3158,7 @@ TrainlogStatus trainlog_database_list_exercises_filtered(
|
|||
{
|
||||
static const char *const SQL =
|
||||
"SELECT id,exercise_id,name,tracking_mode,recording_mode,data_fields,normalized_name "
|
||||
"FROM exercises ORDER BY name COLLATE NOCASE,exercise_id;";
|
||||
"FROM exercises e WHERE NOT EXISTS(SELECT 1 FROM sync_causal_state s WHERE s.target_kind='exercise' AND s.target_id=e.exercise_id AND s.deleted=1) ORDER BY name COLLATE NOCASE,exercise_id;";
|
||||
sqlite3_stmt *statement = NULL;
|
||||
size_t count = 0U;
|
||||
size_t prefix_length;
|
||||
|
|
@ -4352,6 +4356,26 @@ TrainlogStatus trainlog_database_replace_session_exercises(
|
|||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
|
||||
/* WHY: content can return to its earlier bytes after a real correction.
|
||||
* CONTRACT: a successful replacement advances a durable non-deleted
|
||||
* causal revision in the same transaction. INVARIANT: a deleted session
|
||||
* cannot be modified or silently reactivated by this writer. */
|
||||
{
|
||||
sqlite3_stmt *causal = NULL;
|
||||
rc = sqlite3_prepare_v2(database->connection,
|
||||
"INSERT INTO sync_causal_state(target_kind,target_id,current_revision_id,deleted,operation_id) "
|
||||
"VALUES('session',?1,'mu_'||lower(hex(randomblob(16))),0,NULL) "
|
||||
"ON CONFLICT(target_kind,target_id) DO UPDATE SET current_revision_id=excluded.current_revision_id "
|
||||
"WHERE sync_causal_state.deleted=0;", -1, &causal, NULL);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_text(causal, 1, session_id, -1, SQLITE_TRANSIENT);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_step(causal);
|
||||
if (causal != NULL && sqlite3_finalize(causal) != SQLITE_OK && rc == SQLITE_DONE) rc = SQLITE_ERROR;
|
||||
if (rc != SQLITE_DONE || sqlite3_changes(database->connection) != 1) {
|
||||
(void)trainlog_database_rollback(database);
|
||||
return rc == SQLITE_DONE ? TRAINLOG_STATUS_CONFLICT : TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
status = trainlog_database_commit(database);
|
||||
|
||||
if (status != TRAINLOG_STATUS_OK) {
|
||||
|
|
|
|||
|
|
@ -1115,7 +1115,8 @@ static bool run_ai_inbound_diagnostic_case(
|
|||
|
||||
static bool run_all(void)
|
||||
{
|
||||
char temporary[] = "/tmp/trainlog-sync-body-zone-wiring-XXXXXX";
|
||||
char temporary[4096];
|
||||
const char *temporary_root = getenv("TMPDIR");
|
||||
char case_a[4096];
|
||||
char case_b[4096];
|
||||
char case_c[4096];
|
||||
|
|
@ -1135,7 +1136,14 @@ static bool run_all(void)
|
|||
char case_q[4096];
|
||||
char case_r[4096];
|
||||
char fake_tools[4096];
|
||||
char *root = mkdtemp(temporary);
|
||||
char *root;
|
||||
int temporary_written;
|
||||
|
||||
if (temporary_root == NULL || temporary_root[0] == '\0') temporary_root = "/tmp";
|
||||
temporary_written = snprintf(temporary, sizeof(temporary),
|
||||
"%s/trainlog-sync-body-zone-wiring-XXXXXX", temporary_root);
|
||||
CHECK(temporary_written > 0 && (size_t)temporary_written < sizeof(temporary));
|
||||
root = mkdtemp(temporary);
|
||||
|
||||
CHECK(root != NULL);
|
||||
CHECK(install_fake_rclone(root));
|
||||
|
|
|
|||
Loading…
Reference in a new issue