diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt index 7288338..2a65760 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt @@ -216,6 +216,8 @@ sealed interface MobileSessionImportResult { data object DatabaseError : MobileSessionImportResult } +private class SyncLifecycleConflict(val ownerId: String) : RuntimeException() + sealed interface CreateEquipmentResult { data class Created(val equipment: EquipmentCatalogEntry) : CreateEquipmentResult data object Invalid : CreateEquipmentResult @@ -301,6 +303,29 @@ sealed interface StartAiSessionDraftResult { data class Error(val message: String) : StartAiSessionDraftResult } +sealed interface ExecutionDraftImportResult { + data class Applied(val active: Int, val pending: Int, val unchanged: Int, val stale: Int) : ExecutionDraftImportResult + data class Conflict(val sessionId: String) : ExecutionDraftImportResult + data class Invalid(val message: String) : ExecutionDraftImportResult + data object CapacityExceeded : ExecutionDraftImportResult + data object DatabaseError : ExecutionDraftImportResult +} + +sealed interface SyncNoteEditResult { + data class Saved(val revisionId: String) : SyncNoteEditResult + data object NotFound : SyncNoteEditResult + data object Invalid : SyncNoteEditResult + data object DatabaseError : SyncNoteEditResult +} + +sealed interface ActivateExecutionDraftResult { + data object Activated : ActivateExecutionDraftResult + data object ActiveDraftOccupied : ActivateExecutionDraftResult + data object NotFound : ActivateExecutionDraftResult + data class Invalid(val message: String) : ActivateExecutionDraftResult + data object DatabaseError : ActivateExecutionDraftResult +} + sealed interface FinalizeActiveDraftResult { data class Saved( val sessionId: String, @@ -1982,7 +2007,14 @@ class TrainlogRepository( } } - fun finalizeActiveSessionDraft(): + fun finalizeActiveSessionDraft(): FinalizeActiveDraftResult = + finalizeActiveSessionDraftInternal(null) + + /** Finalize exactly one lifecycle identity; retry after commit is idempotent. */ + fun finalizeExecutionDraft(sessionId: String): FinalizeActiveDraftResult = + finalizeActiveSessionDraftInternal(sessionId) + + private fun finalizeActiveSessionDraftInternal(expectedSessionId: String?): FinalizeActiveDraftResult { var db: SQLiteDatabase? = null var transactionOpen = false @@ -1990,6 +2022,18 @@ class TrainlogRepository( db = database.writableDatabase db.beginTransaction() transactionOpen = true + if (expectedSessionId != null) { + val finalized = db.rawQuery( + "SELECT 1 FROM execution_draft_finalizations WHERE session_id=?;", + arrayOf(expectedSessionId), + ).use { it.moveToFirst() } + if (finalized) { + db.setTransactionSuccessful() + db.endTransaction() + transactionOpen = false + return FinalizeActiveDraftResult.Saved(expectedSessionId) + } + } val active = loadActiveSessionDraft(db) if (active == null) { @@ -1999,6 +2043,13 @@ class TrainlogRepository( "Aucune séance en cours." ) } + val lifecycleSessionId = active.draft.sessionId + ?: error("Active execution draft has no stable session_id.") + if (expectedSessionId != null && expectedSessionId != lifecycleSessionId) { + db.endTransaction() + transactionOpen = false + return FinalizeActiveDraftResult.Invalid("The requested execution draft is not active.") + } val completed = SessionDraft( @@ -2026,8 +2077,33 @@ class TrainlogRepository( db, completed, active.draft.sourceSessionId, + forcedSessionId = lifecycleSessionId, + forcedStartedAt = active.draft.startedAt, ) + /* CONTRACT: lifecycle transition changes table ownership, not the + * stable note owner. Copy caches while causal state remains keyed + * by session_id/entry_id; rollback covers every child. */ + db.execSQL( + "UPDATE sessions SET notes=(SELECT notes FROM active_session_draft WHERE id=1) " + + "WHERE session_id=?;", arrayOf(sessionId), + ) + db.execSQL( + "UPDATE session_exercises SET notes=(SELECT de.notes FROM draft_session_exercises de " + + "WHERE de.draft_id=1 AND de.entry_id=session_exercises.entry_id) " + + "WHERE session_row_id=(SELECT id FROM sessions WHERE session_id=?);", arrayOf(sessionId), + ) + + db.insertOrThrow( + "execution_draft_finalizations", + null, + ContentValues().apply { + put("session_id", lifecycleSessionId) + put("final_revision_id", active.draft.revisionId) + put("finalized_at", OffsetDateTime.now().toString()) + }, + ) + /* * INVARIANT: completion and draft deletion share this transaction. * A crash or constraint failure therefore leaves the retryable draft @@ -3237,8 +3313,143 @@ class TrainlogRepository( fun buildMobileExportJson(): String = buildMobileExport(1) + /** Create one direct causal note revision for a stable history owner. */ + fun updateSyncNote(ownerKind: String, ownerId: String, value: String?): SyncNoteEditResult { + if (ownerKind !in setOf("session", "occurrence", "observation") || ownerId.isBlank() || + value != null && value.toByteArray(StandardCharsets.UTF_8).size > MAX_SYNC_NOTE_UTF8_BYTES) + return SyncNoteEditResult.Invalid + val db = database.writableDatabase + return try { + db.beginTransaction() + val owner = when (ownerKind) { + "session" -> db.rawQuery("SELECT id FROM sessions WHERE session_id=?", arrayOf(ownerId)) + .use { if (it.moveToFirst()) Pair("sessions", it.getLong(0)) else null } + "occurrence" -> db.rawQuery("SELECT id FROM session_exercises WHERE entry_id=?", arrayOf(ownerId)) + .use { completed -> if (completed.moveToFirst()) Pair("session_exercises", completed.getLong(0)) + else db.rawQuery("SELECT id FROM draft_session_exercises WHERE entry_id=?", arrayOf(ownerId)) + .use { draft -> if (draft.moveToFirst()) Pair("draft_session_exercises", draft.getLong(0)) else null } } + else -> db.rawQuery("SELECT id FROM body_observations WHERE observation_id=?", arrayOf(ownerId)) + .use { if (it.moveToFirst()) Pair("body_observations", it.getLong(0)) else null } + } ?: return SyncNoteEditResult.NotFound + val parent = db.rawQuery("SELECT revision_id FROM sync_note_state WHERE owner_kind=? AND owner_id=?", + arrayOf(ownerKind, ownerId)).use { if (it.moveToFirst()) it.getString(0) else null } + val revision = "nr_${UUID.randomUUID()}" + applySyncNote(db, ownerKind, ownerId, JSONObject().put("value", value ?: JSONObject.NULL) + .put("revision_id", revision).put("parent_revision_id", parent ?: JSONObject.NULL), + owner.first, owner.second) + db.setTransactionSuccessful(); SyncNoteEditResult.Saved(revision) + } catch (_: Exception) { SyncNoteEditResult.DatabaseError + } finally { db.endTransaction() } + } + + 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 + } + + private fun syncNotePayload( + db: SQLiteDatabase, + ownerKind: String, + ownerId: String, + value: String?, + ): Any { + val state = db.rawQuery( + "SELECT r.revision_id,r.parent_revision_id,r.note FROM sync_note_state s " + + "JOIN sync_note_revisions r ON r.owner_kind=s.owner_kind AND r.owner_id=s.owner_id " + + "AND r.revision_id=s.revision_id WHERE s.owner_kind=? AND s.owner_id=?;", + arrayOf(ownerKind, ownerId), + ).use { cursor -> + if (!cursor.moveToFirst()) null else Triple( + cursor.getString(0), + if (cursor.isNull(1)) null else cursor.getString(1), + if (cursor.isNull(2)) null else cursor.getString(2), + ) + } + if (state == null) { + check(value == null) { "Legacy note has no causal revision: $ownerKind/$ownerId" } + return JSONObject.NULL + } + check(state.third == value) { "Note cache and causal state disagree: $ownerKind/$ownerId" } + return JSONObject() + .put("value", state.third ?: JSONObject.NULL) + .put("revision_id", state.first) + .put("parent_revision_id", state.second ?: JSONObject.NULL) + } + + private fun applySyncNote( + db: SQLiteDatabase, + ownerKind: String, + ownerId: String, + payload: Any?, + table: String, + rowId: Long, + ) { + if (payload === JSONObject.NULL) { + val retained = db.rawQuery( + "SELECT r.note FROM sync_note_state s JOIN sync_note_revisions r " + + "ON r.owner_kind=s.owner_kind AND r.owner_id=s.owner_id AND r.revision_id=s.revision_id " + + "WHERE s.owner_kind=? AND s.owner_id=?;", arrayOf(ownerKind, ownerId), + ).use { cursor -> if (!cursor.moveToFirst()) null else Pair(true, + if (cursor.isNull(0)) null else cursor.getString(0)) } + if (retained != null) db.update(table, ContentValues().apply { + putOptionalString("notes", retained.second) + }, "id=?", arrayOf(rowId.toString())) + return + } + val note = payload as JSONObject + val revision = note.getString("revision_id") + val parent = if (note.isNull("parent_revision_id")) null else note.getString("parent_revision_id") + val content = if (note.isNull("value")) null else note.getString("value") + val current = db.rawQuery( + "SELECT revision_id FROM sync_note_state WHERE owner_kind=? AND owner_id=?;", + arrayOf(ownerKind, ownerId), + ).use { if (it.moveToFirst()) it.getString(0) else null } + val existing = db.rawQuery( + "SELECT parent_revision_id,note FROM sync_note_revisions " + + "WHERE owner_kind=? AND owner_id=? AND revision_id=?;", + arrayOf(ownerKind, ownerId, revision), + ).use { cursor -> if (!cursor.moveToFirst()) null else Pair( + if (cursor.isNull(0)) null else cursor.getString(0), + if (cursor.isNull(1)) null else cursor.getString(1), + ) } + if (existing != null) { + if (existing != Pair(parent, content) || current != revision) throw SyncLifecycleConflict(ownerId) + return + } + if (current != parent) throw SyncLifecycleConflict(ownerId) + db.insertOrThrow("sync_note_revisions", null, ContentValues().apply { + put("owner_kind", ownerKind); put("owner_id", ownerId); put("revision_id", revision) + putOptionalString("parent_revision_id", parent); putOptionalString("note", content) + }) + db.insertWithOnConflict("sync_note_state", null, ContentValues().apply { + put("owner_kind", ownerKind); put("owner_id", ownerId); put("revision_id", revision) + }, SQLiteDatabase.CONFLICT_REPLACE) + db.update(table, ContentValues().apply { putOptionalString("notes", content) }, + "id=?", arrayOf(rowId.toString())) + } + + private fun syncNoteIsCurrent(db: SQLiteDatabase, ownerKind: String, ownerId: String, payload: Any?): Boolean { + val row = db.rawQuery( + "SELECT r.revision_id,r.parent_revision_id,r.note FROM sync_note_state s " + + "JOIN sync_note_revisions r ON r.owner_kind=s.owner_kind AND r.owner_id=s.owner_id " + + "AND r.revision_id=s.revision_id WHERE s.owner_kind=? AND s.owner_id=?;", + arrayOf(ownerKind, ownerId), + ).use { cursor -> if (!cursor.moveToFirst()) null else Triple( + cursor.getString(0), if (cursor.isNull(1)) null else cursor.getString(1), + if (cursor.isNull(2)) null else cursor.getString(2)) } + if (payload === JSONObject.NULL) return row == null + val note = payload as? JSONObject ?: return false + return row == Triple(note.getString("revision_id"), + if (note.isNull("parent_revision_id")) null else note.getString("parent_revision_id"), + if (note.isNull("value")) null else note.getString("value")) + } + private fun buildMobileExport(version: Int): String { - require(version in 1..3) + require(version in 1..4) val root = JSONObject() root.put("format", "trainlog-mobile-export") root.put("version", version) @@ -3262,7 +3473,7 @@ class TrainlogRepository( /* CONTRACT: only completed `sessions` are part of mobile export v1; * active draft tables are intentionally outside the frozen artifact. */ db.rawQuery( - "SELECT id, session_id, started_at, session_type FROM sessions ORDER BY started_at ASC, id ASC;", + "SELECT id,session_id,started_at,session_type,ended_at,notes FROM sessions ORDER BY started_at ASC,id ASC;", null, ).use { sessions -> while (sessions.moveToNext()) { @@ -3270,19 +3481,25 @@ class TrainlogRepository( val startedAt = sessions.getString(2) /* CONTRACT: current V3 publication admits only the exact * Trainlog timestamp language consumed by later analysis. */ - check(version != 3 || TrainlogTimestamp.parse(startedAt) != null) { + check(version < 3 || TrainlogTimestamp.parse(startedAt) != null) { "started_at persistant invalide pour session_id=${sessions.getString(1)}" } val session = JSONObject() .put("session_id", sessions.getString(1)) .put("started_at", startedAt) .put("session_type", sessions.getString(3)) + if (version == 4) { + if (sessions.isNull(4)) session.put("ended_at", JSONObject.NULL) + else session.put("ended_at", sessions.getString(4)) + session.put("note", syncNotePayload(db, "session", sessions.getString(1), + if (sessions.isNull(5)) null else sessions.getString(5))) + } val sessionExercises = JSONArray() db.rawQuery( "SELECT se.id, e.exercise_id, e.name, se.recording_mode, se.tracking_mode, se.data_fields, " + "se.entry_id,se.position,eq.equipment_id,mr.max_weight_kg," + "se.load_mode,se.rest_seconds,se.target_sets,se.target_reps," + - "se.target_duration_seconds,se.target_weight_kg " + + "se.target_duration_seconds,se.target_weight_kg,se.notes " + "FROM session_exercises AS se JOIN exercises AS e ON e.id = se.exercise_row_id " + "LEFT JOIN equipment AS eq ON eq.id=se.equipment_row_id " + "LEFT JOIN max_results AS mr ON mr.session_exercise_row_id=se.id " + @@ -3319,15 +3536,15 @@ class TrainlogRepository( .put("recording_mode", recording) .put("tracking_mode", tracking) .put("data_fields", exerciseCursor.getInt(5)) - .put("load_mode", if (version == 3) exerciseCursor.getString(10) else "none") - .put("rest_seconds", if (version == 3) exerciseCursor.getInt(11) else 0) + .put("load_mode", if (version >= 3) exerciseCursor.getString(10) else "none") + .put("rest_seconds", if (version >= 3) exerciseCursor.getInt(11) else 0) if (version == 2) { item.put("entry_id", exerciseCursor.getString(6)) item.put("position", exerciseCursor.getInt(7)) if (exerciseCursor.isNull(8)) item.put("equipment_id", JSONObject.NULL) else item.put("equipment_id", exerciseCursor.getString(8)) } - if (version == 3) { + if (version >= 3) { item.put("entry_id", exerciseCursor.getString(6)) item.put("position", exerciseCursor.getInt(7)) if (exerciseCursor.isNull(8)) item.put("equipment_id", JSONObject.NULL) @@ -3343,6 +3560,10 @@ class TrainlogRepository( item.put("target", target) } } + if (version == 4) { + item.put("note", syncNotePayload(db, "occurrence", exerciseCursor.getString(6), + if (exerciseCursor.isNull(16)) null else exerciseCursor.getString(16))) + } if (!exerciseCursor.isNull(9)) { /* TRAINLOG_FORMAT_V1 is frozen and has no max * result shape. Refuse instead of inventing 1x1. */ @@ -3406,9 +3627,10 @@ class TrainlogRepository( val bodyArray = JSONArray() db.rawQuery( - "SELECT observation_id, observed_at, body_weight_kg, neck_cm, shoulders_cm, chest_cm, waist_cm, hips_cm, " + - "left_arm_cm, right_arm_cm, left_forearm_cm, right_forearm_cm, left_thigh_cm, right_thigh_cm, left_calf_cm, right_calf_cm " + - "FROM body_observations ORDER BY observed_at ASC, id ASC;", + "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," + + "s.session_id,bo.notes FROM body_observations bo LEFT JOIN sessions s ON s.id=bo.session_row_id " + + "ORDER BY bo.observed_at ASC,bo.id ASC;", null, ).use { cursor -> val names = arrayOf( @@ -3418,7 +3640,7 @@ class TrainlogRepository( ) while (cursor.moveToNext()) { val observedAt = cursor.getString(1) - check(version != 3 || TrainlogTimestamp.parse(observedAt) != null) { + check(version < 3 || TrainlogTimestamp.parse(observedAt) != null) { "observed_at persistant invalide pour observation_id=${cursor.getString(0)}" } val item = JSONObject() @@ -3430,6 +3652,12 @@ class TrainlogRepository( item.put(names[index], cursor.getDouble(column)) } } + if (version == 4) { + if (cursor.isNull(16)) item.put("session_id", JSONObject.NULL) + else item.put("session_id", cursor.getString(16)) + item.put("note", syncNotePayload(db, "observation", cursor.getString(0), + if (cursor.isNull(17)) null else cursor.getString(17))) + } bodyArray.put(item) } } @@ -3447,6 +3675,269 @@ class TrainlogRepository( /** Current occurrence exchange; planning and actual rows travel atomically. */ fun buildMobileExportV3Json(): String = buildMobileExport(3) + /** Explicit staged enriched-history codec; normal synchronization remains V3. */ + fun buildMobileExportV4Json(): String = buildMobileExport(4) + + /** Deterministic staged lifecycle artifact; raw form/UI columns are excluded. */ + fun buildExecutionDraftExportV1Json(): String { + val db = database.readableDatabase + val root = JSONObject().put("format", "trainlog-execution-drafts").put("version", 1) + .put("generated_at", OffsetDateTime.now().toString()) + val drafts = JSONArray() + db.rawQuery( + "SELECT session_id,session_type,source_session_id,started_at,revision_id,parent_revision_id,notes " + + "FROM active_session_draft WHERE id=1;", null, + ).use { header -> if (header.moveToFirst()) { + val sessionId = checkNotNull(if (header.isNull(0)) null else header.getString(0)) + drafts.put(buildExecutionDraftJson(db, sessionId, header.getString(1), + if (header.isNull(2)) null else header.getString(2), + if (header.isNull(3)) null else header.getString(3), header.getString(4), + if (header.isNull(5)) null else header.getString(5), + if (header.isNull(6)) null else header.getString(6), "active")) + } } + db.rawQuery("SELECT payload_json FROM execution_draft_candidates ORDER BY session_id;", null).use { cursor -> + while (cursor.moveToNext()) drafts.put(JSONObject(cursor.getString(0)).put("state", "pending")) + } + return root.put("drafts", drafts).toString() + } + + private fun buildExecutionDraftJson( + db: SQLiteDatabase, sessionId: String, type: String, sourceId: String?, startedAt: String?, + revision: String, parentRevision: String?, sessionNote: String?, state: String, + ): JSONObject { + val draft = JSONObject().put("session_id", sessionId).put("session_type", type) + .put("source_session_id", sourceId ?: JSONObject.NULL) + .put("started_at", startedAt ?: JSONObject.NULL).put("revision_id", revision) + .put("parent_revision_id", parentRevision ?: JSONObject.NULL).put("state", state) + .put("note", syncNotePayload(db, "session", sessionId, sessionNote)) + val entries = JSONArray() + db.rawQuery( + "SELECT de.id,de.entry_id,de.position,e.exercise_id,eq.equipment_id,de.recording_mode,de.tracking_mode," + + "de.data_fields,de.load_mode,de.rest_seconds,de.target_sets,de.target_reps,de.target_duration_seconds," + + "de.target_weight_kg,de.notes,mr.max_weight_kg FROM draft_session_exercises de " + + "JOIN exercises e ON e.id=de.exercise_row_id LEFT JOIN equipment eq ON eq.id=de.equipment_row_id " + + "LEFT JOIN draft_max_results mr ON mr.draft_exercise_row_id=de.id WHERE de.draft_id=1 ORDER BY de.position;", + null, + ).use { cursor -> while (cursor.moveToNext()) { + val rowId = cursor.getLong(0); val entryId = cursor.getString(1) + val item = JSONObject().put("entry_id", entryId).put("position", cursor.getInt(2)) + .put("exercise_id", cursor.getString(3)).put("equipment_id", if (cursor.isNull(4)) JSONObject.NULL else cursor.getString(4)) + .put("recording_mode", cursor.getString(5)).put("tracking_mode", cursor.getString(6)) + .put("data_fields", cursor.getInt(7)).put("load_mode", cursor.getString(8)).put("rest_seconds", cursor.getInt(9)) + if (cursor.isNull(10)) item.put("target", JSONObject.NULL) else item.put("target", JSONObject().put("sets", cursor.getInt(10)).also { target -> + if (!cursor.isNull(11)) target.put("reps", cursor.getInt(11)); if (!cursor.isNull(12)) target.put("duration_seconds", cursor.getInt(12)) + if (!cursor.isNull(13)) target.put("weight_kg", cursor.getDouble(13)) }) + item.put("note", syncNotePayload(db, "occurrence", entryId, if (cursor.isNull(14)) null else cursor.getString(14))) + item.put("sets", JSONObject.NULL).put("continuous", JSONObject.NULL).put("max_weight_kg", JSONObject.NULL) + if (!cursor.isNull(15)) item.put("max_weight_kg", cursor.getDouble(15)) + else if (cursor.getString(5) == "continuous") db.rawQuery( + "SELECT duration_seconds,speed_kmh,distance_km FROM draft_continuous_activity WHERE draft_exercise_row_id=?", + arrayOf(rowId.toString())).use { c -> if (c.moveToFirst()) item.put("continuous", JSONObject() + .put("duration_seconds", c.getInt(0)).also { value -> if (!c.isNull(1)) value.put("speed_kmh", c.getDouble(1)); if (!c.isNull(2)) value.put("distance_km", c.getDouble(2)) }) } + else item.put("sets", JSONArray().also { sets -> db.rawQuery( + "SELECT reps,duration_seconds,weight_kg FROM draft_performed_sets WHERE draft_exercise_row_id=? ORDER BY position", + arrayOf(rowId.toString())).use { values -> while (values.moveToNext()) sets.put(JSONObject().also { set -> + if (!values.isNull(0)) set.put("reps", values.getInt(0)) else set.put("duration_seconds", values.getInt(1)); if (!values.isNull(2)) set.put("weight_kg", values.getDouble(2)) }) } }) + item.put("feedback", JSONArray().also { feedback -> db.rawQuery( + "SELECT feedback_id,observed_at,raw_text FROM draft_exercise_feedback WHERE draft_session_exercise_row_id=? ORDER BY feedback_id", + arrayOf(rowId.toString())).use { f -> while (f.moveToNext()) { + val feedbackId = f.getString(0) + feedback.put(JSONObject().put("feedback_id", feedbackId).put("observed_at", f.getString(1)) + .put("raw_text", f.getString(2)).put("revisions", JSONArray().also { revisions -> db.rawQuery( + "SELECT revision_id,created_at,raw_text FROM draft_exercise_feedback_revisions WHERE feedback_id=? ORDER BY created_at,revision_id", + arrayOf(feedbackId)).use { r -> while (r.moveToNext()) revisions.put(JSONObject() + .put("revision_id", r.getString(0)).put("created_at", r.getString(1)).put("raw_text", r.getString(2))) } })) + } } }) + entries.put(item) + } } + return draft.put("entries", entries) + } + + /** Validate and persist staged execution drafts without selecting transport. */ + fun applyExecutionDraftExportV1Json(json: String): ExecutionDraftImportResult { + if (json.toByteArray(StandardCharsets.UTF_8).size > 4 * 1024 * 1024 || !hasStrictJsonShape(json)) + return ExecutionDraftImportResult.Invalid("Execution-draft JSON is invalid or oversized.") + val root = try { JSONObject(json) } catch (_: Exception) { + return ExecutionDraftImportResult.Invalid("Execution-draft JSON is invalid.") + } + val rootKeys = setOf("format", "version", "generated_at", "drafts") + if (!root.hasExactKeys(rootKeys) || root.optString("format") != "trainlog-execution-drafts" || + !root.value("version").isJsonInt(1, 1) || TrainlogTimestamp.parse(root.optString("generated_at")) == null) + return ExecutionDraftImportResult.Invalid("Unsupported execution-draft artifact.") + val drafts = root.optJSONArray("drafts") ?: return ExecutionDraftImportResult.Invalid("drafts must be an array.") + if (drafts.length() > MAX_PENDING_EXECUTION_DRAFTS) return ExecutionDraftImportResult.CapacityExceeded + val parsed = mutableListOf>() + val ids = mutableSetOf() + try { + for (index in 0 until drafts.length()) { + val item = drafts.getJSONObject(index) + val parsedDraft = parseExecutionDraft(item) + val sessionId = checkNotNull(parsedDraft.sessionId) + if (!ids.add(sessionId)) return ExecutionDraftImportResult.Invalid("Duplicate draft session_id.") + parsed += item to parsedDraft + } + } catch (error: Exception) { + return ExecutionDraftImportResult.Invalid(error.message ?: "Invalid execution draft.") + } + val db = database.writableDatabase + var active = 0; var pending = 0; var unchanged = 0; var stale = 0 + return try { + db.beginTransaction() + for ((payload, draft) in parsed) { + val sessionId = checkNotNull(draft.sessionId); val encoded = payload.toString() + if (db.rawQuery("SELECT 1 FROM execution_draft_finalizations WHERE session_id=?", + arrayOf(sessionId)).use { it.moveToFirst() }) { stale += 1; continue } + val current = db.rawQuery( + "SELECT revision_id,'active' FROM active_session_draft WHERE session_id=? UNION ALL " + + "SELECT revision_id,'pending' FROM execution_draft_candidates WHERE session_id=?;", + arrayOf(sessionId, sessionId), + ).use { if (it.moveToFirst()) Pair(it.getString(0), it.getString(1)) else null } + val known = db.rawQuery( + "SELECT parent_revision_id,payload_json FROM execution_draft_revisions WHERE session_id=? AND revision_id=?;", + arrayOf(sessionId, draft.revisionId), + ).use { if (!it.moveToFirst()) null else Pair(if (it.isNull(0)) null else it.getString(0), it.getString(1)) } + if (known != null) { + if (known != Pair(draft.parentRevisionId, encoded)) throw SyncLifecycleConflict(sessionId) + if (current?.first == draft.revisionId) unchanged += 1 else stale += 1 + continue + } + if (current != null && current.first != draft.parentRevisionId) throw SyncLifecycleConflict(sessionId) + if (current?.second == "active") { + persistImportedExecutionDraft(db, draft, payload); active += 1 + } else if (current?.second == "pending") { + db.update("execution_draft_candidates", ContentValues().apply { + put("revision_id", draft.revisionId); putOptionalString("parent_revision_id", draft.parentRevisionId) + put("payload_json", encoded) + }, "session_id=?", arrayOf(sessionId)); pending += 1 + } else { + val occupied = db.rawQuery("SELECT 1 FROM active_session_draft WHERE id=1", null).use { it.moveToFirst() } + if (!occupied) { persistImportedExecutionDraft(db, draft, payload); active += 1 } + else { + val count = db.rawQuery("SELECT COUNT(*) FROM execution_draft_candidates", null).use { it.moveToFirst(); it.getInt(0) } + if (count >= MAX_PENDING_EXECUTION_DRAFTS) return ExecutionDraftImportResult.CapacityExceeded + db.insertOrThrow("execution_draft_candidates", null, ContentValues().apply { + put("session_id", sessionId); put("session_type", draft.sessionType.wireValue) + putOptionalString("source_session_id", draft.sourceSessionId); putOptionalString("started_at", draft.startedAt) + put("revision_id", draft.revisionId); putOptionalString("parent_revision_id", draft.parentRevisionId) + put("payload_json", encoded); put("received_at", OffsetDateTime.now().toString()) + }); pending += 1 + } + } + db.insertOrThrow("execution_draft_revisions", null, ContentValues().apply { + put("session_id", sessionId); put("revision_id", draft.revisionId) + putOptionalString("parent_revision_id", draft.parentRevisionId); put("payload_json", encoded) + }) + } + db.setTransactionSuccessful() + ExecutionDraftImportResult.Applied(active, pending, unchanged, stale) + } catch (conflict: SyncLifecycleConflict) { ExecutionDraftImportResult.Conflict(conflict.ownerId) + } catch (_: Exception) { ExecutionDraftImportResult.DatabaseError + } finally { db.endTransaction() } + } + + fun activatePendingExecutionDraft(sessionId: String): ActivateExecutionDraftResult { + val db = database.writableDatabase + return try { + db.beginTransaction() + if (db.rawQuery("SELECT 1 FROM active_session_draft WHERE id=1", null).use { it.moveToFirst() }) + return ActivateExecutionDraftResult.ActiveDraftOccupied + val payload = db.rawQuery("SELECT payload_json FROM execution_draft_candidates WHERE session_id=?", + arrayOf(sessionId)).use { if (it.moveToFirst()) JSONObject(it.getString(0)) else null } + ?: return ActivateExecutionDraftResult.NotFound + val draft = parseExecutionDraft(payload) + persistImportedExecutionDraft(db, draft, payload) + db.delete("execution_draft_candidates", "session_id=?", arrayOf(sessionId)) + db.setTransactionSuccessful(); ActivateExecutionDraftResult.Activated + } catch (error: IllegalArgumentException) { ActivateExecutionDraftResult.Invalid(error.message ?: "Invalid candidate") + } catch (_: Exception) { ActivateExecutionDraftResult.DatabaseError + } finally { db.endTransaction() } + } + + private fun parseExecutionDraft(item: JSONObject): ActiveSessionDraft { + val keys = setOf("session_id", "session_type", "source_session_id", "started_at", "revision_id", + "parent_revision_id", "state", "note", "entries") + require(item.hasExactKeys(keys)) { "Execution draft has unknown or missing fields." } + val sessionId = item.getString("session_id"); require(sessionId.isNotBlank()) + val revision = item.getString("revision_id"); require(revision.isNotBlank()) + val parent = if (item.isNull("parent_revision_id")) null else item.getString("parent_revision_id") + require(parent != revision && validSyncNote(item.get("note"))) + val startedAt = if (item.isNull("started_at")) null else item.getString("started_at").also { + require(TrainlogTimestamp.parse(it) != null) } + val catalog = listExercises().associateBy { it.exerciseId } + val entriesJson = item.getJSONArray("entries"); require(entriesJson.length() <= 64) + val identities = mutableSetOf(); val positions = mutableSetOf() + val entries = (0 until entriesJson.length()).map { index -> + val entry = entriesJson.getJSONObject(index); val entryId = entry.getString("entry_id") + require(entry.hasExactKeys(setOf("entry_id", "position", "exercise_id", "equipment_id", + "recording_mode", "tracking_mode", "data_fields", "load_mode", "rest_seconds", + "target", "sets", "continuous", "max_weight_kg", "note", "feedback"))) + require(validSyncNote(entry.get("note"))) + val position = entry.getInt("position"); require(identities.add(entryId) && positions.add(position)) + val exercise = catalog[resolveExerciseId(database.readableDatabase, entry.getString("exercise_id"))] + ?: error("Unknown exercise in execution draft.") + val target = entry.optJSONObject("target") + val plan = target?.let { SessionExercisePlan(it.getInt("sets"), it.optIntOrNull("reps"), + it.optIntOrNull("duration_seconds"), it.optDoubleOrNull("weight_kg"), + SessionLoadMode.fromWire(entry.getString("load_mode")), entry.getInt("rest_seconds")) } + val sets = if (entry.isNull("sets")) emptyList() else (0 until entry.getJSONArray("sets").length()).map { setIndex -> + val set = entry.getJSONArray("sets").getJSONObject(setIndex) + SessionSetDraft(set.optInt("reps", 0), set.optInt("duration_seconds", 0), set.optDoubleOrNull("weight_kg")) } + val continuous = if (entry.isNull("continuous")) null else entry.getJSONObject("continuous") + val feedback = entry.getJSONArray("feedback"); require(feedback.length() <= 256) + for (feedbackIndex in 0 until feedback.length()) { + val value = feedback.getJSONObject(feedbackIndex) + require(value.hasExactKeys(setOf("feedback_id", "observed_at", "raw_text", "revisions"))) + require(value.getString("feedback_id").isNotBlank() && TrainlogTimestamp.parse(value.getString("observed_at")) != null) + require(value.getString("raw_text").toByteArray(StandardCharsets.UTF_8).size <= MAX_FEEDBACK_UTF8_BYTES) + val revisions = value.getJSONArray("revisions") + for (revisionIndex in 0 until revisions.length()) { + val revisionValue = revisions.getJSONObject(revisionIndex) + require(revisionValue.hasExactKeys(setOf("revision_id", "created_at", "raw_text")) && + TrainlogTimestamp.parse(revisionValue.getString("created_at")) != null) + } + } + SessionExerciseDraft(entryId = entryId, exercise = exercise, + equipmentId = if (entry.isNull("equipment_id")) null else entry.getString("equipment_id"), + maxWeightKg = if (entry.isNull("max_weight_kg")) null else entry.getDouble("max_weight_kg"), + plan = plan, sets = sets, continuousDurationSeconds = continuous?.getInt("duration_seconds") ?: 0, + speedKmh = continuous?.optDoubleOrNull("speed_kmh"), + distanceKm = continuous?.optDoubleOrNull("distance_km")) + }.sortedBy { entry -> entriesJson.getJSONObject((0 until entriesJson.length()).first { entriesJson.getJSONObject(it).getString("entry_id") == entry.entryId }).getInt("position") } + return ActiveSessionDraft(sessionId = sessionId, revisionId = revision, + parentRevisionId = parent, startedAt = startedAt, exercises = entries, + sessionType = SessionType.fromWire(item.getString("session_type")), + sourceSessionId = if (item.isNull("source_session_id")) null else item.getString("source_session_id")) + } + + private fun persistImportedExecutionDraft(db: SQLiteDatabase, draft: ActiveSessionDraft, payload: JSONObject) { + persistActiveSessionDraft(db, draft) + val sessionId = checkNotNull(draft.sessionId) + applySyncNote(db, "session", sessionId, payload.get("note"), "active_session_draft", ACTIVE_DRAFT_ID.toLong()) + val entries = payload.getJSONArray("entries") + for (index in 0 until entries.length()) { + val item = entries.getJSONObject(index); val entryId = item.getString("entry_id") + val rowId = db.rawQuery("SELECT id FROM draft_session_exercises WHERE entry_id=?", arrayOf(entryId)).use { + check(it.moveToFirst()); it.getLong(0) } + applySyncNote(db, "occurrence", entryId, item.get("note"), "draft_session_exercises", rowId) + val feedback = item.getJSONArray("feedback") + for (feedbackIndex in 0 until feedback.length()) { + val value = feedback.getJSONObject(feedbackIndex) + db.insertWithOnConflict("draft_exercise_feedback", null, ContentValues().apply { + put("feedback_id", value.getString("feedback_id")); put("draft_session_exercise_row_id", rowId) + put("observed_at", value.getString("observed_at")); put("raw_text", value.getString("raw_text")) + }, SQLiteDatabase.CONFLICT_IGNORE) + val revisions = value.getJSONArray("revisions") + for (revisionIndex in 0 until revisions.length()) { + val revision = revisions.getJSONObject(revisionIndex) + db.insertWithOnConflict("draft_exercise_feedback_revisions", null, ContentValues().apply { + put("revision_id", revision.getString("revision_id")); put("feedback_id", value.getString("feedback_id")) + put("created_at", revision.getString("created_at")); put("raw_text", revision.getString("raw_text")) + }, SQLiteDatabase.CONFLICT_IGNORE) + } + } + } + } + /** Apply the same V2 session artifact emitted by desktop, keyed by entry_id. */ fun applyPcMobileExportV2Json(json: String): MobileSessionImportResult { return applyPcMobileExportJson(json, 2) @@ -3455,6 +3946,10 @@ class TrainlogRepository( fun applyPcMobileExportV3Json(json: String): MobileSessionImportResult = applyPcMobileExportJson(json, 3) + /** Explicit staged V4 reader; normal inbox selection remains V3. */ + fun applyPcMobileExportV4Json(json: String): MobileSessionImportResult = + applyPcMobileExportJson(json, 4) + private fun applyPcMobileExportJson(json: String, version: Int): MobileSessionImportResult { if (!hasStrictJsonShape(json)) { return MobileSessionImportResult.Invalid("Snapshot séances JSON invalide ou champ dupliqué.") @@ -3506,7 +4001,7 @@ class TrainlogRepository( } /* V2 has no complete planning authority; it must never * erase V3 targets while applying a correction. */ - if (version != 3 && session.optString("session_type") != "max_test") return MobileSessionImportResult.Invalid( + if (version < 3 && session.optString("session_type") != "max_test") return MobileSessionImportResult.Invalid( "Une correction de séance exige le snapshot V3 complet." ) if (!pcCompletedCorrectionIsSafe(db, existingRowId, session)) { @@ -3528,9 +4023,26 @@ class TrainlogRepository( sessionsUpdated += 1 } val rowId = existingRowId ?: run { - val values = ContentValues().apply { put("session_id", sessionId); put("started_at", startedAt); put("session_type", type) } + val values = ContentValues().apply { + put("session_id", sessionId); put("started_at", startedAt); put("session_type", type) + if (version == 4 && !session.isNull("ended_at")) put("ended_at", session.getString("ended_at")) + } db.insertOrThrow("sessions", null, values) } + if (version == 4) { + val currentEnd = db.rawQuery("SELECT ended_at FROM sessions WHERE id=?", + arrayOf(rowId.toString())).use { cursor -> + cursor.moveToFirst(); if (cursor.isNull(0)) null else cursor.getString(0) + } + val incomingEnd = if (session.isNull("ended_at")) null else session.getString("ended_at") + if (currentEnd == null && incomingEnd != null) { + db.update("sessions", ContentValues().apply { put("ended_at", incomingEnd) }, + "id=?", arrayOf(rowId.toString())) + } else if (currentEnd != null && incomingEnd != null && currentEnd != incomingEnd) { + throw SyncLifecycleConflict(sessionId) + } + applySyncNote(db, "session", sessionId, session.get("note"), "sessions", rowId) + } val seen = mutableSetOf() for (index in 0 until entries.length()) { val entry = entries.getJSONObject(index) @@ -3577,6 +4089,10 @@ class TrainlogRepository( if (equipmentRowId == null) putNull("equipment_row_id") else put("equipment_row_id", equipmentRowId) } val occurrence = db.insertOrThrow("session_exercises", null, values) + if (version == 4) { + applySyncNote(db, "occurrence", entryId, entry.get("note"), + "session_exercises", occurrence) + } /* INVARIANT: correction transport follows session_id + * entry_id. Retained observations keep all immutable * revisions; an omitted entry intentionally inherits none. */ @@ -3629,11 +4145,22 @@ class TrainlogRepository( if (id.isBlank() || observedAt.isBlank() || metrics.none { item.has(it) }) { return MobileSessionImportResult.Invalid("Observation corporelle V2 invalide.") } + val parentRowId = if (version == 4 && !item.isNull("session_id")) db.rawQuery( + "SELECT id FROM sessions WHERE session_id=?", arrayOf(item.getString("session_id")), + ).use { cursor -> if (cursor.moveToFirst()) cursor.getLong(0) else throw SyncLifecycleConflict(id) } + else null + var existingRowId: Long? = null + var existingParent: Long? = null val existing = db.rawQuery( - "SELECT observed_at,${metrics.joinToString(",")} FROM body_observations WHERE observation_id=?", + "SELECT id,observed_at,${metrics.joinToString(",")},session_row_id FROM body_observations WHERE observation_id=?", arrayOf(id)).use { cursor -> - if (!cursor.moveToFirst()) null else List(1 + metrics.size) { column -> - if (cursor.isNull(column)) null else if (column == 0) cursor.getString(column) else cursor.getDouble(column) + if (!cursor.moveToFirst()) null else { + existingRowId = cursor.getLong(0) + existingParent = if (cursor.isNull(2 + metrics.size)) null else cursor.getLong(2 + metrics.size) + List(1 + metrics.size) { column -> + val actual = column + 1 + if (cursor.isNull(actual)) null else if (column == 0) cursor.getString(actual) else cursor.getDouble(actual) + } } } val expected: List = listOf(observedAt) + metrics.map { metric -> @@ -3641,13 +4168,25 @@ class TrainlogRepository( } if (existing != null) { if (existing != expected) return MobileSessionImportResult.Invalid("Conflit observation corporelle : $id") + if (version == 4) { + if (existingParent == null && parentRowId != null) db.update( + "body_observations", ContentValues().apply { put("session_row_id", parentRowId) }, + "id=?", arrayOf(existingRowId.toString())) + else if (existingParent != null && parentRowId != null && existingParent != parentRowId) + throw SyncLifecycleConflict(id) + applySyncNote(db, "observation", id, item.get("note"), + "body_observations", checkNotNull(existingRowId)) + } bodyObservationsSkipped += 1 continue } - db.insertOrThrow("body_observations", null, ContentValues().apply { + val bodyRowId = db.insertOrThrow("body_observations", null, ContentValues().apply { put("observation_id", id); put("observed_at", observedAt) + if (parentRowId != null) put("session_row_id", parentRowId) metrics.forEach { metric -> if (item.has(metric)) put(metric, item.getDouble(metric)) } }) + if (version == 4) applySyncNote(db, "observation", id, item.get("note"), + "body_observations", bodyRowId) bodyObservationsAdded += 1 } db.setTransactionSuccessful() @@ -3658,6 +4197,8 @@ class TrainlogRepository( bodyObservationsSkipped = bodyObservationsSkipped, sessionsUpdated = sessionsUpdated, ) + } catch (conflict: SyncLifecycleConflict) { + MobileSessionImportResult.Invalid("Lifecycle conflict: ${conflict.ownerId}") } catch (_: Exception) { MobileSessionImportResult.DatabaseError } finally { db.endTransaction() } } @@ -3670,7 +4211,7 @@ class TrainlogRepository( root.value("body_observations") !is JSONArray) { return "Snapshot séances V2 invalide." } - if (version == 3 && TrainlogTimestamp.parse(root.getString("generated_at")) == null) { + if (version >= 3 && TrainlogTimestamp.parse(root.getString("generated_at")) == null) { return "Snapshot séances V3 invalide: generated_at invalide." } return try { @@ -3686,7 +4227,8 @@ class TrainlogRepository( } } - val sessionKeys = setOf("session_id", "started_at", "session_type", "exercises") + val sessionKeys = setOf("session_id", "started_at", "session_type", "exercises") + + if (version == 4) setOf("ended_at", "note") else emptySet() val entryBaseKeys = setOf("exercise_id", "name", "recording_mode", "tracking_mode", "data_fields", "load_mode", "rest_seconds", "entry_id", "position", "equipment_id") val sessionIds = mutableSetOf() @@ -3700,9 +4242,17 @@ class TrainlogRepository( session.value("session_type") !in setOf("training", "max_test") || entries == null || entries.length() == 0) { return "Session V2 invalide." } - if (version == 3 && TrainlogTimestamp.parse(session.getString("started_at")) == null) { + if (version >= 3 && TrainlogTimestamp.parse(session.getString("started_at")) == null) { return "Session V3 invalide: sessions[$sessionIndex].started_at invalide." } + if (version == 4 && (!validSyncNote(session.value("note")) || + !(session.value("ended_at") === JSONObject.NULL || + session.value("ended_at") is String && + TrainlogTimestamp.parse(session.getString("ended_at")) != null && + TrainlogTimestamp.parse(session.getString("ended_at"))!! > + TrainlogTimestamp.parse(session.getString("started_at"))!!))) { + return "Session V4 lifecycle invalide." + } val entryIds = mutableSetOf() val positions = mutableSetOf() for (entryIndex in 0 until entries.length()) { @@ -3710,7 +4260,8 @@ class TrainlogRepository( val recording = entry.value("recording_mode") val tracking = entry.value("tracking_mode") val hasMax = entry.has("max_weight_kg") - val expectedKeys = entryBaseKeys + (if (version == 3) setOf("target") else emptySet()) + when { + val expectedKeys = entryBaseKeys + (if (version >= 3) setOf("target") else emptySet()) + + (if (version == 4) setOf("note") else emptySet()) + when { hasMax -> setOf("max_weight_kg") recording == "continuous" -> setOf("continuous") else -> setOf("sets") @@ -3733,6 +4284,7 @@ class TrainlogRepository( if (!validEntryPlan(entry, version, recording as String, tracking as String, hasMax)) { return "Plan/payload historique V2 incompatible avec son profil." } + if (version == 4 && !validSyncNote(entry.value("note"))) return "Note d'occurrence V4 invalide." if (hasMax && (session.getString("session_type") != "max_test" || !entry.value("max_weight_kg").isPositiveJsonNumber())) { @@ -3780,14 +4332,20 @@ class TrainlogRepository( val item = body.opt(index) as? JSONObject ?: return "Observation corporelle V2 invalide." val id = item.value("observation_id") val presentMetrics = item.keys().asSequence().toSet() intersect metricKeys - if (!item.hasOnlyKeys(metricKeys + setOf("observation_id", "observed_at"), setOf("observation_id", "observed_at")) || + val bodyExtra = if (version == 4) setOf("session_id", "note") else emptySet() + if (!item.hasOnlyKeys(metricKeys + setOf("observation_id", "observed_at") + bodyExtra, + setOf("observation_id", "observed_at") + bodyExtra) || !id.isNonemptyJsonString() || !bodyIds.add(id as String) || !item.value("observed_at").isNonemptyJsonString() || presentMetrics.isEmpty() || presentMetrics.any { !item.value(it).isPositiveJsonNumber() }) { return "Observation corporelle V2 invalide." } - if (version == 3 && TrainlogTimestamp.parse(item.getString("observed_at")) == null) { + if (version >= 3 && TrainlogTimestamp.parse(item.getString("observed_at")) == null) { return "Observation corporelle V3 invalide: body_observations[$index].observed_at invalide." } + if (version == 4 && (!validSyncNote(item.value("note")) || + !(item.value("session_id") === JSONObject.NULL || item.value("session_id").isNonemptyJsonString()))) { + return "Observation corporelle V4 lifecycle invalide." + } } null } catch (_: Exception) { @@ -3806,6 +4364,20 @@ class TrainlogRepository( !(recording == "sets" && (dataFields as Number).toInt() != 0) } + private fun validSyncNote(value: Any?): Boolean { + if (value === JSONObject.NULL) return true + val note = value as? JSONObject ?: return false + if (!note.hasExactKeys(setOf("value", "revision_id", "parent_revision_id"))) return false + val revision = note.value("revision_id") as? String ?: return false + if (revision.isEmpty() || revision.toByteArray(StandardCharsets.UTF_8).size > 128) return false + val parent = note.value("parent_revision_id") + if (!(parent === JSONObject.NULL || parent is String && parent.isNotEmpty() && + parent != revision && parent.toByteArray(StandardCharsets.UTF_8).size <= 128)) return false + val content = note.value("value") + return content === JSONObject.NULL || content is String && + content.toByteArray(StandardCharsets.UTF_8).size <= MAX_SYNC_NOTE_UTF8_BYTES + } + private fun hasStrictJsonShape(json: String): Boolean = try { JsonReader(StringReader(json)).use { reader -> reader.isLenient = false @@ -3894,10 +4466,13 @@ class TrainlogRepository( this is Number && toDouble().isFinite() && toDouble() >= 0.0 private fun pcSessionMatches(db: SQLiteDatabase, rowId: Long, session: JSONObject, version: Int): Boolean { - val headerMatches = db.rawQuery("SELECT started_at,session_type FROM sessions WHERE id=?", arrayOf(rowId.toString())).use { - it.moveToFirst() && it.getString(0) == session.optString("started_at") && it.getString(1) == session.optString("session_type") + val headerMatches = db.rawQuery("SELECT started_at,session_type,ended_at FROM sessions WHERE id=?", arrayOf(rowId.toString())).use { + it.moveToFirst() && it.getString(0) == session.optString("started_at") && it.getString(1) == session.optString("session_type") && + (version != 4 || (if (it.isNull(2)) null else it.getString(2)) == + (if (session.isNull("ended_at")) null else session.getString("ended_at"))) } if (!headerMatches) return false + if (version == 4 && !syncNoteIsCurrent(db, "session", session.getString("session_id"), session.get("note"))) return false val incoming = session.optJSONArray("exercises") ?: return false val rows = mutableListOf() val metadata = mutableListOf>() @@ -3912,7 +4487,7 @@ class TrainlogRepository( cursor.getString(5), cursor.getInt(6), if (cursor.isNull(7)) null else cursor.getString(7)) if (version == 2 && (cursor.getString(8) != "none" || cursor.getInt(9) != 0 || (10..13).any { !cursor.isNull(it) })) return false - if (version == 3) base.addAll(listOf(cursor.getString(8), cursor.getInt(9), + if (version >= 3) base.addAll(listOf(cursor.getString(8), cursor.getInt(9), if (cursor.isNull(10)) null else cursor.getInt(10), if (cursor.isNull(11)) null else cursor.getInt(11), if (cursor.isNull(12)) null else cursor.getInt(12), if (cursor.isNull(13)) null else cursor.getDouble(13))) metadata += base @@ -3925,13 +4500,14 @@ class TrainlogRepository( resolveExerciseId(db, item.optString("exercise_id")), item.optString("recording_mode"), item.optString("tracking_mode"), item.optInt("data_fields", -1), if (item.isNull("equipment_id")) null else item.optString("equipment_id")) - if (version == 3) { + if (version >= 3) { val target = item.optJSONObject("target") expected.addAll(listOf(item.getString("load_mode"), item.getInt("rest_seconds"), target?.getInt("sets"), target?.optIntOrNull("reps"), target?.optIntOrNull("duration_seconds"), target?.optDoubleOrNull("weight_kg"))) } if (metadata[index] != expected) return false + if (version == 4 && !syncNoteIsCurrent(db, "occurrence", item.getString("entry_id"), item.get("note"))) return false if (item.has("max_weight_kg")) { val current = db.rawQuery( "SELECT max_weight_kg FROM max_results WHERE session_exercise_row_id=?", @@ -5788,7 +6364,11 @@ class TrainlogRepository( e.normalized_name, e.recording_mode, e.tracking_mode, - e.data_fields + e.data_fields, + d.session_id, + d.revision_id, + d.parent_revision_id, + d.started_at FROM active_session_draft AS d LEFT JOIN exercises AS e ON e.id = d.selected_exercise_row_id @@ -5829,6 +6409,10 @@ class TrainlogRepository( distanceText = cursor.getString(5), ), sourceSessionId = if (cursor.isNull(11)) null else cursor.getString(11), + sessionId = if (cursor.isNull(18)) null else cursor.getString(18), + revisionId = cursor.getString(19), + parentRevisionId = if (cursor.isNull(20)) null else cursor.getString(20), + startedAt = if (cursor.isNull(21)) null else cursor.getString(21), updatedAt = cursor.getString(6), warning = @@ -6005,6 +6589,10 @@ class TrainlogRepository( return ActiveDraftRestore( draft = ActiveSessionDraft( + sessionId = header.sessionId, + revisionId = header.revisionId, + parentRevisionId = header.parentRevisionId, + startedAt = header.startedAt, exercises = exercises, sessionType = header.sessionType, sourceSessionId = header.sourceSessionId, @@ -6031,6 +6619,10 @@ class TrainlogRepository( val values = ContentValues().apply { put("session_type", draft.sessionType.wireValue) + draft.sessionId?.let { put("session_id", it) } + draft.startedAt?.let { put("started_at", it) } + put("revision_id", draft.revisionId) + putOptionalString("parent_revision_id", draft.parentRevisionId) putOptionalString("source_session_id", draft.sourceSessionId) putOptionalString("selected_equipment_id", draft.form.selectedEquipmentId) put("weight_text", draft.form.weightText) @@ -6063,6 +6655,8 @@ class TrainlogRepository( if (updated == 0) { values.put("id", ACTIVE_DRAFT_ID) + if (draft.sessionId == null) values.put("session_id", "se_${UUID.randomUUID()}") + if (draft.startedAt == null) values.put("started_at", now) db.insertOrThrow( "active_session_draft", null, @@ -6232,18 +6826,20 @@ class TrainlogRepository( draft: SessionDraft, sourceSessionId: String? = null, generalCorrection: Boolean = false, + forcedSessionId: String? = null, + forcedStartedAt: String? = null, ): String { val sessionId: String val sessionRowId: Long val retainedCompletedFeedback = mutableMapOf>>() val retainedCompletedRevisions = mutableMapOf>>() if (sourceSessionId == null) { - sessionId = "se_" + UUID.randomUUID().toString() + sessionId = forcedSessionId ?: ("se_" + UUID.randomUUID().toString()) /* Preserve the existing Android meaning: started_at is assigned * when a new completed session is saved. */ val sessionValues = ContentValues().apply { put("session_id", sessionId) - put("started_at", OffsetDateTime.now().toString()) + put("started_at", forcedStartedAt ?: OffsetDateTime.now().toString()) /* CONTRACT: this exact explicit finalization instant anchors * H+; old NULL values remain unknown and are never backfilled. */ put("ended_at", OffsetDateTime.now().toString()) @@ -6529,6 +7125,10 @@ class TrainlogRepository( val sessionType: SessionType, val form: SessionDraftForm, val sourceSessionId: String?, + val sessionId: String?, + val revisionId: String, + val parentRevisionId: String?, + val startedAt: String?, val updatedAt: String, val warning: String?, ) @@ -6869,6 +7469,9 @@ private const val ANDROID_DATABASE_NAME = "trainlog-android.db" private const val ACTIVE_DRAFT_ID = 1 private const val MAX_DRAFT_FORM_TEXT_LENGTH = 4096 +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_PLAN_SETS = 64 private const val MAX_PLAN_REPS = 10000 private const val MAX_PLAN_DURATION_SECONDS = 86400 @@ -6910,7 +7513,7 @@ private class TrainlogDatabaseHelper( appContext, databaseName, null, - 17, + 18, ) { override fun onConfigure( db: SQLiteDatabase, @@ -6952,6 +7555,7 @@ private class TrainlogDatabaseHelper( createTrainingFeedbackRevisionTables(db) createExerciseProfileStateTable(db, seedLegacy = false) createAiSessionDraftTables(db) + createSyncDataLifecycleTables(db) seedEquipment(db) } @@ -7087,6 +7691,22 @@ private class TrainlogDatabaseHelper( version = 17 } + if (version < 18 && newVersion >= 18) { + /* WHY: an active capture needs one stable cross-device identity, + * while incoming candidates must not replace local raw input. + * CONTRACT: v18 is additive and assigns a missing active identity + * exactly once. INVARIANT: history, occurrence IDs, feedback, raw + * form strings, and AI proposals remain unchanged. */ + createSyncDataLifecycleTables(db) + db.execSQL( + "UPDATE active_session_draft SET session_id='se_' || lower(hex(randomblob(4))) || '-' || " + + "lower(hex(randomblob(2))) || '-4' || substr(lower(hex(randomblob(2))),2) || '-a' || " + + "substr(lower(hex(randomblob(2))),2) || '-' || lower(hex(randomblob(6))), " + + "revision_id='dr_legacy_v1' WHERE session_id IS NULL;", + ) + version = 18 + } + if (version != newVersion) { error( "Unsupported Android DB upgrade " + @@ -7095,6 +7715,61 @@ private class TrainlogDatabaseHelper( } } + private fun createSyncDataLifecycleTables(db: SQLiteDatabase) { + fun addColumn(table: String, name: String, declaration: String) { + if (!tableHasColumn(db, table, name)) { + db.execSQL("ALTER TABLE $table ADD COLUMN $name $declaration;") + } + } + addColumn("sessions", "notes", "TEXT CHECK(notes IS NULL OR length(CAST(notes AS BLOB))<=4096)") + addColumn("session_exercises", "notes", "TEXT CHECK(notes IS NULL OR length(CAST(notes AS BLOB))<=4096)") + addColumn("body_observations", "session_row_id", "INTEGER REFERENCES sessions(id) ON DELETE SET NULL") + addColumn("body_observations", "notes", "TEXT CHECK(notes IS NULL OR length(CAST(notes AS BLOB))<=4096)") + addColumn("active_session_draft", "session_id", "TEXT") + addColumn("active_session_draft", "started_at", "TEXT") + addColumn("active_session_draft", "revision_id", "TEXT NOT NULL DEFAULT 'dr_legacy_v1'") + addColumn("active_session_draft", "parent_revision_id", "TEXT") + addColumn("active_session_draft", "notes", "TEXT CHECK(notes IS NULL OR length(CAST(notes AS BLOB))<=4096)") + addColumn("draft_session_exercises", "notes", "TEXT CHECK(notes IS NULL OR length(CAST(notes AS BLOB))<=4096)") + db.execSQL( + "CREATE UNIQUE INDEX IF NOT EXISTS active_session_draft_session_v18 " + + "ON active_session_draft(session_id) WHERE session_id IS NOT NULL;", + ) + db.execSQL( + "CREATE UNIQUE INDEX IF NOT EXISTS body_observation_session_v18 " + + "ON body_observations(session_row_id) WHERE session_row_id IS NOT NULL;", + ) + db.execSQL( + "CREATE TABLE IF NOT EXISTS sync_note_revisions(" + + "owner_kind TEXT NOT NULL CHECK(owner_kind IN('session','occurrence','observation'))," + + "owner_id TEXT NOT NULL,revision_id TEXT NOT NULL,parent_revision_id TEXT,note TEXT," + + "CHECK(note IS NULL OR length(CAST(note AS BLOB))<=4096)," + + "PRIMARY KEY(owner_kind,owner_id,revision_id));", + ) + db.execSQL( + "CREATE TABLE IF NOT EXISTS sync_note_state(" + + "owner_kind TEXT NOT NULL,owner_id TEXT NOT NULL,revision_id TEXT NOT NULL," + + "PRIMARY KEY(owner_kind,owner_id)," + + "FOREIGN KEY(owner_kind,owner_id,revision_id) " + + "REFERENCES sync_note_revisions(owner_kind,owner_id,revision_id));", + ) + db.execSQL( + "CREATE TABLE IF NOT EXISTS execution_draft_candidates(" + + "session_id TEXT PRIMARY KEY,session_type TEXT NOT NULL CHECK(session_type IN('training','max_test'))," + + "source_session_id TEXT,started_at TEXT,revision_id TEXT NOT NULL,parent_revision_id TEXT," + + "payload_json TEXT NOT NULL,received_at TEXT NOT NULL);", + ) + db.execSQL( + "CREATE TABLE IF NOT EXISTS execution_draft_revisions(" + + "session_id TEXT NOT NULL,revision_id TEXT NOT NULL,parent_revision_id TEXT,payload_json TEXT NOT NULL," + + "PRIMARY KEY(session_id,revision_id));", + ) + db.execSQL( + "CREATE TABLE IF NOT EXISTS execution_draft_finalizations(" + + "session_id TEXT PRIMARY KEY,final_revision_id TEXT NOT NULL,finalized_at TEXT NOT NULL);", + ) + } + private fun createAiSessionDraftTables(db: SQLiteDatabase) { db.execSQL( "CREATE TABLE IF NOT EXISTS ai_session_drafts(" + @@ -7694,6 +8369,7 @@ private class TrainlogDatabaseHelper( session_id TEXT NOT NULL UNIQUE, started_at TEXT NOT NULL, ended_at TEXT, + notes TEXT CHECK(notes IS NULL OR length(CAST(notes AS BLOB)) <= 4096), session_type TEXT NOT NULL CHECK( session_type IN ( @@ -7749,6 +8425,7 @@ private class TrainlogDatabaseHelper( target_duration_seconds INTEGER CHECK(target_duration_seconds BETWEEN 1 AND 86400), target_weight_kg REAL CHECK(target_weight_kg > 0.0), + notes TEXT CHECK(notes IS NULL OR length(CAST(notes AS BLOB)) <= 4096), UNIQUE( session_row_id, position @@ -7874,6 +8551,9 @@ private class TrainlogDatabaseHelper( CHECK(left_calf_cm > 0.0), right_calf_cm REAL CHECK(right_calf_cm > 0.0), + session_row_id INTEGER UNIQUE + REFERENCES sessions(id) ON DELETE SET NULL, + notes TEXT CHECK(notes IS NULL OR length(CAST(notes AS BLOB)) <= 4096), CHECK( body_weight_kg IS NOT NULL OR neck_cm IS NOT NULL OR @@ -7925,7 +8605,12 @@ private class TrainlogDatabaseHelper( duration_text TEXT NOT NULL, speed_text TEXT NOT NULL, distance_text TEXT NOT NULL, - updated_at TEXT NOT NULL + updated_at TEXT NOT NULL, + started_at TEXT, + session_id TEXT UNIQUE, + revision_id TEXT NOT NULL DEFAULT 'dr_legacy_v1', + parent_revision_id TEXT, + notes TEXT CHECK(notes IS NULL OR length(CAST(notes AS BLOB)) <= 4096) ); """.trimIndent() ) @@ -7974,6 +8659,7 @@ private class TrainlogDatabaseHelper( target_duration_seconds INTEGER CHECK(target_duration_seconds BETWEEN 1 AND 86400), target_weight_kg REAL CHECK(target_weight_kg > 0.0), + notes TEXT CHECK(notes IS NULL OR length(CAST(notes AS BLOB)) <= 4096), UNIQUE(draft_id, position) ); """.trimIndent() diff --git a/android/app/src/main/java/com/labfytools/trainlog/model/SessionModels.kt b/android/app/src/main/java/com/labfytools/trainlog/model/SessionModels.kt index 55bd8ba..73a94e6 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/model/SessionModels.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/model/SessionModels.kt @@ -101,6 +101,13 @@ data class SessionDraftForm( * mistake unfinished capture for a completed session. */ data class ActiveSessionDraft( + /** Stable lifecycle identity, allocated once when first persisted. */ + val sessionId: String? = null, + /** Causal draft revision; local UI-only persistence may retain its tip. */ + val revisionId: String = "dr_legacy_v1", + val parentRevisionId: String? = null, + /** Genuine lifecycle start, distinct from local form update time. */ + val startedAt: String? = null, val exercises: List = emptyList(), val sessionType: SessionType = SessionType.TRAINING, /** Existing max_test session updated atomically on finalization, if any. */ diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt index beaeea6..04cb76f 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt @@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest { TrainlogRepository(context, name).useForTest { it.listSessions() } SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db -> - assertEquals(17, scalar(db, "PRAGMA user_version")) + assertEquals(18, scalar(db, "PRAGMA user_version")) for (table in listOf("session_exercises", "draft_session_exercises")) { assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')")) assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL")) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt index 3373a78..c69d1ae 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt @@ -69,7 +69,7 @@ class AndroidV11ExerciseAliasMigrationTest { migrated.close() } SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> - assertEquals(17, scalar(db, "PRAGMA user_version")) + assertEquals(18, scalar(db, "PRAGMA user_version")) /* v13 intentionally adds the frozen machine identities and * exercise-owned metadata; all non-exercise facts stay exact. */ val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") } diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt index f8e2a54..53129d3 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt @@ -78,7 +78,7 @@ class AndroidV16AiSessionDraftMigrationTest { migrated.close() } SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> - assertEquals(17, scalar(db, "PRAGMA user_version")) + assertEquals(18, scalar(db, "PRAGMA user_version")) assertEquals(before, listOf( scalar(db, "SELECT COUNT(*) FROM sessions"), scalar(db, "SELECT COUNT(*) FROM performed_sets"), diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt index 2d8b53a..2104bf6 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt @@ -328,7 +328,7 @@ class BodyZonesTest { SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null, SQLiteDatabase.OPEN_READONLY).use { db -> db.rawQuery("PRAGMA user_version", null).use { cursor -> - assertTrue(cursor.moveToFirst()); assertEquals(17, cursor.getInt(0)) + assertTrue(cursor.moveToFirst()); assertEquals(18, cursor.getInt(0)) } db.rawQuery("SELECT id FROM exercises", null).use { cursor -> assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0)) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt index 562dd8a..e921b67 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt @@ -1794,7 +1794,7 @@ class TrainlogRepositoryDraftTest { ).use { db -> db.rawQuery("PRAGMA user_version;", null).use { cursor -> assertTrue(cursor.moveToFirst()) - assertEquals(17, cursor.getInt(0)) + assertEquals(18, cursor.getInt(0)) } db.rawQuery( "SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " + @@ -1921,7 +1921,7 @@ class TrainlogRepositoryDraftTest { ).use { db -> db.rawQuery("PRAGMA user_version;", null).use { cursor -> assertTrue(cursor.moveToFirst()) - assertEquals(17, cursor.getInt(0)) + assertEquals(18, cursor.getInt(0)) } db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor -> assertTrue(cursor.moveToFirst()) @@ -2446,6 +2446,60 @@ class TrainlogRepositoryDraftTest { assertEquals(3, exported.getJSONArray("sets").length()) } + @Test + fun stagedExecutionDraftPersistsPendingAndFinalizationRejectsStaleReplay() { + val source = openRepository() + val exercise = createExercise(source, "Lifecycle press", RecordingMode.SETS, TrackingMode.REPS) + assertEquals(ActiveDraftMutationResult.Saved, source.saveActiveSessionDraft( + ActiveSessionDraft(exercises = listOf(SessionExerciseDraft( + entryId = "sxe_lifecycle_active", exercise = exercise, + plan = SessionExercisePlan(2, reps = 8, weightKg = 40.0, + loadMode = SessionLoadMode.EXTERNAL, restSeconds = 90), + sets = listOf(SessionSetDraft(reps = 8, weightKg = 42.5)), + )), form = SessionDraftForm(weightText = "42.")), + )) + val artifact = JSONObject(source.buildExecutionDraftExportV1Json()) + val active = artifact.getJSONArray("drafts").getJSONObject(0) + assertFalse(active.has("weight_text")) + assertFalse(active.toString().contains("weightText")) + val sessionId = active.getString("session_id") + val exerciseCatalog = JSONObject(source.buildMobileExportV4Json()).getJSONArray("exercises") + source.close(); repository = null + context.deleteDatabase(databaseName) + + val destination = openRepository() + val catalog = JSONObject().put("format", "trainlog-pc-catalog").put("version", 1) + .put("exercises", exerciseCatalog) + assertTrue(destination.applyPcCatalogJson(catalog.toString()) is PcCatalogImportResult.Applied) + assertEquals(ExecutionDraftImportResult.Applied(1, 0, 0, 0), + destination.applyExecutionDraftExportV1Json(artifact.toString())) + + val pending = JSONObject(active.toString()) + .put("session_id", "se_00000000-0000-4000-8000-000000000042") + .put("revision_id", "dr_00000000-0000-4000-8000-000000000042") + .put("state", "pending") + pending.getJSONArray("entries").getJSONObject(0) + .put("entry_id", "sxe_lifecycle_pending") + val pendingArtifact = JSONObject(artifact.toString()) + .put("drafts", JSONArray().put(pending)) + assertEquals(ExecutionDraftImportResult.Applied(0, 1, 0, 0), + destination.applyExecutionDraftExportV1Json(pendingArtifact.toString())) + destination.close(); repository = null + val reopened = openRepository() + assertEquals(2, JSONObject(reopened.buildExecutionDraftExportV1Json()) + .getJSONArray("drafts").length()) + assertEquals(ActivateExecutionDraftResult.ActiveDraftOccupied, + reopened.activatePendingExecutionDraft(pending.getString("session_id"))) + assertEquals(FinalizeActiveDraftResult.Saved(sessionId), reopened.finalizeExecutionDraft(sessionId)) + assertEquals(FinalizeActiveDraftResult.Saved(sessionId), reopened.finalizeExecutionDraft(sessionId)) + assertEquals(ExecutionDraftImportResult.Applied(0, 0, 0, 1), + reopened.applyExecutionDraftExportV1Json(artifact.toString())) + assertEquals(ActivateExecutionDraftResult.Activated, + reopened.activatePendingExecutionDraft(pending.getString("session_id"))) + assertEquals(pending.getString("session_id"), + (reopened.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft.sessionId) + } + @Test fun exerciseAliasCompanionRekeysHistoryAndDraftAndReplaysIdempotently() { val repo = openRepository() diff --git a/tools/execution_draft_exchange.py b/tools/execution_draft_exchange.py new file mode 100644 index 0000000..659fa7e --- /dev/null +++ b/tools/execution_draft_exchange.py @@ -0,0 +1,275 @@ +#!/usr/bin/env python3 +"""Explicit staged execution-draft codec and desktop lifecycle service. + +This module is deliberately absent from trainlog_sync_run(): local persistence +success is not peer delivery and the active MTP protocol remains V3. +""" +import argparse +import json +import sqlite3 +from datetime import datetime +from pathlib import Path + +from trainlog_sqlite import connect_database +from validate_json import TrainlogSemanticError, parse_timestamp + +FORMAT = "trainlog-execution-drafts" +VERSION = 1 +MAX_DOCUMENT_BYTES = 4 * 1024 * 1024 +MAX_DRAFTS = 16 +MAX_ENTRIES = 64 +ROOT_KEYS = {"format", "version", "generated_at", "drafts"} +DRAFT_KEYS = {"session_id", "session_type", "source_session_id", "started_at", + "revision_id", "parent_revision_id", "state", "note", "entries"} +ENTRY_KEYS = {"entry_id", "position", "exercise_id", "equipment_id", + "recording_mode", "tracking_mode", "data_fields", "load_mode", + "rest_seconds", "target", "sets", "continuous", "max_weight_kg", + "note", "feedback"} + + +class LifecycleError(RuntimeError): + pass + + +def exact(value, keys, label): + if not isinstance(value, dict) or set(value) != keys: + raise LifecycleError(f"{label}: exact keys required") + + +def text(value, label, nullable=False, maximum=128): + if nullable and value is None: + return + if not isinstance(value, str) or not value or len(value.encode()) > maximum: + raise LifecycleError(f"{label}: invalid text") + + +def timestamp(value, label, nullable=False): + if nullable and value is None: + return + text(value, label) + try: + parse_timestamp(value, label) + except TrainlogSemanticError as error: + raise LifecycleError(f"{label}: invalid timestamp") from error + + +def note(value, label): + if value is None: + return + exact(value, {"value", "revision_id", "parent_revision_id"}, label) + text(value["revision_id"], label + ".revision_id") + text(value["parent_revision_id"], label + ".parent_revision_id", nullable=True) + if value["parent_revision_id"] == value["revision_id"]: + raise LifecycleError(f"{label}: self parent") + if value["value"] is not None and (not isinstance(value["value"], str) or + len(value["value"].encode()) > 4096): + raise LifecycleError(f"{label}: note exceeds 4096 UTF-8 bytes") + + +def validate(document): + exact(document, ROOT_KEYS, "root") + if document["format"] != FORMAT or document["version"] != VERSION: + raise LifecycleError("unsupported execution-draft format/version") + timestamp(document["generated_at"], "generated_at") + drafts = document["drafts"] + if not isinstance(drafts, list) or len(drafts) > MAX_DRAFTS: + raise LifecycleError("drafts: invalid bound") + identities = set() + for di, draft in enumerate(drafts): + label = f"drafts[{di}]" + exact(draft, DRAFT_KEYS, label) + text(draft["session_id"], label + ".session_id") + if draft["session_id"] in identities: + raise LifecycleError(label + ": duplicate session_id") + identities.add(draft["session_id"]) + if draft["session_type"] not in ("training", "max_test") or draft["state"] not in ("active", "pending"): + raise LifecycleError(label + ": invalid lifecycle enum") + text(draft["source_session_id"], label + ".source_session_id", nullable=True) + timestamp(draft["started_at"], label + ".started_at", nullable=True) + text(draft["revision_id"], label + ".revision_id") + text(draft["parent_revision_id"], label + ".parent_revision_id", nullable=True) + if draft["revision_id"] == draft["parent_revision_id"]: + raise LifecycleError(label + ": self parent") + note(draft["note"], label + ".note") + entries = draft["entries"] + if not isinstance(entries, list) or len(entries) > MAX_ENTRIES: + raise LifecycleError(label + ": entries bound") + entry_ids, positions = set(), set() + for ei, entry in enumerate(entries): + elabel = f"{label}.entries[{ei}]" + exact(entry, ENTRY_KEYS, elabel) + text(entry["entry_id"], elabel + ".entry_id") + text(entry["exercise_id"], elabel + ".exercise_id") + text(entry["equipment_id"], elabel + ".equipment_id", nullable=True) + if entry["entry_id"] in entry_ids or not isinstance(entry["position"], int) or entry["position"] < 0 or entry["position"] in positions: + raise LifecycleError(elabel + ": duplicate identity/position") + entry_ids.add(entry["entry_id"]); positions.add(entry["position"]) + if entry["recording_mode"] not in ("sets", "continuous") or entry["tracking_mode"] not in ("reps", "duration"): + raise LifecycleError(elabel + ": invalid profile") + if entry["recording_mode"] == "continuous" and entry["tracking_mode"] != "duration": + raise LifecycleError(elabel + ": continuous requires duration") + if not isinstance(entry["data_fields"], int) or entry["data_fields"] < 0 or entry["data_fields"] & ~3: + raise LifecycleError(elabel + ": invalid data_fields") + if entry["load_mode"] not in ("none", "external", "assistance") or not isinstance(entry["rest_seconds"], int) or not 0 <= entry["rest_seconds"] <= 86400: + raise LifecycleError(elabel + ": invalid plan") + note(entry["note"], elabel + ".note") + if not isinstance(entry["feedback"], list) or len(entry["feedback"]) > 256: + raise LifecycleError(elabel + ": feedback bound") + feedback_ids = set() + for fi, feedback in enumerate(entry["feedback"]): + flabel = f"{elabel}.feedback[{fi}]" + exact(feedback, {"feedback_id", "observed_at", "raw_text", "revisions"}, flabel) + text(feedback["feedback_id"], flabel + ".feedback_id") + if feedback["feedback_id"] in feedback_ids: + raise LifecycleError(flabel + ": duplicate feedback_id") + feedback_ids.add(feedback["feedback_id"]) + timestamp(feedback["observed_at"], flabel + ".observed_at") + text(feedback["raw_text"], flabel + ".raw_text", maximum=8192) + if not isinstance(feedback["revisions"], list) or len(feedback["revisions"]) > 32: + raise LifecycleError(flabel + ": revisions bound") + revision_ids = set() + for ri, revision in enumerate(feedback["revisions"]): + rlabel = f"{flabel}.revisions[{ri}]" + exact(revision, {"revision_id", "created_at", "raw_text"}, rlabel) + text(revision["revision_id"], rlabel + ".revision_id") + timestamp(revision["created_at"], rlabel + ".created_at") + text(revision["raw_text"], rlabel + ".raw_text", maximum=8192) + if revision["revision_id"] in revision_ids: + raise LifecycleError(rlabel + ": duplicate revision_id") + revision_ids.add(revision["revision_id"]) + # The V4 history validator owns exact performed-data shapes. Draft + # import refuses ambiguous simultaneous representations here. + shapes = sum(value is not None for value in + (entry["sets"], entry["continuous"], entry["max_weight_kg"])) + if shapes > 1: + raise LifecycleError(elabel + ": contradictory actual shapes") + return document + + +def load(path): + raw = path.read_bytes() + if len(raw) > MAX_DOCUMENT_BYTES: + raise LifecycleError("document exceeds 4 MiB") + def unique(pairs): + result = {} + for key, value in pairs: + if key in result: + raise LifecycleError("duplicate JSON key: " + key) + result[key] = value + return result + try: + return validate(json.loads(raw.decode("utf-8"), object_pairs_hook=unique)) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise LifecycleError("invalid UTF-8/JSON") from error + + +def canonical(value): + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + +def require_schema(connection): + version = connection.execute("PRAGMA user_version").fetchone()[0] + if version != 19: + raise LifecycleError(f"desktop schema v19 required, found v{version}") + + +def import_document(connection, document): + require_schema(connection) + outcomes = [] + connection.execute("BEGIN IMMEDIATE") + try: + for draft in document["drafts"]: + session_id, revision = draft["session_id"], draft["revision_id"] + payload = canonical(draft) + if connection.execute("SELECT 1 FROM execution_draft_finalizations WHERE session_id=?", (session_id,)).fetchone(): + outcomes.append((session_id, "stale")); continue + current = connection.execute( + "SELECT revision_id,payload_json,state FROM execution_drafts WHERE session_id=?", (session_id,)).fetchone() + known = connection.execute( + "SELECT parent_revision_id,payload_json FROM execution_draft_revisions WHERE session_id=? AND revision_id=?", + (session_id, revision)).fetchone() + if known: + if tuple(known) != (draft["parent_revision_id"], payload): + raise LifecycleError("contradictory revision: " + session_id) + outcomes.append((session_id, "unchanged" if current and current[0] == revision else "stale")); continue + if current and draft["parent_revision_id"] != current[0]: + outcomes.append((session_id, "conflict")); continue + if not current: + active = connection.execute("SELECT 1 FROM execution_drafts WHERE state='active'").fetchone() + pending = connection.execute("SELECT COUNT(*) FROM execution_drafts WHERE state='pending'").fetchone()[0] + state = "pending" if active else "active" + if state == "pending" and pending >= MAX_DRAFTS: + raise LifecycleError("pending execution-draft capacity exhausted") + connection.execute( + "INSERT INTO execution_drafts VALUES(?,?,?,?,?,?,?,?)", + (session_id, draft["session_type"], draft["source_session_id"], draft["started_at"], + revision, draft["parent_revision_id"], state, payload)) + outcome = state + else: + connection.execute( + "UPDATE execution_drafts SET revision_id=?,parent_revision_id=?,payload_json=? WHERE session_id=?", + (revision, draft["parent_revision_id"], payload, session_id)) + outcome = "applied" + connection.execute("INSERT INTO execution_draft_revisions VALUES(?,?,?,?)", + (session_id, revision, draft["parent_revision_id"], payload)) + outcomes.append((session_id, outcome)) + connection.commit() + except Exception: + connection.rollback(); raise + return outcomes + + +def export_document(connection): + require_schema(connection) + drafts = [json.loads(row[0]) for row in connection.execute( + "SELECT payload_json FROM execution_drafts ORDER BY CASE state WHEN 'active' THEN 0 ELSE 1 END,session_id")] + for draft, row in zip(drafts, connection.execute( + "SELECT state FROM execution_drafts ORDER BY CASE state WHEN 'active' THEN 0 ELSE 1 END,session_id")): + draft["state"] = row[0] + return {"format": FORMAT, "version": VERSION, + "generated_at": datetime.now().astimezone().isoformat(), "drafts": drafts} + + +def activate(connection, session_id): + require_schema(connection) + connection.execute("BEGIN IMMEDIATE") + try: + if connection.execute("SELECT 1 FROM execution_drafts WHERE state='active'").fetchone(): + result = "occupied" + elif connection.execute("SELECT 1 FROM execution_drafts WHERE session_id=? AND state='pending'", (session_id,)).fetchone(): + connection.execute("UPDATE execution_drafts SET state='active' WHERE session_id=?", (session_id,)); result = "activated" + else: + result = "not_found" + connection.commit(); return result + except Exception: + connection.rollback(); raise + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("mode", choices=("import", "export", "activate")) + parser.add_argument("path", type=Path) + parser.add_argument("--database", type=Path, required=True) + parser.add_argument("--session-id") + args = parser.parse_args() + connection = connect_database(args.database) + try: + if args.mode == "import": + for identity, outcome in import_document(connection, load(args.path)): + print(f"{identity}={outcome}") + elif args.mode == "export": + args.path.write_text(canonical(export_document(connection)), encoding="utf-8") + print("EXECUTION_DRAFT_EXPORT=PASS") + else: + if not args.session_id: raise LifecycleError("--session-id required") + print("activation=" + activate(connection, args.session_id)) + finally: + connection.close() + + +if __name__ == "__main__": + try: + main() + except Exception as error: + print("EXECUTION_DRAFT=FAIL " + str(error)) + raise SystemExit(1) diff --git a/tools/export_ai_history.py b/tools/export_ai_history.py index be58441..6e07904 100644 --- a/tools/export_ai_history.py +++ b/tools/export_ai_history.py @@ -12,7 +12,7 @@ from pathlib import Path from trainlog_sqlite import configure_connection -SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18) +SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19) MEASUREMENT_FIELDS = ( "body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm", "hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm", diff --git a/tools/export_ai_session_drafts.py b/tools/export_ai_session_drafts.py index f05bec2..4e79205 100644 --- a/tools/export_ai_session_drafts.py +++ b/tools/export_ai_session_drafts.py @@ -20,7 +20,7 @@ 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] != 18: + if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19): raise ValueError("schema desktop v18 requis") columns = {row[1] for row in connection.execute( "PRAGMA table_info(ai_session_drafts)")} diff --git a/tools/export_equipment_associations.py b/tools/export_equipment_associations.py index 7960ff9..fc0a2c2 100644 --- a/tools/export_equipment_associations.py +++ b/tools/export_equipment_associations.py @@ -28,7 +28,7 @@ def main(): args = parser.parse_args() connection = connect_database(args.database) try: - if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18): + if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19): raise ValueError("schema desktop v8 à v16 requis") known_equipment = load_supplied_equipment_ids(args.catalog) known_equipment.update(row[0] for row in connection.execute( diff --git a/tools/export_equipment_definitions.py b/tools/export_equipment_definitions.py index d7791c3..f1a1662 100644 --- a/tools/export_equipment_definitions.py +++ b/tools/export_equipment_definitions.py @@ -24,7 +24,7 @@ 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): + if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19): raise ValueError("schema desktop v8 à v16 requis") equipment = [dict(row) for row in connection.execute( "SELECT equipment_id,display_name,label_name,equipment_type,load_semantics " diff --git a/tools/export_exercise_aliases.py b/tools/export_exercise_aliases.py index caeac9e..60dee35 100644 --- a/tools/export_exercise_aliases.py +++ b/tools/export_exercise_aliases.py @@ -16,7 +16,7 @@ def main(): args = parser.parse_args() con = connect_database(args.database) try: - if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17, 18): + if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17, 18, 19): raise ValueError("schema desktop v12-v16 requis") rows = con.execute( "SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases " diff --git a/tools/export_exercise_body_zones.py b/tools/export_exercise_body_zones.py index b208b51..01f0b7f 100644 --- a/tools/export_exercise_body_zones.py +++ b/tools/export_exercise_body_zones.py @@ -41,7 +41,7 @@ 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): + if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17, 18, 19): raise ValueError("schema desktop v11-v16 requis") exercises = [] for exercise in connection.execute("SELECT id,exercise_id FROM exercises ORDER BY exercise_id"): diff --git a/tools/export_exercise_profile_state.py b/tools/export_exercise_profile_state.py index c3275a8..e6f7ee3 100644 --- a/tools/export_exercise_profile_state.py +++ b/tools/export_exercise_profile_state.py @@ -25,7 +25,7 @@ def main(): con.row_factory = sqlite3.Row try: version = con.execute("PRAGMA user_version").fetchone()[0] - if version not in (17, 18): + if version not in (17, 18, 19): raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}") rows = con.execute(""" SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields, diff --git a/tools/export_pc_catalog.py b/tools/export_pc_catalog.py index ef06267..974450d 100755 --- a/tools/export_pc_catalog.py +++ b/tools/export_pc_catalog.py @@ -71,7 +71,7 @@ def main(): # CONTRACT: v8 adds only desktop-local custom equipment. The PC # catalogue artifact is unchanged, but it must read the current # canonical desktop schema rather than accept a stale pre-v8 database. - if version not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18): + if version not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19): raise SystemExit( "PC_CATALOG_EXPORT=FAIL " f"schema={version}" diff --git a/tools/export_pc_mobile.py b/tools/export_pc_mobile.py index e0827c9..eaa3f0c 100644 --- a/tools/export_pc_mobile.py +++ b/tools/export_pc_mobile.py @@ -59,17 +59,33 @@ def validate_v3_timestamp(value, label): raise ValueError(f"{label}: date-heure Trainlog persistée invalide") from error +def note_payload(connection, owner_kind, owner_id, value): + row = connection.execute( + "SELECT r.revision_id,r.parent_revision_id,r.note FROM sync_note_state s " + "JOIN sync_note_revisions r USING(owner_kind,owner_id,revision_id) " + "WHERE s.owner_kind=? AND s.owner_id=?", (owner_kind, owner_id)).fetchone() + if row is None: + if value is not None: + raise ValueError(f"note héritée sans révision causale: {owner_kind}/{owner_id}") + return None + if row[2] != value: + raise ValueError(f"cache de note incohérent: {owner_kind}/{owner_id}") + return {"value": row[2], "revision_id": row[0], "parent_revision_id": row[1]} + + def main(): parser = argparse.ArgumentParser() parser.add_argument("output", type=Path) parser.add_argument("--database", type=Path, default=default_database()) - parser.add_argument("--version", type=int, choices=(2, 3), default=3) + parser.add_argument("--version", type=int, choices=(2, 3, 4), default=3) args = parser.parse_args() con = connect_database(args.database) con.row_factory = sqlite3.Row try: schema_version = con.execute("PRAGMA user_version").fetchone()[0] - if schema_version not in (11, 12, 13, 14, 15, 16, 17, 18) and not (args.version == 2 and schema_version == 10): + if args.version == 4 and schema_version != 19: + raise ValueError("mobile V4 exige le schéma desktop v19") + if schema_version not in (11, 12, 13, 14, 15, 16, 17, 18, 19) and not (args.version == 2 and schema_version == 10): raise ValueError("schema desktop v11-v16 requis (v10 accepté pour export V2 explicite)") known_equipment = supplied_equipment_ids() canonical_names = load_exercise_names() @@ -82,18 +98,25 @@ def main(): exported = dict(row) exported["name"] = canonical_names.get(row["exercise_id"], row["name"]) root["exercises"].append(exported) - for session in con.execute("SELECT id,session_id,started_at,session_type FROM sessions ORDER BY started_at,id"): + for session in con.execute("SELECT id,session_id,started_at,session_type,ended_at,notes FROM sessions ORDER BY started_at,id"): # CONTRACT: a V3 producer must not publish history which the exact # temporal readers reject. V2 export retains its published behavior. - if args.version == 3: + if args.version >= 3: validate_v3_timestamp(session["started_at"], f"session_id={session['session_id']} started_at") payload = {key: session[key] for key in ("session_id", "started_at", "session_type")} + if args.version == 4: + if session["ended_at"] is not None: + validate_v3_timestamp(session["ended_at"], f"session_id={session['session_id']} ended_at") + if parse_timestamp(session["ended_at"], "ended_at") <= parse_timestamp(session["started_at"], "started_at"): + raise ValueError("ended_at doit être postérieur à started_at") + payload["ended_at"] = session["ended_at"] + payload["note"] = note_payload(con, "session", session["session_id"], session["notes"]) payload["exercises"] = [] # INVARIANT: v16 exports the occurrence-owned tracking snapshot; # older readable schemas retain their bounded catalogue fallback. tracking_sql = "se.tracking_mode" if schema_version >= 16 else "e.tracking_mode" - sql = f"SELECT se.id,se.entry_id,se.position,se.recording_mode,{tracking_sql},se.data_fields,se.equipment_id,e.exercise_id,e.name,mr.max_weight_kg,se.load_mode,se.rest_seconds,se.target_sets,se.target_reps,se.target_duration_seconds,se.target_weight_kg FROM session_exercises se JOIN exercises e ON e.id=se.exercise_row_id LEFT JOIN max_results mr ON mr.session_exercise_row_id=se.id WHERE se.session_row_id=? ORDER BY se.position" + sql = f"SELECT se.id,se.entry_id,se.position,se.recording_mode,{tracking_sql},se.data_fields,se.equipment_id,e.exercise_id,e.name,mr.max_weight_kg,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 max_results mr ON mr.session_exercise_row_id=se.id WHERE se.session_row_id=? ORDER BY se.position" for entry in con.execute(sql, (session["id"],)): validate_plan(entry) if entry["recording_mode"] == "continuous" or entry["max_weight_kg"] is not None: @@ -118,8 +141,8 @@ def main(): "name": canonical_names.get(entry["exercise_id"], entry["name"]), "recording_mode": entry["recording_mode"], "tracking_mode": entry["tracking_mode"], "data_fields": entry["data_fields"], - "load_mode": entry["load_mode"] if args.version == 3 else "none", - "rest_seconds": entry["rest_seconds"] if args.version == 3 else 0, + "load_mode": entry["load_mode"] if args.version >= 3 else "none", + "rest_seconds": entry["rest_seconds"] if args.version >= 3 else 0, "equipment_id": entry["equipment_id"]} if args.version == 3: if not has_target: @@ -134,6 +157,16 @@ def main(): if entry["target_duration_seconds"] is not None: target["duration_seconds"] = entry["target_duration_seconds"] if entry["target_weight_kg"] is not None: target["weight_kg"] = entry["target_weight_kg"] item["target"] = target + elif args.version == 4: + if not has_target: + item["target"] = None + else: + target = {"sets": entry["target_sets"]} + if entry["target_reps"] is not None: target["reps"] = entry["target_reps"] + if entry["target_duration_seconds"] is not None: target["duration_seconds"] = entry["target_duration_seconds"] + if entry["target_weight_kg"] is not None: target["weight_kg"] = entry["target_weight_kg"] + item["target"] = target + item["note"] = note_payload(con, "occurrence", entry["entry_id"], entry["notes"]) if entry["max_weight_kg"] is not None: # CONTRACT: explicit max is an occurrence result, never a # synthetic one-repetition performed set. @@ -155,15 +188,18 @@ def main(): "waist_cm", "hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm", "right_forearm_cm", "left_thigh_cm", "right_thigh_cm", "left_calf_cm", "right_calf_cm") - columns = ",".join(("observation_id", "observed_at") + metric_names) - for row in con.execute(f"SELECT {columns} FROM body_observations ORDER BY observed_at,id"): - if args.version == 3: + columns = ",".join(("bo.observation_id", "bo.observed_at") + tuple("bo." + n for n in metric_names)) + for row in con.execute(f"SELECT {columns},s.session_id,bo.notes FROM body_observations bo LEFT JOIN sessions s ON s.id=bo.session_row_id ORDER BY bo.observed_at,bo.id"): + if args.version >= 3: # INVARIANT: validate every stored instant before touching the # destination artifact, so corruption cannot clobber a prior file. validate_v3_timestamp(row["observed_at"], f"observation_id={row['observation_id']} observed_at") item = {"observation_id": row["observation_id"], "observed_at": row["observed_at"]} item.update({name: row[name] for name in metric_names if row[name] is not None}) + if args.version == 4: + item["session_id"] = row["session_id"] + item["note"] = note_payload(con, "observation", row["observation_id"], row["notes"]) root["body_observations"].append(item) args.output.write_text(json.dumps(root, ensure_ascii=False, separators=(",", ":")), encoding="utf-8") print("PC_MOBILE_EXPORT=PASS") diff --git a/tools/export_training_feedback.py b/tools/export_training_feedback.py index b0c452f..b25df7f 100644 --- a/tools/export_training_feedback.py +++ b/tools/export_training_feedback.py @@ -10,7 +10,7 @@ def main(): 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): raise ValueError("schema desktop v15-v18 requis") + if db.execute("PRAGMA user_version").fetchone()[0] not in (15, 16, 17, 18, 19): raise ValueError("schema desktop v15-v19 requis") roots=db.execute("SELECT f.feedback_id,s.session_id,se.entry_id,e.exercise_id,f.observed_at FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id").fetchall() followups=db.execute("SELECT f.followup_id,s.session_id,f.observed_at FROM session_followups f JOIN sessions s ON s.id=f.session_row_id").fetchall() if len(roots)>4096 or len(followups)>4096: raise ValueError("trop d'observations") diff --git a/tools/import_ai_session_draft.py b/tools/import_ai_session_draft.py index e47414d..eb33cd6 100644 --- a/tools/import_ai_session_draft.py +++ b/tools/import_ai_session_draft.py @@ -193,7 +193,7 @@ 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] != 18: + if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19): raise ImportFailure("schema desktop v18 requis") draft, entries = validate(payload, connection) digest = canonical_digest(payload) diff --git a/tools/import_equipment_associations.py b/tools/import_equipment_associations.py index 903e150..e9a7892 100644 --- a/tools/import_equipment_associations.py +++ b/tools/import_equipment_associations.py @@ -173,7 +173,7 @@ def main(): 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): + if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19): fail("schema desktop v8 à v16 requis") known = load_catalog(args.catalog) known.update(row[0] for row in connection.execute( diff --git a/tools/import_equipment_definitions.py b/tools/import_equipment_definitions.py index 2d064ce..0fd62c6 100644 --- a/tools/import_equipment_definitions.py +++ b/tools/import_equipment_definitions.py @@ -61,7 +61,7 @@ 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): + if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19): fail("schema desktop v8 à v16 requis") imported = skipped = 0 # Validate every same-ID row before inserting any definition. diff --git a/tools/import_exercise_aliases.py b/tools/import_exercise_aliases.py index 5e21772..5b44bc3 100644 --- a/tools/import_exercise_aliases.py +++ b/tools/import_exercise_aliases.py @@ -63,7 +63,7 @@ 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): + if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17, 18, 19): fail("schema desktop v12-v16 requis") con.execute("BEGIN IMMEDIATE") for source, canonical in aliases: diff --git a/tools/import_exercise_body_zones.py b/tools/import_exercise_body_zones.py index d9fe93f..e2dc234 100644 --- a/tools/import_exercise_body_zones.py +++ b/tools/import_exercise_body_zones.py @@ -205,7 +205,7 @@ def main(): 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): + if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17, 18, 19): raise ImportFailure("schema desktop v11-v16 requis") connection.execute("PRAGMA foreign_keys=ON") connection.execute("BEGIN IMMEDIATE") diff --git a/tools/import_exercise_profile_state.py b/tools/import_exercise_profile_state.py index 010767f..0d82e86 100644 --- a/tools/import_exercise_profile_state.py +++ b/tools/import_exercise_profile_state.py @@ -53,7 +53,7 @@ def main(): 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): fail("schema desktop v17/v18 requis") + if con.execute("PRAGMA user_version").fetchone()[0] not in (17,18,19): fail("schema desktop v17/v18/v19 requis") applied=equal=ancestor=pending=0;seen=set();con.execute("BEGIN IMMEDIATE") for item in root["exercises"]: validate(item);eid=item["exercise_id"] diff --git a/tools/import_mobile_export.py b/tools/import_mobile_export.py index b21c9c6..4a7fda2 100755 --- a/tools/import_mobile_export.py +++ b/tools/import_mobile_export.py @@ -48,6 +48,7 @@ SESSION_KEYS = { "session_type", "exercises", } +V4_SESSION_KEYS = SESSION_KEYS | {"ended_at", "note"} SESSION_EXERCISE_KEYS = { "exercise_id", @@ -65,11 +66,13 @@ V2_SESSION_EXERCISE_KEYS = SESSION_EXERCISE_KEYS | { "entry_id", "position", "equipment_id", "max_weight_kg" } V3_SESSION_EXERCISE_KEYS = V2_SESSION_EXERCISE_KEYS | {"target"} +V4_SESSION_EXERCISE_KEYS = V3_SESSION_EXERCISE_KEYS | {"note"} BODY_BASE_KEYS = { "observation_id", "observed_at", } +V4_BODY_KEYS = BODY_BASE_KEYS | {"session_id", "note"} BODY_METRIC_KEYS = { "body_weight_kg", @@ -255,14 +258,14 @@ def load_payload(path): "format mobile export invalide" ) - if payload["version"] not in (1, 2, 3): + if payload["version"] not in (1, 2, 3, 4): raise ImportFailure( "version mobile export non supportée" ) # CONTRACT: V1/V2 retain their published nonempty-string admission. V3 is # the current analysis-bearing artifact and uses the exact Trainlog parser. - if payload["version"] == 3: + if payload["version"] >= 3: require_v3_timestamp(payload["generated_at"], "generated_at") else: require_nonempty_string(payload["generated_at"], "generated_at") @@ -447,6 +450,24 @@ def validate_set_item( return ("duration", duration) +def validate_note(value, label): + if value is None: + return + require_exact_keys(value, {"value", "revision_id", "parent_revision_id"}, + {"value", "revision_id", "parent_revision_id"}, label) + revision = require_nonempty_string(value["revision_id"], f"{label}.revision_id") + if len(revision.encode("utf-8")) > 128: + raise ImportFailure(f"{label}.revision_id: hors bornes") + parent = value["parent_revision_id"] + if parent is not None: + require_nonempty_string(parent, f"{label}.parent_revision_id") + if len(parent.encode("utf-8")) > 128 or parent == revision: + raise ImportFailure(f"{label}.parent_revision_id: invalide") + note = value["value"] + if note is not None and (not isinstance(note, str) or len(note.encode("utf-8")) > 4096): + raise ImportFailure(f"{label}.value: texte UTF-8 hors bornes") + + def validate_session_exercise( item, label, @@ -455,7 +476,7 @@ def validate_session_exercise( version, ): is_v2 = version >= 2 - keys = V3_SESSION_EXERCISE_KEYS if version == 3 else ( + keys = V4_SESSION_EXERCISE_KEYS if version == 4 else V3_SESSION_EXERCISE_KEYS if version == 3 else ( V2_SESSION_EXERCISE_KEYS if is_v2 else SESSION_EXERCISE_KEYS) require_exact_keys( item, @@ -464,6 +485,8 @@ def validate_session_exercise( - {"sets", "continuous", "max_weight_kg"}, label, ) + if version == 4: + validate_note(item["note"], f"{label}.note") if is_v2: require_nonempty_string(item["entry_id"], f"{label}.entry_id") @@ -650,12 +673,8 @@ def validate_sessions( ): label = f"sessions[{index}]" - require_exact_keys( - session, - SESSION_KEYS, - SESSION_KEYS, - label, - ) + session_keys = V4_SESSION_KEYS if payload["version"] == 4 else SESSION_KEYS + require_exact_keys(session, session_keys, session_keys, label) session_id = require_nonempty_string( session["session_id"], @@ -669,13 +688,21 @@ def validate_sessions( seen_ids.add(session_id) - if payload["version"] == 3: + if payload["version"] >= 3: # INVARIANT: accepted V3 history must remain readable by temporal # analysis; validation completes before run_import mutates SQLite. require_v3_timestamp(session["started_at"], f"{label}.started_at") else: require_nonempty_string(session["started_at"], f"{label}.started_at") + if payload["version"] == 4: + validate_note(session["note"], f"{label}.note") + if session["ended_at"] is not None: + require_v3_timestamp(session["ended_at"], f"{label}.ended_at") + if parse_timestamp(session["ended_at"], f"{label}.ended_at") <= parse_timestamp( + session["started_at"], f"{label}.started_at"): + raise ImportFailure(f"{label}.ended_at: doit être postérieur au début") + if session["session_type"] not in ( "training", "max_test", @@ -748,18 +775,16 @@ def validate_sessions( def validate_body(payload): seen_ids = set() allowed = BODY_BASE_KEYS | BODY_METRIC_KEYS + if payload["version"] == 4: + allowed |= V4_BODY_KEYS for index, observation in enumerate( payload["body_observations"] ): label = f"body_observations[{index}]" - require_exact_keys( - observation, - allowed, - BODY_BASE_KEYS, - label, - ) + required = BODY_BASE_KEYS | ({"session_id", "note"} if payload["version"] == 4 else set()) + require_exact_keys(observation, allowed, required, label) observation_id = require_nonempty_string( observation["observation_id"], @@ -773,10 +798,14 @@ def validate_body(payload): seen_ids.add(observation_id) - if payload["version"] == 3: + if payload["version"] >= 3: require_v3_timestamp(observation["observed_at"], f"{label}.observed_at") else: require_nonempty_string(observation["observed_at"], f"{label}.observed_at") + if payload["version"] == 4: + validate_note(observation["note"], f"{label}.note") + if observation["session_id"] is not None: + require_nonempty_string(observation["session_id"], f"{label}.session_id") present_metrics = ( set(observation.keys()) @@ -816,9 +845,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): + if version not in (5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19): raise ImportFailure( - f"base desktop schema v5 à v18 attendue, version trouvée: {version}" + f"base desktop schema v5 à v19 attendue, version trouvée: {version}" ) @@ -1268,6 +1297,68 @@ def occurrence_plan_values(item): ) +def apply_note(connection, owner_kind, owner_id, payload, table, where, arguments): + """Apply one direct causal child without using wall-clock ordering.""" + if payload is None: + retained = connection.execute( + "SELECT r.note FROM sync_note_state s JOIN sync_note_revisions r " + "USING(owner_kind,owner_id,revision_id) WHERE s.owner_kind=? AND s.owner_id=?", + (owner_kind, owner_id)).fetchone() + if retained is not None: + connection.execute(f"UPDATE {table} SET notes=? WHERE {where}", + (retained[0], *arguments)) + return "unchanged" + current = connection.execute( + "SELECT revision_id FROM sync_note_state WHERE owner_kind=? AND owner_id=?", + (owner_kind, owner_id)).fetchone() + existing = connection.execute( + "SELECT parent_revision_id,note FROM sync_note_revisions " + "WHERE owner_kind=? AND owner_id=? AND revision_id=?", + (owner_kind, owner_id, payload["revision_id"])).fetchone() + if existing is not None: + if tuple(existing) != (payload["parent_revision_id"], payload["value"]): + raise ImportFailure(f"révision de note contradictoire: {owner_kind}/{owner_id}") + if current is not None and current[0] == payload["revision_id"]: + return "unchanged" + raise ImportFailure(f"révision de note obsolète: {owner_kind}/{owner_id}") + if current is not None and payload["parent_revision_id"] != current[0]: + raise ImportFailure(f"conflit causal de note: {owner_kind}/{owner_id}") + if current is None and payload["parent_revision_id"] is not None: + raise ImportFailure(f"parent de note inconnu: {owner_kind}/{owner_id}") + connection.execute( + "INSERT INTO sync_note_revisions(owner_kind,owner_id,revision_id,parent_revision_id,note) " + "VALUES(?,?,?,?,?)", (owner_kind, owner_id, payload["revision_id"], + payload["parent_revision_id"], payload["value"])) + connection.execute( + "INSERT INTO sync_note_state(owner_kind,owner_id,revision_id) VALUES(?,?,?) " + "ON CONFLICT(owner_kind,owner_id) DO UPDATE SET revision_id=excluded.revision_id", + (owner_kind, owner_id, payload["revision_id"])) + connection.execute(f"UPDATE {table} SET notes=? WHERE {where}", + (payload["value"], *arguments)) + return "applied" + + +def note_matches(connection, owner_kind, owner_id, payload): + row = connection.execute( + "SELECT r.revision_id,r.parent_revision_id,r.note FROM sync_note_state s " + "JOIN sync_note_revisions r USING(owner_kind,owner_id,revision_id) " + "WHERE s.owner_kind=? AND s.owner_id=?", (owner_kind, owner_id)).fetchone() + if payload is None: + return row is None + return row is not None and tuple(row) == ( + payload["revision_id"], payload["parent_revision_id"], payload["value"]) + + +def v4_session_metadata_matches(connection, session_row_id, session): + header = connection.execute( + "SELECT ended_at FROM sessions WHERE id=?", (session_row_id,)).fetchone() + if header is None or header[0] != session["ended_at"] or not note_matches( + connection, "session", session["session_id"], session["note"]): + return False + return all(note_matches(connection, "occurrence", item["entry_id"], item["note"]) + for item in session["exercises"]) + + def import_set_session_exercise( connection, session_row_id, @@ -1348,6 +1439,7 @@ def import_set_session_exercise( duration, set_item.get("weight_kg"), ), ) + return session_exercise_row_id def import_continuous_session_exercise( connection, @@ -1413,6 +1505,7 @@ def import_continuous_session_exercise( continuous.get("distance_km"), ), ) + return cursor.lastrowid def import_max_session_exercise( @@ -1452,6 +1545,7 @@ def import_max_session_exercise( "VALUES(?,?);", (cursor.lastrowid, item["max_weight_kg"]), ) + return cursor.lastrowid def import_sessions( @@ -1491,12 +1585,12 @@ def import_sessions( current = [(row[0], row[1]) for row in rows] legacy = all(value[0].startswith("sxe_legacy_") or value[0].startswith("sxe_v1_") for value in current) header = connection.execute( - "SELECT started_at,session_type FROM sessions WHERE id=?;", + "SELECT started_at,session_type,ended_at FROM sessions WHERE id=?;", (session_row_id,), ).fetchone() resumable_max = ( header is not None - and tuple(header) == (session["started_at"], "max_test") + and tuple(header[:2]) == (session["started_at"], "max_test") and session["session_type"] == "max_test" and len(incoming) >= len(current) and all(current[index] == incoming[index] @@ -1504,9 +1598,9 @@ def import_sessions( ) incoming_by_id = dict(incoming) correction = ( - payload["version"] == 3 + payload["version"] >= 3 and header is not None - and tuple(header) == (session["started_at"], session["session_type"]) + and tuple(header[:2]) == (session["started_at"], session["session_type"]) and len(incoming_by_id) == len(incoming) and all(entry_id not in dict(current) or dict(current)[entry_id] == exercise_id @@ -1524,7 +1618,8 @@ def import_sessions( session_row_id, session, exercise_mapping, - ): + ) and (payload["version"] != 4 or v4_session_metadata_matches( + connection, session_row_id, session)): report["sessions_skipped"] += 1 continue if not legacy and not (resumable_max or correction): @@ -1563,17 +1658,30 @@ def import_sessions( ended_at, session_type, notes - ) VALUES(?, ?, NULL, ?, NULL); + ) VALUES(?, ?, ?, ?, NULL); """, ( session["session_id"], session["started_at"], + session.get("ended_at"), session["session_type"], ), ) session_row_id = cursor.lastrowid + if payload["version"] == 4: + current_end = connection.execute( + "SELECT ended_at FROM sessions WHERE id=?", (session_row_id,)).fetchone()[0] + incoming_end = session["ended_at"] + if current_end is None and incoming_end is not None: + connection.execute("UPDATE sessions SET ended_at=? WHERE id=?", + (incoming_end, session_row_id)) + elif current_end is not None and incoming_end is not None and current_end != incoming_end: + raise ImportFailure("conflit ended_at pour " + session["session_id"]) + apply_note(connection, "session", session["session_id"], session["note"], + "sessions", "id=?", (session_row_id,)) + for position, item in enumerate( session["exercises"] ): @@ -1616,7 +1724,7 @@ def import_sessions( ) if "max_weight_kg" in item: - import_max_session_exercise( + occurrence_row_id = import_max_session_exercise( connection, session_row_id, position, @@ -1624,7 +1732,7 @@ def import_sessions( row_id, ) elif item["recording_mode"] == "continuous": - import_continuous_session_exercise( + occurrence_row_id = import_continuous_session_exercise( connection, session_row_id, position, @@ -1632,13 +1740,16 @@ def import_sessions( row_id, ) else: - import_set_session_exercise( + occurrence_row_id = import_set_session_exercise( connection, session_row_id, position, item, row_id, ) + if payload["version"] == 4: + apply_note(connection, "occurrence", item["entry_id"], item["note"], + "session_exercises", "id=?", (occurrence_row_id,)) retained_ids = {item["entry_id"] for item in session["exercises"]} for entry_id, feedback_id, observed_at, raw_text in retained_feedback: @@ -1784,13 +1895,29 @@ def import_body( for observation in payload[ "body_observations" ]: + parent_row_id = None + if payload["version"] == 4 and observation["session_id"] is not None: + parent = connection.execute( + "SELECT id FROM sessions WHERE session_id=?", (observation["session_id"],)).fetchone() + if parent is None: + raise ImportFailure("parent de l'observation introuvable: " + observation["session_id"]) + parent_row_id = parent[0] existing = connection.execute( - f"SELECT observed_at,{','.join(metric_order)} FROM body_observations WHERE observation_id=?", + f"SELECT id,observed_at,{','.join(metric_order)},session_row_id FROM body_observations WHERE observation_id=?", (observation["observation_id"],)).fetchone() if existing is not None: expected = [observation["observed_at"]] + [observation.get(metric) for metric in metric_order] - if list(existing) != expected: + if list(existing[1:1 + len(expected)]) != expected: raise ImportFailure("conflit observation corporelle: " + observation["observation_id"]) + if payload["version"] == 4: + current_parent = existing[-1] + if current_parent is None and parent_row_id is not None: + connection.execute("UPDATE body_observations SET session_row_id=? WHERE id=?", + (parent_row_id, existing[0])) + elif current_parent is not None and parent_row_id is not None and current_parent != parent_row_id: + raise ImportFailure("conflit parent observation: " + observation["observation_id"]) + apply_note(connection, "observation", observation["observation_id"], observation["note"], + "body_observations", "id=?", (existing[0],)) report["body_skipped"] += 1 continue @@ -1809,6 +1936,16 @@ def import_body( values, ) + if payload["version"] == 4: + row_id = connection.execute( + "SELECT id FROM body_observations WHERE observation_id=?", + (observation["observation_id"],)).fetchone()[0] + if parent_row_id is not None: + connection.execute("UPDATE body_observations SET session_row_id=? WHERE id=?", + (parent_row_id, row_id)) + apply_note(connection, "observation", observation["observation_id"], observation["note"], + "body_observations", "id=?", (row_id,)) + report["body_imported"] += 1 @@ -1850,6 +1987,8 @@ def run_import( ) schema_version = connection.execute("PRAGMA user_version;").fetchone()[0] + if payload["version"] == 4 and schema_version != 19: + raise ImportFailure("mobile V4 exige le schéma desktop v19") has_explicit_max = any( "max_weight_kg" in entry for session in payload["sessions"] diff --git a/tools/import_training_feedback.py b/tools/import_training_feedback.py index edacf94..72067c6 100644 --- a/tools/import_training_feedback.py +++ b/tools/import_training_feedback.py @@ -51,7 +51,7 @@ def main(): 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):fail("schema desktop v15-v18 requis") + if db.execute("PRAGMA user_version").fetchone()[0] not in (15,16,17,18,19):fail("schema desktop v15-v19 requis") 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"} diff --git a/tui/include/trainlog/database.h b/tui/include/trainlog/database.h index 0d7cd53..531d04f 100644 --- a/tui/include/trainlog/database.h +++ b/tui/include/trainlog/database.h @@ -16,7 +16,7 @@ #include "trainlog/model.h" #include "trainlog/status.h" -#define TRAINLOG_DATABASE_SCHEMA_VERSION 18 +#define TRAINLOG_DATABASE_SCHEMA_VERSION 19 typedef struct TrainlogDatabase TrainlogDatabase; diff --git a/tui/src/database.c b/tui/src/database.c index 5ded2f4..8dc5c0c 100644 --- a/tui/src/database.c +++ b/tui/src/database.c @@ -804,6 +804,34 @@ static const char *const MIGRATE_V17_TO_V18_SQL = "BEGIN SELECT RAISE(ABORT,'AI draft import identity is permanent');END;" "PRAGMA user_version=18;COMMIT;"; +/* WHY: V3 cannot carry lifecycle-owned notes, observation parents, or + * resumable execution drafts. CONTRACT: v19 is additive and provides only the + * durable state required by the explicitly selected V4/lifecycle codecs. + * INVARIANT: completed history, AI proposals, feedback, aliases, and every + * existing stable identity remain untouched; NULL legacy notes keep unknown + * ancestry and no timestamp or causal parent is fabricated. */ +static const char *const MIGRATE_V18_TO_V19_SQL = + "BEGIN IMMEDIATE;" + "CREATE TABLE sync_note_revisions(" + "owner_kind TEXT NOT NULL CHECK(owner_kind IN('session','occurrence','observation'))," + "owner_id TEXT NOT NULL,revision_id TEXT NOT NULL,parent_revision_id TEXT,note TEXT," + "CHECK(note IS NULL OR length(CAST(note AS BLOB))<=4096)," + "PRIMARY KEY(owner_kind,owner_id,revision_id));" + "CREATE TABLE sync_note_state(" + "owner_kind TEXT NOT NULL,owner_id TEXT NOT NULL,revision_id TEXT NOT NULL," + "PRIMARY KEY(owner_kind,owner_id)," + "FOREIGN KEY(owner_kind,owner_id,revision_id) REFERENCES sync_note_revisions(owner_kind,owner_id,revision_id));" + "CREATE TABLE execution_drafts(" + "session_id TEXT PRIMARY KEY,session_type TEXT NOT NULL CHECK(session_type IN('training','max_test'))," + "source_session_id TEXT,started_at TEXT,revision_id TEXT NOT NULL,parent_revision_id TEXT," + "state TEXT NOT NULL CHECK(state IN('active','pending')),payload_json TEXT NOT NULL);" + "CREATE TABLE execution_draft_revisions(" + "session_id TEXT NOT NULL,revision_id TEXT NOT NULL,parent_revision_id TEXT,payload_json TEXT NOT NULL," + "PRIMARY KEY(session_id,revision_id));" + "CREATE TABLE execution_draft_finalizations(" + "session_id TEXT PRIMARY KEY,final_revision_id TEXT NOT NULL,finalized_at TEXT NOT NULL);" + "PRAGMA user_version=19;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. */ @@ -1496,7 +1524,8 @@ static TrainlogStatus initialize_or_validate_schema( } else if (version == 10) { status = TRAINLOG_STATUS_OK; } else if (version == 11 || version == 12 || version == 13 || version == 14 || - version == 15 || version == 16 || version == 17 || version == 18) { + version == 15 || version == 16 || version == 17 || version == 18 || + version == 19) { status = TRAINLOG_STATUS_OK; } else { if (version == 1) { @@ -1660,6 +1689,9 @@ static TrainlogStatus initialize_or_validate_schema( if (status == TRAINLOG_STATUS_OK && version < 18) { status = execute_sql(database, MIGRATE_V17_TO_V18_SQL); } + if (status == TRAINLOG_STATUS_OK && version < 19) { + status = execute_sql(database, MIGRATE_V18_TO_V19_SQL); + } if (status == TRAINLOG_STATUS_OK) { status = ensure_v18_ai_draft_publication_state(database); } @@ -1675,7 +1707,7 @@ static TrainlogStatus initialize_or_validate_schema( set_open_diagnostic( output_diagnostic, output_diagnostic_capacity, - version == 0 ? "create schema v18" : "migrate database to schema v18", + version == 0 ? "create schema v19" : "migrate database to schema v19", database->connection, SQLITE_ERROR ); diff --git a/tui/tests/test_body_zones.c b/tui/tests/test_body_zones.c index e8c19e3..656e5ec 100644 --- a/tui/tests/test_body_zones.c +++ b/tui/tests/test_body_zones.c @@ -204,7 +204,8 @@ static int migration_preserves_identity_and_history(void) CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL; CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK); - CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK && version == 18); + CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK && + version == TRAINLOG_DATABASE_SCHEMA_VERSION); CHECK(trainlog_database_list_exercise_body_zones(database, "ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", relations, 4U, &count) == TRAINLOG_STATUS_OK); CHECK(count == 2U); diff --git a/tui/tests/test_schema_v16_migration.c b/tui/tests/test_schema_v16_migration.c index a484e2a..fb30bb5 100644 --- a/tui/tests/test_schema_v16_migration.c +++ b/tui/tests/test_schema_v16_migration.c @@ -84,9 +84,14 @@ static bool run_test(void) (void)fprintf(stderr, "open diagnostic: %s\n", diagnostic); CHECK(false); } - CHECK(trainlog_database_schema_version(db, &version) == TRAINLOG_STATUS_OK && version == 18); + CHECK(trainlog_database_schema_version(db, &version) == TRAINLOG_STATUS_OK && + version == TRAINLOG_DATABASE_SCHEMA_VERSION); trainlog_database_close(db); db = NULL; CHECK(sqlite3_open_v2(path, &raw, SQLITE_OPEN_READWRITE, NULL) == SQLITE_OK); + CHECK(scalar(raw, "SELECT user_version FROM pragma_user_version", TRAINLOG_DATABASE_SCHEMA_VERSION)); + CHECK(scalar(raw, "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN('sync_note_revisions','sync_note_state','execution_drafts','execution_draft_revisions','execution_draft_finalizations')", 5)); + CHECK(scalar(raw, "SELECT COUNT(*) FROM execution_drafts", 0)); + CHECK(scalar(raw, "SELECT COUNT(*) FROM execution_draft_finalizations", 0)); CHECK(scalar(raw, "SELECT COUNT(*) FROM exercises e LEFT JOIN exercise_profile_state s ON s.exercise_row_id=e.id WHERE s.revision_id IS NULL OR s.revision_id<>'pr_legacy_v1' OR s.legacy_seed<>1", 0)); CHECK(scalar(raw, "SELECT COUNT(*) FROM exercises e LEFT JOIN exercise_profile_revisions r ON r.exercise_row_id=e.id AND r.revision_id='pr_legacy_v1' AND r.legacy_seed=1 WHERE r.revision_id IS NULL", 0)); CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_table_info('session_exercises') WHERE name='tracking_mode' AND \"notnull\"=1", 1)); diff --git a/tui/tests/test_schema_v7_migration.c b/tui/tests/test_schema_v7_migration.c index 9507685..0ee2cf1 100644 --- a/tui/tests/test_schema_v7_migration.c +++ b/tui/tests/test_schema_v7_migration.c @@ -134,8 +134,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void) CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR); CHECK(database == NULL); - CHECK(strncmp(diagnostic, "migrate database to schema v18: SQLite rc=", - strlen("migrate database to schema v18: SQLite rc=")) == 0); + CHECK(strncmp(diagnostic, "migrate database to schema v19: SQLite rc=", + strlen("migrate database to schema v19: 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=19;", NULL, NULL, NULL) == SQLITE_OK); + CHECK(sqlite3_exec(raw, "PRAGMA user_version=20;", 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 19 is newer") != NULL); + CHECK(strstr(diagnostic, "schema version 20 is newer") != NULL); CHECK(strstr(diagnostic, "SQLite") == NULL); CHECK(unlink(path) == 0); return true; diff --git a/tui/tests/test_schema_v9_migration.c b/tui/tests/test_schema_v9_migration.c index 4a3da8d..473d101 100644 --- a/tui/tests/test_schema_v9_migration.c +++ b/tui/tests/test_schema_v9_migration.c @@ -151,7 +151,7 @@ static bool test_v9_to_v10_failure_rolls_back(void) CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR); CHECK(database == NULL); - CHECK(strstr(diagnostic, "migrate database to schema v18") != NULL); + CHECK(strstr(diagnostic, "migrate database to schema v19") != NULL); CHECK(strstr(diagnostic, "performed_sets_v9") != NULL); CHECK(sqlite3_open(path, &raw) == SQLITE_OK);