feat(sync): implement data lifecycle staging
This commit is contained in:
parent
77c15172e1
commit
99f3f46d13
32 changed files with 1340 additions and 105 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -101,6 +101,13 @@ data class SessionDraftForm(
|
||||||
* mistake unfinished capture for a completed session.
|
* mistake unfinished capture for a completed session.
|
||||||
*/
|
*/
|
||||||
data class ActiveSessionDraft(
|
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<SessionExerciseDraft> = emptyList(),
|
val exercises: List<SessionExerciseDraft> = emptyList(),
|
||||||
val sessionType: SessionType = SessionType.TRAINING,
|
val sessionType: SessionType = SessionType.TRAINING,
|
||||||
/** Existing max_test session updated atomically on finalization, if any. */
|
/** Existing max_test session updated atomically on finalization, if any. */
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest {
|
||||||
|
|
||||||
TrainlogRepository(context, name).useForTest { it.listSessions() }
|
TrainlogRepository(context, name).useForTest { it.listSessions() }
|
||||||
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
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")) {
|
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(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"))
|
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()
|
migrated.close()
|
||||||
}
|
}
|
||||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
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
|
/* v13 intentionally adds the frozen machine identities and
|
||||||
* exercise-owned metadata; all non-exercise facts stay exact. */
|
* 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") }
|
val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") }
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ class AndroidV16AiSessionDraftMigrationTest {
|
||||||
migrated.close()
|
migrated.close()
|
||||||
}
|
}
|
||||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
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(
|
assertEquals(before, listOf(
|
||||||
scalar(db, "SELECT COUNT(*) FROM sessions"),
|
scalar(db, "SELECT COUNT(*) FROM sessions"),
|
||||||
scalar(db, "SELECT COUNT(*) FROM performed_sets"),
|
scalar(db, "SELECT COUNT(*) FROM performed_sets"),
|
||||||
|
|
|
||||||
|
|
@ -328,7 +328,7 @@ class BodyZonesTest {
|
||||||
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
|
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
|
||||||
SQLiteDatabase.OPEN_READONLY).use { db ->
|
SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||||
db.rawQuery("PRAGMA user_version", null).use { cursor ->
|
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 ->
|
db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))
|
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))
|
||||||
|
|
|
||||||
|
|
@ -1794,7 +1794,7 @@ class TrainlogRepositoryDraftTest {
|
||||||
).use { db ->
|
).use { db ->
|
||||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst())
|
assertTrue(cursor.moveToFirst())
|
||||||
assertEquals(17, cursor.getInt(0))
|
assertEquals(18, cursor.getInt(0))
|
||||||
}
|
}
|
||||||
db.rawQuery(
|
db.rawQuery(
|
||||||
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
|
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
|
||||||
|
|
@ -1921,7 +1921,7 @@ class TrainlogRepositoryDraftTest {
|
||||||
).use { db ->
|
).use { db ->
|
||||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst())
|
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 ->
|
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst())
|
assertTrue(cursor.moveToFirst())
|
||||||
|
|
@ -2446,6 +2446,60 @@ class TrainlogRepositoryDraftTest {
|
||||||
assertEquals(3, exported.getJSONArray("sets").length())
|
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
|
@Test
|
||||||
fun exerciseAliasCompanionRekeysHistoryAndDraftAndReplaysIdempotently() {
|
fun exerciseAliasCompanionRekeysHistoryAndDraftAndReplaysIdempotently() {
|
||||||
val repo = openRepository()
|
val repo = openRepository()
|
||||||
|
|
|
||||||
275
tools/execution_draft_exchange.py
Normal file
275
tools/execution_draft_exchange.py
Normal file
|
|
@ -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)
|
||||||
|
|
@ -12,7 +12,7 @@ from pathlib import Path
|
||||||
from trainlog_sqlite import configure_connection
|
from trainlog_sqlite import configure_connection
|
||||||
|
|
||||||
|
|
||||||
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18)
|
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19)
|
||||||
MEASUREMENT_FIELDS = (
|
MEASUREMENT_FIELDS = (
|
||||||
"body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm",
|
"body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm",
|
||||||
"hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm",
|
"hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm",
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ ID_PREFIX = "aid_"
|
||||||
def ensure_publication_state(connection):
|
def ensure_publication_state(connection):
|
||||||
"""Install the additive v18 cursor for direct syncd/tool invocations."""
|
"""Install the additive v18 cursor for direct syncd/tool invocations."""
|
||||||
owned_transaction = not connection.in_transaction
|
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")
|
raise ValueError("schema desktop v18 requis")
|
||||||
columns = {row[1] for row in connection.execute(
|
columns = {row[1] for row in connection.execute(
|
||||||
"PRAGMA table_info(ai_session_drafts)")}
|
"PRAGMA table_info(ai_session_drafts)")}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ def main():
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
connection = connect_database(args.database)
|
connection = connect_database(args.database)
|
||||||
try:
|
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")
|
raise ValueError("schema desktop v8 à v16 requis")
|
||||||
known_equipment = load_supplied_equipment_ids(args.catalog)
|
known_equipment = load_supplied_equipment_ids(args.catalog)
|
||||||
known_equipment.update(row[0] for row in connection.execute(
|
known_equipment.update(row[0] for row in connection.execute(
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ def main():
|
||||||
connection = connect_database(args.database)
|
connection = connect_database(args.database)
|
||||||
connection.row_factory = sqlite3.Row
|
connection.row_factory = sqlite3.Row
|
||||||
try:
|
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")
|
raise ValueError("schema desktop v8 à v16 requis")
|
||||||
equipment = [dict(row) for row in connection.execute(
|
equipment = [dict(row) for row in connection.execute(
|
||||||
"SELECT equipment_id,display_name,label_name,equipment_type,load_semantics "
|
"SELECT equipment_id,display_name,label_name,equipment_type,load_semantics "
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ def main():
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
con = connect_database(args.database)
|
con = connect_database(args.database)
|
||||||
try:
|
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")
|
raise ValueError("schema desktop v12-v16 requis")
|
||||||
rows = con.execute(
|
rows = con.execute(
|
||||||
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
|
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ def main():
|
||||||
connection = connect_database(args.database)
|
connection = connect_database(args.database)
|
||||||
connection.row_factory = sqlite3.Row
|
connection.row_factory = sqlite3.Row
|
||||||
try:
|
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")
|
raise ValueError("schema desktop v11-v16 requis")
|
||||||
exercises = []
|
exercises = []
|
||||||
for exercise in connection.execute("SELECT id,exercise_id FROM exercises ORDER BY exercise_id"):
|
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
|
con.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
version = con.execute("PRAGMA user_version").fetchone()[0]
|
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}")
|
raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}")
|
||||||
rows = con.execute("""
|
rows = con.execute("""
|
||||||
SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields,
|
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
|
# CONTRACT: v8 adds only desktop-local custom equipment. The PC
|
||||||
# catalogue artifact is unchanged, but it must read the current
|
# catalogue artifact is unchanged, but it must read the current
|
||||||
# canonical desktop schema rather than accept a stale pre-v8 database.
|
# 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(
|
raise SystemExit(
|
||||||
"PC_CATALOG_EXPORT=FAIL "
|
"PC_CATALOG_EXPORT=FAIL "
|
||||||
f"schema={version}"
|
f"schema={version}"
|
||||||
|
|
|
||||||
|
|
@ -59,17 +59,33 @@ def validate_v3_timestamp(value, label):
|
||||||
raise ValueError(f"{label}: date-heure Trainlog persistée invalide") from error
|
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():
|
def main():
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument("output", type=Path)
|
parser.add_argument("output", type=Path)
|
||||||
parser.add_argument("--database", type=Path, default=default_database())
|
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()
|
args = parser.parse_args()
|
||||||
con = connect_database(args.database)
|
con = connect_database(args.database)
|
||||||
con.row_factory = sqlite3.Row
|
con.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
schema_version = con.execute("PRAGMA user_version").fetchone()[0]
|
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)")
|
raise ValueError("schema desktop v11-v16 requis (v10 accepté pour export V2 explicite)")
|
||||||
known_equipment = supplied_equipment_ids()
|
known_equipment = supplied_equipment_ids()
|
||||||
canonical_names = load_exercise_names()
|
canonical_names = load_exercise_names()
|
||||||
|
|
@ -82,18 +98,25 @@ def main():
|
||||||
exported = dict(row)
|
exported = dict(row)
|
||||||
exported["name"] = canonical_names.get(row["exercise_id"], row["name"])
|
exported["name"] = canonical_names.get(row["exercise_id"], row["name"])
|
||||||
root["exercises"].append(exported)
|
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
|
# CONTRACT: a V3 producer must not publish history which the exact
|
||||||
# temporal readers reject. V2 export retains its published behavior.
|
# temporal readers reject. V2 export retains its published behavior.
|
||||||
if args.version == 3:
|
if args.version >= 3:
|
||||||
validate_v3_timestamp(session["started_at"],
|
validate_v3_timestamp(session["started_at"],
|
||||||
f"session_id={session['session_id']} started_at")
|
f"session_id={session['session_id']} started_at")
|
||||||
payload = {key: session[key] for key in ("session_id", "started_at", "session_type")}
|
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"] = []
|
payload["exercises"] = []
|
||||||
# INVARIANT: v16 exports the occurrence-owned tracking snapshot;
|
# INVARIANT: v16 exports the occurrence-owned tracking snapshot;
|
||||||
# older readable schemas retain their bounded catalogue fallback.
|
# older readable schemas retain their bounded catalogue fallback.
|
||||||
tracking_sql = "se.tracking_mode" if schema_version >= 16 else "e.tracking_mode"
|
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"],)):
|
for entry in con.execute(sql, (session["id"],)):
|
||||||
validate_plan(entry)
|
validate_plan(entry)
|
||||||
if entry["recording_mode"] == "continuous" or entry["max_weight_kg"] is not None:
|
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"]),
|
"name": canonical_names.get(entry["exercise_id"], entry["name"]),
|
||||||
"recording_mode": entry["recording_mode"], "tracking_mode": entry["tracking_mode"],
|
"recording_mode": entry["recording_mode"], "tracking_mode": entry["tracking_mode"],
|
||||||
"data_fields": entry["data_fields"],
|
"data_fields": entry["data_fields"],
|
||||||
"load_mode": entry["load_mode"] if args.version == 3 else "none",
|
"load_mode": entry["load_mode"] if args.version >= 3 else "none",
|
||||||
"rest_seconds": entry["rest_seconds"] if args.version == 3 else 0,
|
"rest_seconds": entry["rest_seconds"] if args.version >= 3 else 0,
|
||||||
"equipment_id": entry["equipment_id"]}
|
"equipment_id": entry["equipment_id"]}
|
||||||
if args.version == 3:
|
if args.version == 3:
|
||||||
if not has_target:
|
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_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"]
|
if entry["target_weight_kg"] is not None: target["weight_kg"] = entry["target_weight_kg"]
|
||||||
item["target"] = target
|
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:
|
if entry["max_weight_kg"] is not None:
|
||||||
# CONTRACT: explicit max is an occurrence result, never a
|
# CONTRACT: explicit max is an occurrence result, never a
|
||||||
# synthetic one-repetition performed set.
|
# synthetic one-repetition performed set.
|
||||||
|
|
@ -155,15 +188,18 @@ def main():
|
||||||
"waist_cm", "hips_cm", "left_arm_cm", "right_arm_cm",
|
"waist_cm", "hips_cm", "left_arm_cm", "right_arm_cm",
|
||||||
"left_forearm_cm", "right_forearm_cm", "left_thigh_cm",
|
"left_forearm_cm", "right_forearm_cm", "left_thigh_cm",
|
||||||
"right_thigh_cm", "left_calf_cm", "right_calf_cm")
|
"right_thigh_cm", "left_calf_cm", "right_calf_cm")
|
||||||
columns = ",".join(("observation_id", "observed_at") + metric_names)
|
columns = ",".join(("bo.observation_id", "bo.observed_at") + tuple("bo." + n for n in metric_names))
|
||||||
for row in con.execute(f"SELECT {columns} FROM body_observations ORDER BY observed_at,id"):
|
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:
|
if args.version >= 3:
|
||||||
# INVARIANT: validate every stored instant before touching the
|
# INVARIANT: validate every stored instant before touching the
|
||||||
# destination artifact, so corruption cannot clobber a prior file.
|
# destination artifact, so corruption cannot clobber a prior file.
|
||||||
validate_v3_timestamp(row["observed_at"],
|
validate_v3_timestamp(row["observed_at"],
|
||||||
f"observation_id={row['observation_id']} observed_at")
|
f"observation_id={row['observation_id']} observed_at")
|
||||||
item = {"observation_id": row["observation_id"], "observed_at": row["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})
|
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)
|
root["body_observations"].append(item)
|
||||||
args.output.write_text(json.dumps(root, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
|
args.output.write_text(json.dumps(root, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
|
||||||
print("PC_MOBILE_EXPORT=PASS")
|
print("PC_MOBILE_EXPORT=PASS")
|
||||||
|
|
|
||||||
|
|
@ -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()
|
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)
|
db=connect_database(args.database)
|
||||||
try:
|
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()
|
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()
|
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")
|
if len(roots)>4096 or len(followups)>4096: raise ValueError("trop d'observations")
|
||||||
|
|
|
||||||
|
|
@ -193,7 +193,7 @@ def import_payload(connection, payload, imported_at=None):
|
||||||
INVARIANT: validation and every child insert share one IMMEDIATE transaction;
|
INVARIANT: validation and every child insert share one IMMEDIATE transaction;
|
||||||
any failure leaves no draft, child, or import-identity row behind.
|
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")
|
raise ImportFailure("schema desktop v18 requis")
|
||||||
draft, entries = validate(payload, connection)
|
draft, entries = validate(payload, connection)
|
||||||
digest = canonical_digest(payload)
|
digest = canonical_digest(payload)
|
||||||
|
|
|
||||||
|
|
@ -173,7 +173,7 @@ def main():
|
||||||
fail("clés extension équipement invalides")
|
fail("clés extension équipement invalides")
|
||||||
connection = connect_database(args.database)
|
connection = connect_database(args.database)
|
||||||
try:
|
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")
|
fail("schema desktop v8 à v16 requis")
|
||||||
known = load_catalog(args.catalog)
|
known = load_catalog(args.catalog)
|
||||||
known.update(row[0] for row in connection.execute(
|
known.update(row[0] for row in connection.execute(
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ def main():
|
||||||
definitions = validate(json.loads(args.artifact.read_text(encoding="utf-8")), supplied_ids(args.catalog))
|
definitions = validate(json.loads(args.artifact.read_text(encoding="utf-8")), supplied_ids(args.catalog))
|
||||||
connection = connect_database(args.database)
|
connection = connect_database(args.database)
|
||||||
try:
|
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")
|
fail("schema desktop v8 à v16 requis")
|
||||||
imported = skipped = 0
|
imported = skipped = 0
|
||||||
# Validate every same-ID row before inserting any definition.
|
# Validate every same-ID row before inserting any definition.
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ def main():
|
||||||
con = connect_database(args.database)
|
con = connect_database(args.database)
|
||||||
try:
|
try:
|
||||||
con.execute("PRAGMA foreign_keys=ON")
|
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")
|
fail("schema desktop v12-v16 requis")
|
||||||
con.execute("BEGIN IMMEDIATE")
|
con.execute("BEGIN IMMEDIATE")
|
||||||
for source, canonical in aliases:
|
for source, canonical in aliases:
|
||||||
|
|
|
||||||
|
|
@ -205,7 +205,7 @@ def main():
|
||||||
connection = connect_database(args.database)
|
connection = connect_database(args.database)
|
||||||
updated = skipped = kept_local = 0
|
updated = skipped = kept_local = 0
|
||||||
try:
|
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")
|
raise ImportFailure("schema desktop v11-v16 requis")
|
||||||
connection.execute("PRAGMA foreign_keys=ON")
|
connection.execute("PRAGMA foreign_keys=ON")
|
||||||
connection.execute("BEGIN IMMEDIATE")
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
|
|
||||||
|
|
@ -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")
|
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")
|
parse_timestamp(root["generated_at"], "generated_at")
|
||||||
con=connect_database(a.database);con.row_factory=sqlite3.Row
|
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")
|
applied=equal=ancestor=pending=0;seen=set();con.execute("BEGIN IMMEDIATE")
|
||||||
for item in root["exercises"]:
|
for item in root["exercises"]:
|
||||||
validate(item);eid=item["exercise_id"]
|
validate(item);eid=item["exercise_id"]
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ SESSION_KEYS = {
|
||||||
"session_type",
|
"session_type",
|
||||||
"exercises",
|
"exercises",
|
||||||
}
|
}
|
||||||
|
V4_SESSION_KEYS = SESSION_KEYS | {"ended_at", "note"}
|
||||||
|
|
||||||
SESSION_EXERCISE_KEYS = {
|
SESSION_EXERCISE_KEYS = {
|
||||||
"exercise_id",
|
"exercise_id",
|
||||||
|
|
@ -65,11 +66,13 @@ V2_SESSION_EXERCISE_KEYS = SESSION_EXERCISE_KEYS | {
|
||||||
"entry_id", "position", "equipment_id", "max_weight_kg"
|
"entry_id", "position", "equipment_id", "max_weight_kg"
|
||||||
}
|
}
|
||||||
V3_SESSION_EXERCISE_KEYS = V2_SESSION_EXERCISE_KEYS | {"target"}
|
V3_SESSION_EXERCISE_KEYS = V2_SESSION_EXERCISE_KEYS | {"target"}
|
||||||
|
V4_SESSION_EXERCISE_KEYS = V3_SESSION_EXERCISE_KEYS | {"note"}
|
||||||
|
|
||||||
BODY_BASE_KEYS = {
|
BODY_BASE_KEYS = {
|
||||||
"observation_id",
|
"observation_id",
|
||||||
"observed_at",
|
"observed_at",
|
||||||
}
|
}
|
||||||
|
V4_BODY_KEYS = BODY_BASE_KEYS | {"session_id", "note"}
|
||||||
|
|
||||||
BODY_METRIC_KEYS = {
|
BODY_METRIC_KEYS = {
|
||||||
"body_weight_kg",
|
"body_weight_kg",
|
||||||
|
|
@ -255,14 +258,14 @@ def load_payload(path):
|
||||||
"format mobile export invalide"
|
"format mobile export invalide"
|
||||||
)
|
)
|
||||||
|
|
||||||
if payload["version"] not in (1, 2, 3):
|
if payload["version"] not in (1, 2, 3, 4):
|
||||||
raise ImportFailure(
|
raise ImportFailure(
|
||||||
"version mobile export non supportée"
|
"version mobile export non supportée"
|
||||||
)
|
)
|
||||||
|
|
||||||
# CONTRACT: V1/V2 retain their published nonempty-string admission. V3 is
|
# CONTRACT: V1/V2 retain their published nonempty-string admission. V3 is
|
||||||
# the current analysis-bearing artifact and uses the exact Trainlog parser.
|
# 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")
|
require_v3_timestamp(payload["generated_at"], "generated_at")
|
||||||
else:
|
else:
|
||||||
require_nonempty_string(payload["generated_at"], "generated_at")
|
require_nonempty_string(payload["generated_at"], "generated_at")
|
||||||
|
|
@ -447,6 +450,24 @@ def validate_set_item(
|
||||||
return ("duration", duration)
|
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(
|
def validate_session_exercise(
|
||||||
item,
|
item,
|
||||||
label,
|
label,
|
||||||
|
|
@ -455,7 +476,7 @@ def validate_session_exercise(
|
||||||
version,
|
version,
|
||||||
):
|
):
|
||||||
is_v2 = version >= 2
|
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)
|
V2_SESSION_EXERCISE_KEYS if is_v2 else SESSION_EXERCISE_KEYS)
|
||||||
require_exact_keys(
|
require_exact_keys(
|
||||||
item,
|
item,
|
||||||
|
|
@ -464,6 +485,8 @@ def validate_session_exercise(
|
||||||
- {"sets", "continuous", "max_weight_kg"},
|
- {"sets", "continuous", "max_weight_kg"},
|
||||||
label,
|
label,
|
||||||
)
|
)
|
||||||
|
if version == 4:
|
||||||
|
validate_note(item["note"], f"{label}.note")
|
||||||
|
|
||||||
if is_v2:
|
if is_v2:
|
||||||
require_nonempty_string(item["entry_id"], f"{label}.entry_id")
|
require_nonempty_string(item["entry_id"], f"{label}.entry_id")
|
||||||
|
|
@ -650,12 +673,8 @@ def validate_sessions(
|
||||||
):
|
):
|
||||||
label = f"sessions[{index}]"
|
label = f"sessions[{index}]"
|
||||||
|
|
||||||
require_exact_keys(
|
session_keys = V4_SESSION_KEYS if payload["version"] == 4 else SESSION_KEYS
|
||||||
session,
|
require_exact_keys(session, session_keys, session_keys, label)
|
||||||
SESSION_KEYS,
|
|
||||||
SESSION_KEYS,
|
|
||||||
label,
|
|
||||||
)
|
|
||||||
|
|
||||||
session_id = require_nonempty_string(
|
session_id = require_nonempty_string(
|
||||||
session["session_id"],
|
session["session_id"],
|
||||||
|
|
@ -669,13 +688,21 @@ def validate_sessions(
|
||||||
|
|
||||||
seen_ids.add(session_id)
|
seen_ids.add(session_id)
|
||||||
|
|
||||||
if payload["version"] == 3:
|
if payload["version"] >= 3:
|
||||||
# INVARIANT: accepted V3 history must remain readable by temporal
|
# INVARIANT: accepted V3 history must remain readable by temporal
|
||||||
# analysis; validation completes before run_import mutates SQLite.
|
# analysis; validation completes before run_import mutates SQLite.
|
||||||
require_v3_timestamp(session["started_at"], f"{label}.started_at")
|
require_v3_timestamp(session["started_at"], f"{label}.started_at")
|
||||||
else:
|
else:
|
||||||
require_nonempty_string(session["started_at"], f"{label}.started_at")
|
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 (
|
if session["session_type"] not in (
|
||||||
"training",
|
"training",
|
||||||
"max_test",
|
"max_test",
|
||||||
|
|
@ -748,18 +775,16 @@ def validate_sessions(
|
||||||
def validate_body(payload):
|
def validate_body(payload):
|
||||||
seen_ids = set()
|
seen_ids = set()
|
||||||
allowed = BODY_BASE_KEYS | BODY_METRIC_KEYS
|
allowed = BODY_BASE_KEYS | BODY_METRIC_KEYS
|
||||||
|
if payload["version"] == 4:
|
||||||
|
allowed |= V4_BODY_KEYS
|
||||||
|
|
||||||
for index, observation in enumerate(
|
for index, observation in enumerate(
|
||||||
payload["body_observations"]
|
payload["body_observations"]
|
||||||
):
|
):
|
||||||
label = f"body_observations[{index}]"
|
label = f"body_observations[{index}]"
|
||||||
|
|
||||||
require_exact_keys(
|
required = BODY_BASE_KEYS | ({"session_id", "note"} if payload["version"] == 4 else set())
|
||||||
observation,
|
require_exact_keys(observation, allowed, required, label)
|
||||||
allowed,
|
|
||||||
BODY_BASE_KEYS,
|
|
||||||
label,
|
|
||||||
)
|
|
||||||
|
|
||||||
observation_id = require_nonempty_string(
|
observation_id = require_nonempty_string(
|
||||||
observation["observation_id"],
|
observation["observation_id"],
|
||||||
|
|
@ -773,10 +798,14 @@ def validate_body(payload):
|
||||||
|
|
||||||
seen_ids.add(observation_id)
|
seen_ids.add(observation_id)
|
||||||
|
|
||||||
if payload["version"] == 3:
|
if payload["version"] >= 3:
|
||||||
require_v3_timestamp(observation["observed_at"], f"{label}.observed_at")
|
require_v3_timestamp(observation["observed_at"], f"{label}.observed_at")
|
||||||
else:
|
else:
|
||||||
require_nonempty_string(observation["observed_at"], f"{label}.observed_at")
|
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 = (
|
present_metrics = (
|
||||||
set(observation.keys())
|
set(observation.keys())
|
||||||
|
|
@ -816,9 +845,9 @@ def require_supported_schema(connection):
|
||||||
|
|
||||||
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
|
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
|
||||||
# readable for legacy artifacts and are never made to fake that table.
|
# 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(
|
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(
|
def import_set_session_exercise(
|
||||||
connection,
|
connection,
|
||||||
session_row_id,
|
session_row_id,
|
||||||
|
|
@ -1348,6 +1439,7 @@ def import_set_session_exercise(
|
||||||
duration, set_item.get("weight_kg"),
|
duration, set_item.get("weight_kg"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
return session_exercise_row_id
|
||||||
|
|
||||||
def import_continuous_session_exercise(
|
def import_continuous_session_exercise(
|
||||||
connection,
|
connection,
|
||||||
|
|
@ -1413,6 +1505,7 @@ def import_continuous_session_exercise(
|
||||||
continuous.get("distance_km"),
|
continuous.get("distance_km"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
def import_max_session_exercise(
|
def import_max_session_exercise(
|
||||||
|
|
@ -1452,6 +1545,7 @@ def import_max_session_exercise(
|
||||||
"VALUES(?,?);",
|
"VALUES(?,?);",
|
||||||
(cursor.lastrowid, item["max_weight_kg"]),
|
(cursor.lastrowid, item["max_weight_kg"]),
|
||||||
)
|
)
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
def import_sessions(
|
def import_sessions(
|
||||||
|
|
@ -1491,12 +1585,12 @@ def import_sessions(
|
||||||
current = [(row[0], row[1]) for row in rows]
|
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)
|
legacy = all(value[0].startswith("sxe_legacy_") or value[0].startswith("sxe_v1_") for value in current)
|
||||||
header = connection.execute(
|
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,),
|
(session_row_id,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
resumable_max = (
|
resumable_max = (
|
||||||
header is not None
|
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 session["session_type"] == "max_test"
|
||||||
and len(incoming) >= len(current)
|
and len(incoming) >= len(current)
|
||||||
and all(current[index] == incoming[index]
|
and all(current[index] == incoming[index]
|
||||||
|
|
@ -1504,9 +1598,9 @@ def import_sessions(
|
||||||
)
|
)
|
||||||
incoming_by_id = dict(incoming)
|
incoming_by_id = dict(incoming)
|
||||||
correction = (
|
correction = (
|
||||||
payload["version"] == 3
|
payload["version"] >= 3
|
||||||
and header is not None
|
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 len(incoming_by_id) == len(incoming)
|
||||||
and all(entry_id not in dict(current)
|
and all(entry_id not in dict(current)
|
||||||
or dict(current)[entry_id] == exercise_id
|
or dict(current)[entry_id] == exercise_id
|
||||||
|
|
@ -1524,7 +1618,8 @@ def import_sessions(
|
||||||
session_row_id,
|
session_row_id,
|
||||||
session,
|
session,
|
||||||
exercise_mapping,
|
exercise_mapping,
|
||||||
):
|
) and (payload["version"] != 4 or v4_session_metadata_matches(
|
||||||
|
connection, session_row_id, session)):
|
||||||
report["sessions_skipped"] += 1
|
report["sessions_skipped"] += 1
|
||||||
continue
|
continue
|
||||||
if not legacy and not (resumable_max or correction):
|
if not legacy and not (resumable_max or correction):
|
||||||
|
|
@ -1563,17 +1658,30 @@ def import_sessions(
|
||||||
ended_at,
|
ended_at,
|
||||||
session_type,
|
session_type,
|
||||||
notes
|
notes
|
||||||
) VALUES(?, ?, NULL, ?, NULL);
|
) VALUES(?, ?, ?, ?, NULL);
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
session["session_id"],
|
session["session_id"],
|
||||||
session["started_at"],
|
session["started_at"],
|
||||||
|
session.get("ended_at"),
|
||||||
session["session_type"],
|
session["session_type"],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
session_row_id = cursor.lastrowid
|
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(
|
for position, item in enumerate(
|
||||||
session["exercises"]
|
session["exercises"]
|
||||||
):
|
):
|
||||||
|
|
@ -1616,7 +1724,7 @@ def import_sessions(
|
||||||
)
|
)
|
||||||
|
|
||||||
if "max_weight_kg" in item:
|
if "max_weight_kg" in item:
|
||||||
import_max_session_exercise(
|
occurrence_row_id = import_max_session_exercise(
|
||||||
connection,
|
connection,
|
||||||
session_row_id,
|
session_row_id,
|
||||||
position,
|
position,
|
||||||
|
|
@ -1624,7 +1732,7 @@ def import_sessions(
|
||||||
row_id,
|
row_id,
|
||||||
)
|
)
|
||||||
elif item["recording_mode"] == "continuous":
|
elif item["recording_mode"] == "continuous":
|
||||||
import_continuous_session_exercise(
|
occurrence_row_id = import_continuous_session_exercise(
|
||||||
connection,
|
connection,
|
||||||
session_row_id,
|
session_row_id,
|
||||||
position,
|
position,
|
||||||
|
|
@ -1632,13 +1740,16 @@ def import_sessions(
|
||||||
row_id,
|
row_id,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
import_set_session_exercise(
|
occurrence_row_id = import_set_session_exercise(
|
||||||
connection,
|
connection,
|
||||||
session_row_id,
|
session_row_id,
|
||||||
position,
|
position,
|
||||||
item,
|
item,
|
||||||
row_id,
|
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"]}
|
retained_ids = {item["entry_id"] for item in session["exercises"]}
|
||||||
for entry_id, feedback_id, observed_at, raw_text in retained_feedback:
|
for entry_id, feedback_id, observed_at, raw_text in retained_feedback:
|
||||||
|
|
@ -1784,13 +1895,29 @@ def import_body(
|
||||||
for observation in payload[
|
for observation in payload[
|
||||||
"body_observations"
|
"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(
|
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()
|
(observation["observation_id"],)).fetchone()
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
expected = [observation["observed_at"]] + [observation.get(metric) for metric in metric_order]
|
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"])
|
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
|
report["body_skipped"] += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
@ -1809,6 +1936,16 @@ def import_body(
|
||||||
values,
|
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
|
report["body_imported"] += 1
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1850,6 +1987,8 @@ def run_import(
|
||||||
)
|
)
|
||||||
|
|
||||||
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
|
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(
|
has_explicit_max = any(
|
||||||
"max_weight_kg" in entry
|
"max_weight_kg" in entry
|
||||||
for session in payload["sessions"]
|
for session in payload["sessions"]
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ def main():
|
||||||
roots_added=roots_skipped=revisions_added=revisions_skipped=0
|
roots_added=roots_skipped=revisions_added=revisions_skipped=0
|
||||||
try:
|
try:
|
||||||
db.execute("PRAGMA foreign_keys=ON")
|
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()
|
db.execute("BEGIN IMMEDIATE");seen=set()
|
||||||
for index,item in enumerate(root["exercise_feedback"]):
|
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"}
|
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/model.h"
|
||||||
#include "trainlog/status.h"
|
#include "trainlog/status.h"
|
||||||
|
|
||||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 18
|
#define TRAINLOG_DATABASE_SCHEMA_VERSION 19
|
||||||
|
|
||||||
typedef struct TrainlogDatabase TrainlogDatabase;
|
typedef struct TrainlogDatabase TrainlogDatabase;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -804,6 +804,34 @@ static const char *const MIGRATE_V17_TO_V18_SQL =
|
||||||
"BEGIN SELECT RAISE(ABORT,'AI draft import identity is permanent');END;"
|
"BEGIN SELECT RAISE(ABORT,'AI draft import identity is permanent');END;"
|
||||||
"PRAGMA user_version=18;COMMIT;";
|
"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
|
/* CONTRACT: v18 was already deployed before bounded draft publication was
|
||||||
* added. Keep its version number and add only the nullable lifecycle cursor;
|
* added. Keep its version number and add only the nullable lifecycle cursor;
|
||||||
* NULL deliberately means retryable/not yet published. */
|
* NULL deliberately means retryable/not yet published. */
|
||||||
|
|
@ -1496,7 +1524,8 @@ static TrainlogStatus initialize_or_validate_schema(
|
||||||
} else if (version == 10) {
|
} else if (version == 10) {
|
||||||
status = TRAINLOG_STATUS_OK;
|
status = TRAINLOG_STATUS_OK;
|
||||||
} else if (version == 11 || version == 12 || version == 13 || version == 14 ||
|
} 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;
|
status = TRAINLOG_STATUS_OK;
|
||||||
} else {
|
} else {
|
||||||
if (version == 1) {
|
if (version == 1) {
|
||||||
|
|
@ -1660,6 +1689,9 @@ static TrainlogStatus initialize_or_validate_schema(
|
||||||
if (status == TRAINLOG_STATUS_OK && version < 18) {
|
if (status == TRAINLOG_STATUS_OK && version < 18) {
|
||||||
status = execute_sql(database, MIGRATE_V17_TO_V18_SQL);
|
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) {
|
if (status == TRAINLOG_STATUS_OK) {
|
||||||
status = ensure_v18_ai_draft_publication_state(database);
|
status = ensure_v18_ai_draft_publication_state(database);
|
||||||
}
|
}
|
||||||
|
|
@ -1675,7 +1707,7 @@ static TrainlogStatus initialize_or_validate_schema(
|
||||||
set_open_diagnostic(
|
set_open_diagnostic(
|
||||||
output_diagnostic,
|
output_diagnostic,
|
||||||
output_diagnostic_capacity,
|
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,
|
database->connection,
|
||||||
SQLITE_ERROR
|
SQLITE_ERROR
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -204,7 +204,8 @@ static int migration_preserves_identity_and_history(void)
|
||||||
CHECK(sqlite3_close(raw) == SQLITE_OK);
|
CHECK(sqlite3_close(raw) == SQLITE_OK);
|
||||||
raw = NULL;
|
raw = NULL;
|
||||||
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
|
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,
|
CHECK(trainlog_database_list_exercise_body_zones(database,
|
||||||
"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", relations, 4U, &count) == TRAINLOG_STATUS_OK);
|
"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", relations, 4U, &count) == TRAINLOG_STATUS_OK);
|
||||||
CHECK(count == 2U);
|
CHECK(count == 2U);
|
||||||
|
|
|
||||||
|
|
@ -84,9 +84,14 @@ static bool run_test(void)
|
||||||
(void)fprintf(stderr, "open diagnostic: %s\n", diagnostic);
|
(void)fprintf(stderr, "open diagnostic: %s\n", diagnostic);
|
||||||
CHECK(false);
|
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;
|
trainlog_database_close(db); db = NULL;
|
||||||
CHECK(sqlite3_open_v2(path, &raw, SQLITE_OPEN_READWRITE, NULL) == SQLITE_OK);
|
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_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 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));
|
CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_table_info('session_exercises') WHERE name='tracking_mode' AND \"notnull\"=1", 1));
|
||||||
|
|
|
||||||
|
|
@ -134,8 +134,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void)
|
||||||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
||||||
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
|
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
|
||||||
CHECK(database == NULL);
|
CHECK(database == NULL);
|
||||||
CHECK(strncmp(diagnostic, "migrate database to schema v18: SQLite rc=",
|
CHECK(strncmp(diagnostic, "migrate database to schema v19: SQLite rc=",
|
||||||
strlen("migrate database to schema v18: SQLite rc=")) == 0);
|
strlen("migrate database to schema v19: SQLite rc=")) == 0);
|
||||||
CHECK(strstr(diagnostic, "extended_rc=") != NULL);
|
CHECK(strstr(diagnostic, "extended_rc=") != NULL);
|
||||||
CHECK(strstr(diagnostic, "custom_equipment") != NULL);
|
CHECK(strstr(diagnostic, "custom_equipment") != NULL);
|
||||||
CHECK(strstr(diagnostic, "already exists") != NULL);
|
CHECK(strstr(diagnostic, "already exists") != NULL);
|
||||||
|
|
@ -154,12 +154,12 @@ static bool test_newer_schema_has_application_diagnostic(void)
|
||||||
CHECK(fd >= 0);
|
CHECK(fd >= 0);
|
||||||
CHECK(close(fd) == 0);
|
CHECK(close(fd) == 0);
|
||||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
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(sqlite3_close(raw) == SQLITE_OK);
|
||||||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
||||||
sizeof(diagnostic)) == TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
|
sizeof(diagnostic)) == TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
|
||||||
CHECK(database == NULL);
|
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(strstr(diagnostic, "SQLite") == NULL);
|
||||||
CHECK(unlink(path) == 0);
|
CHECK(unlink(path) == 0);
|
||||||
return true;
|
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,
|
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
||||||
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
|
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
|
||||||
CHECK(database == NULL);
|
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(strstr(diagnostic, "performed_sets_v9") != NULL);
|
||||||
|
|
||||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue