feat(sync): add causal deletion protection
This commit is contained in:
parent
4b8f5b02b8
commit
98cb40749f
42 changed files with 860 additions and 61 deletions
|
|
@ -2,6 +2,14 @@
|
|||
|
||||
## Unreleased — 0.1.2
|
||||
|
||||
- Completed `TRAINLOG_SYNC_CAUSAL_DELETE_V1` with desktop schema v20 and
|
||||
Android schema v19. Added a strict bounded staged causal-deletion artifact,
|
||||
durable operation/state records, domain-specific deletion effects, causal
|
||||
conflict and replay handling, and legacy snapshot non-resurrection gates.
|
||||
Android and desktop both produce and consume the artifact in isolated tests;
|
||||
automatic transport, generation acknowledgement and deployment remain
|
||||
inactive.
|
||||
|
||||
- Completed `TRAINLOG_SYNC_DATA_LIFECYCLE_V1` with desktop schema v19 and
|
||||
Android schema v18. Added explicit staged V4 enriched-history codecs,
|
||||
causal notes, body-observation session links, stable execution-draft
|
||||
|
|
|
|||
|
|
@ -308,7 +308,7 @@ not contain cloud credentials and does not run `rclone`.
|
|||
As of 2026-09-17:
|
||||
|
||||
- `TRAINLOG_FORMAT_V1=PASS/FROZEN`;
|
||||
- desktop SQLite schema v19 and Android SQLite schema v18;
|
||||
- desktop SQLite schema v20 and Android SQLite schema v19;
|
||||
- Notcurses is the only active desktop terminal backend;
|
||||
- direct storage is `/storage/emulated/0/Documents/Trainlog` under Android's
|
||||
all-files access setting;
|
||||
|
|
|
|||
|
|
@ -340,6 +340,15 @@ sealed interface FinalizeActiveDraftResult {
|
|||
) : FinalizeActiveDraftResult
|
||||
}
|
||||
|
||||
sealed interface CausalDeleteResult {
|
||||
data class Applied(val operationId: String) : CausalDeleteResult
|
||||
data class Unchanged(val operationId: String) : CausalDeleteResult
|
||||
data class Conflict(val targetId: String) : CausalDeleteResult
|
||||
data object NotFound : CausalDeleteResult
|
||||
data object Invalid : CausalDeleteResult
|
||||
data object DatabaseError : CausalDeleteResult
|
||||
}
|
||||
|
||||
data class ExerciseOccurrenceCursor(
|
||||
val startedAt: String,
|
||||
val sessionId: String,
|
||||
|
|
@ -800,10 +809,11 @@ class TrainlogRepository(
|
|||
|
||||
fun listEquipment(): List<EquipmentCatalogEntry> {
|
||||
val output = mutableListOf<EquipmentCatalogEntry>()
|
||||
database.readableDatabase.query(
|
||||
"equipment",
|
||||
arrayOf("equipment_id", "label_name", "display_name", "equipment_type", "load_semantics"),
|
||||
null, null, null, null, "display_name COLLATE NOCASE, equipment_id",
|
||||
database.readableDatabase.rawQuery(
|
||||
"SELECT equipment_id,label_name,display_name,equipment_type,load_semantics FROM equipment e " +
|
||||
"WHERE NOT EXISTS(SELECT 1 FROM sync_causal_state s WHERE s.target_kind='custom_equipment' " +
|
||||
"AND s.target_id=e.equipment_id AND s.deleted=1) ORDER BY display_name COLLATE NOCASE,equipment_id",
|
||||
null,
|
||||
).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val id = cursor.getString(0)
|
||||
|
|
@ -922,6 +932,8 @@ class TrainlogRepository(
|
|||
(if (primaryOnly) " AND selected.role='primary'" else "") + ")"
|
||||
arguments += accepted
|
||||
}
|
||||
selection += "NOT EXISTS(SELECT 1 FROM sync_causal_state retired WHERE " +
|
||||
"retired.target_kind='exercise' AND retired.target_id=e.exercise_id AND retired.deleted=1)"
|
||||
val sql = "SELECT e.exercise_id,e.name,e.normalized_name,e.recording_mode," +
|
||||
"e.tracking_mode,e.data_fields FROM exercises e" +
|
||||
(if (selection.isEmpty()) "" else " WHERE " + selection.joinToString(" AND ")) +
|
||||
|
|
@ -3311,6 +3323,188 @@ class TrainlogRepository(
|
|||
}
|
||||
}
|
||||
|
||||
private val causalKinds = setOf("session", "execution_draft", "exercise",
|
||||
"body_observation", "custom_equipment", "feedback", "body_zone_relation")
|
||||
|
||||
private fun canonicalCausal(value: Any?): String = when (value) {
|
||||
null, JSONObject.NULL -> "null"
|
||||
is JSONObject -> value.keys().asSequence().toList().sorted().joinToString(",", "{", "}") {
|
||||
JSONObject.quote(it) + ":" + canonicalCausal(value.get(it))
|
||||
}
|
||||
is JSONArray -> (0 until value.length()).joinToString(",", "[", "]") { canonicalCausal(value.get(it)) }
|
||||
is String -> JSONObject.quote(value)
|
||||
is Boolean, is Number -> value.toString()
|
||||
else -> error("Unsupported causal value")
|
||||
}
|
||||
|
||||
private fun causalDigest(value: Any): String = MessageDigest.getInstance("SHA-256")
|
||||
.digest(canonicalCausal(value).toByteArray(StandardCharsets.UTF_8))
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
|
||||
private fun causalSnapshot(db: SQLiteDatabase, kind: String, targetId: String): JSONArray? {
|
||||
val query = when (kind) {
|
||||
"session" -> Pair("SELECT session_id,started_at,ended_at,session_type,notes FROM sessions WHERE session_id=?", arrayOf(targetId))
|
||||
"execution_draft" -> Pair("SELECT session_id,revision_id,parent_revision_id,payload_json FROM execution_draft_candidates WHERE session_id=? UNION ALL SELECT session_id,revision_id,parent_revision_id,'' FROM active_session_draft WHERE session_id=?", arrayOf(targetId, targetId))
|
||||
"exercise" -> Pair("SELECT exercise_id,name,recording_mode,tracking_mode,data_fields FROM exercises WHERE exercise_id=?", arrayOf(targetId))
|
||||
"body_observation" -> Pair("SELECT observation_id,observed_at,notes FROM body_observations WHERE observation_id=?", arrayOf(targetId))
|
||||
"custom_equipment" -> Pair("SELECT equipment_id,display_name,label_name,equipment_type,load_semantics FROM equipment WHERE equipment_id=? AND equipment_type='custom_machine'", arrayOf(targetId))
|
||||
"feedback" -> Pair("SELECT feedback_id,observed_at,raw_text FROM exercise_feedback WHERE feedback_id=?", arrayOf(targetId))
|
||||
"body_zone_relation" -> {
|
||||
val parts = targetId.split('|'); if (parts.size != 3) return null
|
||||
Pair("SELECT e.exercise_id,z.zone_id,z.role FROM exercise_body_zones z JOIN exercises e ON e.id=z.exercise_row_id WHERE e.exercise_id=? AND z.zone_id=? AND z.role=?", parts.toTypedArray())
|
||||
}
|
||||
else -> return null
|
||||
}
|
||||
return db.rawQuery(query.first, query.second).use { cursor ->
|
||||
if (!cursor.moveToFirst()) null else JSONArray().also { result ->
|
||||
repeat(cursor.columnCount) { index ->
|
||||
result.put(if (cursor.isNull(index)) JSONObject.NULL else when (cursor.getType(index)) {
|
||||
Cursor.FIELD_TYPE_INTEGER -> cursor.getLong(index)
|
||||
Cursor.FIELD_TYPE_FLOAT -> cursor.getDouble(index)
|
||||
else -> cursor.getString(index)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun causalLiveRevision(db: SQLiteDatabase, kind: String, targetId: String): String? =
|
||||
causalSnapshot(db, kind, targetId)?.let { "lv_${causalDigest(it)}" }
|
||||
|
||||
private fun applyCausalEffect(db: SQLiteDatabase, kind: String, targetId: String, operationId: String) {
|
||||
when (kind) {
|
||||
"session" -> {
|
||||
db.execSQL("UPDATE body_observations SET session_row_id=NULL WHERE session_row_id=(SELECT id FROM sessions WHERE session_id=?)", arrayOf(targetId))
|
||||
db.delete("sessions", "session_id=?", arrayOf(targetId))
|
||||
db.insertWithOnConflict("execution_draft_finalizations", null, ContentValues().apply {
|
||||
put("session_id", targetId); put("final_revision_id", "deleted:$operationId")
|
||||
put("finalized_at", OffsetDateTime.now().toString())
|
||||
}, SQLiteDatabase.CONFLICT_IGNORE)
|
||||
}
|
||||
"execution_draft" -> {
|
||||
if (db.rawQuery("SELECT 1 FROM execution_draft_finalizations WHERE session_id=?", arrayOf(targetId)).use { it.moveToFirst() })
|
||||
throw SyncLifecycleConflict(targetId)
|
||||
db.delete("execution_draft_candidates", "session_id=?", arrayOf(targetId))
|
||||
db.delete("active_session_draft", "session_id=?", arrayOf(targetId))
|
||||
}
|
||||
"body_observation" -> db.delete("body_observations", "observation_id=?", arrayOf(targetId))
|
||||
"body_zone_relation" -> {
|
||||
val parts = targetId.split('|')
|
||||
db.execSQL("DELETE FROM exercise_body_zones WHERE exercise_row_id=(SELECT id FROM exercises WHERE exercise_id=?) AND zone_id=? AND role=?", parts.toTypedArray())
|
||||
}
|
||||
// Retirement/withdrawal retains the historic row and immutable revisions.
|
||||
"exercise", "custom_equipment", "feedback" -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateCausalOperation(value: JSONObject): Boolean {
|
||||
val keys = setOf("operation_id", "target_kind", "target_id", "creator_id",
|
||||
"predecessor_revision_id", "created_at", "payload_sha256", "publication_context")
|
||||
if (!value.hasExactKeys(keys) || value.getString("target_kind") !in causalKinds ||
|
||||
!value.isNull("publication_context") || TrainlogTimestamp.parse(value.getString("created_at")) == null) return false
|
||||
for ((key, maximum) in listOf("operation_id" to 128, "target_id" to 512,
|
||||
"creator_id" to 128, "predecessor_revision_id" to 128)) {
|
||||
val text = value.optString(key); if (text.isEmpty() || text.toByteArray().size > maximum) return false
|
||||
}
|
||||
val hash = value.optString("payload_sha256")
|
||||
if (!hash.matches(Regex("^[0-9a-f]{64}$"))) return false
|
||||
val payload = JSONObject(value.toString()).apply { remove("payload_sha256") }
|
||||
return causalDigest(payload) == hash
|
||||
}
|
||||
|
||||
private fun applyCausalOperation(db: SQLiteDatabase, operation: JSONObject): CausalDeleteResult {
|
||||
if (!validateCausalOperation(operation)) return CausalDeleteResult.Invalid
|
||||
val operationId = operation.getString("operation_id")
|
||||
val known = db.rawQuery("SELECT target_kind,target_id,creator_id,predecessor_revision_id,created_at,payload_sha256 FROM sync_causal_operations WHERE operation_id=?", arrayOf(operationId)).use { cursor ->
|
||||
if (!cursor.moveToFirst()) null else (0 until 6).map { cursor.getString(it) }
|
||||
}
|
||||
val expected = listOf("target_kind", "target_id", "creator_id", "predecessor_revision_id", "created_at", "payload_sha256").map(operation::getString)
|
||||
if (known != null) return if (known == expected) CausalDeleteResult.Unchanged(operationId) else CausalDeleteResult.Conflict(operation.getString("target_id"))
|
||||
val kind = operation.getString("target_kind"); val target = operation.getString("target_id")
|
||||
val state = db.rawQuery("SELECT current_revision_id,deleted FROM sync_causal_state WHERE target_kind=? AND target_id=?", arrayOf(kind, target)).use {
|
||||
if (!it.moveToFirst()) null else Pair(it.getString(0), it.getInt(1) != 0)
|
||||
}
|
||||
if (state?.second == true) return CausalDeleteResult.Conflict(target)
|
||||
val current = state?.first ?: causalLiveRevision(db, kind, target) ?: return CausalDeleteResult.NotFound
|
||||
if (current != operation.getString("predecessor_revision_id")) return CausalDeleteResult.Conflict(target)
|
||||
db.insertOrThrow("sync_causal_operations", null, ContentValues().apply {
|
||||
put("operation_id", operationId); put("target_kind", kind); put("target_id", target)
|
||||
put("creator_id", operation.getString("creator_id")); put("predecessor_revision_id", current)
|
||||
put("created_at", operation.getString("created_at")); put("payload_sha256", operation.getString("payload_sha256"))
|
||||
})
|
||||
applyCausalEffect(db, kind, target, operationId)
|
||||
db.insertWithOnConflict("sync_causal_state", null, ContentValues().apply {
|
||||
put("target_kind", kind); put("target_id", target); put("current_revision_id", operationId)
|
||||
put("deleted", 1); put("operation_id", operationId)
|
||||
}, SQLiteDatabase.CONFLICT_REPLACE)
|
||||
return CausalDeleteResult.Applied(operationId)
|
||||
}
|
||||
|
||||
/** Create one durable local deletion without selecting any transport. */
|
||||
fun deleteCausally(kind: String, targetId: String, creatorId: String): CausalDeleteResult {
|
||||
if (kind !in causalKinds || targetId.isBlank() || creatorId.isBlank()) return CausalDeleteResult.Invalid
|
||||
if (kind == "exercise" && trainingKnowledge.getExerciseKnowledge(targetId) != null)
|
||||
return CausalDeleteResult.Invalid
|
||||
val db = database.writableDatabase
|
||||
return try {
|
||||
db.beginTransaction()
|
||||
val predecessor = causalLiveRevision(db, kind, targetId)
|
||||
if (predecessor == null) {
|
||||
val existing = db.rawQuery("SELECT operation_id FROM sync_causal_state WHERE target_kind=? AND target_id=? AND deleted=1", arrayOf(kind, targetId)).use { if (it.moveToFirst()) it.getString(0) else null }
|
||||
if (existing != null) return CausalDeleteResult.Unchanged(existing)
|
||||
return CausalDeleteResult.NotFound
|
||||
}
|
||||
val operation = JSONObject().put("operation_id", "del_${UUID.randomUUID()}")
|
||||
.put("target_kind", kind).put("target_id", targetId).put("creator_id", creatorId)
|
||||
.put("predecessor_revision_id", predecessor).put("created_at", OffsetDateTime.now().toString())
|
||||
.put("publication_context", JSONObject.NULL)
|
||||
operation.put("payload_sha256", causalDigest(operation))
|
||||
val result = applyCausalOperation(db, operation)
|
||||
if (result is CausalDeleteResult.Applied) db.setTransactionSuccessful()
|
||||
result
|
||||
} catch (_: SyncLifecycleConflict) { CausalDeleteResult.Conflict(targetId)
|
||||
} catch (_: Exception) { CausalDeleteResult.DatabaseError
|
||||
} finally { db.endTransaction() }
|
||||
}
|
||||
|
||||
fun buildCausalDeletionExportV1Json(): String {
|
||||
val db = database.readableDatabase
|
||||
val operations = JSONArray()
|
||||
db.rawQuery("SELECT operation_id,target_kind,target_id,creator_id,predecessor_revision_id,created_at,payload_sha256 FROM sync_causal_operations ORDER BY operation_id", null).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
check(operations.length() < MAX_CAUSAL_OPERATIONS) { "Causal protection set exceeds artifact bound." }
|
||||
operations.put(JSONObject().put("operation_id", cursor.getString(0)).put("target_kind", cursor.getString(1))
|
||||
.put("target_id", cursor.getString(2)).put("creator_id", cursor.getString(3))
|
||||
.put("predecessor_revision_id", cursor.getString(4)).put("created_at", cursor.getString(5))
|
||||
.put("payload_sha256", cursor.getString(6)).put("publication_context", JSONObject.NULL))
|
||||
}
|
||||
}
|
||||
return JSONObject().put("format", "trainlog-causal-deletions").put("version", 1)
|
||||
.put("generated_at", OffsetDateTime.now().toString()).put("operations", operations).toString()
|
||||
}
|
||||
|
||||
fun applyCausalDeletionExportV1Json(json: String): CausalDeleteResult {
|
||||
if (json.toByteArray().size > 4 * 1024 * 1024 || !hasStrictJsonShape(json)) return CausalDeleteResult.Invalid
|
||||
val root = try { JSONObject(json) } catch (_: Exception) { return CausalDeleteResult.Invalid }
|
||||
if (!root.hasExactKeys(setOf("format", "version", "generated_at", "operations")) ||
|
||||
root.optString("format") != "trainlog-causal-deletions" || !root.value("version").isJsonInt(1, 1) ||
|
||||
TrainlogTimestamp.parse(root.optString("generated_at")) == null) return CausalDeleteResult.Invalid
|
||||
val values = root.optJSONArray("operations") ?: return CausalDeleteResult.Invalid
|
||||
if (values.length() > MAX_CAUSAL_OPERATIONS) return CausalDeleteResult.Invalid
|
||||
val ids = mutableSetOf<String>(); val db = database.writableDatabase
|
||||
return try {
|
||||
db.beginTransaction(); var last: CausalDeleteResult = CausalDeleteResult.Unchanged("")
|
||||
repeat(values.length()) { index ->
|
||||
val operation = values.getJSONObject(index)
|
||||
if (!ids.add(operation.optString("operation_id"))) return CausalDeleteResult.Invalid
|
||||
last = applyCausalOperation(db, operation)
|
||||
if (last !is CausalDeleteResult.Applied && last !is CausalDeleteResult.Unchanged) return last
|
||||
}
|
||||
db.setTransactionSuccessful(); last
|
||||
} catch (_: Exception) { CausalDeleteResult.DatabaseError
|
||||
} finally { db.endTransaction() }
|
||||
}
|
||||
|
||||
fun buildMobileExportJson(): String = buildMobileExport(1)
|
||||
|
||||
/** Create one direct causal note revision for a stable history owner. */
|
||||
|
|
@ -3450,6 +3644,11 @@ class TrainlogRepository(
|
|||
|
||||
private fun buildMobileExport(version: Int): String {
|
||||
require(version in 1..4)
|
||||
if (database.readableDatabase.rawQuery(
|
||||
"SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1", null,
|
||||
).use { it.moveToFirst() }) {
|
||||
error("Causal protection refuses a mobile snapshot that omits tombstones.")
|
||||
}
|
||||
val root = JSONObject()
|
||||
root.put("format", "trainlog-mobile-export")
|
||||
root.put("version", version)
|
||||
|
|
@ -3785,6 +3984,8 @@ class TrainlogRepository(
|
|||
db.beginTransaction()
|
||||
for ((payload, draft) in parsed) {
|
||||
val sessionId = checkNotNull(draft.sessionId); val encoded = payload.toString()
|
||||
if (db.rawQuery("SELECT 1 FROM sync_causal_state WHERE target_kind='execution_draft' AND target_id=? AND deleted=1",
|
||||
arrayOf(sessionId)).use { it.moveToFirst() }) { stale += 1; continue }
|
||||
if (db.rawQuery("SELECT 1 FROM execution_draft_finalizations WHERE session_id=?",
|
||||
arrayOf(sessionId)).use { it.moveToFirst() }) { stale += 1; continue }
|
||||
val current = db.rawQuery(
|
||||
|
|
@ -3960,6 +4161,21 @@ class TrainlogRepository(
|
|||
validatePcMobileExport(root, version)?.let { return MobileSessionImportResult.Invalid(it) }
|
||||
val sessions = root.getJSONArray("sessions")
|
||||
val db = database.writableDatabase
|
||||
fun protected(kind: String, identity: String): Boolean = db.rawQuery(
|
||||
"SELECT 1 FROM sync_causal_state WHERE target_kind=? AND target_id=? AND deleted=1",
|
||||
arrayOf(kind, identity),
|
||||
).use { it.moveToFirst() }
|
||||
for (index in 0 until sessions.length()) if (protected(
|
||||
"session", sessions.getJSONObject(index).getString("session_id")))
|
||||
return MobileSessionImportResult.Invalid("Causal protection refuses a deleted session replay.")
|
||||
val bodies = root.getJSONArray("body_observations")
|
||||
for (index in 0 until bodies.length()) if (protected(
|
||||
"body_observation", bodies.getJSONObject(index).getString("observation_id")))
|
||||
return MobileSessionImportResult.Invalid("Causal protection refuses a deleted observation replay.")
|
||||
val catalog = root.getJSONArray("exercises")
|
||||
for (index in 0 until catalog.length()) if (protected(
|
||||
"exercise", catalog.getJSONObject(index).getString("exercise_id")))
|
||||
return MobileSessionImportResult.Invalid("Causal protection refuses a retired exercise replay.")
|
||||
var sessionsAdded = 0
|
||||
var sessionsSkipped = 0
|
||||
var sessionsUpdated = 0
|
||||
|
|
@ -7472,6 +7688,7 @@ 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_CAUSAL_OPERATIONS = 4096
|
||||
private const val MAX_PLAN_SETS = 64
|
||||
private const val MAX_PLAN_REPS = 10000
|
||||
private const val MAX_PLAN_DURATION_SECONDS = 86400
|
||||
|
|
@ -7513,7 +7730,7 @@ private class TrainlogDatabaseHelper(
|
|||
appContext,
|
||||
databaseName,
|
||||
null,
|
||||
18,
|
||||
19,
|
||||
) {
|
||||
override fun onConfigure(
|
||||
db: SQLiteDatabase,
|
||||
|
|
@ -7556,6 +7773,7 @@ private class TrainlogDatabaseHelper(
|
|||
createExerciseProfileStateTable(db, seedLegacy = false)
|
||||
createAiSessionDraftTables(db)
|
||||
createSyncDataLifecycleTables(db)
|
||||
createCausalDeleteTables(db)
|
||||
seedEquipment(db)
|
||||
}
|
||||
|
||||
|
|
@ -7707,6 +7925,15 @@ private class TrainlogDatabaseHelper(
|
|||
version = 18
|
||||
}
|
||||
|
||||
if (version < 19 && newVersion >= 19) {
|
||||
/* WHY: old peer snapshots can otherwise recreate intentionally
|
||||
* deleted identities. CONTRACT: v19 is additive and stores only
|
||||
* immutable causal operations/current state. INVARIANT: migration
|
||||
* invents no deletion, ancestry, peer or publication context. */
|
||||
createCausalDeleteTables(db)
|
||||
version = 19
|
||||
}
|
||||
|
||||
if (version != newVersion) {
|
||||
error(
|
||||
"Unsupported Android DB upgrade " +
|
||||
|
|
@ -7770,6 +7997,31 @@ private class TrainlogDatabaseHelper(
|
|||
)
|
||||
}
|
||||
|
||||
private fun createCausalDeleteTables(db: SQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS sync_causal_operations(" +
|
||||
"operation_id TEXT PRIMARY KEY,target_kind TEXT NOT NULL,target_id TEXT NOT NULL," +
|
||||
"creator_id TEXT NOT NULL,predecessor_revision_id TEXT NOT NULL,created_at TEXT NOT NULL," +
|
||||
"payload_sha256 TEXT NOT NULL,publication_context TEXT," +
|
||||
"CHECK(length(operation_id) BETWEEN 1 AND 128)," +
|
||||
"CHECK(length(target_id) BETWEEN 1 AND 512)," +
|
||||
"CHECK(length(creator_id) BETWEEN 1 AND 128)," +
|
||||
"CHECK(length(predecessor_revision_id) BETWEEN 1 AND 128)," +
|
||||
"CHECK(length(payload_sha256)=64));",
|
||||
)
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS sync_causal_state(" +
|
||||
"target_kind TEXT NOT NULL,target_id TEXT NOT NULL,current_revision_id TEXT NOT NULL," +
|
||||
"deleted INTEGER NOT NULL CHECK(deleted IN(0,1)),operation_id TEXT," +
|
||||
"PRIMARY KEY(target_kind,target_id)," +
|
||||
"FOREIGN KEY(operation_id) REFERENCES sync_causal_operations(operation_id));",
|
||||
)
|
||||
db.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS sync_causal_operations_target " +
|
||||
"ON sync_causal_operations(target_kind,target_id);",
|
||||
)
|
||||
}
|
||||
|
||||
private fun createAiSessionDraftTables(db: SQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS ai_session_drafts(" +
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest {
|
|||
|
||||
TrainlogRepository(context, name).useForTest { it.listSessions() }
|
||||
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(18, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(19, scalar(db, "PRAGMA user_version"))
|
||||
for (table in listOf("session_exercises", "draft_session_exercises")) {
|
||||
assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')"))
|
||||
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL"))
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ class AndroidV11ExerciseAliasMigrationTest {
|
|||
migrated.close()
|
||||
}
|
||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(18, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(19, scalar(db, "PRAGMA user_version"))
|
||||
/* v13 intentionally adds the frozen machine identities and
|
||||
* exercise-owned metadata; all non-exercise facts stay exact. */
|
||||
val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") }
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ class AndroidV16AiSessionDraftMigrationTest {
|
|||
migrated.close()
|
||||
}
|
||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(18, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(19, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(before, listOf(
|
||||
scalar(db, "SELECT COUNT(*) FROM sessions"),
|
||||
scalar(db, "SELECT COUNT(*) FROM performed_sets"),
|
||||
|
|
|
|||
|
|
@ -328,7 +328,7 @@ class BodyZonesTest {
|
|||
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
|
||||
SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
db.rawQuery("PRAGMA user_version", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst()); assertEquals(18, cursor.getInt(0))
|
||||
assertTrue(cursor.moveToFirst()); assertEquals(19, cursor.getInt(0))
|
||||
}
|
||||
db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))
|
||||
|
|
|
|||
|
|
@ -649,6 +649,81 @@ class TrainlogRepositoryDraftTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun causalSessionDeletionRoundTripsWithBothRealProducersAndBlocksReplay() {
|
||||
val sourceName = "delete-source-${UUID.randomUUID()}.db"
|
||||
val destinationName = "delete-destination-${UUID.randomUUID()}.db"
|
||||
val source = TrainlogRepository(context, sourceName)
|
||||
val destination = TrainlogRepository(context, destinationName)
|
||||
val root = Files.createTempDirectory("trainlog-causal-delete-").toFile()
|
||||
try {
|
||||
val exercise = createExercise(source, "Delete bridge", RecordingMode.SETS, TrackingMode.REPS)
|
||||
assertTrue(source.saveSession(SessionDraft(listOf(SessionExerciseDraft(
|
||||
entryId = "sxe_60000000-0000-4000-8000-000000000001",
|
||||
exercise = exercise, sets = listOf(SessionSetDraft(reps = 9)),
|
||||
)))) is SaveSessionResult.Saved)
|
||||
val live = File(root, "live-v4.json").apply { writeText(source.buildMobileExportV4Json()) }
|
||||
val sessionId = JSONObject(live.readText()).getJSONArray("sessions")
|
||||
.getJSONObject(0).getString("session_id")
|
||||
|
||||
val androidDelete = source.deleteCausally("session", sessionId, "peer_android_test")
|
||||
assertTrue(androidDelete is CausalDeleteResult.Applied)
|
||||
val androidCausal = File(root, "android-causal.json").apply {
|
||||
writeText(source.buildCausalDeletionExportV1Json())
|
||||
}
|
||||
try { source.buildMobileExportV4Json(); fail("protected legacy export must fail")
|
||||
} catch (_: IllegalStateException) { }
|
||||
|
||||
val desktop = File(root, "desktop.sqlite")
|
||||
runLifecycleBridge("history", live, desktop, File(root, "desktop-live-v4.json"))
|
||||
runDesktopCausal("import", androidCausal, desktop)
|
||||
val desktopCausal = File(root, "desktop-causal.json")
|
||||
runDesktopCausal("export", desktopCausal, desktop)
|
||||
|
||||
val catalog = JSONObject().put("format", "trainlog-pc-catalog").put("version", 1)
|
||||
.put("exercises", JSONObject(live.readText()).getJSONArray("exercises"))
|
||||
assertTrue(destination.applyPcCatalogJson(catalog.toString()) is PcCatalogImportResult.Applied)
|
||||
assertTrue(destination.applyPcMobileExportV4Json(live.readText()) is MobileSessionImportResult.Applied)
|
||||
assertTrue(destination.applyCausalDeletionExportV1Json(desktopCausal.readText()) is CausalDeleteResult.Applied)
|
||||
assertTrue(destination.listSessions().isEmpty())
|
||||
assertTrue(destination.applyCausalDeletionExportV1Json(desktopCausal.readText()) is CausalDeleteResult.Unchanged)
|
||||
assertTrue(destination.applyPcMobileExportV4Json(live.readText()) is MobileSessionImportResult.Invalid)
|
||||
destination.close()
|
||||
val reopened = TrainlogRepository(context, destinationName)
|
||||
assertTrue(reopened.applyCausalDeletionExportV1Json(desktopCausal.readText()) is CausalDeleteResult.Unchanged)
|
||||
assertTrue(reopened.listSessions().isEmpty())
|
||||
reopened.close()
|
||||
|
||||
val desktopProducer = File(root, "desktop-producer.sqlite")
|
||||
runLifecycleBridge("history", live, desktopProducer, File(root, "desktop-producer-live.json"))
|
||||
val localOperation = File(root, "desktop-local-operation.json")
|
||||
runDesktopCausal("delete", localOperation, desktopProducer, "session", sessionId)
|
||||
val desktopProduced = File(root, "desktop-produced.json")
|
||||
runDesktopCausal("export", desktopProduced, desktopProducer)
|
||||
val reverseName = "delete-reverse-${UUID.randomUUID()}.db"
|
||||
val reverse = TrainlogRepository(context, reverseName)
|
||||
assertTrue(reverse.applyPcCatalogJson(catalog.toString()) is PcCatalogImportResult.Applied)
|
||||
assertTrue(reverse.applyPcMobileExportV4Json(live.readText()) is MobileSessionImportResult.Applied)
|
||||
assertTrue(reverse.applyCausalDeletionExportV1Json(desktopProduced.readText()) is CausalDeleteResult.Applied)
|
||||
val returned = File(root, "android-returned-causal.json").apply {
|
||||
writeText(reverse.buildCausalDeletionExportV1Json())
|
||||
}
|
||||
val freshDesktop = File(root, "desktop-fresh.sqlite")
|
||||
runLifecycleBridge("history", live, freshDesktop, File(root, "desktop-fresh-live.json"))
|
||||
runDesktopCausal("import", returned, freshDesktop)
|
||||
val finalArtifact = File(root, "desktop-final-causal.json")
|
||||
runDesktopCausal("export", finalArtifact, freshDesktop)
|
||||
assertEquals(semanticCausalProjection(JSONObject(desktopProduced.readText())),
|
||||
semanticCausalProjection(JSONObject(finalArtifact.readText())))
|
||||
reverse.close(); context.deleteDatabase(reverseName)
|
||||
} finally {
|
||||
try { source.close() } catch (_: Exception) { }
|
||||
try { destination.close() } catch (_: Exception) { }
|
||||
context.deleteDatabase(sourceName); context.deleteDatabase(destinationName)
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun repeatedContinuousExercisePersistsDistinctOccurrencesAcrossReopenAndFinalize() {
|
||||
val repo = openRepository()
|
||||
|
|
@ -1936,7 +2011,7 @@ class TrainlogRepositoryDraftTest {
|
|||
).use { db ->
|
||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals(18, cursor.getInt(0))
|
||||
assertEquals(19, cursor.getInt(0))
|
||||
}
|
||||
db.rawQuery(
|
||||
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
|
||||
|
|
@ -2063,7 +2138,7 @@ class TrainlogRepositoryDraftTest {
|
|||
).use { db ->
|
||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals(18, cursor.getInt(0))
|
||||
assertEquals(19, cursor.getInt(0))
|
||||
}
|
||||
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
|
|
@ -2738,6 +2813,32 @@ class TrainlogRepositoryDraftTest {
|
|||
return canonicalJson(copy).toString()
|
||||
}
|
||||
|
||||
private fun semanticCausalProjection(value: JSONObject): String {
|
||||
val copy = JSONObject(value.toString()); copy.remove("generated_at")
|
||||
val source = copy.getJSONArray("operations")
|
||||
val sorted = (0 until source.length()).map { source.getJSONObject(it) }
|
||||
.sortedBy { it.getString("operation_id") }
|
||||
copy.put("operations", JSONArray().also { output -> sorted.forEach(output::put) })
|
||||
return canonicalJson(copy).toString()
|
||||
}
|
||||
|
||||
private fun runDesktopCausal(
|
||||
mode: String,
|
||||
artifact: File,
|
||||
database: File,
|
||||
kind: String? = null,
|
||||
targetId: String? = null,
|
||||
) {
|
||||
val tool = findRepositoryFile("tools/causal_delete_exchange.py")
|
||||
val arguments = mutableListOf("python3", tool.absolutePath, mode, artifact.absolutePath,
|
||||
"--database", database.absolutePath)
|
||||
if (kind != null && targetId != null) arguments += listOf(
|
||||
"--kind", kind, "--target-id", targetId, "--creator-id", "peer_desktop_test")
|
||||
val process = ProcessBuilder(arguments).redirectErrorStream(true).start()
|
||||
val text = process.inputStream.bufferedReader().readText()
|
||||
assertEquals("desktop causal tool failed: $text", 0, process.waitFor())
|
||||
}
|
||||
|
||||
private fun runLifecycleBridge(
|
||||
kind: String,
|
||||
input: File,
|
||||
|
|
|
|||
|
|
@ -910,3 +910,7 @@ Cancel and Back are safe, outside taps cannot confirm, and the destructive
|
|||
button is explicit. Draft occurrence warnings name nonzero performed-set,
|
||||
continuous, MAX and feedback children; series deletion and whole-draft abandon
|
||||
use the same gate.
|
||||
Android schema v19 additively stores the same immutable causal-deletion
|
||||
operations and current deleted-target state as desktop v20. Existing data is
|
||||
preserved and no migration-time ancestry is invented. Repository entry points
|
||||
create, validate, apply, export, and import the staged causal artifact.
|
||||
|
|
|
|||
|
|
@ -25,8 +25,8 @@ parallel implementation of its rules.
|
|||
| Boundary | Current state |
|
||||
|---|---|
|
||||
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
|
||||
| Desktop SQLite | schema v19 |
|
||||
| Android SQLite | schema v18 |
|
||||
| Desktop SQLite | schema v20 |
|
||||
| Android SQLite | schema v19 |
|
||||
| Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport |
|
||||
| Desktop terminal backend | Notcurses only |
|
||||
| Trainlog product version | `0.1.2` development, synchronized across Android and desktop; latest stable release: `v0.1.1` |
|
||||
|
|
@ -289,6 +289,19 @@ smoke test are not automated. The latter is why
|
|||
|
||||
## Active limitations
|
||||
|
||||
`TRAINLOG_SYNC_CAUSAL_DELETE_V1=PASS/FROZEN` provides isolated Android and
|
||||
desktop producers/consumers for `trainlog-causal-deletions` V1. All contracted
|
||||
domains are durably protected; exact replay is idempotent and unprovable or
|
||||
concurrent ancestry conflicts before mutation. Protected legacy snapshots are
|
||||
refused before resurrection. Active synchronization still selects V3: no
|
||||
generation acknowledgement, service activation, deployment, or user-data
|
||||
migration occurred.
|
||||
|
||||
The isolated closeout passed 76/76 normal Meson tests, 76/76 ASan/UBSan
|
||||
tests, Android debug assembly, and 208 Android tests (204 passed, four optional
|
||||
historical-fixture skips). Strict desktop builds use Clang 22.1.8; the compared
|
||||
pre-existing GCC 16.2.1 Web warning gate remains documented rather than green.
|
||||
|
||||
- `WEB_NEXT_MODULE_SELECTION_V1`: Analyse, Programmes, Séances and Exercices
|
||||
remain shell placeholders until their Core/API ownership and implementation
|
||||
order are explicitly contracted.
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ is a later, separately versioned migration.
|
|||
## 1. Status
|
||||
|
||||
```text
|
||||
TRAINLOG_DATABASE_SCHEMA_VERSION=19
|
||||
DATABASE_SCHEMA_V19=IMPLEMENTED
|
||||
TRAINLOG_DATABASE_SCHEMA_VERSION=20
|
||||
DATABASE_SCHEMA_V20=IMPLEMENTED
|
||||
TRAINLOG_FORMAT_V1=FROZEN
|
||||
```
|
||||
|
||||
|
|
@ -36,7 +36,7 @@ PRAGMA user_version;
|
|||
Current value:
|
||||
|
||||
```text
|
||||
19
|
||||
20
|
||||
```
|
||||
|
||||
The independent actual-set loads documented in the current desktop, Android
|
||||
|
|
@ -49,6 +49,14 @@ Supported historical databases are migrated explicitly through the implemented
|
|||
migration chain. A database newer than the running binary understands is
|
||||
rejected.
|
||||
|
||||
Schema v20 additively introduces `sync_causal_operations` and
|
||||
`sync_causal_state`. Operations are immutable and identify their target,
|
||||
creator, causal predecessor, timestamp, payload digest, and an unassigned
|
||||
publication context. Target state records the durable deleted revision. The
|
||||
v19→v20 migration creates empty protection tables and does not fabricate
|
||||
ancestry for existing rows. Applying an operation, recording its protection,
|
||||
and performing dependent changes share one transaction.
|
||||
|
||||
Version 19 additively stores causal note revisions/current state and staged
|
||||
execution drafts, their immutable revisions, pending/active state and durable
|
||||
finalization ledger. It changes no published V1 format and performs no user
|
||||
|
|
|
|||
|
|
@ -645,3 +645,17 @@ Canonical validation requires both:
|
|||
python tools/validate_json.py
|
||||
python tools/validate_import_contract.py
|
||||
```
|
||||
## Staged causal deletion V1
|
||||
|
||||
`trainlog-causal-deletions` V1 is independent of every published snapshot.
|
||||
Its root contains `format`, integer `version`, `generated_at`, and at most 4096
|
||||
`operations`; the complete UTF-8 artifact is limited to 4 MiB. Each operation
|
||||
contains `operation_id`, `target_kind`, `target_id`, `creator_id`,
|
||||
`predecessor_revision_id`, `created_at`, `payload_sha256`, and a null
|
||||
`publication_context`. Unknown fields, duplicate keys/IDs, invalid timestamps,
|
||||
invalid hashes, unknown ancestry, and concurrent ancestry are rejected
|
||||
atomically. Timestamps are evidence, never last-writer-wins authority.
|
||||
|
||||
The artifact is available only through explicit repository/tool entry points.
|
||||
It is not selected by V3 transport. Null publication context is the typed
|
||||
handoff to the later generation/acknowledgement tranche, not consumption proof.
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ distinction between measured values and estimates.
|
|||
|
||||
## Current baseline
|
||||
|
||||
Desktop schema v19, Android schema v18, Notcurses, direct
|
||||
Desktop schema v20, Android schema v19, Notcurses, direct
|
||||
`Documents/Trainlog` storage, mobile export V3, Training Knowledge V1, Body
|
||||
Zones V1, Training Feedback V1/V2, STATS V1, and Session Generator V1 are
|
||||
implemented. Session Generator V1 is hidden pending V2. AI session-draft
|
||||
|
|
@ -33,7 +33,7 @@ visualizations are implemented; the other four Web routes remain placeholders.
|
|||
## Current cursor
|
||||
|
||||
```text
|
||||
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_SYNC_CAUSAL_DELETE_V1
|
||||
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_SYNC_GENERATION_ACK_V1
|
||||
```
|
||||
|
||||
`TRAINLOG_SYNC_GAP_CONTRACT_V1=CONTRACT_FROZEN / IMPLEMENTATION_NOT_STARTED`
|
||||
|
|
@ -125,9 +125,9 @@ characterization, data/lifecycles, causal deletion, generation/consumption/ack,
|
|||
orchestrator/report, Web API, then button/refresh. The environment/test and
|
||||
characterization and data/lifecycle lots are complete. Enriched V4 history and
|
||||
the separate execution-draft codec are implemented behind explicit entry
|
||||
points, while active transport remains V3. Causal deletion,
|
||||
generation/consumption acknowledgement, orchestration/reporting and Web sync
|
||||
remain unimplemented.
|
||||
points, while active transport remains V3. Causal deletion is implemented and
|
||||
validated through explicit staged entry points; generation/consumption
|
||||
acknowledgement, orchestration/reporting and Web sync remain unimplemented.
|
||||
`WEB_DASHBOARD_V1=PASS/FROZEN` remains closed and unchanged.
|
||||
|
||||
### Gym catalog V1
|
||||
|
|
|
|||
|
|
@ -1,8 +1,17 @@
|
|||
# Synchronization exchange
|
||||
|
||||
## Causal deletion safety gate
|
||||
|
||||
Android and desktop expose staged producers and consumers for the independent
|
||||
`trainlog-causal-deletions` V1 artifact; it is not in the active MTP bundle. A
|
||||
protected store refuses legacy mobile snapshot export, and legacy mobile import
|
||||
rejects protected session, observation, or exercise identities before mutation.
|
||||
Deleted execution-draft replay is stale. Tombstone-free V1/V2/V3 behavior is
|
||||
unchanged.
|
||||
|
||||
## Machine-exercise Phase 1 compatibility
|
||||
|
||||
Desktop schema v19 and Android schema v18 retain mobile export V3, readable V1/V2 imports,
|
||||
Desktop schema v20 and Android schema v19 retain mobile export V3, readable V1/V2 imports,
|
||||
equipment definitions V1, equipment associations V2, exercise aliases V1, and
|
||||
the BODY ZONES companion without wire-format changes. Machine metadata is not
|
||||
silently added to a frozen artifact: stable exercise IDs and canonical names
|
||||
|
|
|
|||
|
|
@ -1190,3 +1190,20 @@ Documents and Download roots while failing on any legacy access.
|
|||
Android unit tests and any instrumented tests run on an emulator; the primary
|
||||
personal phone is reserved for manual, non-destructive install-and-sync smoke
|
||||
validation and must not run `connectedDebugAndroidTest`.
|
||||
## Causal deletion V1
|
||||
|
||||
The normal Meson inventory includes `causal_delete_exchange`, which invokes
|
||||
the production desktop service and proves all target kinds, relationship
|
||||
safety, exact replay, conflicts, finalization protection, and rollback. Android
|
||||
`causalSessionDeletionRoundTripsWithBothRealProducersAndBlocksReplay` uses the
|
||||
real Android repository and desktop service in both producer directions with
|
||||
fresh destinations, reopen/replay, and legacy non-resurrection.
|
||||
|
||||
Strict compilation uses Clang 22.1.8. GCC 16.2.1 retains the compared
|
||||
pre-existing `-Werror=misleading-indentation` failure in unchanged Web baseline
|
||||
sources; it is not reported as green and warning policy is unchanged.
|
||||
|
||||
The causal closeout full harness result is 76/76 normal Meson, 76/76
|
||||
ASan/UBSan, and 208 Android tests: 204 passed and four optional historical
|
||||
database fixtures skipped. `assembleDebug`, JSON validation, import-contract
|
||||
validation, both frontend tests, and the isolated characterization all pass.
|
||||
|
|
|
|||
|
|
@ -24,7 +24,9 @@ CREATE TABLE sync_note_state(owner_kind TEXT NOT NULL,owner_id TEXT NOT NULL,rev
|
|||
CREATE TABLE execution_drafts(session_id TEXT PRIMARY KEY,session_type TEXT NOT NULL,source_session_id TEXT,started_at TEXT,revision_id TEXT NOT NULL,parent_revision_id TEXT,state TEXT NOT NULL,payload_json TEXT NOT NULL);
|
||||
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;
|
||||
CREATE TABLE sync_causal_operations(operation_id TEXT PRIMARY KEY,target_kind TEXT NOT NULL,target_id TEXT NOT NULL,creator_id TEXT NOT NULL,predecessor_revision_id TEXT NOT NULL,created_at TEXT NOT NULL,payload_sha256 TEXT NOT NULL,publication_context TEXT);
|
||||
CREATE TABLE sync_causal_state(target_kind TEXT NOT NULL,target_id TEXT NOT NULL,current_revision_id TEXT NOT NULL,deleted INTEGER NOT NULL,operation_id TEXT,PRIMARY KEY(target_kind,target_id));
|
||||
PRAGMA user_version=20;
|
||||
"""
|
||||
|
||||
|
||||
|
|
|
|||
105
tests/test_causal_delete_exchange.py
Normal file
105
tests/test_causal_delete_exchange.py
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Production-service regressions for staged causal deletion."""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
SPEC = importlib.util.spec_from_file_location("causal", ROOT / "tools/causal_delete_exchange.py")
|
||||
causal = importlib.util.module_from_spec(SPEC); SPEC.loader.exec_module(causal)
|
||||
|
||||
SCHEMA = """
|
||||
PRAGMA foreign_keys=ON;
|
||||
CREATE TABLE exercises(id INTEGER PRIMARY KEY,exercise_id TEXT UNIQUE,name TEXT,recording_mode TEXT,tracking_mode TEXT,data_fields INTEGER);
|
||||
CREATE TABLE sessions(id INTEGER PRIMARY KEY,session_id TEXT UNIQUE,started_at TEXT,ended_at TEXT,session_type TEXT,notes TEXT);
|
||||
CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,session_row_id INTEGER REFERENCES sessions(id) ON DELETE CASCADE,exercise_row_id INTEGER REFERENCES exercises(id),entry_id TEXT UNIQUE);
|
||||
CREATE TABLE performed_sets(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER REFERENCES session_exercises(id) ON DELETE CASCADE);
|
||||
CREATE TABLE continuous_activity(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER REFERENCES session_exercises(id) ON DELETE CASCADE);
|
||||
CREATE TABLE max_results(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER REFERENCES session_exercises(id) ON DELETE CASCADE);
|
||||
CREATE TABLE body_observations(id INTEGER PRIMARY KEY,observation_id TEXT UNIQUE,observed_at TEXT,session_row_id INTEGER REFERENCES sessions(id) ON DELETE SET NULL,notes TEXT);
|
||||
CREATE TABLE custom_equipment(equipment_id TEXT PRIMARY KEY,display_name TEXT,label_name TEXT,equipment_type TEXT,load_semantics TEXT);
|
||||
CREATE TABLE exercise_feedback(id INTEGER PRIMARY KEY,feedback_id TEXT UNIQUE,session_exercise_row_id INTEGER REFERENCES session_exercises(id) ON DELETE CASCADE,observed_at TEXT,raw_text TEXT);
|
||||
CREATE TABLE exercise_feedback_revisions(revision_id TEXT PRIMARY KEY,feedback_id TEXT REFERENCES exercise_feedback(feedback_id) ON DELETE CASCADE,created_at TEXT,raw_text TEXT);
|
||||
CREATE TABLE exercise_body_zones(exercise_row_id INTEGER REFERENCES exercises(id),zone_id TEXT,role TEXT,PRIMARY KEY(exercise_row_id,zone_id));
|
||||
CREATE TABLE execution_drafts(session_id TEXT PRIMARY KEY,revision_id TEXT,parent_revision_id TEXT,payload_json TEXT);
|
||||
CREATE TABLE execution_draft_finalizations(session_id TEXT PRIMARY KEY,final_revision_id TEXT,finalized_at TEXT);
|
||||
CREATE TABLE sync_causal_operations(operation_id TEXT PRIMARY KEY,target_kind TEXT,target_id TEXT,creator_id TEXT,predecessor_revision_id TEXT,created_at TEXT,payload_sha256 TEXT,publication_context TEXT);
|
||||
CREATE TABLE sync_causal_state(target_kind TEXT,target_id TEXT,current_revision_id TEXT,deleted INTEGER,operation_id TEXT,PRIMARY KEY(target_kind,target_id));
|
||||
PRAGMA user_version=20;
|
||||
"""
|
||||
|
||||
|
||||
class CausalDeleteTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory(); self.path = Path(self.tmp.name) / "db.sqlite"
|
||||
self.db = sqlite3.connect(self.path); self.db.executescript(SCHEMA)
|
||||
self.db.execute("INSERT INTO exercises VALUES(1,'ex_user','User','sets','reps',0)")
|
||||
self.db.execute("INSERT INTO sessions VALUES(1,'se_one','2030-01-01T10:00:00Z',NULL,'training','note')")
|
||||
self.db.execute("INSERT INTO session_exercises VALUES(1,1,1,'sxe_one')")
|
||||
self.db.execute("INSERT INTO body_observations VALUES(1,'bo_one','2030-01-01T11:00:00Z',1,'body')")
|
||||
self.db.execute("INSERT INTO custom_equipment VALUES('eq_one','Custom','','custom','external')")
|
||||
self.db.execute("INSERT INTO exercise_feedback VALUES(1,'fb_one',1,'2030-01-01T10:30:00Z','root')")
|
||||
self.db.execute("INSERT INTO exercise_feedback_revisions VALUES('fr_one','fb_one','2030-01-01T10:30:00Z','root')")
|
||||
self.db.execute("INSERT INTO exercise_body_zones VALUES(1,'arms','primary')")
|
||||
self.db.execute("INSERT INTO execution_drafts VALUES('se_draft','dr_one',NULL,'{}')")
|
||||
self.db.commit()
|
||||
|
||||
def tearDown(self): self.db.close(); self.tmp.cleanup()
|
||||
|
||||
def delete(self, kind, target):
|
||||
self.db.execute("BEGIN IMMEDIATE")
|
||||
operation, outcome = causal.local_delete(self.db, kind, target, "peer_desktop_test")
|
||||
self.db.commit(); self.assertEqual("applied", outcome); return operation
|
||||
|
||||
def test_domain_effects_replay_conflict_and_parent_safety(self):
|
||||
feedback = self.delete("feedback", "fb_one")
|
||||
self.assertEqual("root", self.db.execute("SELECT raw_text FROM exercise_feedback").fetchone()[0])
|
||||
self.assertEqual(1, self.db.execute("SELECT count(*) FROM exercise_feedback_revisions").fetchone()[0])
|
||||
self.delete("body_zone_relation", "ex_user|arms|primary")
|
||||
self.assertEqual(0, self.db.execute("SELECT count(*) FROM exercise_body_zones").fetchone()[0])
|
||||
self.delete("exercise", "ex_user"); self.delete("custom_equipment", "eq_one")
|
||||
self.assertEqual(1, self.db.execute("SELECT count(*) FROM exercises").fetchone()[0])
|
||||
self.assertEqual(1, self.db.execute("SELECT count(*) FROM custom_equipment").fetchone()[0])
|
||||
self.delete("execution_draft", "se_draft")
|
||||
self.assertEqual(0, self.db.execute("SELECT count(*) FROM execution_drafts").fetchone()[0])
|
||||
session = self.delete("session", "se_one")
|
||||
self.assertEqual(0, self.db.execute("SELECT count(*) FROM sessions").fetchone()[0])
|
||||
self.assertEqual((1, None), self.db.execute("SELECT count(*),session_row_id FROM body_observations").fetchone())
|
||||
self.assertIsNotNone(self.db.execute("SELECT 1 FROM execution_draft_finalizations WHERE session_id='se_one'").fetchone())
|
||||
self.assertEqual("unchanged", causal.apply_operation(self.db, session))
|
||||
conflicting = dict(session); conflicting["operation_id"] = "del_" + "1" * 36
|
||||
conflicting["payload_sha256"] = causal.digest({k:v for k,v in conflicting.items() if k != "payload_sha256"})
|
||||
with self.assertRaisesRegex(causal.CausalError, "concurrent deletion"):
|
||||
causal.apply_operation(self.db, conflicting)
|
||||
divergent = dict(feedback); divergent["creator_id"] = "other"
|
||||
divergent["payload_sha256"] = causal.digest({k:v for k,v in divergent.items() if k != "payload_sha256"})
|
||||
with self.assertRaisesRegex(causal.CausalError, "reused"):
|
||||
causal.apply_operation(self.db, divergent)
|
||||
|
||||
def test_observation_delete_and_finalized_draft_conflict_roll_back(self):
|
||||
self.delete("body_observation", "bo_one")
|
||||
self.assertEqual(0, self.db.execute("SELECT count(*) FROM body_observations").fetchone()[0])
|
||||
self.db.execute("INSERT OR REPLACE INTO execution_draft_finalizations VALUES('se_draft','dr_final','2030-01-01T12:00:00Z')")
|
||||
self.db.commit(); before = self.db.execute("SELECT count(*) FROM sync_causal_operations").fetchone()[0]
|
||||
self.db.execute("BEGIN IMMEDIATE")
|
||||
with self.assertRaisesRegex(causal.CausalError, "finalization"):
|
||||
causal.local_delete(self.db, "execution_draft", "se_draft", "peer")
|
||||
self.db.rollback()
|
||||
self.assertEqual(before, self.db.execute("SELECT count(*) FROM sync_causal_operations").fetchone()[0])
|
||||
self.assertIsNotNone(self.db.execute("SELECT 1 FROM execution_drafts WHERE session_id='se_draft'").fetchone())
|
||||
|
||||
def test_builtin_exercise_cannot_be_retired(self):
|
||||
built_in = next(iter(causal.load_exercise_names()))
|
||||
self.db.execute("INSERT INTO exercises VALUES(2,?,'Built in','sets','reps',0)", (built_in,))
|
||||
self.db.commit()
|
||||
with self.assertRaisesRegex(causal.CausalError, "built-in"):
|
||||
causal.local_delete(self.db, "exercise", built_in, "peer")
|
||||
|
||||
|
||||
if __name__ == "__main__": unittest.main()
|
||||
219
tools/causal_delete_exchange.py
Normal file
219
tools/causal_delete_exchange.py
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Staged causal-deletion service; automatic synchronization remains V3."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import json
|
||||
import sqlite3
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from trainlog_sqlite import connect_database
|
||||
from validate_json import parse_timestamp
|
||||
from exercise_names import load_exercise_names
|
||||
|
||||
FORMAT = "trainlog-causal-deletions"
|
||||
VERSION = 1
|
||||
MAX_BYTES = 4 * 1024 * 1024
|
||||
MAX_OPERATIONS = 4096
|
||||
KINDS = {"session", "execution_draft", "exercise", "body_observation",
|
||||
"custom_equipment", "feedback", "body_zone_relation"}
|
||||
|
||||
|
||||
class CausalError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def canonical(value) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
|
||||
def digest(value) -> str:
|
||||
return hashlib.sha256(canonical(value).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def require_schema(db: sqlite3.Connection) -> None:
|
||||
version = db.execute("PRAGMA user_version").fetchone()[0]
|
||||
if version != 20:
|
||||
raise CausalError(f"desktop schema v20 required, found v{version}")
|
||||
|
||||
|
||||
def target_snapshot(db: sqlite3.Connection, kind: str, target: str):
|
||||
if kind == "session":
|
||||
row = db.execute("SELECT session_id,started_at,ended_at,session_type,notes FROM sessions WHERE session_id=?", (target,)).fetchone()
|
||||
elif kind == "execution_draft":
|
||||
row = db.execute("SELECT session_id,revision_id,parent_revision_id,payload_json FROM execution_drafts WHERE session_id=?", (target,)).fetchone()
|
||||
elif kind == "exercise":
|
||||
row = db.execute("SELECT exercise_id,name,recording_mode,tracking_mode,data_fields FROM exercises WHERE exercise_id=?", (target,)).fetchone()
|
||||
elif kind == "body_observation":
|
||||
row = db.execute("SELECT observation_id,observed_at,notes FROM body_observations WHERE observation_id=?", (target,)).fetchone()
|
||||
elif kind == "custom_equipment":
|
||||
row = db.execute("SELECT equipment_id,display_name,label_name,equipment_type,load_semantics FROM custom_equipment WHERE equipment_id=?", (target,)).fetchone()
|
||||
elif kind == "feedback":
|
||||
row = db.execute("SELECT feedback_id,observed_at,raw_text FROM exercise_feedback WHERE feedback_id=?", (target,)).fetchone()
|
||||
else:
|
||||
parts = target.split("|")
|
||||
if len(parts) != 3:
|
||||
raise CausalError("body-zone target must be exercise_id|zone_id|role")
|
||||
row = db.execute("SELECT e.exercise_id,z.zone_id,z.role FROM exercise_body_zones z JOIN exercises e ON e.id=z.exercise_row_id WHERE e.exercise_id=? AND z.zone_id=? AND z.role=?", parts).fetchone()
|
||||
return None if row is None else list(row)
|
||||
|
||||
|
||||
def live_revision(db: sqlite3.Connection, kind: str, target: str) -> str | None:
|
||||
snapshot = target_snapshot(db, kind, target)
|
||||
return None if snapshot is None else "lv_" + digest(snapshot)
|
||||
|
||||
|
||||
def apply_effect(db: sqlite3.Connection, kind: str, target: str, operation_id: str) -> None:
|
||||
if kind == "session":
|
||||
db.execute("UPDATE body_observations SET session_row_id=NULL WHERE session_row_id=(SELECT id FROM sessions WHERE session_id=?)", (target,))
|
||||
db.execute("DELETE FROM sessions WHERE session_id=?", (target,))
|
||||
db.execute("INSERT OR IGNORE INTO execution_draft_finalizations VALUES(?,?,?)", (target, "deleted:" + operation_id, dt.datetime.now().astimezone().isoformat()))
|
||||
elif kind == "execution_draft":
|
||||
if db.execute("SELECT 1 FROM execution_draft_finalizations WHERE session_id=?", (target,)).fetchone():
|
||||
raise CausalError("draft finalization conflicts with deletion")
|
||||
db.execute("DELETE FROM execution_drafts WHERE session_id=?", (target,))
|
||||
elif kind == "body_observation":
|
||||
db.execute("DELETE FROM body_observations WHERE observation_id=?", (target,))
|
||||
elif kind == "body_zone_relation":
|
||||
exercise, zone, role = target.split("|")
|
||||
db.execute("DELETE FROM exercise_body_zones WHERE exercise_row_id=(SELECT id FROM exercises WHERE exercise_id=?) AND zone_id=? AND role=?", (exercise, zone, role))
|
||||
# Exercise/equipment retirement and feedback withdrawal retain the row and
|
||||
# immutable history; availability/current-state readers consult the causal state.
|
||||
|
||||
|
||||
def validate_operation(value: object) -> dict:
|
||||
keys = {"operation_id", "target_kind", "target_id", "creator_id",
|
||||
"predecessor_revision_id", "created_at", "payload_sha256",
|
||||
"publication_context"}
|
||||
if not isinstance(value, dict) or set(value) != keys:
|
||||
raise CausalError("operation has unknown or missing fields")
|
||||
for key, maximum in (("operation_id", 128), ("target_id", 512),
|
||||
("creator_id", 128), ("predecessor_revision_id", 128)):
|
||||
if not isinstance(value[key], str) or not 0 < len(value[key].encode()) <= maximum:
|
||||
raise CausalError(f"invalid {key}")
|
||||
if value["target_kind"] not in KINDS or value["publication_context"] is not None:
|
||||
raise CausalError("invalid target kind or publication context")
|
||||
if not isinstance(value["payload_sha256"], str) or len(value["payload_sha256"]) != 64 or any(c not in "0123456789abcdef" for c in value["payload_sha256"]):
|
||||
raise CausalError("invalid payload digest")
|
||||
parse_timestamp(value["created_at"], "created_at")
|
||||
payload = {k: value[k] for k in keys - {"payload_sha256"}}
|
||||
if digest(payload) != value["payload_sha256"]:
|
||||
raise CausalError("operation payload digest mismatch")
|
||||
return value
|
||||
|
||||
|
||||
def apply_operation(db: sqlite3.Connection, operation: dict) -> str:
|
||||
op = validate_operation(operation)
|
||||
identity = op["operation_id"]
|
||||
encoded = canonical(op)
|
||||
known = db.execute("SELECT target_kind,target_id,creator_id,predecessor_revision_id,created_at,payload_sha256,publication_context FROM sync_causal_operations WHERE operation_id=?", (identity,)).fetchone()
|
||||
expected = (op["target_kind"], op["target_id"], op["creator_id"], op["predecessor_revision_id"], op["created_at"], op["payload_sha256"], None)
|
||||
if known:
|
||||
if tuple(known) != expected:
|
||||
raise CausalError("operation identity reused with different content")
|
||||
return "unchanged"
|
||||
state = db.execute("SELECT current_revision_id,deleted FROM sync_causal_state WHERE target_kind=? AND target_id=?", (op["target_kind"], op["target_id"])).fetchone()
|
||||
current = state[0] if state else live_revision(db, op["target_kind"], op["target_id"])
|
||||
if state and state[1]:
|
||||
raise CausalError("concurrent deletion conflict")
|
||||
if current is None:
|
||||
raise CausalError("unknown target or ancestry")
|
||||
if current != op["predecessor_revision_id"]:
|
||||
raise CausalError("causal predecessor conflict")
|
||||
db.execute("INSERT INTO sync_causal_operations VALUES(?,?,?,?,?,?,?,NULL)",
|
||||
(identity, op["target_kind"], op["target_id"], op["creator_id"],
|
||||
op["predecessor_revision_id"], op["created_at"], op["payload_sha256"]))
|
||||
apply_effect(db, op["target_kind"], op["target_id"], identity)
|
||||
db.execute("INSERT OR REPLACE INTO sync_causal_state VALUES(?,?,?,?,?)",
|
||||
(op["target_kind"], op["target_id"], identity, 1, identity))
|
||||
return "applied"
|
||||
|
||||
|
||||
def local_delete(db: sqlite3.Connection, kind: str, target: str, creator: str) -> tuple[dict, str]:
|
||||
if kind == "exercise" and target in load_exercise_names():
|
||||
raise CausalError("built-in exercise identity cannot be retired")
|
||||
current = live_revision(db, kind, target)
|
||||
if current is None:
|
||||
state = db.execute("SELECT operation_id FROM sync_causal_state WHERE target_kind=? AND target_id=? AND deleted=1", (kind, target)).fetchone()
|
||||
if state:
|
||||
row = db.execute("SELECT operation_id,target_kind,target_id,creator_id,predecessor_revision_id,created_at,payload_sha256,publication_context FROM sync_causal_operations WHERE operation_id=?", state).fetchone()
|
||||
keys = ("operation_id", "target_kind", "target_id", "creator_id", "predecessor_revision_id", "created_at", "payload_sha256", "publication_context")
|
||||
return dict(zip(keys, row)), "unchanged"
|
||||
raise CausalError("target not found")
|
||||
operation = {"operation_id": "del_" + str(uuid.uuid4()), "target_kind": kind,
|
||||
"target_id": target, "creator_id": creator,
|
||||
"predecessor_revision_id": current,
|
||||
"created_at": dt.datetime.now().astimezone().isoformat(),
|
||||
"publication_context": None}
|
||||
operation["payload_sha256"] = digest(operation)
|
||||
return operation, apply_operation(db, operation)
|
||||
|
||||
|
||||
def load(path: Path) -> dict:
|
||||
raw = path.read_bytes()
|
||||
if len(raw) > MAX_BYTES:
|
||||
raise CausalError("artifact exceeds 4 MiB")
|
||||
def unique(pairs):
|
||||
result = {}
|
||||
for key, value in pairs:
|
||||
if key in result: raise CausalError("duplicate JSON key")
|
||||
result[key] = value
|
||||
return result
|
||||
try: root = json.loads(raw.decode("utf-8"), object_pairs_hook=unique)
|
||||
except (UnicodeError, json.JSONDecodeError) as error: raise CausalError("invalid UTF-8/JSON") from error
|
||||
if not isinstance(root, dict) or set(root) != {"format", "version", "generated_at", "operations"} or root["format"] != FORMAT or type(root["version"]) is not int or root["version"] != VERSION:
|
||||
raise CausalError("unsupported causal-deletion artifact")
|
||||
parse_timestamp(root["generated_at"], "generated_at")
|
||||
if not isinstance(root["operations"], list) or len(root["operations"]) > MAX_OPERATIONS:
|
||||
raise CausalError("operations bound exceeded")
|
||||
ids = set()
|
||||
for value in root["operations"]:
|
||||
op = validate_operation(value)
|
||||
if op["operation_id"] in ids: raise CausalError("duplicate operation_id")
|
||||
ids.add(op["operation_id"])
|
||||
return root
|
||||
|
||||
|
||||
def export_document(db: sqlite3.Connection) -> dict:
|
||||
keys = ("operation_id", "target_kind", "target_id", "creator_id", "predecessor_revision_id", "created_at", "payload_sha256", "publication_context")
|
||||
rows = db.execute("SELECT " + ",".join(keys) + " FROM sync_causal_operations ORDER BY operation_id").fetchall()
|
||||
if len(rows) > MAX_OPERATIONS: raise CausalError("protection set exceeds artifact bound")
|
||||
return {"format": FORMAT, "version": VERSION,
|
||||
"generated_at": dt.datetime.now().astimezone().isoformat(),
|
||||
"operations": [dict(zip(keys, row)) for row in rows]}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("mode", choices=("delete", "import", "export"))
|
||||
parser.add_argument("path", type=Path)
|
||||
parser.add_argument("--database", type=Path, required=True)
|
||||
parser.add_argument("--kind", choices=sorted(KINDS)); parser.add_argument("--target-id")
|
||||
parser.add_argument("--creator-id", default="peer_desktop_local")
|
||||
args = parser.parse_args()
|
||||
db = connect_database(args.database); require_schema(db)
|
||||
try:
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
if args.mode == "delete":
|
||||
if not args.kind or not args.target_id: raise CausalError("delete requires --kind and --target-id")
|
||||
op, outcome = local_delete(db, args.kind, args.target_id, args.creator_id)
|
||||
args.path.write_text(canonical(op), encoding="utf-8"); print(outcome)
|
||||
elif args.mode == "import":
|
||||
for op in load(args.path)["operations"]: print(op["operation_id"] + "=" + apply_operation(db, op))
|
||||
else:
|
||||
args.path.write_text(canonical(export_document(db)), encoding="utf-8"); print("CAUSAL_DELETE_EXPORT=PASS")
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback(); raise
|
||||
finally: db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try: main()
|
||||
except Exception as error:
|
||||
print("CAUSAL_DELETE=FAIL " + str(error)); raise SystemExit(1)
|
||||
|
|
@ -169,8 +169,8 @@ def canonical(value):
|
|||
|
||||
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}")
|
||||
if version not in (19, 20):
|
||||
raise LifecycleError(f"desktop schema v19/v20 required, found v{version}")
|
||||
|
||||
|
||||
def import_document(connection, document):
|
||||
|
|
@ -181,6 +181,8 @@ def import_document(connection, document):
|
|||
for draft in document["drafts"]:
|
||||
session_id, revision = draft["session_id"], draft["revision_id"]
|
||||
payload = canonical(draft)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='execution_draft' AND target_id=? AND deleted=1", (session_id,)).fetchone():
|
||||
outcomes.append((session_id, "stale")); continue
|
||||
if connection.execute("SELECT 1 FROM execution_draft_finalizations WHERE session_id=?", (session_id,)).fetchone():
|
||||
outcomes.append((session_id, "stale")); continue
|
||||
current = connection.execute(
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from pathlib import Path
|
|||
from trainlog_sqlite import configure_connection
|
||||
|
||||
|
||||
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19)
|
||||
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20)
|
||||
MEASUREMENT_FIELDS = (
|
||||
"body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm",
|
||||
"hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm",
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@ ID_PREFIX = "aid_"
|
|||
def ensure_publication_state(connection):
|
||||
"""Install the additive v18 cursor for direct syncd/tool invocations."""
|
||||
owned_transaction = not connection.in_transaction
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19):
|
||||
raise ValueError("schema desktop v18 requis")
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19, 20):
|
||||
raise ValueError("schema desktop v18-v20 requis")
|
||||
columns = {row[1] for row in connection.execute(
|
||||
"PRAGMA table_info(ai_session_drafts)")}
|
||||
if "published_at" not in columns:
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ def main():
|
|||
args = parser.parse_args()
|
||||
connection = connect_database(args.database)
|
||||
try:
|
||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19):
|
||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20):
|
||||
raise ValueError("schema desktop v8 à v16 requis")
|
||||
known_equipment = load_supplied_equipment_ids(args.catalog)
|
||||
known_equipment.update(row[0] for row in connection.execute(
|
||||
|
|
|
|||
|
|
@ -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, 19):
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20):
|
||||
raise ValueError("schema desktop v8 à v16 requis")
|
||||
equipment = [dict(row) for row in connection.execute(
|
||||
"SELECT equipment_id,display_name,label_name,equipment_type,load_semantics "
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ def main():
|
|||
args = parser.parse_args()
|
||||
con = connect_database(args.database)
|
||||
try:
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17, 18, 19):
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17, 18, 19, 20):
|
||||
raise ValueError("schema desktop v12-v16 requis")
|
||||
rows = con.execute(
|
||||
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
|
||||
|
|
|
|||
|
|
@ -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, 19):
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17, 18, 19, 20):
|
||||
raise ValueError("schema desktop v11-v16 requis")
|
||||
exercises = []
|
||||
for exercise in connection.execute("SELECT id,exercise_id FROM exercises ORDER BY exercise_id"):
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ def main():
|
|||
con.row_factory = sqlite3.Row
|
||||
try:
|
||||
version = con.execute("PRAGMA user_version").fetchone()[0]
|
||||
if version not in (17, 18, 19):
|
||||
if version not in (17, 18, 19, 20):
|
||||
raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}")
|
||||
rows = con.execute("""
|
||||
SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields,
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ def main():
|
|||
# CONTRACT: v8 adds only desktop-local custom equipment. The PC
|
||||
# catalogue artifact is unchanged, but it must read the current
|
||||
# canonical desktop schema rather than accept a stale pre-v8 database.
|
||||
if version not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19):
|
||||
if version not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20):
|
||||
raise SystemExit(
|
||||
"PC_CATALOG_EXPORT=FAIL "
|
||||
f"schema={version}"
|
||||
|
|
|
|||
|
|
@ -83,10 +83,13 @@ def main():
|
|||
con.row_factory = sqlite3.Row
|
||||
try:
|
||||
schema_version = con.execute("PRAGMA user_version").fetchone()[0]
|
||||
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):
|
||||
if args.version == 4 and schema_version not in (19, 20):
|
||||
raise ValueError("mobile V4 exige le schéma desktop v19 ou v20")
|
||||
if schema_version not in (11, 12, 13, 14, 15, 16, 17, 18, 19, 20) and not (args.version == 2 and schema_version == 10):
|
||||
raise ValueError("schema desktop v11-v16 requis (v10 accepté pour export V2 explicite)")
|
||||
if schema_version >= 20 and con.execute(
|
||||
"SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1").fetchone():
|
||||
raise ValueError("causal protection refuses a mobile snapshot that omits tombstones")
|
||||
known_equipment = supplied_equipment_ids()
|
||||
canonical_names = load_exercise_names()
|
||||
known_equipment.update(row[0] for row in con.execute(
|
||||
|
|
|
|||
|
|
@ -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, 19): raise ValueError("schema desktop v15-v19 requis")
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (15, 16, 17, 18, 19, 20): raise ValueError("schema desktop v15-v20 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")
|
||||
|
|
|
|||
|
|
@ -193,8 +193,8 @@ def import_payload(connection, payload, imported_at=None):
|
|||
INVARIANT: validation and every child insert share one IMMEDIATE transaction;
|
||||
any failure leaves no draft, child, or import-identity row behind.
|
||||
"""
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19):
|
||||
raise ImportFailure("schema desktop v18 requis")
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19, 20):
|
||||
raise ImportFailure("schema desktop v18-v20 requis")
|
||||
draft, entries = validate(payload, connection)
|
||||
digest = canonical_digest(payload)
|
||||
imported_at = imported_at or dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
||||
|
|
|
|||
|
|
@ -173,8 +173,8 @@ 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, 19):
|
||||
fail("schema desktop v8 à v16 requis")
|
||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20):
|
||||
fail("schema desktop v8 à v20 requis")
|
||||
known = load_catalog(args.catalog)
|
||||
known.update(row[0] for row in connection.execute(
|
||||
"SELECT equipment_id FROM custom_equipment"))
|
||||
|
|
|
|||
|
|
@ -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, 19):
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20):
|
||||
fail("schema desktop v8 à v16 requis")
|
||||
imported = skipped = 0
|
||||
# Validate every same-ID row before inserting any definition.
|
||||
|
|
|
|||
|
|
@ -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, 19):
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17, 18, 19, 20):
|
||||
fail("schema desktop v12-v16 requis")
|
||||
con.execute("BEGIN IMMEDIATE")
|
||||
for source, canonical in aliases:
|
||||
|
|
|
|||
|
|
@ -205,8 +205,8 @@ 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, 19):
|
||||
raise ImportFailure("schema desktop v11-v16 requis")
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17, 18, 19, 20):
|
||||
raise ImportFailure("schema desktop v11-v20 requis")
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
grouped = {}
|
||||
|
|
|
|||
|
|
@ -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,19): fail("schema desktop v17/v18/v19 requis")
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (17,18,19,20): fail("schema desktop v17-v20 requis")
|
||||
applied=equal=ancestor=pending=0;seen=set();con.execute("BEGIN IMMEDIATE")
|
||||
for item in root["exercises"]:
|
||||
validate(item);eid=item["exercise_id"]
|
||||
|
|
|
|||
|
|
@ -845,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, 19):
|
||||
if version not in (5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20):
|
||||
raise ImportFailure(
|
||||
f"base desktop schema v5 à v19 attendue, version trouvée: {version}"
|
||||
f"base desktop schema v5 à v20 attendue, version trouvée: {version}"
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1987,8 +1987,18 @@ 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")
|
||||
if payload["version"] == 4 and schema_version not in (19, 20):
|
||||
raise ImportFailure("mobile V4 exige le schéma desktop v19 ou v20")
|
||||
if schema_version >= 20:
|
||||
protected = {(row[0], row[1]) for row in connection.execute(
|
||||
"SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")}
|
||||
incoming = ({("session", value["session_id"]) for value in payload["sessions"]} |
|
||||
{("body_observation", value["observation_id"]) for value in payload["body_observations"]} |
|
||||
{("exercise", value["exercise_id"]) for value in payload["exercises"]})
|
||||
collision = protected & incoming
|
||||
if collision:
|
||||
kind, identity = sorted(collision)[0]
|
||||
raise ImportFailure(f"causal protection refuses legacy/live replay: {kind}/{identity}")
|
||||
has_explicit_max = any(
|
||||
"max_weight_kg" in entry
|
||||
for session in payload["sessions"]
|
||||
|
|
|
|||
|
|
@ -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,19):fail("schema desktop v15-v19 requis")
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (15,16,17,18,19,20):fail("schema desktop v15-v20 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"}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
#include "trainlog/model.h"
|
||||
#include "trainlog/status.h"
|
||||
|
||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 19
|
||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 20
|
||||
|
||||
typedef struct TrainlogDatabase TrainlogDatabase;
|
||||
|
||||
|
|
|
|||
|
|
@ -713,6 +713,11 @@ test(
|
|||
python3_trainlog_tests,
|
||||
args: [meson.project_source_root() / 'tests/test_validate_sync_isolated.py'],
|
||||
)
|
||||
test(
|
||||
'causal_delete_exchange',
|
||||
find_program('python3'),
|
||||
args: [meson.project_source_root() / 'tests/test_causal_delete_exchange.py'],
|
||||
)
|
||||
|
||||
test(
|
||||
'equipment_associations_exchange',
|
||||
|
|
|
|||
|
|
@ -832,6 +832,30 @@ static const char *const MIGRATE_V18_TO_V19_SQL =
|
|||
"session_id TEXT PRIMARY KEY,final_revision_id TEXT NOT NULL,finalized_at TEXT NOT NULL);"
|
||||
"PRAGMA user_version=19;COMMIT;";
|
||||
|
||||
/* WHY: offline peers can replay live snapshots after an intentional deletion.
|
||||
* CONTRACT: v20 records immutable causal deletion operations separately from
|
||||
* future publication generations. INVARIANT: no legacy row is marked deleted
|
||||
* and no ancestry, peer acknowledgement, or emission context is fabricated. */
|
||||
static const char *const MIGRATE_V19_TO_V20_SQL =
|
||||
"BEGIN IMMEDIATE;"
|
||||
"CREATE TABLE sync_causal_operations("
|
||||
"operation_id TEXT PRIMARY KEY,target_kind TEXT NOT NULL,target_id TEXT NOT NULL,"
|
||||
"creator_id TEXT NOT NULL,predecessor_revision_id TEXT NOT NULL,"
|
||||
"created_at TEXT NOT NULL,payload_sha256 TEXT NOT NULL,publication_context TEXT,"
|
||||
"CHECK(length(operation_id) BETWEEN 1 AND 128),"
|
||||
"CHECK(length(target_id) BETWEEN 1 AND 512),"
|
||||
"CHECK(length(creator_id) BETWEEN 1 AND 128),"
|
||||
"CHECK(length(predecessor_revision_id) BETWEEN 1 AND 128),"
|
||||
"CHECK(length(payload_sha256)=64),"
|
||||
"UNIQUE(target_kind,target_id,operation_id));"
|
||||
"CREATE TABLE sync_causal_state("
|
||||
"target_kind TEXT NOT NULL,target_id TEXT NOT NULL,current_revision_id TEXT NOT NULL,"
|
||||
"deleted INTEGER NOT NULL CHECK(deleted IN(0,1)),operation_id TEXT,"
|
||||
"PRIMARY KEY(target_kind,target_id),"
|
||||
"FOREIGN KEY(operation_id) REFERENCES sync_causal_operations(operation_id));"
|
||||
"CREATE INDEX sync_causal_operations_target ON sync_causal_operations(target_kind,target_id);"
|
||||
"PRAGMA user_version=20;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. */
|
||||
|
|
@ -1525,7 +1549,7 @@ static TrainlogStatus initialize_or_validate_schema(
|
|||
status = TRAINLOG_STATUS_OK;
|
||||
} else if (version == 11 || version == 12 || version == 13 || version == 14 ||
|
||||
version == 15 || version == 16 || version == 17 || version == 18 ||
|
||||
version == 19) {
|
||||
version == 19 || version == 20) {
|
||||
status = TRAINLOG_STATUS_OK;
|
||||
} else {
|
||||
if (version == 1) {
|
||||
|
|
@ -1692,6 +1716,9 @@ static TrainlogStatus initialize_or_validate_schema(
|
|||
if (status == TRAINLOG_STATUS_OK && version < 19) {
|
||||
status = execute_sql(database, MIGRATE_V18_TO_V19_SQL);
|
||||
}
|
||||
if (status == TRAINLOG_STATUS_OK && version < 20) {
|
||||
status = execute_sql(database, MIGRATE_V19_TO_V20_SQL);
|
||||
}
|
||||
if (status == TRAINLOG_STATUS_OK) {
|
||||
status = ensure_v18_ai_draft_publication_state(database);
|
||||
}
|
||||
|
|
@ -1707,7 +1734,7 @@ static TrainlogStatus initialize_or_validate_schema(
|
|||
set_open_diagnostic(
|
||||
output_diagnostic,
|
||||
output_diagnostic_capacity,
|
||||
version == 0 ? "create schema v19" : "migrate database to schema v19",
|
||||
version == 0 ? "create schema v20" : "migrate database to schema v20",
|
||||
database->connection,
|
||||
SQLITE_ERROR
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 v19: SQLite rc=",
|
||||
strlen("migrate database to schema v19: SQLite rc=")) == 0);
|
||||
CHECK(strncmp(diagnostic, "migrate database to schema v20: SQLite rc=",
|
||||
strlen("migrate database to schema v20: 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=20;", NULL, NULL, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw, "PRAGMA user_version=21;", 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 20 is newer") != NULL);
|
||||
CHECK(strstr(diagnostic, "schema version 21 is newer") != NULL);
|
||||
CHECK(strstr(diagnostic, "SQLite") == NULL);
|
||||
CHECK(unlink(path) == 0);
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ static bool test_v9_to_v10_failure_rolls_back(void)
|
|||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
||||
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
|
||||
CHECK(database == NULL);
|
||||
CHECK(strstr(diagnostic, "migrate database to schema v19") != NULL);
|
||||
CHECK(strstr(diagnostic, "migrate database to schema v20") != NULL);
|
||||
CHECK(strstr(diagnostic, "performed_sets_v9") != NULL);
|
||||
|
||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||
|
|
|
|||
Loading…
Reference in a new issue